@frontera-sdk/cli 1.52.38 → 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,9 +1,10 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
 
4
4
  import { CliError, UsageError } from '../../errors'
5
5
  import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
6
6
  import { assetFilename } from '../skill/bundle-commands'
7
+ import { outsideImports } from '../../function-outside-imports'
7
8
  import { packDirectory } from '../../pack'
8
9
  import { createTarGz } from '../../tar'
9
10
  import { functionEntry } from '../../local/local-dev-sync'
@@ -13,6 +14,11 @@ import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
13
14
  import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
14
15
  import { getSyncKey, readLink, type SyncLink } from '../../local/local-sync-link'
15
16
  import { itemTitle } from '../../local/local-sync-names'
17
+ import { evalsDetail, evalsLine, lastRender, sessionScreenshots } from '../../local/local-request-evidence'
18
+ import { readAppliedRun, readLastRun, recordApplied, recordRun } from '../../local/local-test-baseline'
19
+ import { agentFilesProblem, runCases, selectCaseFiles } from '../../local/local-test-runner'
20
+ import { findAppProjects } from '../../mcp/mcp-apps'
21
+ import { runtimeLink } from '../../mcp/mcp-runtime'
16
22
  import { readLock, type SyncKind } from '../../local/local-sync-plan'
17
23
  import {
18
24
  adoptApplied,
@@ -55,7 +61,10 @@ import { fetchFor, planFor, sourceFor } from './sync'
55
61
  * request [show] the current request: its items, each against its base, and what happened
56
62
  * request apply apply it: each step moves a live version, and a failure puts every earlier one back;
57
63
  * then fetch, so the applied items read as up to date
64
+ * request evidence attach evidence: each Agent's gated evals against its last applied run,
65
+ * each released App's last render, a Forge session's screenshots, a trace link
58
66
  * request close close it without applying
67
+ * push --release desk=1.4.0 also release a Custom App: Apply promotes that deployment, last
59
68
  *
60
69
  * The request's id is kept in `.frontera/sync/requests.json` (not
61
70
  * committed). Keys come from the keychain, as for `sync fetch`; the
@@ -81,6 +90,8 @@ interface Platform {
81
90
  call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts?: { organizationKey?: string | null; timeoutMs?: number }): Promise<T>
82
91
  /** A body sent as it is (a document's bytes, a multipart upload), not as JSON. */
83
92
  send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts?: { timeoutMs?: number }): Promise<T>
93
+ /** A body read as it is (an evidence picture). */
94
+ bytes(path: string): Promise<{ bytes: Uint8Array; mime: string }>
84
95
  }
85
96
 
86
97
  /**
@@ -109,6 +120,11 @@ function platform(link: SyncLink, key: string): Platform {
109
120
  async send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts: { timeoutMs?: number } = {}): Promise<T> {
110
121
  return answer<T>(await request(method, path, contentType ? { 'content-type': contentType } : {}, body, opts.timeoutMs ?? 300_000))
111
122
  },
123
+ async bytes(path: string) {
124
+ const res = await request('GET', path, {}, undefined, 60_000)
125
+ if (!res.ok) return answer<never>(res)
126
+ return { bytes: new Uint8Array(await res.arrayBuffer()), mime: res.headers.get('content-type') ?? 'application/octet-stream' }
127
+ },
112
128
  }
113
129
  }
114
130
 
@@ -125,6 +141,10 @@ async function answer<T>(res: Response): Promise<T> {
125
141
  const message = String(parsed?.message ?? (typeof parsed?.error === 'string' ? parsed.error : '') ?? '').trim() || text.slice(0, 300) || `the platform answered ${res.status}`
126
142
  const checks = checksOf(parsed?.details)
127
143
  if (checks.length) throw new CliError(message, { code: 'SYNC_CHECKS', hint: 'fix the items the checks name, then push again', details: { checks } })
144
+ const details = (parsed?.details ?? null) as { code?: string; gate?: Gate } | null
145
+ if (details?.code === 'GATE_FAILED' && details.gate) {
146
+ throw new CliError(message, { code: 'SYNC_APPLY_GATED', hint: 'make the checks pass (`frontera local sync request evidence` attaches evals and renders), or a workspace admin applies with `--force --reason "<why>"`', details: { gate: details.gate } })
147
+ }
128
148
  if (res.status === 401) throw new CliError('the platform refused the key', { code: 'UNAUTHORIZED', hint: 'check it is a current workspace key (sk-ws-…) for this platform; `frontera local sync connect` stores a new one' })
129
149
  if (res.status === 403) throw new CliError(message, { code: 'FORBIDDEN', hint: 'the key’s role decides what it may propose and apply; ask a workspace admin' })
130
150
  if (res.status === 404) throw new CliError(message, { code: 'NOT_FOUND', hint: 'the change request is gone from the workspace; propose again with `frontera local sync push`' })
@@ -145,6 +165,8 @@ interface ViewItem {
145
165
  id: string
146
166
  ref: string
147
167
  kind: SyncKind
168
+ /** What the item's candidate is: evidence taken for it is bound to it. */
169
+ candidateDigest?: string
148
170
  name: string
