@frontera-sdk/cli 1.53.10 → 1.53.12

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.
@@ -13,9 +13,9 @@ 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, targetProfileName, targetSession, type SyncLink } from '../../local/local-sync-link'
16
+ import { getSyncKey, readLink, registerTargetProfile, setModelMapping, targetLabel, targetOf, targetProfileName, targetSession, targetWords, type SyncLink, type SyncTarget } from '../../local/local-sync-link'
17
17
  import { itemTitle } from '../../local/local-sync-names'
18
- import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
18
+ import { checkBody, markMissingModels, markRefused, movedOnTarget, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
19
19
  import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
20
20
  import { rows } from '../../local/local-sync-remote'
21
21
  import { evalsDetail, evalsLine, lastRender, sessionScreenshots, standInInstalls } from '../../local/local-request-evidence'
@@ -54,7 +54,7 @@ import { directoryOf, readSnapshot, snapshotItems } from '../../local/local-sync
54
54
  import { buildAndExtract } from '../automation/build-entry'
55
55
  import { findAutomationProjectRoot } from '../automation/project-root'
56
56
  import { flagBool, flagString, type CommandContext } from '../types'
57
- import { fetchFor, planFor, sourceFor } from './sync'
57
+ import { chooseTarget, fetchTarget, planFor, sourceFor, targetPlan } from './sync'
58
58
 
59
59
  /**
60
60
  * `frontera local sync push` and `frontera local sync request`: change
@@ -94,13 +94,16 @@ type RequestBody = NonNullable<RequestInit['body']>
94
94
 
95
95
  /**
96
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
97
+ * target, or else the target's keys. One for the whole command: a person
98
98
  * sends no key with their session, so a Blueprint change applied by a person
99
- * is that person's alone.
99
+ * is that person's alone. `target` and `profile` say where: the named target
100
+ * (null for the project's link) and its profile, so every step a hint prints
101
+ * names the same target.
100
102
  */
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 }
103
+ type Acting = { target: string | null; profile: string } & (
104
+ | { as: 'person'; token: string; email: string | null; organizationKeyStored: boolean }
105
+ | { as: 'keys'; workspaceKey: string; organizationKey: string | null }
106
+ )
104
107
 
105
108
  interface Platform {
106
109
  acting: Acting
@@ -163,37 +166,45 @@ async function answerAs<T>(res: Response, acting: Acting): Promise<T> {
163
166
  if (checks.length) throw new CliError(message, { code: 'SYNC_CHECKS', hint: 'fix the items the checks name, then push again', details: { checks } })
164
167
  const details = (parsed?.details ?? null) as { code?: string; gate?: Gate } | null
165
168
  if (details?.code === 'GATE_FAILED' && details.gate) {
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 } })
169
+ throw new CliError(message, { code: 'SYNC_APPLY_GATED', hint: `make the checks pass (\`${syncCommand(acting, 'request evidence')}\` attaches evals and renders), or a workspace admin applies with \`--force --reason "<why>"\``, details: { gate: details.gate } })
167
170
  }
168
171
  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)}\`` })
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' })
172
+ 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; \`${syncCommand(acting, 'connect')}\` stores a new one` })
170
173
  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' })
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`' })
172
- if (res.status === 409) throw new CliError(message, { code: 'CONFLICT', hint: 'see where it stands with `frontera local sync request`' })
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`' })
174
+ if (res.status === 404) throw new CliError(message, { code: 'NOT_FOUND', hint: `the change request is gone from the workspace; propose again with \`${syncCommand(acting, 'push')}\`` })
175
+ if (res.status === 409) throw new CliError(message, { code: 'CONFLICT', hint: `see where it stands with \`${syncCommand(acting, 'request')}\`` })
176
+ if (res.status === 400 || res.status === 422) throw new CliError(message, { code: 'VALIDATION_ERROR', hint: `see where it stands with \`${syncCommand(acting, 'request')}\`` })
174
177
  throw new CliError(`the platform answered ${res.status}: ${message}`, { code: 'UNAVAILABLE', hint: 'check the platform is reachable, then run the command again' })
175
178
  }
176
179
 
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') })
180
+ /** The person signed in for the target when they are a member of its workspace, else the target's own keys: never another target's. */
181
+ async function platformFor(target: Pick<SyncTarget, 'name' | 'owner' | 'link'>): Promise<Platform> {
182
+ const { link } = target
183
+ const person = await targetSession(target.owner, link)
184
+ if (!person) return keysFor(target)
185
+ const profile = targetProfileName(target.owner, link)
186
+ const api = platform(link, { as: 'person', target: target.name, profile, token: person.token, email: person.email ?? null, organizationKeyStored: !!getSyncKey(target.owner, 'organization') })
183
187
  // The platform drops the workspace from a session that isn't a member's, and every change-request call then fails.
184
188
  const me = await api.call<{ workspaceId?: string | null }>('GET', '/v1/whoami')
185
189
  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` })
190
+ throw new CliError(`signed in as ${person.email ?? 'someone'} for ${profile}, who is not a member of ${targetLabel(target)}`, { 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
191
  }
188
192
  return api
189
193
  }
190
194
 
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
+ /** The change-request routes of a target's platform with that target's own keys. */
196
+ function keysFor(target: Pick<SyncTarget, 'name' | 'owner' | 'link'>): Platform {
197
+ const workspaceKey = getSyncKey(target.owner, 'workspace')
198
+ if (!workspaceKey) throw new CliError(`no workspace key for ${target.name ? `target ${target.name}` : 'this project'} on this machine`, { code: 'SYNC_NO_KEY', hint: `run \`${targetWords(target, 'frontera local sync connect')}\` to store one (keys are per machine)` })
199
+ return platform(target.link, { as: 'keys', target: target.name, profile: targetProfileName(target.owner, target.link), workspaceKey, organizationKey: getSyncKey(target.owner, 'organization') })
195
200
  }
196
201
 
202
+ /** A `frontera local sync` command for where `acting` acts: `--target <name>` follows it for a named target. */
203
+ const syncCommand = (acting: Pick<Acting, 'target'>, words: string) => targetWords({ name: acting.target }, `frontera local sync ${words}`)
204
+
205
+ /** How a hint names whose keys and workspace `acting` uses: the link's words (`link`), or the named target. */
206
+ const ownerWords = (acting: Pick<Acting, 'target'>, link: string) => (acting.target ? `target ${acting.target}` : link)
207
+
197
208
  /**
198
209
  * A request takes pushes, evidence, close and Apply from the one who proposed
199
210
  * it (a workspace admin aside), so a request is another's when `acting` did
@@ -212,14 +223,14 @@ function proposedByOther(acting: Acting, view: View): 'key' | 'person' | null {
212
223
  function notProposer(acting: Acting, view: View, doing: 'apply' | 'push to' | 'close' | 'attach evidence to'): CliError | null {
213
224
  const by = proposedByOther(acting, view)
214
225
  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` })
226
+ if (by === 'person') return new CliError(`“${title}” was proposed by a person signed in for ${ownerWords(acting, '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
227
  if (by !== 'key' || acting.as !== 'person') return null
217
- const closeIt = 'close it (`frontera local sync request close` closes it with that key)'
228
+ const closeIt = `close it (\`${syncCommand(acting, 'request close')}\` closes it with that key)`
218
229
  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}`, {
230
+ return new CliError(`“${title}” was proposed with ${ownerWords(acting, 'this project')}’s workspace key before you signed in, so it isn’t yours to ${doing}`, {
220
231
  code: 'SYNC_PROPOSED_WITH_KEY',
221
232
  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` : ''}`
233
+ ? `${closeIt}, then push the changes again as yourself (\`${pushAgain(acting, view)}\`) and apply${withKeys ? `; or sign out (\`frontera auth logout --profile ${acting.profile}\`) to apply it with ${ownerWords(acting, 'the project')}’s keys` : ''}`
223
234
  : `${closeIt}, then push again to propose the changes as yourself`,
224
235
  })
225
236
  }
@@ -227,16 +238,16 @@ function notProposer(acting: Acting, view: View, doing: 'apply' | 'push to' | 'c
227
238
  /** Who proposed the change request a step is for: nobody yet, the person signed in, or the project's workspace key. */
