@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.53.10",
3
+ "version": "1.53.12",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,16 +40,16 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.10",
44
- "@frontera-sdk/functions": "1.53.10",
45
- "ai": "^6.0.116",
43
+ "@frontera-sdk/core": "1.53.12",
44
+ "@frontera-sdk/functions": "1.53.12",
45
+ "ai": "^6.0.319",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
48
48
  "playwright-core": "1.63.0",
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.10",
52
+ "@frontera-sdk/forge-contracts": "1.53.12",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -965,6 +965,49 @@ export class PlatformApi {
965
965
  )
966
966
  }
967
967
 
968
+ // ── Voice phone numbers ───────────────────────────────────────────────────
969
+
970
+ /** The numbers an Agent answers, and whether the deployment has a phone system. Served by the Voice application. */
971
+ voiceNumbers(agentId: string) {
972
+ return this.get<{ numbers: unknown[]; available: boolean }>(`/v1/voice-calls/agents/${encodeURIComponent(agentId)}/numbers`)
973
+ }
974
+
975
+ addVoiceNumber(agentId: string, body: { number?: string; source: 'whatsapp'; channelId?: string | null; callHours?: unknown }) {
976
+ return this.client.request<{ number: unknown }>(`/v1/voice-calls/agents/${encodeURIComponent(agentId)}/numbers`, { method: 'POST', body })
977
+ }
978
+
979
+ applyVoiceNumber(numberId: string) {
980
+ return this.client.request<{ number: unknown }>(`/v1/voice-calls/numbers/${encodeURIComponent(numberId)}/apply`, { method: 'POST' })
981
+ }
982
+
983
+ setVoiceNumberEnabled(numberId: string, enabled: boolean) {
984
+ return this.client.request<{ number: unknown }>(`/v1/voice-calls/numbers/${encodeURIComponent(numberId)}/enabled`, { method: 'PUT', body: { enabled } })
985
+ }
986
+
987
+ /** Changes a number's call hours (null: any time) and applies them again. */
988
+ setVoiceNumberHours(numberId: string, callHours: unknown) {
989
+ return this.client.request<{ number: unknown }>(`/v1/voice-calls/numbers/${encodeURIComponent(numberId)}/hours`, { method: 'PUT', body: { callHours } })
990
+ }
991
+
992
+ /** Starts a call from one of the Agent's numbers. A refusal is a 409 the caller reads. */
993
+ startVoiceCall(agentId: string, body: { numberId: string; to: string }) {
994
+ return this.client.request<{ callId: string; status: string }>(`/v1/voice-calls/agents/${encodeURIComponent(agentId)}/calls`, { method: 'POST', body })
995
+ }
996
+
997
+ voiceCallPermission(numberId: string, to: string) {
998
+ return this.get<{ permission: { status: string; expiresAt?: string; canCall: boolean; canRequest: boolean } }>(
999
+ `/v1/voice-calls/numbers/${encodeURIComponent(numberId)}/call-permission?to=${encodeURIComponent(to)}`,
1000
+ )
1001
+ }
1002
+
1003
+ requestVoiceCallPermission(numberId: string, body: { to: string; message?: string }) {
1004
+ return this.client.request<{ requested: boolean }>(`/v1/voice-calls/numbers/${encodeURIComponent(numberId)}/call-permission-requests`, { method: 'POST', body })
1005
+ }
1006
+
1007
+ removeVoiceNumber(numberId: string) {
1008
+ return this.client.request<unknown>(`/v1/voice-calls/numbers/${encodeURIComponent(numberId)}`, { method: 'DELETE' })
1009
+ }
1010
+
968
1011
  // ── Workspace secrets ─────────────────────────────────────────────────────
969
1012
 