149
171
  baseRevision: string | null
150
172
  state: string
@@ -156,15 +178,107 @@ interface ViewItem {
156
178
  deleted?: boolean
157
179
  assets?: Array<{ path: string; bytes: number; sha256: string }>
158
180
  documents?: { upload: Array<{ fileName: string; folder?: string | null; sha256: string; bytes: number; replaces?: string | null; staged?: boolean }>; remove: Array<{ fileName: string; sourceId: string }> }
181
+ /**
182
+ * `blueprint/workspace-grants`, while the request is still to be applied:
183
+ * each type its file moves, against what the workspace holds now, and the
184
+ * changes that give the workspace `read_sensitive`, in words.
185
+ */
186
+ grantChanges?: GrantChange[]
187
+ disclosures?: string[]
188
+ }
189
+
190
+ /** One type's workspace grant as the request moves it (`from` null: the workspace holds none on it). */
191
+ export interface GrantChange {
192
+ objectType: string
193
+ from: string[] | null
194
+ to: string[]
195
+ disclosure: boolean
196
+ summary?: string
197
+ }
198
+
199
+ /**
200
+ * What a `blueprint/workspace-grants` item changes, under its line: each
201
+ * disclosure first (the workspace gains `read_sensitive`), then the other
202
+ * moves. Nothing when the view carries none (a platform before it, or a
203
+ * request already applied).
204
+ */
205
+ export function grantChangeLines(item: Pick<ViewItem, 'grantChanges' | 'disclosures'>): string[] {
206
+ const changes = item.grantChanges ?? []
207
+ const disclosures = item.disclosures ?? changes.filter((g) => g.disclosure).map((g) => g.summary ?? g.objectType)
208
+ const said = (g: GrantChange) => g.summary ?? `${g.objectType}: ${g.from ? g.from.join(', ') : 'no grant'} → ${g.to.join(', ')}`
209
+ if (!changes.length && !disclosures.length) return item.grantChanges ? [' · changes no grant: the workspace holds each as listed'] : []
210
+ return [
211
+ ...disclosures.map((d) => ` ! discloses: ${d}`),
212
+ ...changes.filter((g) => !g.disclosure).map((g) => ` · ${said(g)}`),
213
+ ]
214
+ }
215
+
216
+ /** The release gate as the platform reads it (design 2026-10-18 §6); absent from a platform before it. */
217
+ export interface Gate {
218
+ required: string[]
219
+ checks: Array<{ id: string; kind: 'evals' | 'app'; ref: string; label: string; status: 'passed' | 'failed' | 'missing'; detail: string; required: boolean; reported?: string | null; evidenceId?: string | null }>
220
+ blocking: number
221
+ }
222
+
223
+ export interface Evidence {
224
+ id: string
225
+ kind: string
226
+ ref: string | null
227
+ title: string
228
+ detail: Record<string, unknown>
229
+ mime: string | null
230
+ bytes: number
231
+ hasImage: boolean
232
+ createdBy: string
233
+ createdAt: string
234
+ stale: boolean
159
235
  }
160
236
 
161
237
  interface View {
162
238
  request: { id: string; title: string; description: string | null; state: string; authorId: string; createdAt: string; updatedAt: string; mergedAt: string | null; closedAt?: string | null }
163
239
  items: ViewItem[]
164
240
  events: Array<{ id: string; kind: string; actorId: string; detail: Record<string, unknown> | null; createdAt: string }>
241
+ gate?: Gate
242
+ evidence?: Evidence[]
165
243
  outcome?: unknown
166
244
  }
167
245
 