228
239
  type ProposedBy = 'nobody' | 'you' | 'key'
229
240
 
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(' ')
241
+ /** The push that proposes these refs and App releases (`--release`'s value), with the title, where `acting` acts. */
242
+ function pushCommand(acting: Pick<Acting, 'target'>, title: string | null, refs: string[], releases?: string): string {
243
+ return syncCommand(acting, ['push', ...refs, ...(releases ? [`--release ${releases}`] : []), `-t "${title ?? '<title>'}"`].join(' '))
233
244
  }
234
245
 
235
246
  /** 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 {
247
+ function pushAgain(acting: Pick<Acting, 'target'>, view: View): string {
237
248
  const release = (i: ViewItem) => (i.kind === 'app' ? releasedDeployment(i) : null)
238
249
  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(','))
250
+ return pushCommand(acting, view.request.title, refs, view.items.flatMap((i) => (release(i) ? [`${i.name}=${release(i)}`] : [])).join(','))
240
251
  }
241
252
 
242
253
  /**
@@ -247,16 +258,16 @@ function pushAgain(view: View): string {
247
258
  */
248
259
  function withStoredKey(acting: Extract<Acting, { as: 'person' }>, push: string, proposed: ProposedBy): string {
249
260
  const signOut = `sign out (\`frontera auth logout --profile ${acting.profile}\`)`
250
- const apply = 'apply (`frontera local sync request apply`)'
261
+ const apply = `apply (\`${syncCommand(acting, 'request apply')}\`)`
251
262
  if (proposed === 'key') return `${signOut}, then ${apply}`
252
263
  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}`
264
+ return proposed === 'you' ? `close it (\`${syncCommand(acting, 'request close')}\`), ${signOut}, then ${pushIt}` : `${signOut}, then ${pushIt}`
254
265
  }
255
266
 
256
267
  /** A refusal of the person's role, with the way to act with the organization key stored instead. */
257
268
  function orStoredKey(err: unknown, acting: Acting, push: string, proposed: ProposedBy): unknown {
258
269
  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` })
270
+ 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 ${ownerWords(acting, 'this project')} instead, ${withStoredKey(acting, push, proposed)}; or ask a workspace admin` })
260
271
  }
261
272
 
262
273
  /**
@@ -279,8 +290,8 @@ function notSteward(acting: Acting, unmet: boolean, push: string, proposed: Prop
279
290
  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
291
  code: 'SYNC_NOT_A_STEWARD',
281
292
  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`,
293
+ ? `${withStoredKey(acting, push, proposed)}: ${ownerWords(acting, 'the project')}’s keys proposed it, and Apply uses the organization key stored for ${ownerWords(acting, 'this project')}`
294
+ : `${withStoredKey(acting, push, proposed)}: ${ownerWords(acting, 'the project')}’s keys propose it, and Apply uses the organization key stored for ${ownerWords(acting, 'this project')}; or ask a Blueprint Steward to sign in for ${ownerWords(acting, 'this project')} and push it`,
284
295
  })
285
296
  }
286
297
 
@@ -306,11 +317,11 @@ function stewardData(acting: Acting, blueprint: boolean, stored: boolean) {
306
317
  * push.
307
318
  */
308
319
  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.'
320
+ if (acting.as === 'person') return `Blueprint changes are applied as you (${acting.email ?? 'signed in'})${acting.organizationKeyStored ? `; the organization key stored for ${ownerWords(acting, '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')}` : ''}.`
321
+ if (acting.organizationKey) return `Blueprint changes are applied with the organization key stored for ${ownerWords(acting, 'this project')}.`
311
322
  const signIn = `\`${signInCommand(acting.profile)}\``
312
323
  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.`
324
+ ? `Blueprint changes are applied by a Blueprint Steward, signed in, and this change request was proposed with ${ownerWords(acting, 'the project')}’s workspace key: to apply it as yourself, sign in (${signIn}), close it (\`${syncCommand(acting, 'request close')}\`) and push again.`
314
325
  : `Blueprint changes are applied by a Blueprint Steward, signed in: sign in before you push, so the change request is yours to apply (${signIn}).`
315
326
  }
316
327
 
@@ -589,17 +600,17 @@ async function deletionUsers(api: Platform, items: PushItem[]): Promise<Map<stri
589
600
  * with its reason and fix. A platform without the check leaves them to
590
601
  * Propose: `checked` says which.
591
602
  */
592
- async function precheck(api: Platform, items: PushItem[], organizationKey: string | null): Promise<{ checked: boolean; refused: string[]; plan: ApplyStep[]; problem?: string }> {
603
+ async function precheck(api: Platform, items: PushItem[], organizationKey: string | null, target: SyncTarget): Promise<{ checked: boolean; refused: string[]; plan: ApplyStep[]; problem?: string }> {
593
604
  const body = checkBody(items)
594
605
  if (!body.length) return { checked: true, refused: [], plan: [] }
595
606
  try {
596
607
  // The organization key stored for the project, so the plan says whether it can make a Steward's steps.
597
608
  const answer = await api.call<{ checks?: unknown[]; plan?: unknown }>('POST', '/v1/change-requests/check', { items: body, ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 120_000 })
598
- return { checked: true, refused: markRefused(items, checksOf(answer)), plan: planOf(answer) }
609
+ return { checked: true, refused: markRefused(items, checksOf(answer), target), plan: planOf(answer) }
599
610
  } catch (err) {
600
611
  if (err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'SYNC_CHECKS')) {
601
612
  // An older platform has no check; a refusal of the check itself names the items as the push would.
602
- if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)), plan: [] }
613
+ if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details), target), plan: [] }
603
614
  return { checked: false, refused: [], plan: [] }
604
615
  }
605
616
  return { checked: false, refused: [], plan: [], problem: (err as Error).message.split('\n')[0] }
@@ -616,11 +627,25 @@ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
616
627
  }
617
628
  }
618
629
 
