@frontera-sdk/cli 1.53.7 → 1.53.9

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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/governed-action-api.ts +9 -3
  3. package/src/blueprint/action-rules.ts +175 -9
  4. package/src/blueprint/diff.ts +27 -8
  5. package/src/blueprint/ontology-edit-plan.ts +155 -4
  6. package/src/blueprint/org-name-check.ts +10 -8
  7. package/src/blueprint/projection.ts +13 -3
  8. package/src/blueprint/property-intents.ts +248 -0
  9. package/src/blueprint/publish-plan.ts +40 -4
  10. package/src/blueprint/render.ts +4 -0
  11. package/src/blueprint/tree.ts +22 -0
  12. package/src/commands/action/deploy.ts +31 -24
  13. package/src/commands/action/retire.ts +9 -1
  14. package/src/commands/action/submit.ts +21 -5
  15. package/src/commands/auth/login.ts +2 -2
  16. package/src/commands/blueprint/authoring.ts +22 -4
  17. package/src/commands/blueprint/declarative.ts +140 -32
  18. package/src/commands/knowledge/index-commands.ts +3 -4
  19. package/src/commands/local/check.ts +6 -4
  20. package/src/commands/local/index-commands.ts +8 -2
  21. package/src/commands/local/sync-request.ts +252 -66
  22. package/src/commands/local/sync.ts +29 -24
  23. package/src/device-login.ts +6 -1
  24. package/src/local/local-actions.ts +13 -1
  25. package/src/local/local-applied.ts +1 -1
  26. package/src/local/local-blueprint-schemas.ts +40 -6
  27. package/src/local/local-dev-references.ts +4 -3
  28. package/src/local/local-jobs.ts +28 -0
  29. package/src/local/local-portability.ts +1 -1
  30. package/src/local/local-sync-link.ts +62 -4
  31. package/src/local/local-sync-merge.ts +37 -1
  32. package/src/local/local-sync-plan.ts +44 -6
  33. package/src/local/local-sync-precheck.ts +2 -1
  34. package/src/local/local-sync-push.ts +22 -11
  35. package/src/local/local-sync-remote.ts +13 -8
  36. package/src/local/local-synthetic-data.ts +2 -2
  37. package/src/main.ts +7 -2
  38. package/src/vendor/sdk-sources.json +2 -2
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
 
4
- import { signInStep } from '../../device-login'
4
+ import { signInCommand, signInStep } from '../../device-login'
5
5
  import { CliError, UsageError } from '../../errors'
6
6
  import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
7
7
  import { assetFilename } from '../skill/bundle-commands'
@@ -13,7 +13,7 @@ import { mcpServersNotCarried } from '../../local/local-mcp-servers'
13
13
  import { localProject, type LocalProject } from '../../local/local-project'
14
14
  import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
15
15
  import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
16
- import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetOf, type SyncLink } from '../../local/local-sync-link'
16
+ import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetOf, targetProfileName, targetSession, type SyncLink } from '../../local/local-sync-link'
17
17
  import { itemTitle } from '../../local/local-sync-names'
18
18
  import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
19
19
  import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
@@ -72,9 +72,10 @@ import { fetchFor, planFor, sourceFor } from './sync'
72
72
  * push --release desk=1.4.0 also release a Custom App: Apply promotes that deployment, last
73
73
  *
74
74
  * The request's id is kept in `.frontera/sync/requests.json` (not
75
- * committed). Keys come from the keychain, as for `sync fetch`; the
76
- * organization key goes beside the workspace key only when Apply has
77
- * Blueprint items.
75
+ * committed). The calls act as the person signed in for the project's
76
+ * target profile (`frontera auth login --browser --profile <target>`), or
77
+ * else with the project's keys from the keychain, as for `sync fetch`: the
78
+ * workspace key, and the organization key beside it when one is stored.
78
79
  */
79
80
 
80
81
  const ORGANIZATION_KEY_HEADER = 'x-frontera-organization-key'
@@ -91,7 +92,18 @@ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
91
92
 
92
93
  type RequestBody = NonNullable<RequestInit['body']>
93
94
 
95
+ /**
96
+ * Who a command's change-request calls act as: the person signed in for the
97
+ * target, or else the project's keys. One for the whole command: a person
98
+ * sends no key with their session, so a Blueprint change applied by a person
99
+ * is that person's alone.
100
+ */
101
+ type Acting =
102
+ | { as: 'person'; profile: string; token: string; email: string | null; organizationKeyStored: boolean }
103
+ | { as: 'keys'; profile: string; workspaceKey: string; organizationKey: string | null }
104
+
94
105
  interface Platform {
106
+ acting: Acting
95
107
  call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts?: { organizationKey?: string | null; timeoutMs?: number }): Promise<T>
96
108
  /** A body sent as it is (a document's bytes, a multipart upload), not as JSON. */
97
109
  send<T>(method: 'POST' | 'PUT', path: string, body: RequestBody, contentType: string | null, opts?: { timeoutMs?: number }): Promise<T>
@@ -100,21 +112,24 @@ interface Platform {
100
112
  }
101
113
 
102
114
  /**
103
- * The change-request routes with the project's workspace key. A refusal by
104
- * the blocking checks keeps them (`details.checks`), so the caller can say
105
- * which item each names; Apply may take minutes, so it waits longer than a
106
- * read.
115
+ * The change-request routes as `acting`: a person's session names the
116
+ * linked workspace, a workspace key carries it. A refusal by the blocking
117
+ * checks keeps them (`details.checks`), so the caller can say which item
118
+ * each names; Apply may take minutes, so it waits longer than a read.
107
119
  */