246
+ /** A Custom App release as a change request carries it: `app/<slug>`, naming the deployment Apply promotes. */
247
+ export function releaseItem(slug: string, deployment: string): { ref: string; files: Record<string, string> } {
248
+ return { ref: `app/${slug}`, files: { [`apps/${slug}.release.json`]: `${JSON.stringify({ deployment }, null, 2)}\n` } }
249
+ }
250
+
251
+ /** The deployment a release item names. */
252
+ export function releasedDeployment(item: Pick<ViewItem, 'name' | 'files'>): string | null {
253
+ try {
254
+ const doc = JSON.parse(item.files[`apps/${item.name}.release.json`] ?? 'null') as { deployment?: unknown } | null
255
+ return typeof doc?.deployment === 'string' ? doc.deployment : null
256
+ } catch {
257
+ return null
258
+ }
259
+ }
260
+
261
+ /** `--release desk=1.4.0,portal`: each App's slug and the deployment named (none: the newest). */
262
+ export function parseReleases(flag: string | undefined): Array<{ slug: string; deployment: string | null }> {
263
+ if (!flag?.trim()) return []
264
+ return flag.split(',').map((part) => part.trim()).filter(Boolean).map((part) => {
265
+ const [slug = '', deployment = ''] = part.split('=')
266
+ if (!/^[A-Za-z0-9][\w.-]*$/.test(slug.trim())) throw new UsageError(`--release: ${part} is not an App slug`, '--release desk=1.4.0 (its deployment id or release label), or --release desk for its newest deployment')
267
+ return { slug: slug.trim(), deployment: deployment.trim() || null }
268
+ })
269
+ }
270
+
271
+ /** The words for a gate check's state. */
272
+ const GATE_WORD = { passed: 'passed', failed: 'failed', missing: 'no evidence' } as const
273
+
274
+ export function gateLines(gate: Gate | undefined): string[] {
275
+ if (!gate?.checks.length) return []
276
+ return [
277
+ gate.blocking ? `Checks: ${plural(gate.blocking, 'required check')} not passed — Apply waits for them` : 'Checks: every required check passed',
278
+ ...gate.checks.map((c) => ` ${c.status === 'passed' ? '✓' : c.required ? '✗' : '·'} ${c.label}: ${c.detail}${c.required ? '' : ' (not required)'}${c.status === 'passed' ? '' : ` [${GATE_WORD[c.status]}]`}${c.reported ? `\n ${c.reported}` : ''}`),
279
+ ]
280
+ }
281
+
168
282
  const STATE_WORD: Record<string, string> = {
169
283
  draft: 'Draft',
170
284
  open: 'Proposed',
@@ -198,7 +312,7 @@ export function functionBuilder(project: Pick<LocalProject, 'root'>, ctx: Pick<C
198
312
  const at = findAutomationProjectRoot(resolve(root, entry))
199
313
  const inside = at !== null && (at === root || at.startsWith(`${root}/`))
200
314
  const tgz = inside
201
- ? packDirectory(at!, ctx.output, `source of ${item.name}`).tgz
315
+ ? packDirectory(at!, ctx.output, `source of ${item.name}`, outsideImports(at!, resolve(root, entry))).tgz
202
316
  : createTarGz(Object.entries(item.files).map(([rel, text]) => ({ rel, bytes: new TextEncoder().encode(text) })))
203
317
  const source = Buffer.from(tgz).toString('base64')
204
318
  return { manifest, bundle: Buffer.from(code, 'utf8').toString('base64'), source }
@@ -306,6 +420,29 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
306
420
  return lines.join('\n')
307
421
  }
308
422
 
423
+ /**
424
+ * The release items asked for: each App resolved in the workspace, and the
425
+ * deployment named, or its newest one (what was deployed last, previews
426
+ * included) when only the slug is given.
427
+ */
428
+ async function releaseItems(api: Platform, asked: Array<{ slug: string; deployment: string | null }>) {
429
+ const out: Array<{ ref: string; files: Record<string, string>; slug: string; deployment: string; chosen: boolean }> = []
430
+ for (const r of asked) {
431
+ let deployment = r.deployment
432
+ if (!deployment) {
433
+ const app = await api.call<{ id: string }>('GET', `/v1/platform-apps/resolve/${encodeURIComponent(r.slug)}`).catch((err: CliError) => {
434
+ throw err.code === 'NOT_FOUND' ? new UsageError(`the workspace has no Custom App ${r.slug}`, 'deploy it once with `frontera app deploy` in its folder, then release it') : err
435
+ })
436
+ const ledger = await api.call<{ deployments: Array<{ id: string; label: string | null; pruned: unknown }> }>('GET', `/v1/platform-apps/${app.id}/deployments`)
437
+ const newest = ledger.deployments.find((d) => !d.pruned)
438
+ if (!newest) throw new UsageError(`${r.slug} has no deployment to release`, 'deploy one with `frontera app deploy` in its folder first')
439
+ deployment = newest.label ?? newest.id
440
+ }
441
+ out.push({ ...releaseItem(r.slug, deployment), slug: r.slug, deployment, chosen: !r.deployment })
442
+ }
443
+ return out
444
+ }
445
+
309
446
  export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
310
447
  const project = localProject(ctx.cwd)
311
448
  const link = needLink(project)
@@ -367,7 +504,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
367
504
  })