619
- function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map(), person: ReturnType<typeof needsAPerson> = [], notCarried: string | null = null): string {
630
+ /**
631
+ * What a named target changed since this project last delivered it, and the
632
+ * project did not: not proposed, so the target keeps its version, with the
633
+ * command that puts the project's back over it.
634
+ */
635
+ function changedThereLines(target: SyncTarget, changed: Array<{ ref: string; kind: SyncKind; title: string }>): string[] {
636
+ if (!changed.length) return []
637
+ return [
638
+ '',
639
+ `Changed in ${targetLabel(target)} since this project last delivered it, so not proposed:`,
640
+ ...changed.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: to put the project’s version back over it, \`${targetWords(target, `frontera local sync resolve ${i.ref} --keep mine`)}\``),
641
+ ]
642
+ }
643
+
644
+ function previewText(target: SyncTarget, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map(), person: ReturnType<typeof needsAPerson> = [], notCarried: string | null = null, changedThere: Array<{ ref: string; kind: SyncKind; title: string }> = []): string {
620
645
  const ok = items.filter((i) => i.proposable && !i.sample)
621
646
  const samples = items.filter((i) => i.proposable && i.sample)
622
647
  const not = items.filter((i) => !i.proposable)
623
- const name = link.workspaceName ?? link.workspace
648
+ const name = targetLabel(target)
624
649
  const lines = [
625
650
  ok.length
626
651
  ? `Would propose ${plural(ok.length, 'item')} to ${name}${kept ? `, updating “${kept.title}”` : ''}:`
@@ -630,6 +655,7 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
630
655
  ]
631
656
  if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
632
657
  if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
658
+ lines.push(...changedThereLines(target, changedThere))
633
659
  const before = person.filter((p) => !p.after)
634
660
  const after = person.filter((p) => p.after)
635
661
  if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
@@ -637,7 +663,7 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
637
663
  if (orgNote) lines.push('', orgNote)
638
664
  if (notCarried) lines.push('', notCarried)
639
665
  if (!available) lines.push('', 'This platform does not take change requests yet.')
640
- if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
666
+ if (ok.length && available) lines.push('', `Propose with \`${targetWords(target, 'frontera local sync push -t "<title>"')}\`, or name items to propose only those.`)
641
667
  return lines.join('\n')
642
668
  }
643
669
 
@@ -685,7 +711,7 @@ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown
685
711
  return { data: { local, workspace: null }, text: `${local} is no longer mapped: an item that names it isn’t proposed until it is mapped again.` }
686
712
  }
687
713
  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`)
688
- const offered = await workspaceModels(await platformFor(project, link))
714
+ const offered = await workspaceModels(await platformFor({ name: null, owner: project, link }))
689
715
  if (offered.length && !offered.some((m) => m.slug === workspace)) {
690
716
  throw new UsageError(`${name} has no chat model ${workspace}`, `choose one of: ${offered.map((m) => m.slug).slice(0, 12).join(', ')}${offered.length > 12 ? ', …' : ''}`)
691
717
  }
@@ -696,11 +722,14 @@ export async function syncMapModel(ctx: CommandContext): Promise<{ data: unknown
696
722
 
697
723
  export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
698
724
  const project = localProject(ctx.cwd)
699
- const link = needLink(project)
725
+ const target = chooseTarget(project, ctx)
726
+ const { link } = target
700
727
  const named = ctx.positional.slice(1)
701
728
  const preview = flagBool(ctx, 'preview')
729
+ const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
702
730
  // Fetch first, as git does before a push: an item the workspace moved past is pulled and merged first.
703
- const { plan, lock, snapshot, remote } = await planFor(project, link, { fetch: 'always', repair: 'online' })
731
+ // A named target is never pulled from: it is compared with what this project last delivered to it (`targetPlan`).
732
+ const { plan, lock, snapshot, remote, delivered } = target.name ? await targetPlan(project, target) : { ...(await planFor(project, link, { fetch: 'always', repair: 'online' })), delivered: null }
704
733
  if (!plan || !snapshot) throw new CliError('could not fetch the workspace', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch` and look at what it says' })
705
734
  const available = snapshot.capabilities.includes('change-requests')
706
735
  // Knowledge bases whose documents come too: opt-in, as for a pull (`*` for every one the project has).
@@ -709,19 +738,36 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
709
738
  const documentNames = asked.includes('*') ? knowledgeHere : asked
710
739
  const notHere = documentNames.filter((n) => !knowledgeHere.includes(n))
711
740
  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)
741
+ const api = await platformFor(target)
713
742
  const documents = documentNames.length
714
- ? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(api, documentNames, remote.items) }
743
+ ? { names: new Set(documentNames), pulled: readPulledDocuments(target.state, link.workspace).bases, platform: await platformDocuments(api, documentNames, remote.items) }
715
744
  : undefined
716
745
  const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
746
+ // A named target is never pulled from: what it changed since this project delivered it has its own words.
747
+ const changedThere = target.name ? plan.filter((p) => p.status === 'changed').map((p) => ({ ref: p.ref, kind: p.kind, title: p.title })) : []
748
+ if (target.name) {
749
+ const bothChanged = new Set(plan.filter((p) => p.status === 'conflict').map((p) => p.ref))
750
+ for (const i of all) {
751
+ // With no delivery to compare with, a change made on the target can't be told from one it hasn't had yet.
752
+ if (i.proposable && i.change !== 'new' && !delivered?.has(i.ref)) i.notes.push(`No delivery of it from this project is recorded on this computer: proposed over what ${targetLabel(target)} holds now, so a change made there is replaced`)
753
+ if (i.blocked === 'workspace-deleted') {
754
+ i.reason = `Deleted in ${targetLabel(target)} since this project last delivered it`
755
+ i.fix = `to deliver it again, \`${command(`resolve ${i.ref} --keep mine`)}\``
756
+ continue
757
+ }
758
+ if (i.blocked !== 'moved' || !bothChanged.has(i.ref)) continue
759
+ i.reason = `Changed in ${targetLabel(target)} too since this project last delivered it`
760
+ i.fix = `see what changed there with \`${command(`diff ${i.ref}`)}\`, then keep the project’s version with \`${command(`resolve ${i.ref} --keep mine`)}\`, or carry that change into the files here first`
761
+ }
762
+ }
717
763
  // Before anything is sent: models only this computer has, then the workspace's own checks.
718
764
  const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
719
765
  const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
720
- const checked = available ? await precheck(api, all, organizationKeyOf(api.acting)) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
766
+ const checked = available ? await precheck(api, all, organizationKeyOf(api.acting), target) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
721
767
  const person = needsAPerson(checked.plan)
722
- const kept = currentRequest(readRequests(project.root, link.workspace))
768
+ const kept = currentRequest(readRequests(target.state, link.workspace))
723
769
  const updating = kept && isOpen(kept.state) ? kept : null
724
- const orgStored = !!getSyncKey(project, 'organization')
770
+ const orgStored = !!getSyncKey(target.owner, 'organization')
725
771
  const titles = Object.fromEntries(all.map((i) => [i.ref, i.title]))
726
772
  const mcp = mcpServersNotCarried(project.root)
727
773
  const notCarried = mcp ? { mcpServers: mcp.files, save: mcp.command } : null
@@ -732,34 +778,35 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
732
778
  const unknown = named.filter((r) => !byRef.has(r))
733
779
  const servers = unknown.filter((r) => r.startsWith('mcp-server'))
734
780
  if (mcp && servers.length) throw new UsageError(`a change request does not carry ${servers.join(', ')}`, mcp.line)
735
- if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`, 'list what can be proposed with `frontera local sync push --preview`')
781
+ if (unknown.length) throw new UsageError(`nothing to propose in ${unknown.join(', ')}: ${unknown.length === 1 ? 'it matches' : 'they match'} the workspace, or the project has no such item`, `list what can be proposed with \`${command('push --preview')}\``)
736
782
  chosen = named.map((r) => byRef.get(r)!)
737
783
  } else chosen = all
738
784
  // Samples from setting up the project go only when named.