970
1013
  /**
@@ -72,6 +72,36 @@ export function readBacking(file: AuthoredFile): AuthoredBacking | undefined {
72
72
  }
73
73
  }
74
74
 
75
+ /** A file's property that reads no column: only Actions write its values. */
76
+ export function isEditOnly(property: unknown): boolean {
77
+ return (property as { editOnly?: unknown } | null)?.editOnly === true
78
+ }
79
+
80
+ /**
81
+ * The file shape of every edit-only property, refused before any write. It reads no
82
+ * column, so `backing.mapping` names none for it; and only an Action can give it a
83
+ * value, so a file that states `backing.editable` lists it there.
84
+ */
85
+ export function assertEditOnlyShape(file: AuthoredFile): void {
86
+ const backing = readBacking(file)
87
+ const declared = Array.isArray(file.document.properties) ? file.document.properties as Array<Record<string, unknown>> : []
88
+ for (const property of declared.filter(isEditOnly)) {
89
+ const name = String(property.apiName)
90
+ if (backing?.mapping?.[name] !== undefined) {
91
+ throw new CliError(`${file.path}: property "${name}" is edit-only and reads no column, but backing.mapping names one for it.`, {
92
+ code: 'USAGE',
93
+ hint: `Remove "${name}" from backing.mapping, or drop \`editOnly: true\` to read that column.`,
94
+ })
95
+ }
96
+ if (backing?.editable && !backing.editable.includes(name)) {
97
+ throw new CliError(`${file.path}: property "${name}" is edit-only, so only an Action can give it a value, and backing.editable does not list it.`, {
98
+ code: 'USAGE',
99
+ hint: `Add "${name}" to backing.editable.`,
100
+ })
101
+ }
102
+ }
103
+ }
104
+
75
105
  /**
76
106
  * `sharedField: accountCode` → the uuid the wire takes. THREE-VALUED, because the
77
107
  * authored key has three states and the wire has the same three:
@@ -211,10 +241,13 @@ export async function objectTypeCreateBody(
211
241
  })
212
242
  }
213
243
 
244
+ assertEditOnlyShape(file)
214
245
  const { revisionId } = await resolveDataset(backing.dataset)
215
246
  // A calculated field (a location from latitude and longitude) reads no column, and the
216
- // create route has no channel for it: it is added after the type, by `add_field`.
217
- const properties = (Array.isArray(document.properties) ? document.properties : []).filter((entry) => !(entry as { calculation?: unknown }).calculation)
247
+ // create route has no channel for it: it is added after the type, by `add_field`. So
248
+ // is an edit-only one, which reads no column either.
249
+ const properties = (Array.isArray(document.properties) ? document.properties : [])
250
+ .filter((entry) => !(entry as { calculation?: unknown }).calculation && !isEditOnly(entry))
218
251
 
219
252
  return {
220
253
  apiName: requireString(document, 'apiName', file.path),
@@ -48,15 +48,16 @@ export interface ColumnMapping {
48
48
  * Shared by `bind` and by `apply`'s rebind, which send the same draft command.
49
49
  */