108
- function platform(link: SyncLink, key: string): Platform {
120
+ function platform(link: SyncLink, acting: Acting): Platform {
109
121
  const origin = normalizeOrigin(link.platform)
122
+ const credential: Record<string, string> = acting.as === 'person' ? { authorization: `Bearer ${acting.token}`, 'x-workspace-id': link.workspace } : { authorization: `Bearer ${acting.workspaceKey}` }
123
+ const answer = <T>(res: Response) => answerAs<T>(res, acting)
110
124
  const request = async (method: string, path: string, headers: Record<string, string>, body: RequestBody | undefined, timeoutMs: number): Promise<Response> => {
111
125
  try {
112
- return await fetch(new URL(path, origin), { method, headers: { authorization: `Bearer ${key}`, accept: 'application/json', ...headers }, body, signal: AbortSignal.timeout(timeoutMs) })
126
+ return await fetch(new URL(path, origin), { method, headers: { ...credential, accept: 'application/json', ...headers }, body, signal: AbortSignal.timeout(timeoutMs) })
113
127
  } catch (err) {
114
128
  throw new CliError(`${origin} did not answer: ${(err as Error).message}`, { code: 'UNAVAILABLE', hint: 'check the address and your network, then run the command again' })
115
129
  }
116
130
  }
117
131
  return {
132
+ acting,
118
133
  async call<T>(method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown, opts: { organizationKey?: string | null; timeoutMs?: number } = {}): Promise<T> {
119
134
  const res = await request(method, path, {
120
135
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
@@ -134,7 +149,7 @@ function platform(link: SyncLink, key: string): Platform {
134
149
  }
135
150
 
136
151
  /** A platform answer: its data, or a refusal in words (the checks it names kept in `details`). */
137
- async function answer<T>(res: Response): Promise<T> {
152
+ async function answerAs<T>(res: Response, acting: Acting): Promise<T> {
138
153
  const text = await res.text().catch(() => '')
139
154
  let parsed: Record<string, unknown> | null = null
140
155
  try {
@@ -150,18 +165,153 @@ async function answer<T>(res: Response): Promise<T> {
150
165
  if (details?.code === 'GATE_FAILED' && details.gate) {
151
166
  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 } })
152
167
  }
168
+ if (res.status === 401 && acting.as === 'person') throw new CliError(`the platform no longer takes the browser sign-in for ${acting.profile}`, { code: 'UNAUTHORIZED', hint: `sign in again with \`${signInCommand(acting.profile)}\`` })
153
169
  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' })
154
- 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' })
170
+ if (res.status === 403) throw new CliError(message, { code: 'FORBIDDEN', hint: acting.as === 'keys' ? 'the key’s role decides what it may propose and apply; ask a workspace admin' : 'your role decides what you may propose and apply; ask a workspace admin' })
155
171
  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`' })
156
172
  if (res.status === 409) throw new CliError(message, { code: 'CONFLICT', hint: 'see where it stands with `frontera local sync request`' })
157
173
  if (res.status === 400 || res.status === 422) throw new CliError(message, { code: 'VALIDATION_ERROR', hint: 'see where it stands with `frontera local sync request`' })
158
174
  throw new CliError(`the platform answered ${res.status}: ${message}`, { code: 'UNAVAILABLE', hint: 'check the platform is reachable, then run the command again' })
159
175
  }
160
176
 
161
- function platformFor(project: LocalProject, link: SyncLink): Platform {
162
- const key = getSyncKey(project, 'workspace')
163
- if (!key) throw new CliError('no workspace key for this project on this machine', { code: 'SYNC_NO_KEY', hint: 'run `frontera local sync connect` to store one (keys are per machine)' })
164
- return platform(link, key)
177
+ /** The person signed in for the target when they are a member of the linked workspace, else the project's keys. */
178
+ async function platformFor(project: LocalProject, link: SyncLink): Promise<Platform> {
179
+ const person = await targetSession(project, link)
180
+ if (!person) return keysFor(project, link)
181
+ const profile = targetProfileName(project, link)
182
+ const api = platform(link, { as: 'person', profile, token: person.token, email: person.email ?? null, organizationKeyStored: !!getSyncKey(project, 'organization') })
183
+ // The platform drops the workspace from a session that isn't a member's, and every change-request call then fails.
184
+ const me = await api.call<{ workspaceId?: string | null }>('GET', '/v1/whoami')
185
+ if (me.workspaceId?.toLowerCase() !== link.workspace.toLowerCase()) {
186
+ throw new CliError(`signed in as ${person.email ?? 'someone'} for ${profile}, who is not a member of ${link.workspaceName ?? link.workspace}`, { code: 'SYNC_NOT_A_MEMBER', hint: `sign out with \`frontera auth logout --profile ${profile}\` and sign in as a member (\`${signInCommand(profile)}\`), or ask a workspace admin to add you` })
187
+ }
188
+ return api
189
+ }
190
+
191
+ function keysFor(project: LocalProject, link: SyncLink): Platform {
192
+ const workspaceKey = getSyncKey(project, 'workspace')
193
+ if (!workspaceKey) throw new CliError('no workspace key for this project on this machine', { code: 'SYNC_NO_KEY', hint: 'run `frontera local sync connect` to store one (keys are per machine)' })
194
+ return platform(link, { as: 'keys', profile: targetProfileName(project, link), workspaceKey, organizationKey: getSyncKey(project, 'organization') })
195
+ }
196
+
197
+ /**
198
+ * A request takes pushes, evidence, close and Apply from the one who proposed
199
+ * it (a workspace admin aside), so a request is another's when `acting` did
200
+ * not propose it: `key` when the project's workspace key did and a person
201
+ * acts now, `person` when a person did and the keys act now. Null when it is
202
+ * `acting`'s, or the view does not say.
203
+ */
204
+ function proposedByOther(acting: Acting, view: View): 'key' | 'person' | null {
205
+ const author = view.request.authorId
206
+ if (typeof author !== 'string') return null
207
+ if (acting.as === 'person') return author.startsWith('wskey:') ? 'key' : null
208
+ return /^(wskey|orgkey|apikey):/.test(author) ? null : 'person'
209
+ }
210
+
211
+ /** The refusal, with the way out, for a command on a request `acting` did not propose; null when it did. */
212
+ function notProposer(acting: Acting, view: View, doing: 'apply' | 'push to' | 'close' | 'attach evidence to'): CliError | null {
213
+ const by = proposedByOther(acting, view)
214
+ const title = view.request.title
215
+ if (by === 'person') return new CliError(`“${title}” was proposed by a person signed in for this project’s workspace, and no one is signed in on this computer now`, { code: 'SYNC_NOT_SIGNED_IN', hint: `sign in again with \`${signInCommand(acting.profile)}\`, then run the command again` })
216
+ if (by !== 'key' || acting.as !== 'person') return null
217
+ const closeIt = 'close it (`frontera local sync request close` closes it with that key)'
218
+ const withKeys = doing === 'apply' && (acting.organizationKeyStored || !view.items.some((i) => i.kind === 'blueprint'))
219
+ return new CliError(`“${title}” was proposed with this project’s workspace key before you signed in, so it isn’t yours to ${doing}`, {
220
+ code: 'SYNC_PROPOSED_WITH_KEY',
221
+ hint: doing === 'apply'
222
+ ? `${closeIt}, then push the changes again as yourself (\`${pushAgain(view)}\`) and apply${withKeys ? `; or sign out (\`frontera auth logout --profile ${acting.profile}\`) to apply it with the project’s keys` : ''}`
223
+ : `${closeIt}, then push again to propose the changes as yourself`,
224
+ })
225
+ }
226
+
227
+ /** Who proposed the change request a step is for: nobody yet, the person signed in, or the project's workspace key. */
228
+ type ProposedBy = 'nobody' | 'you' | 'key'
229
+
230
+ /** The push that proposes these refs and App releases (`--release`'s value), with the title. */
231
+ function pushCommand(title: string | null, refs: string[], releases?: string): string {
232
+ return ['frontera local sync push', ...refs, ...(releases ? [`--release ${releases}`] : []), `-t "${title ?? '<title>'}"`].join(' ')
233
+ }
234
+
235
+ /** The push that proposes a request's items again: a Custom App's release by `--release`, anything else by its ref. */
236
+ function pushAgain(view: View): string {
237
+ const release = (i: ViewItem) => (i.kind === 'app' ? releasedDeployment(i) : null)
238
+ const refs = view.items.filter((i) => !release(i) || Object.keys(i.files).length > 1).map((i) => i.ref)
239
+ return pushCommand(view.request.title, refs, view.items.flatMap((i) => (release(i) ? [`${i.name}=${release(i)}`] : [])).join(','))
240
+ }
241
+
242
+ /**
243
+ * How a person signed in goes with the organization key stored instead. The
244
+ * keys apply only a request they proposed, so the person's own request is
245
+ * closed first and pushed again (`push`, the command that proposes its items);
246
+ * the key's is applied as it is.
247
+ */
248
+ function withStoredKey(acting: Extract<Acting, { as: 'person' }>, push: string, proposed: ProposedBy): string {
249
+ const signOut = `sign out (\`frontera auth logout --profile ${acting.profile}\`)`
250
+ const apply = 'apply (`frontera local sync request apply`)'
251
+ if (proposed === 'key') return `${signOut}, then ${apply}`
252
+ const pushIt = `push${proposed === 'you' ? ' it again' : ''} (\`${push}\`) and ${apply}`
253
+ return proposed === 'you' ? `close it (\`frontera local sync request close\`), ${signOut}, then ${pushIt}` : `${signOut}, then ${pushIt}`
254
+ }
255
+
256
+ /** A refusal of the person's role, with the way to act with the organization key stored instead. */
257
+ function orStoredKey(err: unknown, acting: Acting, push: string, proposed: ProposedBy): unknown {
258
+ if (!(err instanceof CliError) || err.code !== 'FORBIDDEN' || acting.as !== 'person' || !acting.organizationKeyStored) return err
259
+ return new CliError(err.message, { code: err.code, hint: `your role decides what you may propose and apply. To use the organization key stored for this project instead, ${withStoredKey(acting, push, proposed)}; or ask a workspace admin` })
260
+ }
261
+
262
+ /**
263
+ * Whether Apply would stop at a step of these items that a Blueprint Steward
264
+ * makes and the checks' principal can't. The plan's `blueprint` step stands
265
+ * for every Blueprint file but a saved object set, whose own step an
266
+ * organization admin takes too. A new Agent's grants wait until after Apply.
267
+ */
268
+ function stewardStepUnmet(person: ReturnType<typeof needsAPerson>, items: Array<{ ref: string; kind: string }>): boolean {
269
+ return person.some((p) => (p.code === 'blueprint_needs_steward' || p.code === 'grant_needs_steward') && !p.after && items.some((i) => i.ref === p.ref || (p.ref === 'blueprint' && i.kind === 'blueprint' && !i.ref.startsWith('blueprint/object-sets/'))))
270
+ }
271
+
272
+ /**
273
+ * The refusal of Blueprint changes and grants the person signed in could not
274
+ * apply (`unmet`): with an organization key stored, the keys propose and
275
+ * apply them instead.
276
+ */
277
+ function notSteward(acting: Acting, unmet: boolean, push: string, proposed: ProposedBy): CliError | null {
278
+ if (acting.as !== 'person' || !acting.organizationKeyStored || !unmet) return null
279
+ return new CliError(`you are signed in as ${acting.email ?? 'someone'}, who is not a Blueprint Steward, so the Blueprint changes and grants in this change request could not be applied as you`, {
280
+ code: 'SYNC_NOT_A_STEWARD',
281
+ hint: proposed === 'key'
282
+ ? `${withStoredKey(acting, push, proposed)}: the project’s keys proposed it, and Apply uses the organization key stored for this project`
283
+ : `${withStoredKey(acting, push, proposed)}: the project’s keys propose it, and Apply uses the organization key stored for this project; or ask a Blueprint Steward to sign in for this project and push it`,
284
+ })
285
+ }
286
+
287
+ /** The organization key a command sends beside the workspace key: none when a person acts. */
288
+ const organizationKeyOf = (acting: Acting) => (acting.as === 'keys' ? acting.organizationKey : null)
289
+
290
+ /**
291
+ * What Forge reads about who applies Blueprint changes: an organization key
292
+ * is needed only when no person is signed in, and `signIn` is the step that
293
+ * makes it unneeded.
294
+ */
295
+ function stewardData(acting: Acting, blueprint: boolean, stored: boolean) {
296
+ return {
297
+ organizationKey: { needed: blueprint && acting.as === 'keys', stored },
298
+ signIn: blueprint && acting.as === 'keys' && !acting.organizationKey ? signInCommand(acting.profile) : null,
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Who applies the request's Blueprint changes, in words, for the preview
304
+ * (`proposed` false: nothing is proposed yet), the push and the request. A
305
+ * request the keys propose stays the keys', so the sign-in comes before the
306
+ * push.
307
+ */
308
+ function stewardNote(acting: Acting, proposed: boolean, push: string): string {
309
+ if (acting.as === 'person') return `Blueprint changes are applied as you (${acting.email ?? 'signed in'})${acting.organizationKeyStored ? `; the organization key stored for this project is not used while you’re signed in: to apply ${proposed ? 'this change request ' : ''}with it instead, ${withStoredKey(acting, push, proposed ? 'you' : 'nobody')}` : ''}.`
310
+ if (acting.organizationKey) return 'Blueprint changes are applied with the organization key stored for this project.'
311
+ const signIn = `\`${signInCommand(acting.profile)}\``
312
+ return proposed
313
+ ? `Blueprint changes are applied by a Blueprint Steward, signed in, and this change request was proposed with the project’s workspace key: to apply it as yourself, sign in (${signIn}), close it (\`frontera local sync request close\`) and push again.`
314
+ : `Blueprint changes are applied by a Blueprint Steward, signed in: sign in before you push, so the change request is yours to apply (${signIn}).`
165
315
  }
166
316
 
167
317
  // ── The platform's view of a request ────────────────────────────────────────
@@ -422,11 +572,11 @@ async function platformDocuments(api: Platform, names: string[], remote: Array<{
422
572
  * own in-use check before anything is proposed, so a deletion names its
423
573
  * dependents first. An older platform without the read says nothing.
424
574
  */
425
- async function deletionUsers(project: LocalProject, link: SyncLink, items: PushItem[]): Promise<Map<string, string[]>> {
575
+ async function deletionUsers(api: Platform, items: PushItem[]): Promise<Map<string, string[]>> {
426
576
  const refs = items.filter((i) => i.change === 'deleted' && i.proposable && i.kind !== 'blueprint').map((i) => i.ref)
427
577
  if (!refs.length) return new Map()
428
578
  try {
429
- const answer = await platformFor(project, link).call<{ items: Array<{ ref: string; usedBy: string[] }> }>('POST', '/v1/change-requests/usage', { refs })
579
+ const answer = await api.call<{ items: Array<{ ref: string; usedBy: string[] }> }>('POST', '/v1/change-requests/usage', { refs })
430
580
  return new Map((answer.items ?? []).filter((u) => u.usedBy.length).map((u) => [u.ref, u.usedBy]))
431
581
  } catch {
432
582
  return new Map()
@@ -535,7 +685,7 @@ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown
535
685
  return { data: { local, workspace: null }, text: `${local} is no longer mapped: an item that names it isn’t proposed until it is mapped again.` }
536
686
  }
537
687
  if (!workspace) throw new UsageError('name the workspace model it goes as', `frontera local sync map-model ${local} <workspace model>; \`push --preview\` lists ${name}’s models`)
538
- const offered = await workspaceModels(platformFor(project, link))
688
+ const offered = await workspaceModels(await platformFor(project, link))
539
689
  if (offered.length && !offered.some((m) => m.slug === workspace)) {
540
690
  throw new UsageError(`${name} has no chat model ${workspace}`, `choose one of: ${offered.map((m) => m.slug).slice(0, 12).join(', ')}${offered.length > 12 ? ', …' : ''}`)
541
691
  }
@@ -559,15 +709,15 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
559
709
  const documentNames = asked.includes('*') ? knowledgeHere : asked
560
710
  const notHere = documentNames.filter((n) => !knowledgeHere.includes(n))
561
711
  if (notHere.length) throw new UsageError(`no knowledge base ${notHere.join(', ')} in this project`, '--documents takes knowledge base names (knowledge/<name>), or * for every one')
712
+ const api = await platformFor(project, link)
562
713
  const documents = documentNames.length
563
- ? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(platformFor(project, link), documentNames, remote.items) }
714
+ ? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(api, documentNames, remote.items) }
564
715
  : undefined
565
716
  const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
566
717
  // Before anything is sent: models only this computer has, then the workspace's own checks.
567
718
  const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
568
719
  const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
569
- const api = platformFor(project, link)
570
- const checked = available ? await precheck(api, all, getSyncKey(project, 'organization')) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
720
+ const checked = available ? await precheck(api, all, organizationKeyOf(api.acting)) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
571
721
  const person = needsAPerson(checked.plan)
572
722
  const kept = currentRequest(readRequests(project.root, link.workspace))
573
723
  const updating = kept && isOpen(kept.state) ? kept : null
@@ -587,17 +737,33 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
587
737
  } else chosen = all
588
738
  // Samples from setting up the project go only when named.
589
739
  const leftSamples = named.length ? [] : chosen.filter((i) => i.proposable && i.sample)
590
- const orgNote = needsOrganizationKey(chosen) ? (orgStored ? 'Blueprint changes are applied with the organization key stored for this project.' : 'Blueprint changes need the organization key to apply: connect again with `--org-key -` before applying.') : null
740
+ const title = flagString(ctx, 'title')?.trim()
741
+ // The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
742
+ let target = updating
743
+ let stop: CliError | null = null
744
+ if (target) {
745
+ const live = await api.call<View>('GET', `/v1/change-requests/${target.id}`).catch((err: CliError) => (err.code === 'NOT_FOUND' ? null : Promise.reject(err)))
746
+ if (!live || !isOpen(live.request.state)) {
747
+ if (live) recordRequest(project.root, link.workspace, live)
748
+ target = null
749
+ } else stop = notProposer(api.acting, live, 'push to')
750
+ }
751
+ // Following a refusal proposes what was asked: the items named (none: every change) and the releases.
752
+ const again = pushCommand(target?.title ?? title ?? null, named, flagString(ctx, 'release'))
753
+ stop ??= notSteward(api.acting, stewardStepUnmet(person, chosen), again, target ? 'you' : 'nobody')
754
+ const orgNote = needsOrganizationKey(chosen) ? stewardNote(api.acting, false, pushCommand(title ?? null, named, flagString(ctx, 'release'))) : null
591
755
 
592
756
  if (preview) {
593
- const usedBy = available ? await deletionUsers(project, link, chosen) : new Map<string, string[]>()
757
+ const usedBy = available ? await deletionUsers(api, chosen) : new Map<string, string[]>()
594
758
  return {
595
759
  data: {
596
760
  link,
597
761
  available,
598
- request: updating ? { id: updating.id, title: updating.title, state: updating.state, items: updating.refs.length, refs: updating.refs } : null,
762
+ request: target ? { id: target.id, title: target.title, state: target.state, items: target.refs.length, refs: target.refs } : null,
599
763
  items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
600
764
  counts: { proposable: chosen.filter((i) => i.proposable && !i.sample).length, samples: leftSamples.length, blocked: chosen.filter((i) => !i.proposable).length },
765
+ // A push now would be refused before anything is sent: why, and the way out.
766
+ refused: stop ? { code: stop.code, message: stop.message, hint: stop.hint } : null,
601
767
  // Whether the workspace's checks ran before proposing; a platform without them checks at Propose.
602
768
  checked: checked.checked,
603
769
  ...(('problem' in checked && checked.problem) ? { checkProblem: checked.problem } : {}),
@@ -606,14 +772,14 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
606
772
  workspace: missing.size ? await workspaceModels(api) : [],
607
773
  mapped: link.models ?? {},
608
774
  },
609
- organizationKey: { needed: needsOrganizationKey(chosen), stored: orgStored },
775
+ ...stewardData(api.acting, needsOrganizationKey(chosen), orgStored),
610
776
  // Apply's steps in order, and the ones the project's credentials can't take (N3).
611
777
  applyPlan: checked.plan,
612
778
  needsPerson: person,
613
779
  notCarried,
614
780
  fetchedAt: snapshot.fetchedAt,
615
781
  },
616
- text: previewText(link, chosen, updating, orgNote, available, usedBy, person, mcp?.line),
782
+ text: previewText(link, chosen, target, stop ? `A push now would be refused: ${stop.message}; ${stop.hint}.` : orgNote, available, usedBy, person, mcp?.line),
617
783
  }
618
784
  }
619
785
 
@@ -645,17 +811,8 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
645
811
  hint: `${mcp ? `${mcp.line} For anything else, ` : ''}change an item in the project first; \`frontera local sync status\` shows where it stands`,
646
812
  })
647
813
  }
648
- const title = flagString(ctx, 'title')?.trim()
649
814
  const description = flagString(ctx, 'description')
650
- // The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
651
- let target = updating
652
- if (target) {
653
- const live = await api.call<View>('GET', `/v1/change-requests/${target.id}`).catch((err: CliError) => (err.code === 'NOT_FOUND' ? null : Promise.reject(err)))
654
- if (!live || !isOpen(live.request.state)) {
655
- if (live) recordRequest(project.root, link.workspace, live)
656
- target = null
657
- }
658
- }
815
+ if (stop) throw stop
659
816
  if (!target && !title) throw new UsageError('give the change request a title', 'frontera local sync push -t "Tighten the refund procedure"')
660
817
 
661
818
  // Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
@@ -677,7 +834,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
677
834
  details: { checks },
678
835
  })
679
836
  }
680
- throw err
837
+ throw orStoredKey(err, api.acting, again, target ? 'you' : 'nobody')
681
838
  }
682
839
  // A knowledge base's documents follow the request, one at a time (each up to the platform's limit).
683
840
  const staged = await stageDocuments(project, api, view)
@@ -695,7 +852,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
695
852
  releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
696
853
  notProposed: notProposed.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })),