739
785
  const leftSamples = named.length ? [] : chosen.filter((i) => i.proposable && i.sample)
740
786
  const title = flagString(ctx, 'title')?.trim()
741
787
  // The current request still taking pushes is updated; one applied or closed (here or on the platform) is not.
742
- let target = updating
788
+ let open = updating
743
789
  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)))
790
+ if (open) {
791
+ const live = await api.call<View>('GET', `/v1/change-requests/${open.id}`).catch((err: CliError) => (err.code === 'NOT_FOUND' ? null : Promise.reject(err)))
746
792
  if (!live || !isOpen(live.request.state)) {
747
- if (live) recordRequest(project.root, link.workspace, live)
748
- target = null
793
+ if (live) recordRequest(target.state, link.workspace, live)
794
+ open = null
749
795
  } else stop = notProposer(api.acting, live, 'push to')
750
796
  }
751
797
  // 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
798
+ const again = pushCommand(api.acting, open?.title ?? title ?? null, named, flagString(ctx, 'release'))
799
+ stop ??= notSteward(api.acting, stewardStepUnmet(person, chosen), again, open ? 'you' : 'nobody')
800
+ const orgNote = needsOrganizationKey(chosen) ? stewardNote(api.acting, false, pushCommand(api.acting, title ?? null, named, flagString(ctx, 'release'))) : null
755
801
 
756
802
  if (preview) {
757
803
  const usedBy = available ? await deletionUsers(api, chosen) : new Map<string, string[]>()
758
804
  return {
759
805
  data: {
760
806
  link,
807
+ target: target.name,
761
808
  available,
762
- request: target ? { id: target.id, title: target.title, state: target.state, items: target.refs.length, refs: target.refs } : null,
809
+ request: open ? { id: open.id, title: open.title, state: open.state, items: open.refs.length, refs: open.refs } : null,
763
810
  items: chosen.map((i) => ({ ...itemJson(i), ...(usedBy.has(i.ref) ? { usedBy: usedBy.get(i.ref) } : {}) })),
764
811
  counts: { proposable: chosen.filter((i) => i.proposable && !i.sample).length, samples: leftSamples.length, blocked: chosen.filter((i) => !i.proposable).length },
765
812
  // A push now would be refused before anything is sent: why, and the way out.
@@ -777,20 +824,25 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
777
824
  applyPlan: checked.plan,
778
825
  needsPerson: person,
779
826
  notCarried,
827
+ ...(target.name ? { changedThere: changedThere.map((i) => i.ref) } : {}),
780
828
  fetchedAt: snapshot.fetchedAt,
781
829
  },
782
- text: previewText(link, chosen, target, stop ? `A push now would be refused: ${stop.message}; ${stop.hint}.` : orgNote, available, usedBy, person, mcp?.line),
830
+ text: previewText(target, chosen, open, stop ? `A push now would be refused: ${stop.message}; ${stop.hint}.` : orgNote, available, usedBy, person, mcp?.line, changedThere),
783
831
  }
784
832
  }
785
833
 
786
- if (!available) throw new CliError(`${link.workspaceName ?? 'this workspace'}’s platform does not take change requests yet`, { code: 'SYNC_NO_CHANGE_REQUESTS', hint: 'make the change on the platform, or ask your administrator to update it' })
834
+ if (!available) throw new CliError(`${targetLabel(target)}’s platform does not take change requests yet`, { code: 'SYNC_NO_CHANGE_REQUESTS', hint: 'make the change on the platform, or ask your administrator to update it' })
787
835
  // Named items must each be proposable; with none named, an item the workspace moved past stops the push.
788
836
  const stopping = chosen.filter((i) => !i.proposable && (named.length > 0 || i.blocked === 'moved' || i.blocked === 'conflicts'))
789
837
  if (stopping.length) {
790
838
  const lines = stopping.map((i) => ` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`)
791
839
  throw new CliError(`not proposed: ${plural(stopping.length, 'item')} cannot go yet\n${lines.join('\n')}`, {
792
840
  code: 'SYNC_PUSH_BLOCKED',
793
- hint: stopping.some((i) => i.blocked === 'moved' || i.blocked === 'conflicts') ? 'pull and merge them first (`frontera local sync pull <ref>`), or name the items to propose' : 'name only items that can be proposed; `--preview` lists them',
841
+ hint: target.name && stopping.some((i) => i.blocked === 'moved')
842
+ ? `see what changed there with \`${command('diff <ref>')}\`, then keep the project’s version with \`${command('resolve <ref> --keep mine')}\`, or name only the items to propose`
843
+ : target.name && stopping.some((i) => i.blocked === 'conflicts')
844
+ ? 'resolve the conflicts from the link’s pull first (`frontera local sync resolve <ref>`), or name only the items to propose'
845
+ : stopping.some((i) => i.blocked === 'moved' || i.blocked === 'conflicts') ? 'pull and merge them first (`frontera local sync pull <ref>`), or name the items to propose' : `name only items that can be proposed; \`${command('push --preview')}\` lists them`,
794
846
  details: { blocked: stopping.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason })) },
795
847
  })
796
848
  }
@@ -802,10 +854,16 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
802
854
  if (held.length) {
803
855
  throw new CliError(`nothing to propose: ${plural(held.length, 'change')} here cannot go yet\n${held.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]).join('\n')}`, {
804
856
  code: 'SYNC_PUSH_BLOCKED',
805
- hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with \`frontera local sync push --preview\``,
857
+ hint: `fix ${held.length === 1 ? 'it' : 'them'} as said, then check with \`${command('push --preview')}\``,
806
858
  details: { blocked: held.map((i) => ({ ref: i.ref, code: i.blocked, message: i.reason, ...(i.fix ? { fix: i.fix } : {}) })) },
807
859
  })
808
860
  }
