@frontera-sdk/cli 1.52.27 → 1.52.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
@@ -0,0 +1,986 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, posix } from 'node:path'
4
+
5
+ import matter from 'gray-matter'
6
+ import { parse as parseYaml } from 'yaml'
7
+
8
+ import { mimeFromPath } from '../commands/skill/bundle-commands'
9
+ import { writeFileAtomic } from '../fs-atomic'
10
+ import { CliError } from '../errors'
11
+ import { documentChanges, localDocuments, splitDocumentPath, type PulledDocument } from './local-knowledge-documents'
12
+ import { QUALITY_CHECKS_FILE, qualityChecksPath, readQualityChecks } from './local-quality-checks'
13
+ import { lessonsPath, lessonsProposal } from './local-lessons'
14
+ import { importedFiles } from './local-function-shared'
15
+ import { isIgnoredName } from './local-dev-sync'
16
+ import { comparableText, withoutSecrets } from './local-secrets'
17
+ import { deepEqual } from './local-sync-merge'
18
+ import { itemTitle } from './local-sync-names'
19
+ import { hash, itemHash, readLocal, safePath, writeLock, type LockEntry, type PlanItem, type RemoteItem, type SyncKind, type SyncLock } from './local-sync-plan'
20
+ import { dropBase, SYNC_DIR, writeBase, type RemoteSnapshot } from './local-sync-snapshot'
21
+
22
+ /**
23
+ * Change requests v1, the project's side (docs/specs/2026-10-17-change-requests-v1-design.md
24
+ * §4, §7, §11): what a push proposes, the request it keeps, and what Apply
25
+ * did, in words.
26
+ *
27
+ * what is pushed items changed here (a merge or a resolve leaves them so) and items only here
28
+ * what is refused an item the workspace changed too (pull and merge first), one with conflicts,
29
+ * a deletion, and kinds a request does not carry (plugins, evaluations, datasets)
30
+ * each item the lock's id, the revision its base is (the Blueprint's for a Blueprint file),
31
+ * every file of the item, and for a Function its `function deploy` build
32
+ * the request `.frontera/sync/requests.json` (not committed): the current one, so the next
33
+ * push updates it, and a copy of each item's base for the Changes view
34
+ *
35
+ * Pure but for the project's files: the platform calls and the build are the
36
+ * command's (`commands/local/sync-request.ts`).
37
+ */
38
+
39
+ /** The kinds a change request carries. */
40
+ export const PUSH_KINDS: readonly SyncKind[] = ['blueprint', 'skill', 'knowledge', 'plugin', 'function', 'workflow', 'agent', 'evaluation', 'chat-app', 'lessons']
41
+
42
+ /** Why the other kinds are not proposed, in the reader's words. */
43
+ export const NOT_PROPOSED: Partial<Record<SyncKind, string>> = {
44
+ dataset: 'Datasets aren’t proposed; upload them on the platform',
45
+ brand: 'The organization’s brand is set on the platform, in Settings; it isn’t proposed from a project',
46
+ library: 'Library installations are made from the library on the platform; this file only records them',
47
+ app: 'An App’s code ships with its own project, and the Functions it may start are set on the platform',
48
+ }
49
+
50
+ /** Kinds the platform cannot delete, and what to do instead. */
51
+ const NOT_DELETED: Partial<Record<SyncKind, string>> = {
52
+ function: 'A Function can’t be deleted in a workspace: switch it off on the platform, or keep its file',
53
+ lessons: 'An Agent’s rules are retired on the platform, where they are reviewed: a change request only proposes new ones',
54
+ }
55
+
56
+ /** Why an item cannot be proposed now. */
57
+ export type BlockCode = 'moved' | 'conflicts' | 'deleted' | 'workspace-deleted' | 'kind' | 'shape'
58
+
59
+ /** A skill's file that is not text (an image, a font, a template): uploaded as a skill asset when proposed. */
60
+ export interface PushAsset {
61
+ /** Relative to the skill's folder, as the platform keeps it: `assets/logo.png`. */
62
+ path: string
63
+ /** Project path. */
64
+ file: string
65
+ sha256: string
66
+ bytes: number
67
+ mime: string
68
+ visible: boolean
69
+ change: 'new' | 'changed' | 'same'
70
+ }
71
+
72
+ /** What the project brings of a knowledge base's documents. */
73
+ export interface PushDocuments {
74
+ upload: Array<{ fileName: string; sha256: string; bytes: number; mime: string; replaces: string | null; change: 'new' | 'changed' }>
75
+ remove: Array<{ fileName: string; sourceId: string }>
76
+ }
77
+
78
+ export interface PushFile {
79
+ path: string
80
+ /** The file as the item's base has it; null when the base does not have it (or the item is new). */
81
+ base: string | null
82
+ /** The file here; null when it was removed here. */
83
+ local: string | null
84
+ }
85
+
86
+ export interface PushItem {
87
+ ref: string
88
+ kind: SyncKind
89
+ name: string
90
+ title: string
91
+ /** `changed`: the workspace has it and it changed here. `new`: only here. `deleted`: deleted here, so Apply deletes it there. */
92
+ change: 'changed' | 'new' | 'deleted'
93
+ proposable: boolean
94
+ /** Why not, when it is not. */
95
+ blocked: BlockCode | null
96
+ reason: string | null
97
+ /** The platform's id, from the lock (or the snapshot). */
98
+ id: string | null
99
+ /** The revision the item was changed from; for a Blueprint file, the Blueprint's. */
100
+ baseRevision: string | null
101
+ /** Every file of the item as it stands here: what is sent. */
102
+ files: Record<string, string>
103
+ /** Each file that differs from the base, for the preview and the Changes view. */
104
+ diff: PushFile[]
105
+ /** The base's files, whole: kept with the request for its Changes view. */
106
+ base: Record<string, string>
107
+ /** What is left out, said rather than dropped (a secret's value, a knowledge base's documents). */
108
+ notes: string[]
109
+ /** A skill's assets, the whole set, when it has any here or in the workspace. */
110
+ assets?: PushAsset[]
111
+ /** A knowledge base's documents, when they are brought. */
112
+ documents?: PushDocuments
113
+ }
114
+
115
+ const BLUEPRINT_DIRS = ['semantic-types', 'shared-fields', 'object-types', 'link-types', 'metrics', 'actions'] as const
116
+
117
+ const BLUEPRINT_WORD: Record<string, string> = {
118
+ 'semantic-types': 'semantic type',
119
+ 'shared-fields': 'shared field',
120
+ 'object-types': 'object type',
121
+ 'link-types': 'link type',
122
+ metrics: 'metric',
123
+ actions: 'Action',
124
+ }
125
+
126
+ const SKIP_DIRS = new Set(['node_modules', '.frontera', '.git', 'dist', 'build', 'coverage'])
127
+
128
+ /** Files under a folder, project-relative, skipping hidden files, editor leftovers and the workspace's conflict copies. */
129
+ function filesUnder(root: string, rel: string, depth = 0): string[] {
130
+ const dir = join(root, rel)
131
+ if (depth > 8 || !existsSync(dir)) return []
132
+ let names: string[]
133
+ try {
134
+ names = readdirSync(dir).sort()
135
+ } catch {
136
+ return []
137
+ }
138
+ const out: string[] = []
139
+ for (const name of names) {
140
+ if (SKIP_DIRS.has(name) || isIgnoredName(name)) continue
141
+ const path = posix.join(rel, name)
142
+ let stat
143
+ try {
144
+ stat = lstatSync(join(root, path))
145
+ } catch {
146
+ continue
147
+ }
148
+ if (stat.isDirectory()) out.push(...filesUnder(root, path, depth + 1))
149
+ else if (stat.isFile()) out.push(path)
150
+ }
151
+ return out
152
+ }
153
+
154
+ /** The relative imports a Function file makes inside `functions/`, followed through its helpers. */
155
+ /** A Function's file and what it imports: its helpers in `functions/`, and the shared files it imports from outside it. */
156
+ function functionImports(root: string, entry: string): string[] {
157
+ return importedFiles(root, entry)
158
+ }
159
+
160
+ /**
161
+ * Every file of one item as the project holds it, in the layout a pull
162
+ * writes and a request carries (`change-request-candidates.ts` on the
163
+ * service). The lock's paths that still exist come along, so nothing the
164
+ * last pull wrote is left behind.
165
+ */
166
+ export function itemFiles(root: string, kind: SyncKind, name: string, entry?: LockEntry, opts: { documents?: boolean } = {}): { files: Record<string, string>; notes: string[]; problem?: string; assets?: string[] } {
167
+ const notes: string[] = []
168
+ let paths: string[] = []
169
+ const assets: string[] = []
170
+ switch (kind) {
171
+ case 'agent':
172
+ paths = ['agent.json', 'operating.json', 'bindings.json'].map((f) => `agents/${name}/${f}`)
173
+ break
174
+ case 'workflow': {
175
+ paths = [`workflows/${name}.yaml`, `workflows/${name}.yml`]
176
+ if (!paths.some((p) => readLocal(root, p) !== null)) {
177
+ const other = [`workflows/${name}.json`, `workflows/${name}.workflow.yaml`, `workflows/${name}.workflow.yml`].find((p) => readLocal(root, p) !== null)
178
+ if (other) return { files: {}, notes, problem: `${other}: a change request carries a workflow as workflows/${name}.yaml; rename the file to propose it` }
179
+ }
180
+ break
181
+ }
182
+ case 'function': {
183
+ if (existsSync(join(root, 'functions', name, 'package.json'))) paths = filesUnder(root, `functions/${name}`)
184
+ else {
185
+ const entryFile = `functions/${name}.ts`
186
+ paths = [...functionImports(root, entryFile), `functions/${name}.test.ts`]
187
+ }
188
+ break
189
+ }
190
+ case 'skill': {
191
+ for (const path of filesUnder(root, `skills/${name}`)) {
192
+ const rel = path.slice(`skills/${name}/`.length)
193
+ const top = rel.split('/')[0]
194
+ if (rel === 'SKILL.md' || top === 'refs' || top === 'references' || top === 'scripts' || rel.endsWith('.md')) paths.push(path)
195
+ else if (!path.endsWith('.workspace')) assets.push(path)
196
+ }
197
+ break
198
+ }
199
+ case 'plugin':
200
+ paths = [`mcp/${name}.plugin.json`]
201
+ break
202
+ case 'chat-app':
203
+ paths = [`chat-apps/${name}.json`]
204
+ break
205
+ case 'lessons':
206
+ paths = [lessonsPath(name)]
207
+ break
208
+ case 'evaluation': {
209
+ const [agent, dataset] = name.split('.')
210
+ paths = [`agents/${agent}/evals/${dataset}.yaml`, `agents/${agent}/evals/${dataset}.yml`]
211
+ break
212
+ }
213
+ case 'knowledge': {
214
+ // Its settings and its quality checks; documents only when brought.
215
+ paths = [`knowledge/${name}/knowledge.json`, qualityChecksPath(name)]
216
+ const documents = filesUnder(root, `knowledge/${name}`).filter((p) => !paths.includes(p))
217
+ if (documents.length && !opts.documents) notes.push(`Its ${documents.length} document${documents.length === 1 ? '' : 's'} stay${documents.length === 1 ? 's' : ''} as the workspace has them unless you bring them; the request carries the knowledge base’s settings`)
218
+ break
219
+ }
220
+ case 'blueprint':
221
+ paths = [`blueprint/${name}.yaml`, `blueprint/${name}.yml`, `blueprint/${name}.json`]
222
+ break
223
+ default:
224
+ return { files: {}, notes }
225
+ }
226
+ // What the last pull wrote for it comes along when it is still here (an agent's files are fixed; the rest may not be).
227
+ if (kind !== 'agent' && kind !== 'knowledge') for (const p of Object.keys(entry?.files ?? {})) if (!paths.includes(p)) paths.push(p)
228
+ const files: Record<string, string> = {}
229
+ for (const path of [...new Set(paths)].sort()) {
230
+ if (path.endsWith('.workspace') || assets.includes(path)) continue
231
+ const text = readLocal(root, path)
232
+ if (text === null) continue
233
+ if (kind === 'plugin') {
234
+ const clean = withoutSecrets(text)
235
+ files[path] = clean.text
236
+ notes.push(...clean.notes)
237
+ } else files[path] = text
238
+ }
239
+ return { files, notes, ...(kind === 'skill' ? { assets } : {}) }
240
+ }
241
+
242
+ /**
243
+ * The items the project holds, by ref, as a request names them: Agents,
244
+ * workflows, Functions (a file or a folder of their own), skills, knowledge
245
+ * bases, Blueprint files, and plugins (listed so a push can say why not).
246
+ */
247
+ export function projectRefs(root: string): Map<string, { kind: SyncKind; name: string }> {
248
+ const out = new Map<string, { kind: SyncKind; name: string }>()
249
+ const add = (kind: SyncKind, name: string) => out.set(`${kind}/${name}`, { kind, name })
250
+ const dirs = (rel: string) => {
251
+ try {
252
+ return readdirSync(join(root, rel)).filter((n) => !isIgnoredName(n) && !SKIP_DIRS.has(n)).sort()
253
+ } catch {
254
+ return []
255
+ }
256
+ }
257
+ for (const slug of dirs('agents')) if (existsSync(join(root, 'agents', slug, 'agent.json'))) add('agent', slug)
258
+ for (const f of dirs('workflows')) {
259
+ const m = /^(.+?)(?:\.workflow)?\.(ya?ml|json)$/.exec(f)
260
+ if (m) add('workflow', m[1]!)
261
+ }
262
+ for (const f of dirs('functions')) {
263
+ if (/\.ts$/.test(f) && !/\.(test|d)\.ts$/.test(f)) add('function', f.replace(/\.ts$/, ''))
264
+ else if (existsSync(join(root, 'functions', f, 'package.json'))) add('function', f)
265
+ }
266
+ for (const n of dirs('skills')) if (existsSync(join(root, 'skills', n, 'SKILL.md'))) add('skill', n)
267
+ for (const n of dirs('knowledge')) if (existsSync(join(root, 'knowledge', n, 'knowledge.json'))) add('knowledge', n)
268
+ for (const dir of BLUEPRINT_DIRS) for (const f of dirs(`blueprint/${dir}`)) if (/\.(ya?ml|json)$/.test(f)) add('blueprint', `${dir}/${f.replace(/\.(ya?ml|json)$/, '')}`)
269
+ for (const f of dirs('mcp')) if (f.endsWith('.plugin.json') && f !== '.plugin.json') add('plugin', f.slice(0, -'.plugin.json'.length))
270
+ for (const f of dirs('chat-apps')) if (f.endsWith('.json') && f !== '.json') add('chat-app', f.slice(0, -'.json'.length))
271
+ for (const slug of dirs('agents')) if (existsSync(join(root, lessonsPath(slug)))) add('lessons', slug)
272
+ for (const slug of dirs('agents')) {
273
+ for (const f of dirs(`agents/${slug}/evals`)) {
274
+ const m = /^([^.]+)\.ya?ml$/.exec(f)
275
+ if (m) add('evaluation', `${slug}.${m[1]}`)
276
+ }
277
+ }
278
+ return out
279
+ }
280
+
281
+ /** An item's name as people know it, as every surface says it (`local-sync-names.ts`). */
282
+ function titleFor(kind: SyncKind, name: string, files: Record<string, string>, known?: string): string {
283
+ return itemTitle(kind, name, files, known)
284
+ }
285
+
286
+ export interface CandidateInput {
287
+ root: string
288
+ plan: PlanItem[]
289
+ lock: SyncLock | null
290
+ snapshot: RemoteSnapshot | null
291
+ /** The snapshot's items with their files: what the workspace holds as last fetched. */
292
+ remote: RemoteItem[]
293
+ /**
294
+ * Knowledge bases whose documents are brought (opt-in, as for a pull):
295
+ * what the pull wrote of each (`documents.json`) and what the workspace has now.
296
+ */
297
+ documents?: {
298
+ names: Set<string>
299
+ pulled: Record<string, Record<string, PulledDocument>>
300
+ platform: Map<string, Array<{ id: string; fileName: string }>>
301
+ }
302
+ }
303
+
304
+ const BLOCK_REASON: Record<Exclude<BlockCode, 'kind' | 'shape'>, string> = {
305
+ moved: 'Changed in the workspace too since your last pull: pull and merge it first',
306
+ conflicts: 'Has conflicts from a pull: resolve them first',
307
+ deleted: 'Deleted here',
308
+ 'workspace-deleted': 'The workspace deleted it: decide in a pull whether to keep it here',
309
+ }
310
+
311
+ const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
312
+
313
+ /** The paths a skill's front matter marks visible (`visibleAssets:`), as `skill push` reads them. */
314
+ function visibleAssets(skillMd: string | undefined): Set<string> {
315
+ if (!skillMd) return new Set()
316
+ try {
317
+ const fm = matter(skillMd).data as { visibleAssets?: unknown }
318
+ return new Set(Array.isArray(fm.visibleAssets) ? fm.visibleAssets.filter((v): v is string => typeof v === 'string') : [])
319
+ } catch {
320
+ return new Set()
321
+ }
322
+ }
323
+
324
+ /**
325
+ * A skill's assets here against the workspace's (the snapshot's copies):
326
+ * the whole set the push would send, each marked new, changed or the same,
327
+ * and those the workspace has that this folder no longer does.
328
+ */
329
+ export function skillAssets(root: string, name: string, paths: string[], theirs: RemoteItem | undefined): { assets: PushAsset[]; removed: string[]; changed: boolean } {
330
+ const prefix = `skills/${name}/`
331
+ const visible = visibleAssets(readLocal(root, `${prefix}SKILL.md`) ?? undefined)
332
+ const remote = new Map((theirs?.assets ?? []).map((a) => [a.path, a]))
333
+ const assets: PushAsset[] = []
334
+ for (const file of paths) {
335
+ let bytes: Uint8Array
336
+ try {
337
+ bytes = new Uint8Array(readFileSync(safePath(root, file)))
338
+ } catch {
339
+ continue
340
+ }
341
+ const rel = file.slice(prefix.length)
342
+ const digest = sha256(bytes)
343
+ const copy = remote.get(file)
344
+ let change: PushAsset['change'] = 'new'
345
+ if (copy) {
346
+ let theirBytes: Uint8Array | null = copy.bytes ?? null
347
+ if (!theirBytes && copy.from && existsSync(copy.from)) theirBytes = new Uint8Array(readFileSync(copy.from))
348
+ change = theirBytes && sha256(theirBytes) === digest ? 'same' : 'changed'
349
+ }
350
+ assets.push({ path: rel, file, sha256: digest, bytes: bytes.byteLength, mime: mimeFromPath(file), visible: visible.has(rel), change })
351
+ }
352
+ const removed = [...remote.keys()].filter((p) => !paths.includes(p))
353
+ return { assets, removed, changed: removed.length > 0 || assets.some((a) => a.change !== 'same') }
354
+ }
355
+
356
+ const kb = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`)
357
+
358
+ /**
359
+ * What a push would propose, item by item: every item changed here or only
360
+ * here, with what it sends, or why it cannot be proposed now. Items that
361
+ * match the workspace, or only changed there, are not listed.
362
+ */
363
+ export function pushCandidates(input: CandidateInput): PushItem[] {
364
+ const { root, plan, snapshot } = input
365
+ const lock = input.lock && (!snapshot || input.lock.workspace === snapshot.workspace) ? input.lock : null
366
+ const remote = new Map(input.remote.map((r) => [r.ref, r]))
367
+ const byPlan = new Map(plan.map((p) => [p.ref, p]))
368
+ const here = projectRefs(root)
369
+ const refs = new Set<string>([...plan.map((p) => p.ref), ...here.keys()])
370
+ const out: PushItem[] = []
371
+ for (const ref of [...refs].sort()) {
372
+ const slash = ref.indexOf('/')
373
+ const kind = (byPlan.get(ref)?.kind ?? here.get(ref)?.kind ?? ref.slice(0, slash)) as SyncKind
374
+ const name = byPlan.get(ref)?.name ?? here.get(ref)?.name ?? ref.slice(slash + 1)
375
+ const p = byPlan.get(ref)
376
+ const entry = lock?.items[ref]
377
+ const theirs = remote.get(ref)
378
+ // A plan's "only here" for a Function in its own folder is named after its entry file: the folder names it here.
379
+ if (p?.status === 'local-only' && !entry && !here.has(ref)) continue
380
+ // An Agent's lessons are never in the plan: they are proposed while the file differs from what the last pull wrote.
381
+ const lessons = kind === 'lessons' ? lessonsProposal(root, input.lock?.workspace ?? snapshot?.workspace ?? '', name) : null
382
+ if (kind === 'lessons' && !lessons) continue
383
+ // Not in the plan: something the folder has that the workspace and the last pull never did.
384
+ const status = p?.status ?? 'local-only'
385
+ if (status === 'new' || status === 'changed') continue
386
+ const deleted = status === 'deleted-local'
387
+ const bringDocuments = kind === 'knowledge' && !!input.documents?.names.has(name)
388
+ const { files, notes, problem, assets: assetPaths } = deleted ? { files: {} as Record<string, string>, notes: [] as string[], problem: undefined, assets: undefined } : itemFiles(root, kind, name, entry, { documents: bringDocuments })
389
+ // What a file hash does not cover: a skill's assets, and a knowledge base's documents when they are brought.
390
+ const assetState = kind === 'skill' && !deleted && (assetPaths?.length || theirs?.assets?.length) ? skillAssets(root, name, assetPaths ?? [], theirs) : null
391
+ const docs = bringDocuments && !deleted && input.documents
392
+ ? documentChanges(localDocuments(root, name), input.documents.pulled[name] ?? {}, input.documents.platform.get(name) ?? [])
393
+ : null
394
+ const extra = Boolean(assetState?.changed) || Boolean(docs && (docs.upload.length || docs.remove.length))
395
+ // `same` in the plan reads the base's paths; a file added to the item since is still a change here.
396
+ // A Plugin whose only difference is this computer's secrets (a header set here) is the same, as status reads it.
397
+ const pluginSame = kind === 'plugin' && !!theirs && Object.keys(files).length > 0 && Object.keys(files).every((p) => theirs.files[p] !== undefined && comparableText(p, readLocal(root, p) ?? '', theirs.files[p]!) === theirs.files[p])
398
+ const textSame = !entry || itemHash(files) === entry.remote || !theirs || itemHash(theirs.files) !== entry.remote || pluginSame
399
+ if (status === 'same' && textSame && !extra) continue
400
+ if (status === 'same' && Object.keys(files).length === 0) continue
401
+ const isNew = !entry && !theirs
402
+ const baseFiles: Record<string, string> = !isNew && theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.files : {}
403
+ const diffPaths = [...new Set([...Object.keys(baseFiles), ...Object.keys(files)])].sort()
404
+ const diff: PushFile[] = diffPaths.map((path) => ({ path, base: baseFiles[path] ?? null, local: files[path] ?? null })).filter((f) => f.base !== f.local)
405
+ const item: PushItem = {
406
+ ref,
407
+ kind,
408
+ name,
409
+ title: titleFor(kind, name, kind === 'lessons' ? { [`agents/${name}/agent.json`]: readLocal(root, `agents/${name}/agent.json`) ?? '' } : deleted ? baseFiles : files, theirs?.title ?? (p && p.title !== p.name ? p.title : undefined)),
410
+ change: deleted ? 'deleted' : isNew ? 'new' : 'changed',
411
+ proposable: true,
412
+ blocked: null,
413
+ reason: null,
414
+ id: entry?.id ?? theirs?.id ?? null,
415
+ baseRevision: null,
416
+ files,
417
+ diff,
418
+ base: baseFiles,
419
+ notes,
420
+ }
421
+ if (assetState) {
422
+ item.assets = assetState.assets
423
+ for (const a of assetState.assets) if (a.change !== 'same') notes.push(`Asset ${a.path}: ${a.change === 'new' ? 'new' : 'changed'} (${kb(a.bytes)})`)
424
+ for (const r of assetState.removed) notes.push(`Asset ${r.slice(`skills/${name}/`.length)}: removed here, so it leaves the skill`)
425
+ }
426
+ if (docs) {
427
+ item.documents = { upload: docs.upload.map(({ content: _content, ...d }) => d), remove: docs.remove }
428
+ for (const d of docs.upload) notes.push(`Document ${d.fileName}: ${d.change === 'new' ? 'new' : 'changed'} (${kb(d.bytes)})${d.replaces ? '; the copy you pulled is taken off once everything else is applied' : ''}`)
429
+ for (const d of docs.remove) notes.push(`Document ${d.fileName}: you removed it after bringing it, so it is taken off the workspace (this cannot be undone)`)
430
+ notes.push(...docs.notes)
431
+ }
432
+ if (lessons) {
433
+ item.change = lessons.pulled ? 'changed' : 'new'
434
+ if (lessons.added.length) notes.push(`${lessons.added.length} new rule${lessons.added.length === 1 ? '' : 's'}, proposed for review: ${lessons.added.length === 1 ? 'it reaches' : 'they reach'} the Agent once a reviewer approves ${lessons.added.length === 1 ? 'it' : 'them'} on the platform`)
435
+ if (lessons.removed.length) notes.push(`${lessons.removed.length} rule${lessons.removed.length === 1 ? '' : 's'} removed here ${lessons.removed.length === 1 ? 'isn’t' : 'aren’t'} proposed: an active rule is retired on the platform, where rules are reviewed`)
436
+ if (!lessons.added.length && !lessons.removed.length) notes.push('Proposes the rules the workspace doesn’t have yet, for review')
437
+ }
438
+ const block = (code: BlockCode, reason: string) => Object.assign(item, { proposable: false, blocked: code, reason })
439
+ if (NOT_PROPOSED[kind]) block('kind', NOT_PROPOSED[kind]!)
440
+ else if (!PUSH_KINDS.includes(kind)) block('kind', 'Not a kind a change request carries')
441
+ else if (status === 'conflict') block('moved', BLOCK_REASON.moved)
442
+ else if (status === 'unresolved') block('conflicts', BLOCK_REASON.conflicts)
443
+ else if (deleted && NOT_DELETED[kind]) block('shape', NOT_DELETED[kind]!)
444
+ else if (status === 'deleted-remote') block('workspace-deleted', BLOCK_REASON['workspace-deleted'])
445
+ else if (deleted) {
446
+ // Proposable: Apply deletes it there, after everything that names it is updated.
447
+ } else if (problem) block('shape', problem)
448
+ else if (!Object.keys(files).length) block('shape', 'No files to propose')
449
+ else if (kind === 'blueprint' && Object.keys(files).length > 1) block('shape', `${Object.keys(files).join(' and ')}: keep one file per Blueprint artifact`)
450
+ else if (assetState?.changed && entry?.revision && theirs?.revision && entry.revision !== theirs.revision && textSame) {
451
+ // A pull never writes over an asset the folder has, so one the workspace changed since reads as a change here.
452
+ block('moved', 'Its assets changed in the workspace since your last pull: delete the copies here to bring the workspace’s, then propose')
453
+ }
454
+ if (item.proposable && deleted) {
455
+ item.baseRevision = (kind === 'blueprint' || kind === 'evaluation' ? snapshot?.groups[kind] : undefined) ?? theirs?.revision ?? entry?.revision ?? null
456
+ if (!item.baseRevision) block('shape', 'The workspace’s revision of it isn’t known: fetch, then propose')
457
+ } else if (item.proposable && !isNew) {
458
+ // The base is what the workspace holds now (the plan says only this side moved): its current
459
+ // revision, which for a Blueprint file is the Blueprint's.
460
+ item.baseRevision = (kind === 'blueprint' || kind === 'evaluation' ? snapshot?.groups[kind] : undefined) ?? (theirs && entry && itemHash(theirs.files) === entry.remote ? theirs.revision : undefined) ?? entry?.revision ?? null
461
+ } else if (item.proposable && kind === 'blueprint') {
462
+ item.baseRevision = snapshot?.groups.blueprint ?? null
463
+ }
464
+ out.push(item)
465
+ }
466
+ return out
467
+ }
468
+
469
+ /** Whether pushing these needs the organization key at Apply: any Blueprint item does. */
470
+ export function needsOrganizationKey(items: Array<{ kind: string; proposable?: boolean }>): boolean {
471
+ return items.some((i) => i.kind === 'blueprint' && i.proposable !== false)
472
+ }
473
+
474
+ // ── What the platform takes ─────────────────────────────────────────────────
475
+
476
+ export interface FunctionBuild {
477
+ manifest: Record<string, unknown>
478
+ /** base64 */
479
+ bundle: string
480
+ /** base64 tar.gz of the source, when the Function sits in a project */
481
+ source?: string | null
482
+ }
483
+
484
+ export interface PushBodyItem {
485
+ ref: string
486
+ id?: string | null
487
+ baseRevision?: string | null
488
+ files: Record<string, string>
489
+ build?: FunctionBuild
490
+ deleted?: true
491
+ assets?: Array<{ path: string; storageKey: string; mime: string; bytes: number; sha256: string; visible?: boolean }>
492
+ documents?: { upload: Array<{ fileName: string; folder?: string; sha256: string; bytes: number; mime: string; replaces: string | null }>; remove: Array<{ fileName: string; sourceId: string }> }
493
+ }
494
+
495
+ /** A document as a request lists it: its own file name, and its folder when it is in one. */
496
+ function asRequestDocument<T extends { fileName: string }>(d: T): T & { folder?: string } {
497
+ const { folder, fileName } = splitDocumentPath(d.fileName)
498
+ return { ...d, fileName, ...(folder ? { folder } : {}) }
499
+ }
500
+
501
+ /** The entry `function deploy` builds for a Function item. */
502
+ export function functionEntryOf(root: string, name: string, find: (root: string, name: string) => string | null): string {
503
+ if (existsSync(join(root, 'functions', name, 'package.json'))) {
504
+ const entry = find(root, name)
505
+ if (!entry) throw new CliError(`functions/${name}/ has no entry file to build`, { code: 'USAGE', hint: `add functions/${name}/index.ts, or a file whose automation() is named ${name}` })
506
+ return entry
507
+ }
508
+ return `functions/${name}.ts`
509
+ }
510
+
511
+ /**
512
+ * Each proposable item as the platform takes it: a Function is built first,
513
+ * a skill's assets uploaded (content-addressed, so an asset the workspace
514
+ * has already is not stored twice), a deletion sent with no files, and a
515
+ * knowledge base's documents listed by digest (their bytes follow once the
516
+ * request exists).
517
+ */
518
+ export async function pushBody(
519
+ items: PushItem[],
520
+ build: (item: PushItem) => Promise<FunctionBuild>,
521
+ uploadAsset: (item: PushItem, asset: PushAsset) => Promise<string> = async () => {
522
+ throw new CliError('skill assets need the platform to upload to', { code: 'USAGE', hint: 'propose with `frontera local sync push`, which uploads them' })
523
+ },
524
+ ): Promise<PushBodyItem[]> {
525
+ const out: PushBodyItem[] = []
526
+ for (const item of items) {
527
+ if (!item.proposable) continue
528
+ const assets = item.assets ? await Promise.all(item.assets.map(async (a) => ({
529
+ path: a.path,
530
+ storageKey: await uploadAsset(item, a),
531
+ mime: a.mime,
532
+ bytes: a.bytes,
533
+ sha256: a.sha256,
534
+ ...(a.visible ? { visible: true } : {}),
535
+ }))) : undefined
536
+ out.push({
537
+ ref: item.ref,
538
+ ...(item.id ? { id: item.id } : {}),
539
+ ...(item.baseRevision ? { baseRevision: item.baseRevision } : {}),
540
+ files: item.files,
541
+ ...(item.change === 'deleted' ? { deleted: true as const } : {}),
542
+ ...(item.kind === 'function' && item.change !== 'deleted' ? { build: await build(item) } : {}),
543
+ ...(assets ? { assets } : {}),
544
+ ...(item.documents ? { documents: { upload: item.documents.upload.map(({ change: _change, ...d }) => asRequestDocument(d)), remove: item.documents.remove.map(asRequestDocument).map(({ folder: _folder, ...d }) => d) } } : {}),
545
+ })
546
+ }
547
+ return out
548
+ }
549
+
550
+ // ── The request this project keeps ──────────────────────────────────────────
551
+
552
+ export type RequestState = 'draft' | 'open' | 'approved' | 'merging' | 'merged' | 'closed'
553
+
554
+ export interface KeptRequest {
555
+ id: string
556
+ title: string
557
+ state: RequestState
558
+ refs: string[]
559
+ /** What people call each item, so the home and the page name them without reading the platform. */
560
+ titles: Record<string, string>
561
+ proposedAt: string
562
+ updatedAt: string
563
+ /** When Apply last ran, and whether it went through. */
564
+ applied?: { at: string; ok: boolean }
565
+ }
566
+
567
+ export interface RequestsFile {
568
+ version: 1
569
+ workspace: string
570
+ /** The request the next push updates: open until it is applied or closed. */
571
+ current: string | null
572
+ requests: Record<string, KeptRequest>
573
+ }
574
+
575
+ const REQUESTS_JSON = join(SYNC_DIR, 'requests.json')
576
+ const REQUESTS_DIR = join(SYNC_DIR, 'requests')
577
+
578
+ /** `.frontera/sync/` stays out of git, the requests with it. */
579
+ function ignoreSync(root: string): void {
580
+ const ignore = join(root, '.frontera', '.gitignore')
581
+ mkdirSync(dirname(ignore), { recursive: true })
582
+ const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : ''
583
+ if (text.split(/\r?\n/).some((l) => l.trim() === 'sync/' || l.trim() === 'sync')) return
584
+ if (text) appendFileSync(ignore, `${text.endsWith('\n') ? '' : '\n'}sync/\n`)
585
+ else writeFileSync(ignore, 'local/\nsync/\n')
586
+ }
587
+
588
+ /** The requests file for this workspace; another workspace's (a relinked project) reads as none. */
589
+ export function readRequests(root: string, workspace: string): RequestsFile {
590
+ const empty: RequestsFile = { version: 1, workspace, current: null, requests: {} }
591
+ try {
592
+ const file = join(root, REQUESTS_JSON)
593
+ if (!existsSync(file)) return empty
594
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as RequestsFile
595
+ if (!parsed || parsed.version !== 1 || parsed.workspace !== workspace || typeof parsed.requests !== 'object' || !parsed.requests) return empty
596
+ return parsed
597
+ } catch {
598
+ return empty
599
+ }
600
+ }
601
+
602
+ export function writeRequests(root: string, file: RequestsFile): void {
603
+ ignoreSync(root)
604
+ writeFileAtomic(join(root, REQUESTS_JSON), `${JSON.stringify(file, null, 2)}\n`, 0o644)
605
+ }
606
+
607
+ /** A request still taking pushes: the next push updates it rather than opening another. */
608
+ export function isOpen(state: string | null | undefined): boolean {
609
+ return state === 'draft' || state === 'open' || state === 'approved'
610
+ }
611
+
612
+ /** The current request, when there is one still open or being applied. */
613
+ export function currentRequest(file: RequestsFile): KeptRequest | null {
614
+ const kept = file.current ? file.requests[file.current] : undefined
615
+ return kept ?? null
616
+ }
617
+
618
+ /** The platform's view of a request, as much of it as this file keeps. */
619
+ export interface RequestViewLike {
620
+ request: { id: string; title: string; state: string; createdAt?: string; updatedAt?: string }
621
+ items: Array<{ ref: string }>
622
+ }
623
+
624
+ /** Record what the platform said about a request; an applied or closed one stops being current. */
625
+ export function recordRequest(root: string, workspace: string, view: RequestViewLike, opts: { titles?: Record<string, string>; applied?: { ok: boolean }; pushed?: boolean; now?: Date } = {}): RequestsFile {
626
+ const file = readRequests(root, workspace)
627
+ const now = (opts.now ?? new Date()).toISOString()
628
+ const before = file.requests[view.request.id]
629
+ const refs = view.items.map((i) => i.ref).sort()
630
+ const titles = Object.fromEntries(refs.map((r) => [r, opts.titles?.[r] ?? before?.titles[r] ?? r]))
631
+ file.requests[view.request.id] = {
632
+ id: view.request.id,
633
+ title: view.request.title,
634
+ state: view.request.state as RequestState,
635
+ refs,
636
+ titles,
637
+ proposedAt: before?.proposedAt ?? view.request.createdAt ?? now,
638
+ updatedAt: now,
639
+ // A push after a failed Apply proposes the request again: it no longer reads "Apply failed".
640
+ ...(opts.applied ? { applied: { at: now, ok: opts.applied.ok } } : before?.applied && !opts.pushed ? { applied: before.applied } : {}),
641
+ }
642
+ // Applied or closed, it stays listed (the card says "Applied") and the next push opens a new one.
643
+ file.current = view.request.id
644
+ writeRequests(root, file)
645
+ return file
646
+ }
647
+
648
+ /**
649
+ * The base versions kept with a request: the files each item was changed
650
+ * from, for its Changes view. `replace: false` (a push of named items) keeps
651
+ * the other items' copies.
652
+ */
653
+ export function writeRequestBase(root: string, id: string, items: PushItem[], opts: { replace?: boolean } = {}): void {
654
+ if (!/^[0-9a-f-]{36}$/i.test(id)) return
655
+ ignoreSync(root)
656
+ const dir = join(root, REQUESTS_DIR, id, 'base')
657
+ const listed = join(dir, '.items')
658
+ const previous = opts.replace === false && existsSync(listed) ? readFileSync(listed, 'utf8').split('\n').filter(Boolean) : []
659
+ if (opts.replace !== false) rmSync(dir, { recursive: true, force: true })
660
+ mkdirSync(dir, { recursive: true })
661
+ for (const item of items) {
662
+ for (const [path, text] of Object.entries(item.base)) {
663
+ safePath(root, path)
664
+ const full = safePath(dir, path)
665
+ mkdirSync(dirname(full), { recursive: true })
666
+ writeFileSync(full, text)
667
+ }
668
+ }
669
+ // Which items the copy is for, whether each was new (a new item has no base to keep), and its base's
670
+ // paths (a deletion sends no files, so its Changes view lists them from here).
671
+ const lines = new Map(previous.map((l) => [l.split('\t')[0]!, l]))
672
+ for (const i of items) lines.set(i.ref, `${i.ref}\t${i.change}\t${Object.keys(i.base).join('\u001f')}`)
673
+ writeFileSync(listed, `${[...lines.values()].join('\n')}\n`)
674
+ }
675
+
676
+ /** A base file kept with a request, or null; `known` says whether the copy exists at all, `paths` an item's base files. */
677
+ export function readRequestBase(root: string, id: string): { known: boolean; read: (path: string) => string | null; paths: (ref: string) => string[] } {
678
+ const dir = join(root, REQUESTS_DIR, id, 'base')
679
+ const known = /^[0-9a-f-]{36}$/i.test(id) && existsSync(join(dir, '.items'))
680
+ const listed = known ? readFileSync(join(dir, '.items'), 'utf8').split('\n').filter(Boolean).map((l) => l.split('\t')) : []
681
+ return {
682
+ known,
683
+ paths: (ref) => (listed.find((l) => l[0] === ref)?.[2] ?? '').split('\u001f').filter(Boolean),
684
+ read: (path) => {
685
+ if (!known) return null
686
+ try {
687
+ const full = safePath(dir, path)
688
+ return existsSync(full) ? readFileSync(full, 'utf8') : null
689
+ } catch {
690
+ return null
691
+ }
692
+ },
693
+ }
694
+ }
695
+
696
+ // ── After Apply ─────────────────────────────────────────────────────────────
697
+
698
+ /** Whether two versions of a file say the same: the same document for JSON and YAML (key order aside), the same text otherwise. */
699
+ export function sameDocument(path: string, a: string, b: string): boolean {
700
+ if (a === b) return true
701
+ try {
702
+ if (/^agents\/[^/]+\/evals\/[^/]+\.ya?ml$/.test(path)) return deepEqual(evaluationCases(a), evaluationCases(b))
703
+ // A knowledge base's quality checks are a set: the platform lists them in its own order.
704
+ if (path.endsWith(`/${QUALITY_CHECKS_FILE}`)) return deepEqual(checkSet(a), checkSet(b))
705
+ if (/\.json$/i.test(path)) return deepEqual(JSON.parse(a), JSON.parse(b))
706
+ if (/\.ya?ml$/i.test(path)) return deepEqual(parseYaml(a), parseYaml(b))
707
+ } catch {
708
+ return false
709
+ }
710
+ return false
711
+ }
712
+
713
+ /** Quality checks as a set: by question, each one's lists in order of their words. */
714
+ function checkSet(text: string): unknown {
715
+ return readQualityChecks(text)
716
+ .map((c) => ({ ...c, documents: [...(c.documents ?? [])].sort(), keywords: [...(c.keywords ?? [])].sort() }))
717
+ .sort((x, y) => x.question.localeCompare(y.question))
718
+ }
719
+
720
+ /**
721
+ * An evaluation file's cases as the platform keeps them: each question with
722
+ * its expected answer and phrases, in order. A case's name and gold flag are
723
+ * the platform's to give (a pull names cases by position), so two files with
724
+ * the same cases say the same.
725
+ */
726
+ function evaluationCases(text: string): unknown {
727
+ const doc = (parseYaml(text) ?? {}) as { cases?: Array<Record<string, unknown>> }
728
+ return (doc.cases ?? []).map((c) =>
729
+ c.scenario !== undefined
730
+ ? { scenario: c.scenario, expect: c.expect ?? null }
731
+ : {
732
+ question: (c.conversation as Array<{ user?: string }> | undefined)?.[0]?.user ?? null,
733
+ expected: typeof c.expected === 'string' ? c.expected : null,
734
+ contains: Array.isArray(c.expectedContains) ? c.expectedContains : [],
735
+ // A check's text is the platform's to word.
736
+ checks: Array.isArray(c.checks) ? (c.checks as Array<Record<string, unknown>>).map(({ text: _text, ...check }) => check) : [],
737
+ judged: Array.isArray(c.judged) ? c.judged : [],
738
+ },
739
+ )
740
+ }
741
+
742
+ /**
743
+ * After an Apply went through and a fetch read the new versions: each
744
+ * applied item's base moves to the workspace's version, as a pull leaves it.
745
+ * A file that says the same as the workspace's (the platform may order an
746
+ * Agent's keys its own way) takes the workspace's text, so the item reads as
747
+ * up to date; one that differs is left alone and shows as a change here.
748
+ * Returns the refs moved and the files rewritten.
749
+ */
750
+ export function adoptApplied(root: string, workspace: string, refs: string[], remote: RemoteItem[], lock: SyncLock | null, now = new Date(), opts: { deleted?: string[] } = {}): { adopted: string[]; rewritten: string[] } {
751
+ const byRef = new Map(remote.map((r) => [r.ref, r]))
752
+ const next: SyncLock = { version: 2, workspace, ...(lock?.workspace === workspace && lock.pulledAt ? { pulledAt: lock.pulledAt } : {}), items: { ...(lock?.workspace === workspace ? lock.items : {}) } }
753
+ const moved: RemoteItem[] = []
754
+ const rewritten: string[] = []
755
+ const forgotten: string[] = []
756
+ for (const ref of refs) {
757
+ // Deleted here and applied, so gone there too: the item leaves the lock and its base, as a pull's Delete here
758
+ // leaves it (whatever a fetch that did not read the whole index still lists).
759
+ if (opts.deleted?.includes(ref)) {
760
+ if (next.items[ref]) {
761
+ forgotten.push(...Object.keys(next.items[ref]!.files))
762
+ delete next.items[ref]
763
+ }
764
+ continue
765
+ }
766
+ const item = byRef.get(ref)
767
+ if (!item) continue
768
+ for (const [path, text] of Object.entries(item.files)) {
769
+ const here = readLocal(root, path)
770
+ if (here !== null && here !== text && sameDocument(path, here, text)) {
771
+ writeFileSync(safePath(root, path), text)
772
+ rewritten.push(path)
773
+ }
774
+ }
775
+ next.items[ref] = {
776
+ ...(item.id ? { id: item.id } : {}),
777
+ ...(item.revision ? { revision: item.revision } : {}),
778
+ remote: itemHash(item.files),
779
+ files: Object.fromEntries(Object.entries(item.files).map(([p, c]) => [p, hash(c)])),
780
+ ...(item.version ? { version: item.version } : {}),
781
+ pulledAt: now.toISOString(),
782
+ }
783
+ moved.push(item)
784
+ }
785
+ if (!moved.length && !forgotten.length) return { adopted: [], rewritten }
786
+ if (moved.length) writeBase(root, moved, lock)
787
+ if (forgotten.length) dropBase(root, forgotten)
788
+ writeLock(root, next)
789
+ const gone = (opts.deleted ?? []).filter((r) => refs.includes(r))
790
+ return { adopted: [...moved.map((m) => m.ref), ...gone], rewritten }
791
+ }
792
+
793
+ // ── In words ────────────────────────────────────────────────────────────────
794
+
795
+ export const KIND_WORD: Record<SyncKind, string> = {
796
+ agent: 'Agent',
797
+ workflow: 'Workflow',
798
+ function: 'Function',
799
+ skill: 'Skill',
800
+ knowledge: 'Knowledge base',
801
+ blueprint: 'Blueprint',
802
+ plugin: 'Plugin',
803
+ evaluation: 'Evaluation',
804
+ dataset: 'Dataset',
805
+ theme: 'Slide theme',
806
+ 'chat-app': 'Chat App',
807
+ app: 'App',
808
+ brand: 'Brand',
809
+ library: 'Library',
810
+ lessons: 'Lessons for Agent',
811
+ }
812
+
813
+ /**
814
+ * What an Agent's bindings file changes: its tools (Functions and workflows),
815
+ * its Context (the object types it reads), or both. Unread, it says both.
816
+ */
817
+ function bindingsParts(file: PushFile | undefined): string[] {
818
+ const read = (text: string | null | undefined) => {
819
+ try {
820
+ const doc = JSON.parse(text ?? '{}') as Record<string, unknown>
821
+ return { tools: JSON.stringify([doc.functions ?? [], doc.workflows ?? []]), context: JSON.stringify(doc.blueprint ?? []) }
822
+ } catch {
823
+ return null
824
+ }
825
+ }
826
+ const before = read(file?.base)
827
+ const after = read(file?.local)
828
+ if (!file || !before || !after) return ['tools and Context']
829
+ const parts = [before.tools !== after.tools ? 'tools' : '', before.context !== after.context ? 'Context' : ''].filter(Boolean)
830
+ return parts.length ? parts : ['tools and Context']
831
+ }
832
+
833
+ /** What changed in an item, in the product's words: "configuration and behavior settings". */
834
+ export function changedParts(kind: SyncKind, name: string, paths: string[], files: PushFile[] = []): string[] {
835
+ const parts = new Set<string>()
836
+ for (const path of paths) {
837
+ if (kind === 'agent') {
838
+ if (path.endsWith('/agent.json')) parts.add('configuration')
839
+ else if (path.endsWith('/operating.json')) parts.add('behavior settings')
840
+ else if (path.endsWith('/bindings.json')) for (const p of bindingsParts(files.find((f) => f.path === path))) parts.add(p)
841
+ } else if (kind === 'skill') {
842
+ const rel = path.slice(`skills/${name}/`.length)
843
+ parts.add(rel === 'SKILL.md' ? 'instructions' : rel.startsWith('scripts/') ? 'scripts' : 'references')
844
+ } else if (kind === 'workflow') parts.add('definition')
845
+ else if (kind === 'function') parts.add('code')
846
+ else if (kind === 'knowledge') parts.add(path.endsWith(`/${QUALITY_CHECKS_FILE}`) ? 'quality checks' : 'settings')
847
+ else if (kind === 'plugin') parts.add('settings and policy')
848
+ else if (kind === 'evaluation') parts.add('cases')
849
+ else if (kind === 'chat-app') parts.add('experience')
850
+ else if (kind === 'lessons') parts.add('rules')
851
+ }
852
+ return [...parts]
853
+ }
854
+
855
+ function joinWords(words: string[]): string {
856
+ if (words.length <= 1) return words[0] ?? ''
857
+ return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
858
+ }
859
+
860
+ /** One line per item, as the Overview says it: "Agent Support Concierge: behavior settings changed". */
861
+ export function itemLine(item: { kind: SyncKind; name: string; title: string; change: 'changed' | 'new' | 'deleted'; changedPaths?: string[]; changedFiles?: PushFile[]; assetsChanged?: boolean; documentsChanged?: boolean }): string {
862
+ const [dir, apiName] = item.name.split('/')
863
+ const word = BLUEPRINT_WORD[dir ?? '']
864
+ // A Blueprint file says what it is: "Object type Ticket".
865
+ const label = item.kind === 'blueprint' ? (word && apiName ? `${word[0]!.toUpperCase()}${word.slice(1)} ${item.title}` : `Blueprint ${item.title}`) : `${KIND_WORD[item.kind]} ${item.title}`
866
+ if (item.change === 'deleted') return `${label}: delete`
867
+ // The platform keeps no lessons file, so "new" or "changed" depends only on
868
+ // whether this project pulled one; what the request does is the same either way.
869
+ if (item.kind === 'lessons') return `${label}: rules proposed for review`
870
+ if (item.change === 'new') return `${label}: new`
871
+ const parts = changedParts(item.kind, item.name, item.changedPaths ?? [], item.changedFiles)
872
+ if (item.assetsChanged) parts.push('assets')
873
+ if (item.documentsChanged) parts.push('documents')
874
+ return parts.length ? `${label}: ${joinWords(parts)} changed` : `${label}: changed`
875
+ }
876
+
877
+ /** The line for a candidate, with what its files do not show (assets, documents). */
878
+ export function pushItemLine(item: PushItem): string {
879
+ return itemLine({
880
+ ...item,
881
+ changedPaths: item.diff.map((f) => f.path),
882
+ changedFiles: item.diff,
883
+ assetsChanged: Boolean(item.assets?.some((a) => a.change !== 'same')) || item.notes.some((n) => n.startsWith('Asset ') && n.includes('removed here')),
884
+ documentsChanged: Boolean(item.documents && (item.documents.upload.length || item.documents.remove.length)),
885
+ })
886
+ }
887
+
888
+ // ── Apply's outcome ─────────────────────────────────────────────────────────
889
+
890
+ export type StepStatus = 'done' | 'failed' | 'not-run'
891
+
892
+ export interface ApplyStep {
893
+ key: string
894
+ phase: string
895
+ ref: string
896
+ label: string
897
+ status: StepStatus
898
+ error?: string
899
+ notes?: string[]
900
+ }
901
+
902
+ export interface ApplyOutcome {
903
+ ok: boolean
904
+ steps: ApplyStep[]
905
+ failure?: { key: string; ref: string; itemIds: string[]; message: string }
906
+ compensation: Array<{ label: string; type: string; status: 'put-back' | 'failed' | 'irreversible'; error?: string }>
907
+ }
908
+
909
+ /** Apply's outcome as the service sent it, read defensively: a field it lacks reads as empty. */
910
+ export function parseOutcome(raw: unknown): ApplyOutcome {
911
+ const o = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
912
+ const steps = (Array.isArray(o.steps) ? o.steps : []).flatMap((s): ApplyStep[] => {
913
+ if (!s || typeof s !== 'object') return []
914
+ const r = s as Record<string, unknown>
915
+ const status: StepStatus = r.status === 'done' || r.status === 'failed' ? r.status : 'not-run'
916
+ return [{
917
+ key: String(r.key ?? ''),
918
+ phase: String(r.phase ?? ''),
919
+ ref: String(r.ref ?? ''),
920
+ label: String(r.label ?? r.key ?? ''),
921
+ status,
922
+ ...(typeof r.error === 'string' ? { error: r.error } : {}),
923
+ ...(Array.isArray(r.notes) ? { notes: r.notes.filter((n): n is string => typeof n === 'string') } : {}),
924
+ }]
925
+ })
926
+ const f = o.failure as Record<string, unknown> | undefined
927
+ const compensation = (Array.isArray(o.compensation) ? o.compensation : []).flatMap((c) => {
928
+ if (!c || typeof c !== 'object') return []
929
+ const r = c as Record<string, unknown>
930
+ const status = r.status === 'failed' || r.status === 'irreversible' ? r.status : 'put-back'
931
+ return [{ label: String(r.label ?? ''), type: String(r.type ?? ''), status: status as 'put-back' | 'failed' | 'irreversible', ...(typeof r.error === 'string' ? { error: r.error } : {}) }]
932
+ })
933
+ return {
934
+ ok: o.ok === true,
935
+ steps,
936
+ ...(f && typeof f === 'object' ? { failure: { key: String(f.key ?? ''), ref: String(f.ref ?? ''), itemIds: Array.isArray(f.itemIds) ? f.itemIds.map(String) : [], message: String(f.message ?? 'A step failed') } } : {}),
937
+ compensation,
938
+ }
939
+ }
940
+
941
+ const STEP_MARK: Record<StepStatus, string> = { done: '✓', failed: '✗', 'not-run': '·' }
942
+ const BACK_WORD = { 'put-back': 'put back', failed: 'could not be put back', irreversible: 'cannot be withdrawn' } as const
943
+
944
+ /** Apply's steps, and after a failure what was put back, as `request apply` prints them. */
945
+ export function describeOutcome(outcome: ApplyOutcome, titles: Record<string, string> = {}): string[] {
946
+ const lines = outcome.steps.flatMap((s) => [
947
+ ` ${STEP_MARK[s.status]} ${s.label}${s.status === 'not-run' && !outcome.ok ? ' (not run)' : ''}`,
948
+ ...(s.notes ?? []).map((n) => ` · ${n}`),
949
+ ])
950
+ if (outcome.ok) return [`Applied ${outcome.steps.length} step${outcome.steps.length === 1 ? '' : 's'}:`, ...lines]
951
+ const failed = outcome.failure
952
+ const name = failed ? titles[failed.ref] ?? failed.ref : 'a step'
953
+ return [
954
+ `Not applied: ${failed ? `${name} failed: ${failed.message}` : 'a step failed'}`,
955
+ ...lines,
956
+ ...(outcome.compensation.length
957
+ ? ['', 'What was put back:', ...outcome.compensation.map((c) => ` ${c.label}: ${BACK_WORD[c.status]}${c.error ? ` (${c.error})` : ''}`)]
958
+ : ['', 'Nothing had changed yet, so nothing needed putting back.']),
959
+ ]
960
+ }
961
+
962
+ export interface Check {
963
+ ref: string
964
+ itemId?: string
965
+ code: string
966
+ message: string
967
+ }
968
+
969
+ /** The checks that say where an item stands, in the reader's words; the others say what is wrong in a file, as they are. */
970
+ const CHECK_WORDS: Record<string, string> = {
971
+ stale_base: 'Changed in the workspace since it was proposed: pull and merge it, then propose again',
972
+ gone: 'No longer in the workspace: propose it again as new, or leave it out',
973
+ exists: 'Now exists in the workspace: pull and merge it first',
974
+ }
975
+
976
+ /** The blocking checks a refusal carries (`details.checks`), read defensively. */
977
+ export function checksOf(details: unknown): Check[] {
978
+ const d = (details && typeof details === 'object' ? details : {}) as { checks?: unknown; details?: { checks?: unknown } }
979
+ const list = Array.isArray(d.checks) ? d.checks : Array.isArray(d.details?.checks) ? d.details!.checks as unknown[] : []
980
+ return list.flatMap((c) => {
981
+ if (!c || typeof c !== 'object') return []
982
+ const r = c as Record<string, unknown>
983
+ if (typeof r.ref !== 'string' || typeof r.message !== 'string') return []
984
+ return [{ ref: r.ref, ...(typeof r.itemId === 'string' ? { itemId: r.itemId } : {}), code: typeof r.code === 'string' ? r.code : 'invalid', message: (typeof r.code === 'string' && CHECK_WORDS[r.code]) || r.message }]
985
+ })
986
+ }