368
505
  }
369
506
  const going = chosen.filter((i) => i.proposable)
370
- if (!going.length) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
507
+ const releaseAsks = parseReleases(flagString(ctx, 'release'))
508
+ if (!going.length && !releaseAsks.length) throw new CliError('nothing to propose: nothing changed here since the last pull', { code: 'SYNC_NOTHING_TO_PUSH', hint: 'change an item in the project first; `frontera local sync status` shows where it stands' })
371
509
  const title = flagString(ctx, 'title')?.trim()
372
510
  const description = flagString(ctx, 'description')
373
511
  const api = platformFor(project, link)
@@ -382,7 +520,10 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
382
520
  }
383
521
  if (!target && !title) throw new UsageError('give the change request a title', 'frontera local sync push -t "Tighten the refund procedure"')
384
522
 
385
- const items = await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))
523
+ // Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
524
+ const releases = await releaseItems(api, releaseAsks)
525
+ const keptReleases = target ? (await api.call<View>('GET', `/v1/change-requests/${target.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref)).map((i) => ({ ref: i.ref, files: i.files })) : []
526
+ const items = [...(await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))), ...releases.map(({ ref, files }) => ({ ref, files })), ...keptReleases]
386
527
  let view: View
387
528
  try {
388
529
  view = target
@@ -411,13 +552,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
411
552
  proposed: true,
412
553
  created: !target,
413
554
  request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
414
- pushed: going.map((i) => i.ref),
555
+ pushed: [...going.map((i) => i.ref), ...releases.map((r) => r.ref)],
556
+ releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
415
557
  notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })),
416
558
  organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
417
559
  },
418
560
  text: [
419
561
  `${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
420
562
  ...going.map((i) => ` ${pushItemLine(i)}`),
563
+ ...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
421
564
  ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)] : []),
422
565
  ...(orgNote ? ['', orgNote] : []),
423
566
  '',
@@ -462,7 +605,11 @@ async function basesFor(project: LocalProject, link: SyncLink, view: View): Prom
462
605
  const source = sourceFor(project, link)
463
606
  const asked = item.kind === 'blueprint' ? 'blueprint' : item.ref
464
607
  const answer = await source.workspace.find<{ ref: string; kind: string; revision: string; content: Record<string, unknown> }>(`/v1/sync/item?ref=${encodeURIComponent(asked)}&revision=${encodeURIComponent(item.baseRevision)}`)
465
- if (answer) out[item.ref] = { files: await projectAtRevision(source, item.ref, answer, Object.keys(item.files), directoryOf(snapshot)), known: true }
608
+ if (answer) {
609
+ const files = await projectAtRevision(source, item.ref, answer, Object.keys(item.files), directoryOf(snapshot))
610
+ // A Blueprint revision is the whole model: the item's base is its own file of it (`blueprint/<name>.<ext>`).
611
+ out[item.ref] = { files: item.kind === 'blueprint' ? Object.fromEntries(Object.entries(files).filter(([p]) => p.startsWith(`blueprint/${item.name}.`))) : files, known: true }
612
+ }
466
613
  } catch {
467
614
  // Shown without its base; the page says so.
468
615
  }