861
+ if (target.name) {
862
+ throw new CliError([`nothing to propose to ${targetLabel(target)}: nothing here differs from what it holds${changedThere.length ? ', other than what changed there' : ''}`, ...changedThereLines(target, changedThere)].join('\n'), {
863
+ code: 'SYNC_NOTHING_TO_PUSH',
864
+ hint: changedThere.length ? `to put the project’s version back over a change made there, \`${command('resolve <ref> --keep mine')}\`` : `change an item in the project first; \`${command('push --preview')}\` lists what would go`,
865
+ })
866
+ }
809
867
  throw new CliError(`nothing to propose: nothing ${mcp ? 'a change request carries ' : ''}changed here since the last pull`, {
810
868
  code: 'SYNC_NOTHING_TO_PUSH',
811
869
  hint: `${mcp ? `${mcp.line} For anything else, ` : ''}change an item in the project first; \`frontera local sync status\` shows where it stands`,
@@ -813,40 +871,45 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
813
871
  }
814
872
  const description = flagString(ctx, 'description')
815
873
  if (stop) throw stop
816
- if (!target && !title) throw new UsageError('give the change request a title', 'frontera local sync push -t "Tighten the refund procedure"')
874
+ if (!open && !title) throw new UsageError('give the change request a title', command('push -t "Tighten the refund procedure"'))
817
875
 
818
876
  // Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
819
877
  const releases = await releaseItems(api, releaseAsks)
820
- 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) && releasedDeployment(i)).map((i) => releaseItem(i.name, releasedDeployment(i)!)) : []
878
+ const keptReleases = open ? (await api.call<View>('GET', `/v1/change-requests/${open.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref) && releasedDeployment(i)).map((i) => releaseItem(i.name, releasedDeployment(i)!)) : []
821
879
  // An App's settings pushed now and its release go as one item (X6).
822
880
  const items = mergeAppItems([...(await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))), ...releases.map(({ ref, files }) => ({ ref, files })), ...keptReleases])
823
881
  let view: View
824
882
  try {
825
- view = target
826
- ? await api.call<View>('PUT', `/v1/change-requests/${target.id}`, { ...(title ? { title } : {}), ...(description !== undefined ? { description: description || null } : {}), items, replace: true }, { timeoutMs: 120_000 })
883
+ view = open
884
+ ? await api.call<View>('PUT', `/v1/change-requests/${open.id}`, { ...(title ? { title } : {}), ...(description !== undefined ? { description: description || null } : {}), items, replace: true }, { timeoutMs: 120_000 })
827
885
  : await api.call<View>('POST', '/v1/change-requests', { title, ...(description ? { description } : {}), items }, { timeoutMs: 120_000 })
828
886
  } catch (err) {
829
887
  if (err instanceof CliError && err.code === 'SYNC_CHECKS') {
830
888
  const checks = checksOf(err.details)
831
- throw new CliError(`not proposed: the workspace refused ${plural(checks.length, 'check')}\n${checkLines(checks, titles).join('\n')}`, {
889
+ const moved = checks.map((c) => movedOnTarget(c, target))
890
+ const there = moved.find((m) => m !== null)
891
+ throw new CliError(`not proposed: the workspace refused ${plural(checks.length, 'check')}\n${checkLines(checks.map((c, n) => ({ ...c, message: moved[n]?.reason ?? c.message })), titles).join('\n')}`, {
832
892
  code: 'SYNC_PUSH_REFUSED',
833
- hint: checks.some((c) => c.code === 'stale_base' || c.code === 'exists' || c.code === 'gone') ? 'fetch and pull the items named, merge them, then push again' : 'fix the items named, then push again',
893
+ hint: there
894
+ ? `${there.fix}, then push again`
895
+ : checks.some((c) => c.code === 'stale_base' || c.code === 'exists' || c.code === 'gone') ? 'fetch and pull the items named, merge them, then push again' : 'fix the items named, then push again',
834
896
  details: { checks },
835
897
  })
836
898
  }
837
- throw orStoredKey(err, api.acting, again, target ? 'you' : 'nobody')
899
+ throw orStoredKey(err, api.acting, again, open ? 'you' : 'nobody')
838
900
  }
839
901
  // A knowledge base's documents follow the request, one at a time (each up to the platform's limit).
840
902
  const staged = await stageDocuments(project, api, view)
841
903
  if (staged) view = await api.call<View>('GET', `/v1/change-requests/${view.request.id}`)
842
904
  const allTitles = { ...(kept?.titles ?? {}), ...titles }
843
- recordRequest(project.root, link.workspace, view, { titles: allTitles, pushed: true })
844
- writeRequestBase(project.root, view.request.id, going)
905
+ recordRequest(target.state, link.workspace, view, { titles: allTitles, pushed: true })
906
+ writeRequestBase(target.state, view.request.id, going)
845
907
  const notProposed = chosen.filter((i) => !i.proposable)
846
908
  return {
847
909
  data: {
848
910
  proposed: true,
849
- created: !target,
911
+ target: target.name,
912
+ created: !open,
850
913
  request: { id: view.request.id, title: view.request.title, state: view.request.state, items: view.items.length, refs: view.items.map((i) => i.ref) },
851
914
  pushed: [...going.map((i) => i.ref), ...releases.map((r) => r.ref)],
852
915
  releases: releases.map((r) => ({ ref: r.ref, deployment: r.deployment })),
@@ -856,15 +919,16 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
856
919
  notCarried,
857
920
  },
858
921
  text: [
859
- `${target ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${link.workspaceName ?? link.workspace}: ${plural(view.items.length, 'item')}.`,
922
+ `${open ? `Updated “${view.request.title}” in` : `Proposed “${view.request.title}” to`} ${targetLabel(target)}: ${plural(view.items.length, 'item')}.`,
860
923
  ...going.map((i) => ` ${pushItemLine(i)}`),
861
924
  ...releases.map((r) => ` Custom App ${r.slug}: release ${r.deployment}${r.chosen ? ' (its newest deployment)' : ''}; Apply promotes it last`),
862
925
  ...(notProposed.length ? ['', 'Not proposed:', ...notProposed.flatMap((i) => [` ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])])] : []),
926
+ ...changedThereLines(target, changedThere),
863
927
  ...(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`] : []),
864
- ...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(view))] : []),
928
+ ...(needsOrganizationKey(view.items) ? ['', stewardNote(api.acting, true, pushAgain(api.acting, view))] : []),
865
929
  ...(mcp ? ['', mcp.line] : []),
866
930
  '',
867
- 'Apply it with `frontera local sync request apply`; see it with `frontera local sync request`.',
931
+ `Apply it with \`${command('request apply')}\`; see it with \`${command('request')}\`.`,
868
932
  ].join('\n'),
869
933
  }
870
934
  }
@@ -872,10 +936,11 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
872
936
  // ── request ─────────────────────────────────────────────────────────────────
873
937
 
874
938
  /** The base each item was changed from: the copy kept at push, the fetched snapshot, or the platform at that revision. */