697
854
  samples: leftSamples.map((i) => i.ref),
698
- organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
855
+ ...stewardData(api.acting, needsOrganizationKey(view.items), orgStored),
699
856
  notCarried,
700
857
  },
701
858
  text: [
@@ -704,7 +861,7 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
704
861
  ...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
705
862
  ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
706
863
  ...(leftSamples.length ? ['', `Left out: ${plural(leftSamples.length, 'sample')} from setting up the project (${leftSamples.map((i) => i.title).join(', ')}); name ${leftSamples.length === 1 ? 'it' : 'them'} to propose`] : []),
707
- ...(orgNote ? ['', orgNote] : []),
864
+ ...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(view))] : []),
708
865
  ...(mcp ? ['', mcp.line] : []),
709
866
  '',
710
867
  'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
@@ -745,7 +902,7 @@ async function basesFor(project: LocalProject, link: SyncLink, view: View): Prom
745
902
  // Earlier versions the platform keeps: workflows, Functions and the Blueprint by revision.
746
903
  if (item.kind === 'agent' || !HISTORY_KINDS.includes(item.kind) || !snapshot?.capabilities.includes('item-revision')) continue
747
904
  try {
748
- const source = sourceFor(project, link)
905
+ const source = await sourceFor(project, link)
749
906
  const asked = item.kind === 'blueprint' ? 'blueprint' : item.ref
750
907
  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)}`)
751
908
  if (answer) {
@@ -835,12 +992,13 @@ function activityOf(view: View, titles: Record<string, string>) {
835
992
  })
836
993
  }
837
994
 
838
- async function currentView(project: LocalProject, link: SyncLink): Promise<{ view: View; titles: Record<string, string> } | null> {
995
+ async function currentView(project: LocalProject, link: SyncLink): Promise<{ view: View; titles: Record<string, string>; api: Platform } | null> {
839
996
  const kept = currentRequest(readRequests(project.root, link.workspace))
840
997
  if (!kept) return null
841
- const view = await platformFor(project, link).call<View>('GET', `/v1/change-requests/${kept.id}`)
998
+ const api = await platformFor(project, link)
999
+ const view = await api.call<View>('GET', `/v1/change-requests/${kept.id}`)
842
1000
  recordRequest(project.root, link.workspace, view)
843
- return { view, titles: kept.titles }
1001
+ return { view, titles: kept.titles, api }
844
1002
  }
845
1003
 
846
1004
  /**
@@ -903,6 +1061,8 @@ async function show(ctx: CommandContext) {
903
1061
  const activity = activityOf(view, allTitles)
904
1062
  const orgStored = !!getSyncKey(project, 'organization')
905
1063
  const r = view.request
1064
+ const { acting } = current.api
1065
+ const other = isOpen(r.state) ? proposedByOther(acting, view) : null
906
1066
  return {
907
1067
  data: {
908
1068
  request: { id: r.id, title: r.title, description: r.description, state: r.state, stateLabel: STATE_WORD[r.state] ?? r.state, createdAt: r.createdAt, updatedAt: r.updatedAt, mergedAt: r.mergedAt },
@@ -910,7 +1070,7 @@ async function show(ctx: CommandContext) {
910
1070
  items,
911
1071
  activity,
912
1072
  checks: checks.filter((c) => !items.some((i) => i.ref === c.ref)).map((c) => ({ ref: c.ref, code: c.code, message: c.message })),
913
- organizationKey: { needed: needsOrganizationKey(view.items), stored: orgStored },
1073
+ ...stewardData(acting, needsOrganizationKey(view.items), orgStored),
914
1074
  gate: view.gate ?? null,
915
1075
  evidence: view.evidence ?? [],
916
1076
  },
@@ -930,7 +1090,10 @@ async function show(ctx: CommandContext) {
930
1090
  '',
931
1091
  'Activity:',
932
1092
  ...activity.map((a) => ` ${a.at.slice(0, 16).replace('T', ' ')} ${a.text}`),
933
- ...(isOpen(r.state) ? ['', 'Apply it with `frontera local sync request apply`, or close it with `request close`.'] : []),
1093
+ ...(isOpen(r.state) && !other && needsOrganizationKey(view.items) ? ['', stewardNote(acting, true, pushAgain(view))] : []),
1094
+ ...(other === 'key' ? ['', 'Proposed with the project’s workspace key before you signed in: close it (`frontera local sync request close`) and push again to apply it as yourself.'] : []),
1095
+ ...(other === 'person' ? ['', `Proposed by a person signed in, and no one is signed in on this computer now: sign in again with \`${signInCommand(acting.profile)}\` to apply it.`] : []),
1096
+ ...(isOpen(r.state) && !other ? ['', 'Apply it with `frontera local sync request apply`, or close it with `request close`.'] : []),
934
1097
  ].join('\n'),