@@ -511,7 +658,7 @@ function activityOf(view: View, titles: Record<string, string>) {
511
658
  text = 'Apply started'
512
659
  break
513
660
  case 'apply_refused':
514
- text = `Apply refused: ${plural(checksOf(d).length, 'check')} failed`
661
+ text = Array.isArray(d.gate) ? `Apply waited: ${plural(d.gate.length, 'required check')} not passed` : `Apply refused: ${plural(checksOf(d).length, 'check')} failed`
515
662
  break
516
663
  case 'apply_failed': {
517
664
  outcome = parseOutcome({ ok: false, steps: d.steps, failure: d.failure, compensation: d.compensation })
@@ -529,6 +676,15 @@ function activityOf(view: View, titles: Record<string, string>) {
529
676
  case 'closed':
530
677
  text = 'Closed'
531
678
  break
679
+ case 'gate_forced':
680
+ text = `Applied with ${plural(Array.isArray(d.skipped) ? d.skipped.length : 0, 'required check')} skipped (Force Apply): ${typeof d.reason === 'string' ? d.reason : ''}`
681
+ break
682
+ case 'evidence_attached':
683
+ text = `Attached ${typeof d.title === 'string' ? d.title : 'evidence'}`
684
+ break
685
+ case 'evidence_removed':
686
+ text = 'Removed a piece of evidence'
687
+ break
532
688
  default:
533
689
  text = e.kind.replace(/_/g, ' ')
534
690
  }
@@ -551,6 +707,7 @@ async function currentView(project: LocalProject, link: SyncLink): Promise<{ vie
551
707
  * then what its proposed files declare. The kept titles are not rewritten.
552
708
  */
553
709
  function titleOf(item: ViewItem, titles: Record<string, string>): string {
710
+ if (item.kind === 'app') return `${item.name} ${releasedDeployment(item) ?? ''}`.trim()
554
711
  return itemTitle(item.kind, item.name, item.files, titles[item.ref])
555
712
  }
556
713
 
@@ -577,7 +734,9 @@ async function show(ctx: CommandContext) {
577
734
  name: item.name,
578
735
  title,
579
736
  change,
580
- line: itemLine({ kind: item.kind, name: item.name, title, change, changedPaths: base.known ? files.map((f) => f.path) : [], assetsChanged: Boolean(item.assets?.length) && base.known && !files.length, documentsChanged }),
737
+ line: item.kind === 'app'
738
+ ? `Custom App ${item.name}: release ${releasedDeployment(item) ?? '(no deployment)'}`
739
+ : itemLine({ kind: item.kind, name: item.name, title, change, changedPaths: base.known ? files.map((f) => f.path) : [], assetsChanged: Boolean(item.assets?.length) && base.known && !files.length, documentsChanged }),
581
740
  state: item.state,
582
741
  stateLabel: ITEM_STATE_WORD[item.state] ?? item.state,
583
742
  error: item.error,
@@ -586,6 +745,7 @@ async function show(ctx: CommandContext) {
586
745
  files,
587
746
  build: item.build ? { hasSource: item.build.hasSource } : null,
588
747
  ...(item.assets ? { assets: item.assets.map((a) => ({ path: a.path, bytes: a.bytes })) } : {}),
748
+ ...(item.grantChanges || item.disclosures ? { grantChanges: item.grantChanges ?? [], disclosures: item.disclosures ?? [] } : {}),
589
749
  ...(item.documents
590
750
  ? {
591
751
  documents: {
@@ -608,17 +768,22 @@ async function show(ctx: CommandContext) {
608
768
  activity,
609
769
  checks: checks.filter((c) => !items.some((i) => i.ref === c.ref)).map((c) => ({ ref: c.ref, code: c.code, message: c.message })),
610
770
  organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
771
+ gate: view.gate ?? null,
772
+ evidence: view.evidence ?? [],
611
773
  },
612
774
  text: [
613
775
  `${r.title} — ${STATE_WORD[r.state] ?? r.state} · ${plural(items.length, 'item')} · ${link.workspaceName ?? link.workspace}`,
614
776
  ...(r.description ? [r.description] : []),
615
777
  '',
616
- ...items.flatMap((i) => [
778
+ ...items.flatMap((i, n) => [
617
779
  ` ${i.line} (${i.stateLabel})`,
618
780
  ...(i.error ? [` ! ${i.error}`] : []),
619
781
  ...i.checks.map((c) => ` ! ${c.message}`),
782
+ ...grantChangeLines(view.items[n]!),
620
783
  ...(i.baseKnown ? [] : [' · its base isn’t on this machine; its files are shown whole']),
621
784
  ]),
785
+ ...(view.gate?.checks.length ? ['', ...gateLines(view.gate)] : []),
786
+ ...(view.evidence?.length ? ['', 'Evidence:', ...view.evidence.map((e) => ` ${e.title}${e.stale ? ' (before the last push)' : ''}`)] : []),
622
787
  '',
623
788
  'Activity:',
624
789
  ...activity.map((a) => ` ${a.at.slice(0, 16).replace('T', ' ')} ${a.text}`),
@@ -642,10 +807,17 @@ async function apply(ctx: CommandContext) {
642
807
  throw new CliError('this change request changes the Blueprint, and applying it needs the organization key; none is stored for this project', { code: 'SYNC_NO_ORG_KEY', hint: 'connect again with `frontera local sync connect --workspace-key - --org-key -` (both keys on stdin), then apply' })
643
808
  }
644
809
  const migration = flagString(ctx, 'migration')?.trim()
810
+ const force = flagBool(ctx, 'force')
811
+ const reason = flagString(ctx, 'reason')?.trim()
812
+ if (force && !reason) throw new UsageError('--force needs --reason', 'frontera local sync request apply --force --reason "why it applies without its required checks"')
645
813
  let answer: View
646
814
  try {
647
- answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, migration ? { migrationInstruction: migration } : {}, { organizationKey, timeoutMs: 15 * 60_000 })
815
+ answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
648
816
  } catch (err) {
817
+ if (err instanceof CliError && err.code === 'SYNC_APPLY_GATED') {
818
+ const gate = (err.details as { gate?: Gate } | undefined)?.gate
819
+ throw new CliError(['not applied: required checks have not passed', ...gateLines(gate).slice(1).filter((l) => l.includes('✗'))].join('\n'), { code: err.code, hint: err.hint, details: err.details })
820
+ }
649
821
  if (err instanceof CliError && err.code === 'SYNC_CHECKS') {
650
822
  const checks = checksOf(err.details)
651
823
  throw new CliError(`not applied: ${plural(checks.length, 'check')} failed\n${checkLines(checks, allTitles).join('\n')}`, {
@@ -676,7 +848,9 @@ async function apply(ctx: CommandContext) {
676
848
  // A deletion shows only in a full read of the workspace's index (an incremental one lists no deletions).
677
849
  const deleted = answer.items.filter((i) => i.deleted).map((i) => i.ref)
678
850
  const result = await fetchFor(project, link, { full: deleted.length > 0 })
679
- const { adopted } = adoptApplied(project.root, link.workspace, answer.items.map((i) => i.ref), snapshotItems(project.root, result.snapshot), readLock(project.root), new Date(), { deleted })
851
+ // A Custom App release moved a pointer; the App's own sync item (its description) is not the project's to adopt.
852
+ const adoptable = answer.items.filter((i) => i.kind !== 'app').map((i) => i.ref)
853
+ const { adopted } = adoptApplied(project.root, link.workspace, adoptable, snapshotItems(project.root, result.snapshot), readLock(project.root), new Date(), { deleted })
680
854
  // An Agent's lessons were proposed for review: not proposed again; the next pull brings what reviewers approve.
681
855
  adoptProposedLessons(project.root, link.workspace, answer.items.flatMap((i) => (i.ref.startsWith('lessons/') ? [i.ref.slice('lessons/'.length)] : [])))
682
856
  fetched = { changed: result.changed.length, adopted, error: null }
@@ -685,6 +859,8 @@ async function apply(ctx: CommandContext) {
685
859
  }
686
860
  // Documents applied are the project's copies of the workspace's now: recorded as a pull records them.
687
861
  await recordAppliedDocuments(project, link, answer).catch(() => null)
862
+ // The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
863
+ recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
688
864
  return {
689
865
  data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched },
690
866
  text: [
@@ -740,11 +916,142 @@ async function close(ctx: CommandContext) {
740
916
  return { data: { closed: true, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”. The project’s files are unchanged; the next push opens a new change request.` }
741
917
  }
742
918
 
743
- const REQUEST_ACTIONS: Record<string, (ctx: CommandContext) => Promise<{ data: unknown; text: string }>> = { show, apply, close }
919
+ /**
920
+ * Attach evidence to the current request (design 2026-10-18 §6): each Agent
921
+ * it changes, run on its gated cases against the local runtime and compared
922
+ * with its last applied run; each App it releases, by its last render; the
923
+ * newest screenshots of a Forge session; a trace link. With no flag, all
924
+ * that applies: evals and renders, and this session's screenshots inside
925
+ * Forge.
926
+ */
927
+ async function evidence(ctx: CommandContext) {
928
+ const project = localProject(ctx.cwd)
929
+ const link = needLink(project)
930
+ const current = await currentView(project, link)
931
+ if (!current) throw new UsageError('no change request to attach evidence to', 'propose one with `frontera local sync push -t "<title>"`')
932
+ const { view, titles } = current
933
+ if (!isOpen(view.request.state)) throw new CliError(`“${view.request.title}” is ${STATE_WORD[view.request.state] ?? view.request.state}; it takes no evidence`, { code: 'CONFLICT', hint: 'propose new changes with `frontera local sync push -t "<title>"`' })
934
+ const session = flagString(ctx, 'screenshots') ?? process.env.FORGE_SESSION ?? null
935
+ const trace = flagString(ctx, 'trace')
936
+ const any = flagBool(ctx, 'evals') || flagBool(ctx, 'renders') || flagString(ctx, 'screenshots') !== undefined || trace !== undefined
937
+ const want = { evals: any ? flagBool(ctx, 'evals') : true, renders: any ? flagBool(ctx, 'renders') : true, screenshots: any ? flagString(ctx, 'screenshots') !== undefined : !!session }
938
+ const api = platformFor(project, link)
939
+ const attach = (body: Record<string, unknown>) => api.call<View>('POST', `/v1/change-requests/${view.request.id}/evidence`, body, { timeoutMs: 120_000 })
940
+ const lines: string[] = []
941
+ const attached: Array<{ kind: string; ref: string | null; title: string }> = []
942
+ const skipped: string[] = []
943
+
944
+ if (want.evals) {
945
+ const agents = view.items.filter((i) => i.kind === 'agent' && !i.deleted)
946
+ if (agents.length) {
947
+ const runtime = runtimeLink(project)
948
+ if (typeof runtime === 'string') throw new CliError(`the evals run against the local runtime: ${runtime}`, { code: 'LOCAL_NOT_RUNNING', hint: 'start it with `frontera local start`, or attach only renders and screenshots with `--renders --screenshots <session>`' })
949
+ const applied = readAppliedRun(project)
950
+ const last = readLastRun(project)
951
+ for (const item of agents) {
952
+ // Chosen as `local test` and MCP `test_run { gate: true }` choose them, and run on what the files say:
953
+ // a draft that isn't fails its cases with what to run, as there.
954
+ const files = selectCaseFiles(project.root, { agent: item.name, gate: true })
955
+ const results = files.length ? await runCases({ serviceUrl: runtime.serviceUrl, creds: runtime.creds, files, live: false, staleDraft: (slug) => agentFilesProblem(project, runtime.creds.organizationId, slug), onResult: () => {} }) : []
956
+ // Compared with the run when the Agent was last applied from here; never applied from here, with the last run.
957
+ const baseline = Object.keys(applied).some((k) => k.startsWith(`${item.name}\u0000`)) ? applied : last
958
+ const detail = evalsDetail({ files, results, baseline, runtime: `local ${runtime.state.version ?? ''}`.trim() })
959
+ if (results.length) recordRun(project, results)
960
+ // Cases that couldn't run (the provider refused them) prove nothing either way: attached, they would read as none newly failing.
961
+ if (detail.couldNotRun.length) {
962
+ skipped.push(evalsLine(`Evals · ${titleOf(item, titles)}`, detail))
963
+ continue
964
+ }
965
+ const title = `Evals · ${titleOf(item, titles)}`
966
+ await attach({ kind: 'evals', ref: item.ref, candidateDigest: item.candidateDigest ?? null, title, detail })
967
+ attached.push({ kind: 'evals', ref: item.ref, title })
968
+ lines.push(` ${evalsLine(titleOf(item, titles), detail)}`)
969
+ }
970
+ }
971
+ }
972
+
973
+ if (want.renders) {
974
+ const apps = findAppProjects(project.root)
975
+ for (const item of view.items.filter((i) => i.kind === 'app')) {
976
+ const app = apps.find((a) => a.slug === item.name)
977
+ const render = app ? lastRender(app.root) : null
978
+ if (!render) {
979
+ skipped.push(`${item.name}: no render in this project yet (render it with the coding agent's app_render, or open it in Forge's Browser)`)
980
+ continue
981
+ }
982
+ const big = render.image && render.image.byteLength > 2 * 1024 * 1024
983
+ const title = `Render · ${item.name}${typeof render.detail.path === 'string' ? ` ${render.detail.path}` : ''}`
984
+ await attach({ kind: 'render', ref: item.ref, title, detail: { ...render.detail, deployment: releasedDeployment(item) }, ...(render.image && !big ? { image: render.image.toString('base64') } : {}) })
985
+ attached.push({ kind: 'render', ref: item.ref, title })
986
+ lines.push(` ${title}${big ? ' (its picture is over 2 MB and stays here)' : ''}`)
987
+ }
988
+ }
989
+
990
+ if (want.screenshots && session) {
991
+ const shots = sessionScreenshots(project.root, session)
992
+ if (!shots.length) skipped.push(`session ${session}: no screenshots saved`)
993
+ for (const shot of shots) {
994
+ if (shot.bytes > 2 * 1024 * 1024) {
995
+ skipped.push(`${shot.name}: over 2 MB`)
996
+ continue
997
+ }
998
+ const title = shot.name.replace(/\.(png|jpe?g|webp)$/i, '').replace(/[-_]+/g, ' ')
999
+ await attach({ kind: 'screenshot', title, detail: { session, file: shot.name }, image: readFileSync(shot.path).toString('base64') })
1000
+ attached.push({ kind: 'screenshot', ref: null, title })
1001
+ lines.push(` ${title}`)
1002
+ }
1003
+ }
1004
+
1005
+ if (trace !== undefined) {
1006
+ if (!/^https?:\/\//.test(trace)) throw new UsageError('--trace takes an http(s) address', 'frontera local sync request evidence --trace https://…/traces?trace=<id>')
1007
+ const title = flagString(ctx, 'title')?.trim() || 'Trace'
1008
+ await attach({ kind: 'trace', title, detail: { url: trace } })
1009
+ attached.push({ kind: 'trace', ref: null, title })
1010
+ lines.push(` ${title}: ${trace}`)
1011
+ }
1012
+
1013
+ const after = await api.call<View>('GET', `/v1/change-requests/${view.request.id}`)
1014
+ recordRequest(project.root, link.workspace, after)
1015
+ return {
1016
+ data: { attached, skipped, gate: after.gate ?? null },
1017
+ text: [
1018
+ attached.length ? `Attached ${plural(attached.length, 'piece')} of evidence to “${view.request.title}”:` : `Nothing attached to “${view.request.title}”.`,
1019
+ ...lines,
1020
+ ...(skipped.length ? ['', 'Not attached:', ...skipped.map((l) => ` ${l}`)] : []),
1021
+ ...(after.gate?.checks.length ? ['', ...gateLines(after.gate)] : []),
1022
+ ].join('\n'),
1023
+ }
1024
+ }
1025
+
1026
+ const PICTURE_EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' }
1027
+
1028
+ /** One evidence picture, kept on this machine (in the runtime's state folder) for Forge to show. */
1029
+ async function evidenceImage(ctx: CommandContext) {
1030
+ const project = localProject(ctx.cwd)
1031
+ const link = needLink(project)
1032
+ const id = ctx.positional[2]
1033
+ if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw new UsageError('say which evidence picture', 'frontera local sync request evidence-image <evidence id>')
1034
+ const kept = currentRequest(readRequests(project.root, link.workspace))
1035
+ if (!kept) throw new UsageError('no change request', 'propose one with `frontera local sync push -t "<title>"`')
1036
+ const dir = join(project.stateDir, 'evidence', kept.id)
1037
+ for (const ext of Object.values(PICTURE_EXT)) {
1038
+ const path = join(dir, `${id}.${ext}`)
1039
+ if (existsSync(path)) return { data: { path, mime: Object.entries(PICTURE_EXT).find(([, e]) => e === ext)![0] }, text: path }
1040
+ }
1041
+ const { bytes, mime } = await platformFor(project, link).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
1042
+ const ext = PICTURE_EXT[mime.split(';')[0]!.trim()]
1043
+ if (!ext) throw new CliError('the platform did not answer with a picture', { code: 'UNAVAILABLE', hint: 'run the command again' })
1044
+ mkdirSync(dir, { recursive: true })
1045
+ const path = join(dir, `${id}.${ext}`)
1046
+ writeFileSync(path, bytes)
1047
+ return { data: { path, mime }, text: path }
1048
+ }
1049
+
1050
+ const REQUEST_ACTIONS: Record<string, (ctx: CommandContext) => Promise<{ data: unknown; text: string }>> = { show, apply, close, evidence, 'evidence-image': evidenceImage }
744
1051
 
745
1052
  export async function syncRequest(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
746
1053
  const action = ctx.positional[1] ?? 'show'
747
1054
  const run = REQUEST_ACTIONS[action]
748
- if (!run) throw new UsageError(`unknown request action \`${action}\``, 'use show, apply or close')
1055
+ if (!run) throw new UsageError(`unknown request action \`${action}\``, 'use show, evidence, apply or close')
749
1056
  return run(ctx)
750
1057
  }