875
- async function basesFor(project: LocalProject, link: SyncLink, view: View): Promise<Record<string, { files: Record<string, string>; known: boolean }>> {
876
- const kept = readRequestBase(project.root, view.request.id)
877
- const snapshot = readSnapshot(project.root)
878
- const remote = snapshot && snapshot.workspace === link.workspace ? new Map(snapshotItems(project.root, snapshot).map((i) => [i.ref, i])) : new Map()
939
+ async function basesFor(target: SyncTarget, view: View): Promise<Record<string, { files: Record<string, string>; known: boolean }>> {
940
+ const { link } = target
941
+ const kept = readRequestBase(target.state, view.request.id)
942
+ const snapshot = readSnapshot(target.state)
943
+ const remote = snapshot && snapshot.workspace === link.workspace ? new Map(snapshotItems(target.state, snapshot).map((i) => [i.ref, i])) : new Map()
879
944
  const out: Record<string, { files: Record<string, string>; known: boolean }> = {}
880
945
  for (const item of view.items) {
881
946
  if (!item.baseRevision) {
@@ -902,7 +967,7 @@ async function basesFor(project: LocalProject, link: SyncLink, view: View): Prom
902
967
  // Earlier versions the platform keeps: workflows, Functions and the Blueprint by revision.
903
968
  if (item.kind === 'agent' || !HISTORY_KINDS.includes(item.kind) || !snapshot?.capabilities.includes('item-revision')) continue
904
969
  try {
905
- const source = await sourceFor(project, link)
970
+ const source = await sourceFor(target.owner, link, target.name)
906
971
  const asked = item.kind === 'blueprint' ? 'blueprint' : item.ref
907
972
  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)}`)
908
973
  if (answer) {
@@ -992,12 +1057,12 @@ function activityOf(view: View, titles: Record<string, string>) {
992
1057
  })
993
1058
  }
994
1059
 
995
- async function currentView(project: LocalProject, link: SyncLink): Promise<{ view: View; titles: Record<string, string>; api: Platform } | null> {
996
- const kept = currentRequest(readRequests(project.root, link.workspace))
1060
+ async function currentView(target: SyncTarget): Promise<{ view: View; titles: Record<string, string>; api: Platform } | null> {
1061
+ const kept = currentRequest(readRequests(target.state, target.link.workspace))
997
1062
  if (!kept) return null
998
- const api = await platformFor(project, link)
1063
+ const api = await platformFor(target)
999
1064
  const view = await api.call<View>('GET', `/v1/change-requests/${kept.id}`)
1000
- recordRequest(project.root, link.workspace, view)
1065
+ recordRequest(target.state, target.link.workspace, view)
1001
1066
  return { view, titles: kept.titles, api }
1002
1067
  }
1003
1068
 
@@ -1014,13 +1079,15 @@ function titleOf(item: ViewItem, titles: Record<string, string>): string {
1014
1079
 
1015
1080
  async function show(ctx: CommandContext) {
1016
1081
  const project = localProject(ctx.cwd)
1017
- const link = needLink(project)
1018
- const current = await currentView(project, link)
1082
+ const target = chooseTarget(project, ctx)
1083
+ const { link } = target
1084
+ const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
1085
+ const current = await currentView(target)
1019
1086
  if (!current) {
1020
- return { data: { request: null }, text: 'No change request yet. Propose what changed here with `frontera local sync push -t "<title>"`.' }
1087
+ return { data: { request: null, target: target.name }, text: `No change request yet. Propose what changed here with \`${command('push -t "<title>"')}\`.` }
1021
1088
  }
1022
1089
  const { view, titles } = current
1023
- const bases = await basesFor(project, link, view)
1090
+ const bases = await basesFor(target, view)
1024
1091
  const checks = lastChecks(view)
1025
1092
  const items = view.items.map((item) => {
1026
1093
  const base = bases[item.ref]!
@@ -1059,12 +1126,13 @@ async function show(ctx: CommandContext) {
1059
1126
  })
1060
1127
  const allTitles = Object.fromEntries(items.map((i) => [i.ref, i.title]))
1061
1128
  const activity = activityOf(view, allTitles)
1062
- const orgStored = !!getSyncKey(project, 'organization')
1129
+ const orgStored = !!getSyncKey(target.owner, 'organization')
1063
1130
  const r = view.request
1064
1131
  const { acting } = current.api
1065
1132
  const other = isOpen(r.state) ? proposedByOther(acting, view) : null
1066
1133
  return {
1067
1134
  data: {
1135
+ target: target.name,
1068
1136
  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 },
1069
1137
  workspace: { name: link.workspaceName ?? null, organizationName: link.organizationName ?? null },
1070
1138
  items,
@@ -1075,7 +1143,7 @@ async function show(ctx: CommandContext) {
1075
1143
  evidence: view.evidence ?? [],
1076
1144
  },
1077
1145
  text: [
1078
- `${r.title} — ${STATE_WORD[r.state] ?? r.state} · ${plural(items.length, 'item')} · ${link.workspaceName ?? link.workspace}`,
1146
+ `${r.title} — ${STATE_WORD[r.state] ?? r.state} · ${plural(items.length, 'item')} · ${targetLabel(target)}`,
1079
1147
  ...(r.description ? [r.description] : []),
1080
1148
  '',
1081
1149
  ...items.flatMap((i, n) => [
@@ -1090,29 +1158,31 @@ async function show(ctx: CommandContext) {
1090
1158
  '',
1091
1159
  'Activity:',
1092
1160
  ...activity.map((a) => ` ${a.at.slice(0, 16).replace('T', ' ')} ${a.text}`),
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.'] : []),
1161
+ ...(isOpen(r.state) && !other && needsOrganizationKey(view.items) ? ['', stewardNote(acting, true, pushAgain(acting, view))] : []),
1162
+ ...(other === 'key' ? ['', `Proposed with ${ownerWords(acting, 'the project')}’s workspace key before you signed in: close it (\`${command('request close')}\`) and push again to apply it as yourself.`] : []),
1095
1163
  ...(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`.'] : []),
1164
+ ...(isOpen(r.state) && !other ? ['', `Apply it with \`${command('request apply')}\`, or close it with \`${targetWords(target, 'request close')}\`.`] : []),
1097
1165
  ].join('\n'),
1098
1166
  }
1099
1167
  }
1100
1168
 
1101
1169
  async function apply(ctx: CommandContext) {
1102
1170
  const project = localProject(ctx.cwd)
1103
- const link = needLink(project)
1104
- const current = await currentView(project, link)
1105
- if (!current) throw new UsageError('no change request to apply', 'propose one with `frontera local sync push -t "<title>"`')
1171
+ const target = chooseTarget(project, ctx)
1172
+ const { link } = target
1173
+ const command = (words: string) => targetWords(target, `frontera local sync ${words}`)
1174
+ const current = await currentView(target)
1175
+ if (!current) throw new UsageError(`no change request to apply${target.name ? ` to target ${target.name}` : ''}`, `propose one with \`${command('push -t "<title>"')}\``)
1106
1176
  const { view, titles, api } = current
1107
1177
  const { acting } = api
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>"`' })
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>"`' })
1178
+ if (view.request.state === 'merged') throw new CliError(`“${view.request.title}” is already applied`, { code: 'CONFLICT', hint: `propose new changes with \`${command('push -t "<title>"')}\`` })
1179
+ if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: `propose the changes again with \`${command('push -t "<title>"')}\`` })
1110
1180
  const proposed: ProposedBy = proposedByOther(acting, view) === 'key' ? 'key' : 'you'
1111
1181
  if (acting.as === 'person' && acting.organizationKeyStored && view.items.some((i) => i.kind === 'blueprint' || i.kind === 'agent')) {
1112
1182
  // The platform refuses a person who is no Steward only once Apply runs, so ask its checks, with every item so references resolve.
1113
1183
  const items = view.items.map((i) => ({ ref: i.ref, files: i.files, ...(i.baseRevision ? { baseRevision: i.baseRevision } : {}), ...(i.deleted ? { deleted: true } : {}) }))
1114
1184
  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)
1185
+ const stop = notSteward(acting, stewardStepUnmet(needsAPerson(plan), view.items), pushAgain(acting, view), proposed)
1116
1186
  if (stop) throw stop
1117
1187
  }
1118
1188
  const refused = notProposer(acting, view, 'apply')
@@ -1123,7 +1193,7 @@ async function apply(ctx: CommandContext) {
1123
1193
  // 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
1194
  const organizationKey = organizationKeyOf(acting)
1125
1195
  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` })
1196
+ throw new CliError(`this change request changes the Blueprint, which a Blueprint Steward applies as themself, and no one is signed in for ${ownerWords(acting, 'this project')}’s workspace on this computer`, { code: 'SYNC_NOT_SIGNED_IN', hint: `sign in with \`${signInCommand(acting.profile)}\`, then close this change request (\`${command('request close')}\`) and push it again, so it is yours to apply` })
1127
1197
  }
1128
1198
  const migration = flagString(ctx, 'migration')?.trim()
1129
1199
  const force = flagBool(ctx, 'force')
@@ -1149,22 +1219,22 @@ async function apply(ctx: CommandContext) {
1149
1219
  })
1150
1220
  }
1151
1221
  if (refused && err instanceof CliError && err.code === 'FORBIDDEN') throw refused