935
1098
  }
936
1099
  }
@@ -940,15 +1103,27 @@ async function apply(ctx: CommandContext) {
940
1103
  const link = needLink(project)
941
1104
  const current = await currentView(project, link)
942
1105
  if (!current) throw new UsageError('no change request to apply', 'propose one with `frontera local sync push -t "<title>"`')
943
- const { view, titles } = current
1106
+ const { view, titles, api } = current
1107
+ const { acting } = api
944
1108
  if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint: 'propose new changes with `frontera local sync push -t "<title>"`' })
945
1109
  if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: 'propose the changes again with `frontera local sync push -t "<title>"`' })
1110
+ const proposed: ProposedBy = proposedByOther(acting, view) === 'key' ? 'key' : 'you'
1111
+ if (acting.as === 'person' && acting.organizationKeyStored && view.items.some((i) => i.kind === 'blueprint' || i.kind === 'agent')) {
1112
+ // The platform refuses a person who is no Steward only once Apply runs, so ask its checks, with every item so references resolve.
1113
+ const items = view.items.map((i) => ({ ref: i.ref, files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
1114
+ const plan = await api.call<unknown>('POST', '/v1/change-requests/check', { items }, { timeoutMs: 120_000 }).then(planOf, () => [])
1115
+ const stop = notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(view), proposed)
1116
+ if (stop) throw stop
1117
+ }
1118
+ const refused = notProposer(acting, view, 'apply')
1119
+ // A workspace admin signed in may apply another's request once every item is approved: the platform decides.
1120
+ if (refused && !(acting.as === 'person' && view.request.state === 'approved')) throw refused
946
1121
  const allTitles = Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
947
1122
  const blueprint = view.items.some((i) => i.kind === 'blueprint')
948
- // Sent whenever one is stored: an Agent's Blueprint grants are a Steward's too, in a request without Blueprint files (P5 A5).
949
- const organizationKey = getSyncKey(project, 'organization')
950
- if (blueprint && !organizationKey) {
951
- 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' })
1123
+ // With the keys, sent whenever one is stored: an Agent's Blueprint grants are a Steward's too, in a request without Blueprint files (P5 A5).
1124
+ const organizationKey = organizationKeyOf(acting)
1125
+ if (blueprint && acting.as === 'keys' && !organizationKey) {
1126
+ throw new CliError('this change request changes the Blueprint, which a Blueprint Steward applies as themself, and no one is signed in for this project’s workspace on this computer', { code: 'SYNC_NOT_SIGNED_IN', hint: `sign in with \`${signInCommand(acting.profile)}\`, then close this change request (\`frontera local sync request close\`) and push it again, so it is yours to apply` })
952
1127
  }
953
1128
  const migration = flagString(ctx, 'migration')?.trim()
954
1129
  const force = flagBool(ctx, 'force')
@@ -957,7 +1132,7 @@ async function apply(ctx: CommandContext) {
957
1132
  let answer: View
958
1133
  try {
959
1134
  // The key in the body: the platform's edge strips `x-frontera-*` headers a client sends. The header stays for an older platform.
960
- answer = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}), ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
1135
+ answer = await api.call<View>('POST', `/v1/change-requests/${view.request.id}/apply`, { ...(migration ? { migrationInstruction: migration } : {}), ...(force ? { force: { reason } } : {}), ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 15 * 60_000 })
961
1136
  } catch (err) {
962
1137
  if (err instanceof CliError && err.code === 'SYNC_APPLY_GATED') {
963
1138
  const gate = (err.details as { gate?: Gate } | undefined)?.gate
@@ -973,7 +1148,8 @@ async function apply(ctx: CommandContext) {
973
1148
  details: { checks },
974
1149
  })
975
1150
  }
976
- throw err
1151
+ if (refused && err instanceof CliError && err.code === 'FORBIDDEN') throw refused
1152
+ throw orStoredKey(err, acting, pushAgain(view), proposed)
977
1153
  }
978
1154
  const outcome = parseOutcome(answer.outcome)
979
1155
  recordRequest(project.root, link.workspace, answer, { applied: { ok: outcome.ok } })
@@ -981,9 +1157,14 @@ async function apply(ctx: CommandContext) {
981
1157
  // Fetch, so the project sees the workspace as it was put back. The request itself applies again as it is:
982
1158
  // the platform takes each put-back item's new revision as its base.
983
1159
  await fetchFor(project, link).catch(() => null)
1160
+ // When the checks could not be read before sending, the Steward refusal comes back as the failed step.
1161
+ const stewardFailed = /Blueprint Steward assignment required/.test(outcome.failure?.message ?? '')
1162
+ const steward = notSteward(acting, stewardFailed, pushAgain(view), proposed)
1163
+ // With no organization key stored, applying again fails the same way.
1164
+ const noKey = stewardFailed ? 'ask a Blueprint Steward who is a workspace admin to approve it and apply it; or store the project’s organization key (`frontera local sync connect --workspace-key - --org-key -`), then apply again (`frontera local sync request apply`) and follow the steps it prints' : null
984
1165
  throw new CliError(describeOutcome(outcome, allTitles).join('\n'), {
985
1166
  code: 'SYNC_APPLY_FAILED',
986
- hint: /migration instruction/i.test(outcome.failure?.message ?? '') ? 'say what to do with existing records: apply again with `--migration "<instructions>"`' : 'once what failed is fixed, apply again (`frontera local sync request apply`); a fix in the project’s files is pushed first (`frontera local sync push`)',
1167
+ hint: steward?.hint ?? noKey ?? (/migration instruction/i.test(outcome.failure?.message ?? '') ? 'say what to do with existing records: apply again with `--migration "<instructions>"`' : 'once what failed is fixed, apply again (`frontera local sync request apply`); a fix in the project’s files is pushed first (`frontera local sync push`)'),
987
1168
  details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: failedName(outcome, allTitles), message: outcome.failure.message } : null },
988
1169
  })