50
50
  export function columnMappingsFrom(
51
- properties: ReadonlyArray<{ id?: string; apiName?: string; calculation?: unknown }>,
51
+ properties: ReadonlyArray<{ id?: string; apiName?: string; calculation?: unknown; editOnly?: unknown }>,
52
52
  mapping: Readonly<Record<string, MappingEntry | null | undefined>>,
53
53
  ): { columnMappings: ColumnMapping[]; unmapped: string[] } {
54
54
  const columnMappings: ColumnMapping[] = []
55
55
  const unmapped: string[] = []
56
56
  for (const property of properties) {
57
57
  const name = property.apiName
58
- // A calculated field (a location from latitude and longitude) reads no column.
59
- if (!name || property.calculation) continue
58
+ // A calculated field (a location from latitude and longitude) reads no column, nor
59
+ // does an edit-only one: its values are the ones Actions write.
60
+ if (!name || property.calculation || property.editOnly) continue
60
61
  const entry = mapping[name]
61
62
  if (entry === undefined || entry === null) {
62
63
  unmapped.push(name)
@@ -282,12 +282,15 @@ function describeUpdate(local: Record<string, unknown>, live: Record<string, unk
282
282
  const replacements = (intents?.replacements ?? []).filter((entry) => entry.objectApiName === objectApiName)
283
283
  const added = after.filter((name) => !before.has(name) && !replacements.some((entry) => entry.added === name))
284
284
  const removed = [...before].filter((name) => !after.includes(name) && !replacements.some((entry) => entry.removed === name))
285
+ // An edit-only field is named as one: it reads no column, so its values start empty.
286
+ const editOnly = new Set((Array.isArray(local.properties) ? local.properties as Array<Record<string, unknown>> : [])
287
+ .filter((property) => property.editOnly === true).map((property) => String(property.apiName)))
285
288
  const parts: string[] = [
286
289
  ...(intents?.renames ?? []).filter((entry) => entry.objectApiName === objectApiName)
287
290
  .map((entry) => `rename ${entry.from} → ${entry.to} (its values, edits and references stay)`),
288
291
  ...replacements.map((entry) => `replace ${entry.removed} with ${entry.added} (${entry.removed}'s values are not carried over)`),
289
292
  ]
290
- if (added.length) parts.push(`+${added.length} propert${added.length === 1 ? 'y' : 'ies'} (${added.join(', ')})`)
293
+ if (added.length) parts.push(`+${added.length} propert${added.length === 1 ? 'y' : 'ies'} (${added.map((name) => editOnly.has(name) ? `${name}: edit-only, no column` : name).join(', ')})`)
291
294
  if (removed.length) parts.push(`-${removed.length} propert${removed.length === 1 ? 'y' : 'ies'} (${removed.join(', ')})`)
292
295
  return parts.length ? parts.join(', ') : 'fields changed'
293
296
  }
@@ -403,7 +403,7 @@ export const automationDev: Command = {
403
403
  // came back empty.
404
404
  out.note('[dev] runs are DRY — ctx.agent, ctx.plugin, ctx.http and ctx.blueprint return empty and send nothing')
405
405
  if (session.created) out.note(`[dev] created the automation "${slug}" (no version yet)`)
406
- out.note(`[dev] waiting — press Run dev in the Console, or \`frontera function run ${slug} --dev\``)
406
+ out.note(`[dev] waiting — press Test run on the function's page in Studio on the platform, or \`frontera function run ${slug} --dev\``)
407
407
 
408
408
  const workDir = mkdtempSync(join(tmpdir(), 'frontera-dev-'))
409
409
  let builds = 0
@@ -3,6 +3,8 @@ import { join, resolve } from 'node:path'
3
3
 
4
4
  import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
5
5
  import {
6
+ assertEditOnlyShape,
7
+ isEditOnly,
6
8
  linkTypeCreateBody,
7
9
  linkTypeStructure,
8
10
  objectTypeCreateBody,
@@ -619,7 +621,8 @@ export async function applyOperation(
619
621
  await objectTypeCreateBody(file, revision, resolveDataset, ids),
620
622
  revision,
621
623
  )
622
- return applyCalculatedFields(client, operation.apiName, file, nextRevision(created, revision))
624
+ const withCalculated = await applyCalculatedFields(client, operation.apiName, file, nextRevision(created, revision))
625
+ return applyEditOnlyFields(client, operation.apiName, file, withCalculated, ids)
623
626
  }
624
627
  // Refused BEFORE the first write, not after some of them. Everything this
625
628
  // executor can express is enumerated below; a difference outside that set has no
@@ -780,6 +783,7 @@ export function assertApplicable(
780
783
  live: Record<string, unknown> | undefined,
781
784
  ): void {
782
785
  if (!live) return
786
+ if (kind === 'object-type') assertEditOnlyKept(file, live)
783
787
  const applicable = APPLICABLE_FIELDS[kind]
784
788
  // The SAME predicate the plan used to decide this artifact changed. An equality
785
789
  // comparison here would disagree with it about key order and about the fields a file
@@ -915,6 +919,98 @@ async function applyCalculatedFields(client: BlueprintAuthoringApi, apiName: str
915
919
  return current
916
920
  }
917
921
 
922
+ /**
923
+ * A field is edit-only from the moment it is added, or never: the values Actions
924
+ * wrote and the values a column holds are not the same values, and no command moves
925
+ * one into the other. A file and a draft that disagree about it are refused by name.
926
+ */
927
+ function assertEditOnlyKept(file: AuthoredFile, live: Record<string, unknown>): void {
928
+ const declared = Array.isArray(file.document.properties) ? file.document.properties as Array<Record<string, unknown>> : []
929
+ const liveByName = new Map((Array.isArray(live.properties) ? live.properties as Array<Record<string, unknown>> : [])
930
+ .map((property) => [String(property.apiName), property]))
931
+ for (const property of declared) {
932
+ const counterpart = liveByName.get(String(property.apiName))
933
+ if (!counterpart || isEditOnly(property) === isEditOnly(counterpart)) continue
934
+ throw new CliError(
935
+ `${file.path}: property "${String(property.apiName)}" ${isEditOnly(property) ? 'is edit-only in the file but reads a column on the draft' : 'is edit-only on the draft but not in the file'}.`,
936
+ {
937
+ code: 'USAGE',
938
+ hint: 'A field is edit-only from when it is added, or never. Add a new field with the other choice, '
939
+ + 'and remove this one once nothing needs its values.',
940
+ },
941
+ )
942
+ }
943
+ }
944
+
945
+ /**
946
+ * The `add_field` a file's property is added with: its column, or none for an
947
+ * edit-only one, which the service makes editable in the same command.
948
+ */
949
+ function addFieldCommand(
950
+ objectId: string,
951
+ file: AuthoredFile,
952
+ property: Record<string, unknown>,
953
+ mapped: string | { column: string; field?: string | null } | undefined,
954
+ ids: AuthoringIds,
955
+ ): Record<string, unknown> {
956
+ const name = String(property.apiName)
957
+ const column = mapped === undefined ? '' : typeof mapped === 'string' ? mapped : mapped.column
958
+ const sourceField = mapped === undefined || typeof mapped === 'string' ? undefined : mapped.field
959
+ // Resolved here rather than at plan time: a shared field created earlier in
960
+ // this same run only entered the map when its create answered.
961
+ const sharedPropertyId = resolveSharedProperty(file, name, property.sharedField, ids.sharedPropertyIdByApiName)
962
+ const semanticTypeId = resolveSemanticType(file, `field "${name}"`, property.semanticType, ids.semanticTypeIdByApiName)
963
+ return {
964
+ kind: 'add_field',
965
+ objectId,
966
+ field: {
967
+ // The one identifier the CLI generates, and it never reaches a file: the
968
+ // command addresses a field that does not exist yet, so there is nothing on
969
+ // the draft to resolve a name against. It lives for one request.
970
+ id: crypto.randomUUID(),
971
+ apiName: name,
972
+ displayName: String(property.displayName ?? name),
973
+ propertyType: declaredPropertyType(property, ids.sharedPropertyTypeByApiName),
974
+ valueType: String(property.valueType ?? 'string'),
975
+ sourceColumn: column,
976
+ ...(sourceField === undefined || sourceField === null ? {} : { sourceField }),
977
+ ...(isEditOnly(property) ? { editOnly: true } : {}),
978
+ required: Boolean(property.required ?? false),
979
+ unique: Boolean(property.unique ?? false),
980
+ status: 'active',
981
+ visibility: 'normal',
982
+ // A field being ADDED has no reference to detach, so an authored `null`
983
+ // is simply "no shared field" — `add_field`'s uuid is not nullable, and a
984
+ // detach command on a field that is being created makes no sense anyway.
985
+ ...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
986
+ // Same collapse: `add_field`'s uuid is not nullable, and a field being
987
+ // created has no meaning to detach from.
988
+ ...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
989
+ // Its own classification, when the file gives one other than the object type's.
990
+ ...fieldSensitivityOf(property, objectTypeSensitivity(file.document)),
991
+ },
992
+ }
993
+ }
994
+
995
+ /**
996
+ * A created type's edit-only fields: the create route takes only fields that read a
997
+ * column, so each is added after it, as a calculated one is.
998
+ */
999
+ async function applyEditOnlyFields(client: BlueprintAuthoringApi, apiName: string, file: AuthoredFile, revision: number, ids: AuthoringIds): Promise<number> {
1000
+ const declared = Array.isArray(file.document.properties) ? (file.document.properties as Array<Record<string, unknown>>).filter(isEditOnly) : []
1001
+ if (!declared.length) return revision
1002
+ const detail = await client.getObjectTypeDetail(apiName)
1003
+ if (!detail?.id) return revision
1004
+ const present = new Set(detail.properties.map((property) => String(property.apiName)))
1005
+ let current = revision
1006
+ for (const property of declared) {
1007
+ if (present.has(String(property.apiName))) continue
1008
+ current = (await client.draftCommand(addFieldCommand(detail.id, file, property, undefined, ids), current)
1009
+ .catch((err) => { throw stewardRefusal(err, file, String(property.apiName), fieldSensitivityOf(property, objectTypeSensitivity(file.document)).sensitivity !== undefined) })).revision
1010
+ }
1011
+ return current
1012
+ }
1013
+
918
1014
  /**
919
1015
  * `primaryKey` and `title` — the two references a file states by property apiName.
920
1016
  *
@@ -1204,16 +1300,19 @@ async function applyPropertyPlan(
1204
1300
  : []
1205
1301
  const plan = planProperties(file, liveProperties, backing, idByApiName, objectTypeSensitivity(liveDocument ?? {}))
1206
1302
 
1303
+ assertEditOnlyShape(file)
1207
1304
  // Every add needs a column, and the service offers no name-match fallback. Checked
1208
- // for ALL of them before the first write, so a partial run cannot start.
1305
+ // for ALL of them before the first write, so a partial run cannot start. An
1306
+ // edit-only field reads none.
1209
1307
  for (const property of plan.adds) {
1210
1308
  const name = String(property.apiName)
1211
- if (backing?.mapping?.[name] === undefined) {
1309
+ if (!isEditOnly(property) && backing?.mapping?.[name] === undefined) {
1212
1310
  throw new CliError(
1213
1311
  `${file.path}: new property "${name}" has no column in \`backing.mapping\`.`,
1214
1312
  {
1215
1313
  code: 'USAGE',
1216
- hint: 'A property reads a column, and there is no name-match fallback. Add it to backing.mapping.',
1314
+ hint: 'A property reads a column, and there is no name-match fallback. Add it to backing.mapping, '
1315
+ + 'or, for a field only Actions write, add `editOnly: true` to it.',
1217
1316
  },
1218
1317
  )
1219
1318
  }
@@ -1222,46 +1321,9 @@ async function applyPropertyPlan(
1222
1321
  let current = revision
1223
1322
  for (const property of plan.adds) {
1224
1323
  const name = String(property.apiName)
1225
- const mapped = backing!.mapping[name]!
1226
- const column = typeof mapped === 'string' ? mapped : mapped.column
1227
- const sourceField = typeof mapped === 'string' ? undefined : mapped.field
1228
- // Resolved here rather than at plan time: a shared field created earlier in
1229
- // this same run only entered the map when its create answered.
1230
- const sharedPropertyId = resolveSharedProperty(
1231
- file, name, property.sharedField, ids.sharedPropertyIdByApiName,
1232
- )
1233
- const semanticTypeId = resolveSemanticType(
1234
- file, `field "${name}"`, property.semanticType, ids.semanticTypeIdByApiName,
1235
- )
1236
- const result = await client.draftCommand({
1237
- kind: 'add_field',
1238
- objectId: detail.id,
1239
- field: {
1240
- // The one identifier the CLI generates, and it never reaches a file: the
1241
- // command addresses a field that does not exist yet, so there is nothing on
1242
- // the draft to resolve a name against. It lives for one request.
1243
- id: crypto.randomUUID(),
1244
- apiName: name,
1245
- displayName: String(property.displayName ?? name),
1246
- propertyType: declaredPropertyType(property, ids.sharedPropertyTypeByApiName),
1247
- valueType: String(property.valueType ?? 'string'),
1248
- sourceColumn: column,
1249
- ...(sourceField === undefined || sourceField === null ? {} : { sourceField }),
1250
- required: Boolean(property.required ?? false),
1251
- unique: Boolean(property.unique ?? false),
1252
- status: 'active',
1253
- visibility: 'normal',
1254
- // A field being ADDED has no reference to detach, so an authored `null`
1255
- // is simply "no shared field" — `add_field`'s uuid is not nullable, and a
1256
- // detach command on a field that is being created makes no sense anyway.
1257
- ...(typeof sharedPropertyId === 'string' ? { sharedPropertyId } : {}),
1258
- // Same collapse: `add_field`'s uuid is not nullable, and a field being
1259
- // created has no meaning to detach from.
1260
- ...(typeof semanticTypeId === 'string' ? { semanticTypeId } : {}),
1261
- // Its own classification, when the file gives one other than the object type's.
1262
- ...fieldSensitivityOf(property, objectTypeSensitivity(file.document)),
1263
- },
1264
- }, current).catch((err) => { throw stewardRefusal(err, file, name, fieldSensitivityOf(property, objectTypeSensitivity(file.document)).sensitivity !== undefined) })
1324
+ const mapped = isEditOnly(property) ? undefined : backing!.mapping[name]!
1325
+ const result = await client.draftCommand(addFieldCommand(detail.id, file, property, mapped, ids), current)
1326
+ .catch((err) => { throw stewardRefusal(err, file, name, fieldSensitivityOf(property, objectTypeSensitivity(file.document)).sensitivity !== undefined) })
1265
1327
  current = result.revision
1266
1328
  }
1267
1329
  for (const change of plan.schemaChanges) {
@@ -618,7 +618,7 @@ const agents: Command = {
618
618
  * would be the easiest way to do the most damage. It waits for the count of
619
619
  * what will go with it.
620
620
  */
621
- const RESERVED = 'deleting a knowledge base is not available in this release; delete it from the Console'
621
+ const RESERVED = 'deleting a knowledge base is not available in this release; delete it from Agent knowledge in Studio on the platform'
622
622
 
623
623
  const reserved: Command[] = (
624
624
  [['delete', 'Delete a knowledge base']] as const
@@ -1,6 +1,6 @@
1
1
  import { UsageError } from '../../errors'
2
2
  import { FRONTERA_READ_ONLY_COMMANDS } from '../../forge/permission-rules'
3
- import { addPersonStep, clearSettledSteps, CREDENTIAL_WORDS, CREDENTIALS, currentTarget, deliveryGroups, readPersonSteps, runStepChecks, settlePersonStep, staleProblem, type PersonStep, type StepTarget } from '../../local/local-person-steps'
3
+ import { addPersonStep, clearSettledSteps, CREDENTIAL_WORDS, CREDENTIALS, deliveryGroups, readPersonSteps, runStepChecks, settlePersonStep, staleProblem, stepTargets, type PersonStep, type StepTarget } from '../../local/local-person-steps'
4
4
  import { runSelf } from '../../mcp/mcp-cli'
5
5
  import { localProject } from '../../local/local-project'
6
6
  import { flagBool, flagString, type Command } from '../types'
@@ -15,9 +15,9 @@ import { flagBool, flagString, type Command } from '../types'
15
15
 
16
16
  const ACTIONS = ['list', 'add', 'done', 'dismiss', 'reopen', 'check', 'clear'] as const
17
17
 
18
- function stepLines(steps: PersonStep[], current: StepTarget | null): string[] {
18
+ function stepLines(steps: PersonStep[], targets: StepTarget[]): string[] {
19
19
  return steps.flatMap((s, n) => {
20
- const stale = staleProblem(s, current)
20
+ const stale = staleProblem(s, targets)
21
21
  return [
22
22
  ` ${n + 1}. ${s.command ?? `${s.changes} (in the product)`}${s.check ? ' · checks itself' : ''}`,
23
23
  ` ${s.why} Changes: ${s.changes} Needs ${CREDENTIAL_WORDS[s.credential] ?? s.credential}. (${s.id})`,
@@ -101,7 +101,7 @@ export const localSteps: Command = {
101
101
  const groups = deliveryGroups(steps)
102
102
  const settled = steps.filter((s) => s.status !== 'pending')
103
103
  const lines = groups.length
104
- ? groups.flatMap((g) => [g.delivery ? `${g.delivery}:` : 'Other steps:', ...stepLines(g.steps, currentTarget(project.root)), ''])
104
+ ? groups.flatMap((g) => [g.delivery ? `${g.delivery}:` : 'Other steps:', ...stepLines(g.steps, stepTargets(project.root)), ''])
105
105
  : ['No steps wait on the person.', '']
106
106
  if (flagBool(ctx, 'all') && settled.length) lines.push('Settled:', ...settled.map((s) => ` ${s.status === 'done' ? '✓' : '–'} ${s.command ?? s.changes} (${s.id})`))
107
107
  return { data: { pending: groups, ...(flagBool(ctx, 'all') ? { settled } : {}) }, text: lines.join('\n').trimEnd() }