1152
- throw orStoredKey(err, acting, pushAgain(view), proposed)
1222
+ throw orStoredKey(err, acting, pushAgain(acting, view), proposed)
1153
1223
  }
1154
1224
  const outcome = parseOutcome(answer.outcome)
1155
- recordRequest(project.root, link.workspace, answer, { applied: { ok: outcome.ok } })
1225
+ recordRequest(target.state, link.workspace, answer, { applied: { ok: outcome.ok } })
1156
1226
  if (!outcome.ok) {
1157
1227
  // Fetch, so the project sees the workspace as it was put back. The request itself applies again as it is:
1158
1228
  // the platform takes each put-back item's new revision as its base.
1159
- await fetchFor(project, link).catch(() => null)
1229
+ await fetchTarget(project, target).catch(() => null)
1160
1230
  // When the checks could not be read before sending, the Steward refusal comes back as the failed step.
1161
1231
  const stewardFailed = /Blueprint Steward assignment required/.test(outcome.failure?.message ?? '')
1162
- const steward = notSteward(acting, stewardFailed, pushAgain(view), proposed)
1232
+ const steward = notSteward(acting, stewardFailed, pushAgain(acting, view), proposed)
1163
1233
  // 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
1234
+ const noKey = stewardFailed ? `ask a Blueprint Steward who is a workspace admin to approve it and apply it; or store ${ownerWords(acting, 'the project')}’s organization key (\`${command('connect --workspace-key - --org-key -')}\`), then apply again (\`${command('request apply')}\`) and follow the steps it prints` : null
1165
1235
  throw new CliError(describeOutcome(outcome, allTitles).join('\n'), {
1166
1236
  code: 'SYNC_APPLY_FAILED',
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`)'),
1237
+ 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 (\`${command('request apply')}\`); a fix in the project’s files is pushed first (\`${command('push')}\`)`),
1168
1238
  details: { outcome, failed: outcome.failure ? { ref: outcome.failure.ref, title: failedName(outcome, allTitles), message: outcome.failure.message } : null },
1169
1239
  })
1170
1240
  }
@@ -1173,40 +1243,45 @@ async function apply(ctx: CommandContext) {
1173
1243
  try {
1174
1244
  // A deletion shows only in a full read of the workspace's index (an incremental one lists no deletions).
1175
1245
  const deleted = answer.items.filter((i) => i.deleted).map((i) => i.ref)
1176
- const result = await fetchFor(project, link, { full: deleted.length > 0 })
1246
+ const result = await fetchTarget(project, target, { full: deleted.length > 0 })
1177
1247
  // A Custom App release moved a pointer; the App's own sync item (its description) is not the project's to adopt.
1178
1248
  const adoptable = answer.items.filter((i) => i.kind !== 'app').map((i) => i.ref)
1179
- const { adopted } = adoptApplied(project.root, link.workspace, adoptable, snapshotItems(project.root, result.snapshot), readLock(project.root), new Date(), { deleted })
1249
+ // A named target's base is its own (`target.state`), so the next push to it reads what changed there since.
1250
+ const { adopted } = adoptApplied(target.state, link.workspace, adoptable, snapshotItems(target.state, result.snapshot), readLock(target.state), new Date(), { deleted })
1180
1251
  // An Agent's lessons were proposed for review: not proposed again; the next pull brings what reviewers approve.
1181
- adoptProposedLessons(project.root, link.workspace, answer.items.flatMap((i) => (i.ref.startsWith('lessons/') ? [i.ref.slice('lessons/'.length)] : [])))
1252
+ if (!target.name) adoptProposedLessons(project.root, link.workspace, answer.items.flatMap((i) => (i.ref.startsWith('lessons/') ? [i.ref.slice('lessons/'.length)] : [])))
1182
1253
  fetched = { changed: result.changed.length, adopted, error: null }
1183
1254
  } catch (err) {
1184
1255
  fetched.error = (err as Error).message
1185
1256
  }
1186
1257
  // Documents applied are the project's copies of the workspace's now: recorded as a pull records them.
1187
- await recordAppliedDocuments(project, link, api, answer).catch(() => null)
1258
+ await recordAppliedDocuments(target, api, answer).catch(() => null)
1188
1259
  // The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
1189
1260
  recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
1190
1261
  // What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
1191
1262
  const after = afterApplyOf(answer.outcome)
1192
1263
  // The steps change the workspace the request went to, so they name it: the folder is bound to its local runtime.
1193
- const { target, targetError } = after.length
1194
- ? await registerTargetProfile(project, link).then((name) => ({ target: name, targetError: null }), (err: Error) => ({ target: null, targetError: err.message }))
1195
- : { target: null, targetError: null }
1196
- const pending = recordAfterApply(project.root, answer.request.title, after, target ? { profile: target, target: targetOf(link) } : null)
1197
- const signIn = target && pending.length ? await signInStep({ profile: target, apiUrl: link.platform, named: true }) : null
1264
+ const { profile, profileError } = after.length
1265
+ ? await registerTargetProfile(target.owner, link).then((name) => ({ profile: name, profileError: null }), (err: Error) => ({ profile: null, profileError: err.message }))
1266
+ : { profile: null, profileError: null }
1267
+ // A named target's steps are a delivery of their own: the same request title applied to the link keeps its steps.
1268
+ const delivery = target.name ? `${answer.request.title} · ${target.name}` : answer.request.title
1269
+ const pending = recordAfterApply(project.root, delivery, after, profile ? { profile, target: targetOf(link) } : null)
1270
+ const signIn = profile && pending.length ? await signInStep({ profile, apiUrl: link.platform, named: true }) : null
1198
1271
  return {
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 },
1272
+ data: { applied: true, target: target.name, 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 },
1200
1273
  text: [
1201
- `Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}${acting.as === 'person' ? ` as ${acting.email ?? 'you'} (signed in)` : ''}.`,
1274
+ `Applied “${answer.request.title}” to ${targetLabel(target)}${acting.as === 'person' ? ` as ${acting.email ?? 'you'} (signed in)` : ''}.`,
1202
1275
  ...describeOutcome(outcome, allTitles),
1203
1276
  '',
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.`,
1277
+ fetched.error
1278
+ ? `Could not fetch afterwards: ${fetched.error}.${target.name ? '' : ' Run `frontera local sync fetch`.'}`
1279
+ : target.name ? `Fetched ${targetLabel(target)}: the next push to it compares with what this Apply left there.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
1205
1280
  ...(pending.length
1206
1281
  ? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`),
1207
- target
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` : ''}.`
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.`,
1282
+ profile
1283
+ ? `They run against ${new URL(link.platform).host} with \`--profile ${profile}\` (${getSyncKey(target.owner, 'organization') ? (target.name ? `target ${target.name}’s keys` : "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` : ''}.`
1284
+ : `They name no target: the CLI could not record a profile for ${new URL(link.platform).host}${profileError ? ` (${profileError})` : ''}. Add \`--profile <a profile for that platform>\` when you run them.`,
1210
1285
  'Forge lists them on the project’s home; `frontera local steps` here.']
1211
1286
  : []),
1212
1287
  ].join('\n'),