989
1170
  }
@@ -1003,7 +1184,7 @@ async function apply(ctx: CommandContext) {
1003
1184
  fetched.error = (err as Error).message
1004
1185
  }
1005
1186
  // Documents applied are the project's copies of the workspace's now: recorded as a pull records them.
1006
- await recordAppliedDocuments(project, link, answer).catch(() => null)
1187
+ await recordAppliedDocuments(project, link, api, answer).catch(() => null)
1007
1188
  // The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
1008
1189
  recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
1009
1190
  // What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
@@ -1015,16 +1196,16 @@ async function apply(ctx: CommandContext) {
1015
1196
  const pending = recordAfterApply(project.root, answer.request.title, after, target ? { profile: target, target: targetOf(link) } : null)
1016
1197
  const signIn = target && pending.length ? await signInStep({ profile: target, apiUrl: link.platform, named: true }) : null
1017
1198
  return {
1018
- data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
1199
+ data: { applied: true, as: { signedIn: acting.as === 'person', email: acting.as === 'person' ? acting.email : null, profile: acting.profile }, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
1019
1200
  text: [
1020
- `Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}.`,
1201
+ `Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}${acting.as === 'person' ? ` as ${acting.email ?? 'you'} (signed in)` : ''}.`,
1021
1202
  ...describeOutcome(outcome, allTitles),
1022
1203
  '',
1023
1204
  fetched.error ? `Could not fetch afterwards: ${fetched.error}. Run \`frontera local sync fetch\`.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
1024
1205
  ...(pending.length
1025
1206
  ? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`),
1026
1207
  target
1027
- ? `They run against ${new URL(link.platform).host} with \`--profile ${target}\` (this project's keys), not the local runtime${signIn ? `; a step a signed-in person takes needs \`${signIn}\` first` : ''}.`
1208
+ ? `They run against ${new URL(link.platform).host} with \`--profile ${target}\` (${getSyncKey(project, 'organization') ? "this project's keys" : 'as the person signed in on it'}), not the local runtime${signIn ? `; a step a signed-in person takes needs \`${signIn}\` first` : ''}.`
1028
1209
  : `They name no target: the CLI could not record a profile for ${new URL(link.platform).host}${targetError ? ` (${targetError})` : ''}. Add \`--profile <a profile for that platform>\` when you run them.`,
1029
1210
  'Forge lists them on the project’s home; `frontera local steps` here.']