@@ -1218,11 +1293,12 @@ async function apply(ctx: CommandContext) {
1218
1293
  * workspace's new copy (the newest document of that name), and each taken off
1219
1294
  * is forgotten, so the next push sees them as unchanged.
1220
1295
  */
1221
- async function recordAppliedDocuments(project: LocalProject, link: SyncLink, api: Platform, view: View): Promise<void> {
1296
+ async function recordAppliedDocuments(target: SyncTarget, api: Platform, view: View): Promise<void> {
1297
+ const { link } = target
1222
1298
  const items = view.items.filter((i) => i.kind === 'knowledge' && i.documents)
1223
1299
  if (!items.length) return
1224
- const snapshot = readSnapshot(project.root)
1225
- const remote = snapshot ? snapshotItems(project.root, snapshot) : []
1300
+ const snapshot = readSnapshot(target.state)
1301
+ const remote = snapshot ? snapshotItems(target.state, snapshot) : []
1226
1302
  for (const item of items) {
1227
1303
  const id = remote.find((r) => r.ref === item.ref)?.id
1228
1304
  if (!id) continue
@@ -1237,28 +1313,29 @@ async function recordAppliedDocuments(project: LocalProject, link: SyncLink, api
1237
1313
  if (newest?.id) entries[path] = { sourceId: newest.id, sha256: d.sha256, bytes: d.bytes, at: new Date().toISOString() }
1238
1314
  }
1239
1315
  // A document taken off is forgotten by the copy it was, wherever it was filed.
1240
- const pulled = readPulledDocuments(project.root, link.workspace).bases[item.name] ?? {}
1316
+ const pulled = readPulledDocuments(target.state, link.workspace).bases[item.name] ?? {}
1241
1317
  for (const d of item.documents!.remove) {
1242
1318
  const at = Object.entries(pulled).find(([, was]) => was.sourceId === d.sourceId)?.[0]
1243
1319
  entries[at ?? d.fileName] = null
1244
1320
  }
1245
- recordPulledDocuments(project.root, link.workspace, item.name, entries)
1321
+ recordPulledDocuments(target.state, link.workspace, item.name, entries)
1246
1322
  }
1247
1323
  }
1248
1324
 
1249
1325
  async function close(ctx: CommandContext) {
1250
1326
  const project = localProject(ctx.cwd)
1251
- const link = needLink(project)
1252
- const kept = currentRequest(readRequests(project.root, link.workspace))
1253
- if (!kept || !isOpen(kept.state)) throw new UsageError('no open change request to close', 'see where it stands with `frontera local sync request`')
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')
1327
+ const target = chooseTarget(project, ctx)
1328
+ const kept = currentRequest(readRequests(target.state, target.link.workspace))
1329
+ if (!kept || !isOpen(kept.state)) throw new UsageError(`no open change request to close${target.name ? ` on target ${target.name}` : ''}`, `see where it stands with \`${targetWords(target, 'frontera local sync request')}\``)
1330
+ const current = (await currentView(target))!
1331
+ const { acting } = current.api
1332
+ // Closing writes nothing to the workspace, so a request the key proposed is closed with that key.
1333
+ const byKey = proposedByOther(acting, current.view) === 'key'
1334
+ const refused = byKey ? null : notProposer(acting, current.view, 'close')
1258
1335
  if (refused) throw refused
1259
- const view = await (byKey ? keysFor(project, link) : current.api).call<View>('POST', `/v1/change-requests/${kept.id}/close`, {})
1260
- recordRequest(project.root, link.workspace, view)
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.` }
1336
+ const view = await (byKey ? keysFor(target) : current.api).call<View>('POST', `/v1/change-requests/${kept.id}/close`, {})
1337
+ recordRequest(target.state, target.link.workspace, view)
1338
+ return { data: { closed: true, target: target.name, request: { id: view.request.id, title: view.request.title, state: view.request.state } }, text: `Closed “${view.request.title}”${target.name ? ` on ${targetLabel(target)}` : ''}${byKey ? ` with ${ownerWords(acting, 'the project')}’s workspace key that proposed it` : ''}. The project’s files are unchanged; the next push opens a new change request.` }
1262
1339
  }
1263
1340
 
1264
1341
  /**
@@ -1271,11 +1348,11 @@ async function close(ctx: CommandContext) {
1271
1348
  */
1272
1349
  async function evidence(ctx: CommandContext) {
1273
1350
  const project = localProject(ctx.cwd)
1274
- const link = needLink(project)
1275
- const current = await currentView(project, link)
1276
- if (!current) throw new UsageError('no change request to attach evidence to', 'propose one with `frontera local sync push -t "<title>"`')
1351
+ const target = chooseTarget(project, ctx)
1352
+ const current = await currentView(target)
1353
+ if (!current) throw new UsageError('no change request to attach evidence to', `propose one with \`${targetWords(target, 'frontera local sync push -t "<title>"')}\``)
1277
1354
  const { view, titles, api } = current
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>"`' })
1355
+ 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 \`${targetWords(target, 'frontera local sync push -t "<title>"')}\`` })
1279
1356
  const refused = notProposer(api.acting, view, 'attach evidence to')
1280
1357
  if (refused) throw refused
1281
1358
  const session = flagString(ctx, 'screenshots') ?? process.env.FORGE_SESSION ?? null
@@ -1366,7 +1443,7 @@ async function evidence(ctx: CommandContext) {
1366
1443
  }
1367
1444
 
1368
1445
  const after = await api.call<View>('GET', `/v1/change-requests/${view.request.id}`)
1369
- recordRequest(project.root, link.workspace, after)
1446
+ recordRequest(target.state, target.link.workspace, after)
1370
1447
  return {
1371
1448
  data: { attached, skipped, gate: after.gate ?? null },
1372
1449
  text: [
@@ -1383,17 +1460,17 @@ const PICTURE_EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg':
1383
1460
  /** One evidence picture, kept on this machine (in the runtime's state folder) for Forge to show. */
1384
1461
  async function evidenceImage(ctx: CommandContext) {
1385
1462
  const project = localProject(ctx.cwd)
1386
- const link = needLink(project)
1463
+ const target = chooseTarget(project, ctx)
1387
1464
  const id = ctx.positional[2]
1388
1465
  if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw new UsageError('say which evidence picture', 'frontera local sync request evidence-image <evidence id>')
1389
- const kept = currentRequest(readRequests(project.root, link.workspace))
1390
- if (!kept) throw new UsageError('no change request', 'propose one with `frontera local sync push -t "<title>"`')
1466
+ const kept = currentRequest(readRequests(target.state, target.link.workspace))
1467
+ if (!kept) throw new UsageError('no change request', `propose one with \`${targetWords(target, 'frontera local sync push -t "<title>"')}\``)
1391
1468
  const dir = join(project.stateDir, 'evidence', kept.id)
1392
1469
  for (const ext of Object.values(PICTURE_EXT)) {
1393
1470
  const path = join(dir, `${id}.${ext}`)
1394
1471
  if (existsSync(path)) return { data: { path, mime: Object.entries(PICTURE_EXT).find(([, e]) => e === ext)![0] }, text: path }
1395
1472
  }
1396
- const { bytes, mime } = await (await platformFor(project, link)).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
1473
+ const { bytes, mime } = await (await platformFor(target)).bytes(`/v1/change-requests/${kept.id}/evidence/${id}/content`)
1397
1474
  const ext = PICTURE_EXT[mime.split(';')[0]!.trim()]
1398
1475
  if (!ext) throw new CliError('the platform did not answer with a picture', { code: 'UNAVAILABLE', hint: 'run the command again' })
1399
1476
  mkdirSync(dir, { recursive: true })