1030
1211
  : []),
@@ -1037,12 +1218,11 @@ async function apply(ctx: CommandContext) {
1037
1218
  * workspace's new copy (the newest document of that name), and each taken off
1038
1219
  * is forgotten, so the next push sees them as unchanged.
1039
1220
  */
1040
- async function recordAppliedDocuments(project: LocalProject, link: SyncLink, view: View): Promise<void> {
1221
+ async function recordAppliedDocuments(project: LocalProject, link: SyncLink, api: Platform, view: View): Promise<void> {
1041
1222
  const items = view.items.filter((i) => i.kind === 'knowledge' && i.documents)
1042
1223
  if (!items.length) return
1043
1224
  const snapshot = readSnapshot(project.root)
1044
1225
  const remote = snapshot ? snapshotItems(project.root, snapshot) : []
1045
- const api = platformFor(project, link)
1046
1226
  for (const item of items) {
1047
1227
  const id = remote.find((r) => r.ref === item.ref)?.id
1048
1228
  if (!id) continue
@@ -1071,9 +1251,14 @@ async function close(ctx: CommandContext) {
1071
1251
  const link = needLink(project)
1072
1252
  const kept = currentRequest(readRequests(project.root, link.workspace))
1073
1253
  if (!kept || !isOpen(kept.state)) throw new UsageError('no open change request to close', 'see where it stands with `frontera local sync request`')
1074
- const view = await platformFor(project, link).call<View>('POST', `/v1/change-requests/${kept.id}/close`, {})
1254
+ const current = (await currentView(project, link))!
1255
+ // Closing writes nothing to the workspace, so a request the project's key proposed is closed with that key.
1256
+ const byKey = proposedByOther(current.api.acting, current.view) === 'key'
1257
+ const refused = byKey ? null : notProposer(current.api.acting, current.view, 'close')
1258
+ if (refused) throw refused
1259
+ const view = await (byKey ? keysFor(project, link) : current.api).call<View>('POST', `/v1/change-requests/${kept.id}/close`, {})
1075
1260
  recordRequest(project.root, link.workspace, view)
1076
- 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.` }
1261
+ return { data: { closed: true, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”${byKey ? ' with the project’s workspace key that proposed it' : ''}. The project’s files are unchanged; the next push opens a new change request.` }
1077
1262
  }
1078
1263
 
1079
1264
  /**
@@ -1089,13 +1274,14 @@ async function evidence(ctx: CommandContext) {
1089
1274
  const link = needLink(project)
1090
1275
  const current = await currentView(project, link)
1091
1276
  if (!current) throw new UsageError('no change request to attach evidence to', 'propose one with `frontera local sync push -t "<title>"`')
1092
- const { view, titles } = current
1277
+ const { view, titles, api } = current
1093
1278
  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>"`' })
1279
+ const refused = notProposer(api.acting, view, 'attach evidence to')
1280
+ if (refused) throw refused
1094
1281
  const session = flagString(ctx, 'screenshots') ?? process.env.FORGE_SESSION ?? null
1095
1282
  const trace = flagString(ctx, 'trace')
1096
1283
  const any = flagBool(ctx, 'evals') || flagBool(ctx, 'renders') || flagString(ctx, 'screenshots') !== undefined || trace !== undefined
1097
1284
  const want = { evals: any ? flagBool(ctx, 'evals') : true, renders: any ? flagBool(ctx, 'renders') : true, screenshots: any ? flagString(ctx, 'screenshots') !== undefined : !!session }
1098
- const api = platformFor(project, link)
1099
1285
  const attach = (body: Record<string, unknown>) => api.call<View>('POST', `/v1/change-requests/${view.request.id}/evidence`, body, { timeoutMs: 120_000 })
1100
1286
  const lines: string[] = []
1101
1287
  const attached: Array<{ kind: string; ref: string | null; title: string }> = []
@@ -1207,7 +1393,7 @@ async function evidenceImage(ctx: CommandContext) {
1207
1393
  const path = join(dir, `${id}.${ext}`)
1208
1394
  if (existsSync(path)) return { data: { path, mime: Object.entries(PICTURE_EXT).find(([, e]) => e === ext)![0] }, text: path }
1209
1395
  }
1210
- const { bytes, mime } = await platformFor(project, link).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
1396
+ const { bytes, mime } = await (await platformFor(project, link)).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
1211
1397
  const ext = PICTURE_EXT[mime.split(';')[0]!.trim()]
1212
1398
  if (!ext) throw new CliError('the platform did not answer with a picture', { code: 'UNAVAILABLE', hint: 'run the command again' })
1213
1399
  mkdirSync(dir, { recursive: true })