@frontera-sdk/cli 1.52.45 → 1.52.47

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 (105) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +3 -1
  3. package/src/api/automation-api.ts +32 -0
  4. package/src/api/credential-failure.ts +1 -1
  5. package/src/api/governed-action-api.ts +24 -0
  6. package/src/api/media-api.ts +24 -0
  7. package/src/api/platform-api.ts +13 -0
  8. package/src/binding-registry.ts +9 -2
  9. package/src/blueprint/compile.ts +3 -1
  10. package/src/blueprint/ontology-edit-plan.ts +71 -4
  11. package/src/blueprint/org-name-check.ts +64 -0
  12. package/src/blueprint/tree.ts +28 -0
  13. package/src/commands/action/deploy.ts +22 -5
  14. package/src/commands/action/index-commands.ts +2 -0
  15. package/src/commands/action/retire.ts +60 -0
  16. package/src/commands/agent/compose.ts +1 -0
  17. package/src/commands/agent/index-commands.ts +42 -0
  18. package/src/commands/app/deploy.ts +1 -1
  19. package/src/commands/app/dev.ts +2 -2
  20. package/src/commands/app/save.ts +1 -1
  21. package/src/commands/auth/add.ts +5 -1
  22. package/src/commands/auth/current.ts +6 -0
  23. package/src/commands/auth/index-commands.ts +3 -0
  24. package/src/commands/auth/login.ts +84 -0
  25. package/src/commands/auth/use.ts +21 -2
  26. package/src/commands/automation/bindings.ts +54 -10
  27. package/src/commands/automation/index-commands.ts +2 -3
  28. package/src/commands/blueprint/bind.ts +12 -1
  29. package/src/commands/blueprint/declarative.ts +6 -2
  30. package/src/commands/conversation/index-commands.ts +66 -0
  31. package/src/commands/conversation/local-runtime.ts +18 -0
  32. package/src/commands/local/check.ts +57 -11
  33. package/src/commands/local/data.ts +75 -7
  34. package/src/commands/local/dev.ts +77 -9
  35. package/src/commands/local/embeddings.ts +74 -0
  36. package/src/commands/local/index-commands.ts +133 -11
  37. package/src/commands/local/new.ts +78 -0
  38. package/src/commands/local/publish.ts +34 -0
  39. package/src/commands/local/steps.ts +105 -0
  40. package/src/commands/local/sync-request.ts +69 -23
  41. package/src/commands/local/sync.ts +2 -0
  42. package/src/commands/media/index-commands.ts +58 -3
  43. package/src/commands/registry.ts +11 -3
  44. package/src/commands/trace/index-commands.ts +56 -0
  45. package/src/commands/workspace/index-commands.ts +14 -7
  46. package/src/config.ts +4 -1
  47. package/src/dev-broker.ts +15 -1
  48. package/src/device-login.ts +143 -0
  49. package/src/exit.ts +36 -8
  50. package/src/flag-help.ts +30 -0
  51. package/src/forge/claude-adapter.ts +17 -4
  52. package/src/forge/permission-rules.ts +158 -0
  53. package/src/forge/touches.ts +7 -6
  54. package/src/function-outside-imports.ts +13 -2
  55. package/src/function-source.ts +90 -0
  56. package/src/harness.ts +4 -4
  57. package/src/key-reach.ts +64 -0
  58. package/src/local/local-actions.ts +15 -3
  59. package/src/local/local-apply-error.ts +26 -0
  60. package/src/local/local-apps.ts +9 -3
  61. package/src/local/local-blueprint-schemas.ts +15 -1
  62. package/src/local/local-confidential.ts +81 -0
  63. package/src/local/local-data-load.ts +130 -0
  64. package/src/local/local-dev-references.ts +24 -4
  65. package/src/local/local-embedding-setup.ts +62 -0
  66. package/src/local/local-jobs.ts +108 -0
  67. package/src/local/local-new.ts +148 -0
  68. package/src/local/local-org-settings.ts +1 -1
  69. package/src/local/local-person-steps.ts +340 -0
  70. package/src/local/local-personas.ts +16 -5
  71. package/src/local/local-portability.ts +22 -1
  72. package/src/local/local-publish.ts +26 -0
  73. package/src/local/local-request-evidence.ts +35 -6
  74. package/src/local/local-runtime-history.ts +4 -3
  75. package/src/local/local-said-once.ts +51 -0
  76. package/src/local/local-scaffold.ts +40 -2
  77. package/src/local/local-schemas.ts +60 -11
  78. package/src/local/local-stand-ins.ts +44 -0
  79. package/src/local/local-supervisor.ts +22 -0
  80. package/src/local/local-sync-models.ts +5 -2
  81. package/src/local/local-sync-names.ts +3 -0
  82. package/src/local/local-sync-portable.ts +64 -15
  83. package/src/local/local-sync-precheck.ts +30 -1
  84. package/src/local/local-sync-push.ts +81 -8
  85. package/src/local/local-sync-remote.ts +10 -7
  86. package/src/local/local-test-models.ts +173 -0
  87. package/src/local/local-test-runner.ts +232 -25
  88. package/src/local/local-test-scope.ts +174 -0
  89. package/src/local/local-ungranted-reads.ts +55 -0
  90. package/src/local/local-writers.ts +171 -0
  91. package/src/main.ts +75 -17
  92. package/src/mcp/browser/ax-snapshot.ts +13 -1
  93. package/src/mcp/browser/page-scripts.ts +1 -0
  94. package/src/mcp/mcp-conversations.ts +171 -0
  95. package/src/mcp/mcp-server.ts +4 -1
  96. package/src/mcp/mcp-traces.ts +7 -2
  97. package/src/mcp/tools/agent-tools.ts +94 -2
  98. package/src/mcp/tools/project-tools.ts +149 -13
  99. package/src/mcp/tools/test-tools.ts +64 -13
  100. package/src/output.ts +22 -7
  101. package/src/pack.ts +16 -0
  102. package/src/project-context.ts +18 -5
  103. package/src/project.ts +4 -0
  104. package/src/remote-guard.ts +52 -0
  105. package/src/vendor/kit-assets.json +11 -11
@@ -0,0 +1,148 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, relative, sep } from 'node:path'
3
+ import { parseDocument } from 'yaml'
4
+
5
+ import { CliError, UsageError } from '../errors'
6
+ import { starterAgentFiles } from './local-scaffold'
7
+
8
+ /**
9
+ * Starting and tidying project files without the web app (Forge improvement
10
+ * plan L8).
11
+ *
12
+ * `frontera local new agent <slug>` writes a new Agent's folder: the
13
+ * scaffold's starter, or with `--like <agent>` a copy of another Agent's
14
+ * files (its instructions, bindings, behavior settings and cases) under the
15
+ * new name. `frontera local fmt` writes the project's JSON and YAML files in
16
+ * one shape (two-space indents, a final newline, comments kept), so a diff
17
+ * shows what changed rather than how it was typed.
18
+ */
19
+
20
+ const SLUG = /^[a-z][a-z0-9-]{0,62}$/
21
+
22
+ export function titleOf(slug: string): string {
23
+ return slug.split('-').filter(Boolean).map((w) => w[0]!.toUpperCase() + w.slice(1)).join(' ')
24
+ }
25
+
26
+ function filesUnder(root: string, dir: string): string[] {
27
+ const out: string[] = []
28
+ const walk = (at: string) => {
29
+ for (const entry of readdirSync(at)) {
30
+ const full = join(at, entry)
31
+ if (statSync(full).isDirectory()) walk(full)
32
+ else out.push(relative(root, full).split(sep).join('/'))
33
+ }
34
+ }
35
+ if (existsSync(join(root, dir))) walk(join(root, dir))
36
+ return out.sort()
37
+ }
38
+
39
+ export interface NewAgentOptions {
40
+ like?: string
41
+ name?: string
42
+ description?: string
43
+ model?: string
44
+ customer?: boolean
45
+ }
46
+
47
+ /** The new Agent's files by project path; nothing is written. */
48
+ export function newAgentFiles(root: string, slug: string, opts: NewAgentOptions = {}): Record<string, string> {
49
+ if (!SLUG.test(slug)) throw new UsageError(`"${slug}" is not an Agent slug`, 'lower-case letters, digits and hyphens, starting with a letter: `support-concierge`')
50
+ if (existsSync(join(root, 'agents', slug))) throw new CliError(`agents/${slug}/ is already there`, { code: 'USAGE', hint: 'choose another slug, or edit that Agent' })
51
+ const name = opts.name ?? titleOf(slug)
52
+ if (!opts.like) return starterAgentFiles(slug, { name, ...(opts.description ? { description: opts.description } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.customer ? { customer: true } : {}) })
53
+
54
+ const from = `agents/${opts.like}`
55
+ const files = filesUnder(root, from)
56
+ if (!files.includes(`${from}/agent.json`)) throw new CliError(`there is no Agent at ${from}/ to start from`, { code: 'USAGE', hint: '`frontera local status` lists the project\'s Agents' })
57
+ if (opts.customer) throw new UsageError('--customer and --like together', 'the copy keeps the other Agent\'s behavior settings as they are; leave --customer out')
58
+ const out: Record<string, string> = {}
59
+ for (const rel of files) out[`agents/${slug}/${rel.slice(from.length + 1)}`] = readFileSync(join(root, rel), 'utf8')
60
+ // Its own name and description; the model only when one is given.
61
+ const agentPath = `agents/${slug}/agent.json`
62
+ let doc: { config?: Record<string, unknown>; modelConfigs?: Array<Record<string, unknown>> }
63
+ try {
64
+ doc = JSON.parse(out[agentPath]!) as typeof doc
65
+ } catch (err) {
66
+ throw new CliError(`${from}/agent.json could not be read: ${(err as Error).message}`, { code: 'USAGE', hint: 'fix that file, or start from the scaffold without --like' })
67
+ }
68
+ doc.config = { ...(doc.config ?? {}), name, description: opts.description ?? (doc.config?.description as string | null | undefined) ?? null }
69
+ if (opts.model) {
70
+ doc.modelConfigs = [{ modeKey: 'default', modelSlug: opts.model, displayName: 'Default', sortOrder: 0 }]
71
+ doc.config.defaultMode = 'default'
72
+ }
73
+ out[agentPath] = `${JSON.stringify(doc, null, 2)}\n`
74
+ return out
75
+ }
76
+
77
+ /** Write files that are not there; one that is would be the person's, so nothing is written then. */
78
+ export function writeNewFiles(root: string, files: Record<string, string>): string[] {
79
+ const taken = Object.keys(files).filter((rel) => existsSync(join(root, rel)))
80
+ if (taken.length) throw new CliError(`${taken[0]} is already there`, { code: 'USAGE', hint: 'nothing was written' })
81
+ for (const [rel, text] of Object.entries(files)) {
82
+ mkdirSync(dirname(join(root, rel)), { recursive: true })
83
+ writeFileSync(join(root, rel), text)
84
+ }
85
+ return Object.keys(files).sort()
86
+ }
87
+
88
+ // ── fmt ─────────────────────────────────────────────────────────────────────
89
+
90
+ /** The folders whose JSON and YAML are project files; an App's own code and the tool state are left alone. */
91
+ const FORMATTED_DIRS = ['agents', 'workflows', 'blueprint', 'knowledge', 'skills', 'chat-apps', 'themes', 'datasets', 'mcp']
92
+ const FORMATTED_ROOT_FILES = ['frontera.yaml']
93
+ const FORMATTED = /\.(json|ya?ml)$/
94
+
95
+ /** The project files `fmt` reads: under the project folders, apps/<slug>.json, and frontera.yaml. */
96
+ export function formattableFiles(root: string): string[] {
97
+ const out = [...FORMATTED_DIRS.flatMap((d) => filesUnder(root, d)).filter((rel) => FORMATTED.test(rel) && !rel.split('/').includes('node_modules'))]
98
+ if (existsSync(join(root, 'apps'))) for (const entry of readdirSync(join(root, 'apps'))) if (entry.endsWith('.json') && statSync(join(root, 'apps', entry)).isFile()) out.push(`apps/${entry}`)
99
+ for (const f of FORMATTED_ROOT_FILES) if (existsSync(join(root, f))) out.push(f)
100
+ return out.sort()
101
+ }
102
+
103
+ /** The file in the project's shape, or an error naming where it can't be read. */
104
+ export function formatted(rel: string, text: string): { text: string } | { error: string } {
105
+ if (rel.endsWith('.json')) {
106
+ try {
107
+ return { text: `${JSON.stringify(JSON.parse(text), null, 2)}\n` }
108
+ } catch (err) {
109
+ return { error: (err as Error).message }
110
+ }
111
+ }
112
+ const doc = parseDocument(text)
113
+ if (doc.errors.length) {
114
+ const e = doc.errors[0]!
115
+ return { error: `${e.linePos?.[0] ? `line ${e.linePos[0].line}, column ${e.linePos[0].col}: ` : ''}${e.message.split('\n')[0]}` }
116
+ }
117
+ // Comments and block text kept; long text never folded onto new lines.
118
+ const out = doc.toString({ indent: 2, lineWidth: 0, minContentWidth: 0 })
119
+ return { text: out.endsWith('\n') ? out : `${out}\n` }
120
+ }
121
+
122
+ export interface FmtOutcome {
123
+ changed: string[]
124
+ unchanged: number
125
+ failed: Array<{ path: string; error: string }>
126
+ }
127
+
128
+ /** Format the project's files; with `check`, write nothing and only say which would change. */
129
+ export function formatProject(root: string, opts: { check?: boolean; only?: readonly string[] } = {}): FmtOutcome {
130
+ const all = formattableFiles(root)
131
+ const files = opts.only?.length ? all.filter((rel) => opts.only!.some((p) => rel === p || rel.startsWith(`${p.replace(/\/$/, '')}/`))) : all
132
+ const outcome: FmtOutcome = { changed: [], unchanged: 0, failed: [] }
133
+ for (const rel of files) {
134
+ const before = readFileSync(join(root, rel), 'utf8')
135
+ const after = formatted(rel, before)
136
+ if ('error' in after) {
137
+ outcome.failed.push({ path: rel, error: after.error })
138
+ continue
139
+ }
140
+ if (after.text === before) {
141
+ outcome.unchanged++
142
+ continue
143
+ }
144
+ outcome.changed.push(rel)
145
+ if (!opts.check) writeFileSync(join(root, rel), after.text)
146
+ }
147
+ return outcome
148
+ }
@@ -101,7 +101,7 @@ export const KEYLESS_KNOWLEDGE_THRESHOLD = 0.99
101
101
  * embeddings (`local status`, `knowledge get` and `search`, the apply's
102
102
  * warning): the retrieval is approximate, and what gives the platform's.
103
103
  */
104
- export const KEYLESS_RETRIEVAL_NOTE = `retrieval here runs on approximate local embeddings (words hashed into vectors): a passage is found by the words it shares with the question, not by its meaning, and each knowledge base searches with threshold ${KEYLESS_KNOWLEDGE_THRESHOLD}, set for these vectors (its file keeps its own threshold for the workspace). A key the provider accepts for an embedding model gives the platform's retrieval: \`frontera local key-set <provider>\` (openai or openrouter; google when the workspace embeds with Gemini's model), then \`frontera local stop\` and \`frontera local start\`; the next \`frontera local dev\` sets each base's threshold for that model`
104
+ export const KEYLESS_RETRIEVAL_NOTE = `retrieval here runs on approximate local embeddings (words hashed into vectors): a passage is found by the words it shares with the question, not by its meaning, and each knowledge base searches with threshold ${KEYLESS_KNOWLEDGE_THRESHOLD}, set for these vectors (its file keeps its own threshold for the workspace). A key the provider accepts for an embedding model gives the platform's retrieval (\`frontera local embeddings\` says which key you already have): \`frontera local key-set <provider>\` (openai or openrouter; google when the workspace embeds with Gemini's model), then \`frontera local stop\` and \`frontera local start\`; the next \`frontera local dev\` sets each base's threshold for that model`
105
105
 
106
106
  /** How a difference line says the runtime refused the workspace's model through this computer's key (`mirrorOrgSettings`). */
107
107
  const KEY_REFUSED = /; the runtime refused it through this computer’s (\S+) key/
@@ -0,0 +1,340 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+
6
+ /**
7
+ * Person-steps (Forge improvement plan T4, N4): a step only the person may
8
+ * take (publishing, promoting, applying a change request, deciding for an
9
+ * Action, granting, setting a secret), prepared by the coding agent and run
10
+ * by the person, in their own terminal, with their own identity.
11
+ *
12
+ * The agent never approves its own work. Before, it could only say what was
13
+ * ready, so the person became its terminal: commands typed out by hand and
14
+ * their output pasted back, ten or more times in one delivery. Now the agent
15
+ * records the step (`person_step`, `frontera local steps add`): the command,
16
+ * why it is needed, what it changes and the credential it needs. Forge shows
17
+ * the steps as one delivery card, and **Run in my terminal** types the
18
+ * command into a shell without pressing Enter. The person reads it and
19
+ * presses Enter. Nothing here runs a command.
20
+ *
21
+ * Kept in `.frontera/sync/person-steps.json`, a file, so the list outlives
22
+ * the agent's context and a restart.
23
+ */
24
+
25
+ export const PERSON_STEPS_FILE = join('.frontera', 'sync', 'person-steps.json')
26
+
27
+ /** Who or what the step needs. */
28
+ export const CREDENTIALS = ['workspace-key', 'organization-key', 'steward', 'admin', 'second-person', 'person'] as const
29
+ export type Credential = (typeof CREDENTIALS)[number]
30
+
31
+ /** Each credential in the person's words, as the card and the list say it. */
32
+ export const CREDENTIAL_WORDS: Record<Credential, string> = {
33
+ 'workspace-key': 'the project’s workspace key',
34
+ 'organization-key': 'an organization key',
35
+ steward: 'a Blueprint Steward',
36
+ admin: 'a workspace admin',
37
+ 'second-person': 'a second person (not whoever made the change)',
38
+ person: 'you, signed in (`frontera auth login --browser`)',
39
+ }
40
+
41
+ /**
42
+ * The read that proves a step done (N6): a read-only `frontera … --json`
43
+ * command, the list in its data (`in`, a property; the data itself when it
44
+ * is a list), the row whose fields equal `find`, and the fields that equal
45
+ * `expect` once the step is done.
46
+ */
47
+ export interface StepCheck {
48
+ command: string
49
+ in?: string
50
+ find: Record<string, string | number | boolean>
51
+ expect: Record<string, string | number | boolean>
52
+ }
53
+
54
+ export interface PersonStep {
55
+ id: string
56
+ /** One `frontera` command, as the person runs it; null for a step done in the product. */
57
+ command: string | null
58
+ /** Why it is needed now. */
59
+ why: string
60
+ /** What it changes, in the workspace's words. */
61
+ changes: string
62
+ credential: Credential
63
+ /** The delivery it belongs to: steps with the same one show as one card, in order. */
64
+ delivery: string | null
65
+ status: 'pending' | 'done' | 'dismissed'
66
+ createdAt: string
67
+ doneAt?: string
68
+ /** Who saw it done: the person's mark, or its check. */
69
+ doneBy?: 'person' | 'check'
70
+ /** Where it came from: the coding agent, or what a change request left after Apply. */
71
+ source?: 'agent' | 'change-request'
72
+ /** A change request's own key for it (`action-deploy:qualifyLead`), so applying again updates it. */
73
+ key?: string
74
+ check?: StepCheck
75
+ lastCheckedAt?: string
76
+ }
77
+
78
+ interface StepsFile {
79
+ version: 1
80
+ steps: PersonStep[]
81
+ }
82
+
83
+ const MAX_STEPS = 200
84
+
85
+ /**
86
+ * What would make a command unfit for the person to run from a card:
87
+ * anything but one `frontera` command on one line. A step is typed into the
88
+ * person's shell, so it never chains (`;`, `&&`, `|`), substitutes (`$(…)`,
89
+ * backticks) or redirects, and it never carries a secret: a value is piped
90
+ * in by the person (`… --from -`), never written into the step.
91
+ */
92
+ export function commandProblem(command: string): string | null {
93
+ const c = command.trim()
94
+ if (!c) return 'the command is empty'
95
+ if (c.length > 500) return 'the command is longer than 500 characters'
96
+ if (/[\r\n]/.test(c)) return 'a step is one command on one line'
97
+ if (!/^frontera\s+\S/.test(c)) return 'a step is a `frontera` command'
98
+ if (/[;&|`<>]|\$\(/.test(c)) return 'a step is one command: no `;`, `&&`, `|`, redirects or `$(…)`'
99
+ if (looksSecret(c)) return 'the command carries what looks like a key, token or password; the person pipes a secret in (`--from -`), it is never written into a step'
100
+ return null
101
+ }
102
+
103
+ /** A key, token or password written into a command. */
104
+ export function looksSecret(text: string): boolean {
105
+ return (
106
+ /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/i.test(text) ||
107
+ /\b(?:sk|pk|rk|fk|fpk|fok|fwk|ghp|gho|xox[abpr])[-_][A-Za-z0-9_-]{12,}/.test(text) ||
108
+ /--(?:token|password|secret|api-key|key-value)[= ]\S+/i.test(text) ||
109
+ /\b[A-Fa-f0-9]{40,}\b/.test(text) ||
110
+ /\b[A-Za-z0-9+/]{48,}={0,2}(?![A-Za-z0-9+/=])/.test(text)
111
+ )
112
+ }
113
+
114
+ function filePath(root: string): string {
115
+ return join(root, PERSON_STEPS_FILE)
116
+ }
117
+
118
+ export function readPersonSteps(root: string): PersonStep[] {
119
+ const path = filePath(root)
120
+ if (!existsSync(path)) return []
121
+ try {
122
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<StepsFile>
123
+ return Array.isArray(parsed.steps) ? parsed.steps.filter((s): s is PersonStep => !!s && typeof s.id === 'string' && (typeof s.command === 'string' || s.command === null)) : []
124
+ } catch {
125
+ return []
126
+ }
127
+ }
128
+
129
+ function writeSteps(root: string, steps: PersonStep[]): void {
130
+ const path = filePath(root)
131
+ mkdirSync(dirname(path), { recursive: true })
132
+ writeFileAtomic(path, `${JSON.stringify({ version: 1, steps } satisfies StepsFile, null, 2)}\n`)
133
+ }
134
+
135
+ export interface NewPersonStep {
136
+ command: string
137
+ why: string
138
+ changes: string
139
+ credential: string
140
+ delivery?: string | null
141
+ /** The read that proves it done, already checked (`checkProblem`). */
142
+ check?: StepCheck
143
+ }
144
+
145
+ /** Each problem with a step as given; empty when it can be recorded. */
146
+ export function stepProblems(input: NewPersonStep): string[] {
147
+ const problems: string[] = []
148
+ const command = commandProblem(input.command)
149
+ if (command) problems.push(command)
150
+ if (!input.why?.trim()) problems.push('say why the step is needed (`why`)')
151
+ if (!input.changes?.trim()) problems.push('say what the step changes (`changes`)')
152
+ if (!(CREDENTIALS as readonly string[]).includes(input.credential)) problems.push(`the credential is one of: ${CREDENTIALS.join(', ')}`)
153
+ for (const [field, value] of [['why', input.why], ['changes', input.changes], ['delivery', input.delivery ?? '']] as const) {
154
+ if (value && looksSecret(value)) problems.push(`\`${field}\` carries what looks like a key, token or password`)
155
+ if (value && value.length > 1000) problems.push(`\`${field}\` is longer than 1000 characters`)
156
+ }
157
+ return problems
158
+ }
159
+
160
+ /**
161
+ * Record a step. The same command pending in the same delivery is the same
162
+ * step, updated rather than listed twice, so an agent restating its plan
163
+ * after a compaction doesn't double the card.
164
+ */
165
+ export function addPersonStep(root: string, input: NewPersonStep, now = new Date()): { step: PersonStep; updated: boolean } {
166
+ const problems = stepProblems(input)
167
+ if (problems.length) throw new Error(problems.join('; '))
168
+ const steps = readPersonSteps(root)
169
+ const command = input.command.trim().replace(/\s+/g, ' ')
170
+ const delivery = input.delivery?.trim() || null
171
+ const same = steps.find((s) => s.status === 'pending' && s.command === command && s.delivery === delivery)
172
+ if (same) {
173
+ Object.assign(same, { why: input.why.trim(), changes: input.changes.trim(), credential: input.credential as Credential, ...(input.check ? { check: input.check } : {}) })
174
+ writeSteps(root, steps)
175
+ return { step: same, updated: true }
176
+ }
177
+ if (steps.filter((s) => s.status === 'pending').length >= MAX_STEPS) throw new Error(`${MAX_STEPS} steps are already pending; mark the done ones done first`)
178
+ const step: PersonStep = {
179
+ id: `ps_${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`,
180
+ command,
181
+ why: input.why.trim(),
182
+ changes: input.changes.trim(),
183
+ credential: input.credential as Credential,
184
+ delivery,
185
+ status: 'pending',
186
+ createdAt: now.toISOString(),
187
+ source: 'agent',
188
+ ...(input.check ? { check: input.check } : {}),
189
+ }
190
+ writeSteps(root, [...steps, step])
191
+ return { step, updated: false }
192
+ }
193
+
194
+ /** Mark a step done (the person ran it) or dismissed (not needed after all). */
195
+ export function settlePersonStep(root: string, id: string, status: 'done' | 'dismissed' | 'pending', now = new Date()): PersonStep {
196
+ const steps = readPersonSteps(root)
197
+ const step = steps.find((s) => s.id === id)
198
+ if (!step) throw new Error(`no person-step ${id}; \`frontera local steps\` lists them`)
199
+ step.status = status
200
+ if (status === 'pending') {
201
+ delete step.doneAt
202
+ delete step.doneBy
203
+ } else {
204
+ step.doneAt = now.toISOString()
205
+ step.doneBy = 'person'
206
+ }
207
+ writeSteps(root, steps)
208
+ return step
209
+ }
210
+
211
+ /** Take settled steps off the list; pending ones stay. */
212
+ export function clearSettledSteps(root: string): number {
213
+ const steps = readPersonSteps(root)
214
+ const kept = steps.filter((s) => s.status === 'pending')
215
+ if (kept.length !== steps.length) writeSteps(root, kept)
216
+ return steps.length - kept.length
217
+ }
218
+
219
+ /** The pending steps grouped as delivery cards, each in the order recorded; steps with no delivery come last as one group. */
220
+ export function deliveryGroups(steps: PersonStep[]): Array<{ delivery: string | null; steps: PersonStep[] }> {
221
+ const pending = steps.filter((s) => s.status === 'pending')
222
+ const order: Array<string | null> = []
223
+ for (const s of pending) if (!order.includes(s.delivery)) order.push(s.delivery)
224
+ order.sort((a, b) => (a === null ? 1 : b === null ? -1 : 0))
225
+ return order.map((delivery) => ({ delivery, steps: pending.filter((s) => s.delivery === delivery) }))
226
+ }
227
+
228
+ // ── What a change request leaves after Apply (N6) ───────────────────────────
229
+
230
+ /** One item of an applied request's after-apply list, as the platform returns it. */
231
+ export interface AfterApplyItem {
232
+ key: string
233
+ label: string
234
+ why: string
235
+ command?: string
236
+ credential: string
237
+ check?: StepCheck
238
+ }
239
+
240
+ /** The after-apply list from an Apply's outcome; empty from a platform that sends none. */
241
+ export function afterApplyOf(outcome: unknown): AfterApplyItem[] {
242
+ const list = (outcome as { afterApply?: unknown } | null)?.afterApply
243
+ if (!Array.isArray(list)) return []
244
+ return list.filter((i): i is AfterApplyItem => !!i && typeof i === 'object' && typeof (i as AfterApplyItem).key === 'string' && typeof (i as AfterApplyItem).label === 'string')
245
+ }
246
+
247
+ /** Why a check can't be run: it must be a read-only `frontera` command with `--json`. */
248
+ export function checkProblem(check: StepCheck, readOnly: readonly string[]): string | null {
249
+ const problem = commandProblem(check.command)
250
+ if (problem) return problem
251
+ const words = check.command.trim().split(/\s+/).slice(1)
252
+ if (!words.includes('--json')) return 'a check reads JSON (`--json`)'
253
+ const path = words.filter((w) => !w.startsWith('-')).join(' ')
254
+ if (!readOnly.some((r) => path === r || path.startsWith(`${r} `))) return 'a check is a command that only reads'
255
+ return null
256
+ }
257
+
258
+ /**
259
+ * Record what a request left for a person, as person-steps of its delivery
260
+ * (the request's title). An item already listed (by its key) is updated, a
261
+ * settled one stays settled, so applying a request again doesn't bring back
262
+ * what was done.
263
+ */
264
+ export function recordAfterApply(root: string, delivery: string, items: AfterApplyItem[], now = new Date()): PersonStep[] {
265
+ const steps = readPersonSteps(root)
266
+ const out: PersonStep[] = []
267
+ for (const item of items) {
268
+ const credential = ((CREDENTIALS as readonly string[]).includes(item.credential) ? item.credential : 'person') as Credential
269
+ const command = item.command && !commandProblem(item.command) ? item.command : null
270
+ const existing = steps.find((s) => s.key === item.key && s.delivery === delivery)
271
+ if (existing) {
272
+ Object.assign(existing, { command, why: item.why, changes: item.label, credential, ...(item.check ? { check: item.check } : {}) })
273
+ out.push(existing)
274
+ continue
275
+ }
276
+ const step: PersonStep = {
277
+ id: `ps_${crypto.randomUUID().replace(/-/g, '').slice(0, 10)}`,
278
+ command,
279
+ why: item.why,
280
+ changes: item.label,
281
+ credential,
282
+ delivery,
283
+ status: 'pending',
284
+ createdAt: now.toISOString(),
285
+ source: 'change-request',
286
+ key: item.key,
287
+ ...(item.check ? { check: item.check } : {}),
288
+ }
289
+ steps.push(step)
290
+ out.push(step)
291
+ }
292
+ if (out.length) writeSteps(root, steps)
293
+ return out
294
+ }
295
+
296
+ /** Whether a check's data says the step is done: the row `find` names, with every field of `expect`. */
297
+ export function checkPasses(check: StepCheck, data: unknown): boolean {
298
+ const list = Array.isArray(data) ? data : check.in && data && typeof data === 'object' ? (data as Record<string, unknown>)[check.in] : null
299
+ if (!Array.isArray(list)) return false
300
+ const row = list.find((r) => r && typeof r === 'object' && Object.entries(check.find).every(([k, v]) => (r as Record<string, unknown>)[k] === v))
301
+ return !!row && Object.entries(check.expect).every(([k, v]) => (row as Record<string, unknown>)[k] === v)
302
+ }
303
+
304
+ /**
305
+ * Run each pending step's check, and mark the ones it proves done. `read`
306
+ * runs a check's command (the CLI itself, `--json`) and returns its data, or
307
+ * null when it could not run; such a step stays pending and says when it was
308
+ * last checked.
309
+ */
310
+ export async function runStepChecks(
311
+ root: string,
312
+ read: (args: string[]) => Promise<unknown | null>,
313
+ readOnly: readonly string[],
314
+ now = new Date(),
315
+ ): Promise<{ checked: number; done: PersonStep[]; problems: Array<{ id: string; problem: string }> }> {
316
+ const steps = readPersonSteps(root)
317
+ const done: PersonStep[] = []
318
+ const problems: Array<{ id: string; problem: string }> = []
319
+ let checked = 0
320
+ for (const step of steps) {
321
+ if (step.status !== 'pending' || !step.check) continue
322
+ const problem = checkProblem(step.check, readOnly)
323
+ if (problem) {
324
+ problems.push({ id: step.id, problem })
325
+ continue
326
+ }
327
+ checked++
328
+ const args = step.check.command.trim().split(/\s+/).slice(1).filter((w) => w !== '--json')
329
+ const data = await read(args)
330
+ step.lastCheckedAt = now.toISOString()
331
+ if (data !== null && checkPasses(step.check, data)) {
332
+ step.status = 'done'
333
+ step.doneAt = now.toISOString()
334
+ step.doneBy = 'check'
335
+ done.push(step)
336
+ }
337
+ }
338
+ if (checked) writeSteps(root, steps)
339
+ return { checked, done, problems }
340
+ }
@@ -19,7 +19,8 @@ import type { LocalCredentials } from './local-state'
19
19
  * approver may not be the submitter, and a chain needs one person per step.
20
20
  * The submitter is an `owner` (an Action grants its invoke capability to
21
21
  * owners only); approvers are `admin`s. `frontera action available|submit|approve|reject --as
22
- * <persona>` then runs as that persona. The session it uses is minted for the
22
+ * <persona>` then runs as that persona, and so do `media list|upload|stage` (both roles hold
23
+ * Media content write, so a persona can stage the file an attachment Action takes). The session it uses is minted for the
23
24
  * one command, sent only to the local runtime, and never printed or logged.
24
25
  */
25
26
 
@@ -27,7 +28,11 @@ import type { LocalCredentials } from './local-state'
27
28
  export const SUBMITTER = 'submitter'
28
29
 
29
30
  /** The commands `--as` works on. */
30
- export const ACT_AS_COMMANDS = ['action available', 'action submit', 'action approve', 'action reject'] as const
31
+ export const ACT_AS_COMMANDS = [
32
+ 'action available', 'action submit', 'action approve', 'action reject',
33
+ // L6: a persona's files, into the runtime's stand-in Media Sets, and staged for an Action to attach.
34
+ 'media list', 'media upload', 'media stage',
35
+ ] as const
31
36
 
32
37
  const KEY = /^[a-z][a-z0-9_-]{0,62}$/
33
38
  /** The most personas one seed takes (the route's limit). */
@@ -82,9 +87,11 @@ function actionDocs(root: string): Array<{ file: string; doc: Doc }> {
82
87
 
83
88
  /**
84
89
  * The personas the project's Actions need: one per approval step (a chain's
85
- * stage key; `approver` for an Action approved in one step), and a submitter
86
- * when any Action asks for approval. A step decided by an App role is said,
87
- * not seeded: a persona holds an organization role, not an App's.
90
+ * stage key; `approver` for an Action approved in one step), and always a
91
+ * submitter, so `--as submitter` works for an Action that needs no approval
92
+ * too (Forge improvement plan X2; P1 #7, P4 2.3: PERSONA_NOT_FOUND when every
93
+ * Action had `approval: none`). A step decided by an App role is said, not
94
+ * seeded: a persona holds an organization role, not an App's.
88
95
  */
89
96
  export function personaPlan(root: string): PersonaPlan {
90
97
  const byKey = new Map<string, PersonaSpec>()
@@ -117,6 +124,10 @@ export function personaPlan(root: string): PersonaPlan {
117
124
  }
118
125
  add(SUBMITTER, `requests for ${apiName}`)
119
126
  }
127
+ if (!byKey.has(SUBMITTER)) {
128
+ const names = actionDocs(root).map(({ file, doc }) => (typeof doc.apiName === 'string' ? doc.apiName : file))
129
+ add(SUBMITTER, names.length ? `requests for ${names.join(', ')}` : 'requests, as the person who submits them')
130
+ }
120
131
  // The submitter first, then the approvers as their steps come.
121
132
  const personas = [...byKey.values()].sort((a, b) => Number(b.key === SUBMITTER) - Number(a.key === SUBMITTER))
122
133
  if (personas.length > MAX_PERSONAS) notes.push(`${personas.length - MAX_PERSONAS} more approval steps than the ${MAX_PERSONAS} personas a runtime seeds at once: the rest were left out`)
@@ -332,10 +332,31 @@ export function actionFit(doc: Doc, semantic?: SemanticFacts, opts: { changed?:
332
332
  if (!effect || !ONTOLOGY_EDITS.has(effect)) return { runsLocally: false, reason: 'it changes a system outside the local runtime', subject, ...inherited }
333
333
  const changes = (Array.isArray(doc.businessOutcomes) ? doc.businessOutcomes : []).flatMap((o: Doc) => (Array.isArray(o?.expectedChanges) ? o.expectedChanges : []))
334
334
  const sets = changes.some((c: Doc) => c?.kind === 'property_set' && c?.value?.kind === 'input')
335
- if (effect !== 'delete' && !sets) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject, ...inherited }
335
+ // An Action whose file says how its file is attached writes that (L6), even with no other field.
336
+ if (effect !== 'delete' && !sets && !actionAttachment(doc)) return { runsLocally: false, reason: 'it sets no field from its inputs, so there is nothing to write locally', subject, ...inherited }
336
337
  return { runsLocally: true, subject, ...inherited }
337
338
  }
338
339
 
340
+ /**
341
+ * How a deployment of the Action attaches the file it takes (L6), from its
342
+ * file's `attachment`: the input the file arrives in (`fileInput`), and the
343
+ * media reference property (`property`), the text property that keeps the
344
+ * item's id (`itemIdProperty`), or both, with the Media Set by API name when
345
+ * it is not the property's own. Null when the file says none. A deployment
346
+ * choice, not the Action's contract: `local dev` deploys with it, and it
347
+ * stays in the project when the Action is proposed.
348
+ */
349
+ export function actionAttachment(doc: Doc): { fileInput: string; property?: string; itemIdProperty?: string; mediaSet?: string } | null {
350
+ const a = doc.attachment
351
+ if (!a || typeof a !== 'object' || typeof a.fileInput !== 'string' || !a.fileInput) return null
352
+ return {
353
+ fileInput: a.fileInput,
354
+ ...(typeof a.property === 'string' && a.property ? { property: a.property } : {}),
355
+ ...(typeof a.itemIdProperty === 'string' && a.itemIdProperty ? { itemIdProperty: a.itemIdProperty } : {}),
356
+ ...(typeof a.mediaSet === 'string' && a.mediaSet ? { mediaSet: a.mediaSet } : {}),
357
+ }
358
+ }
359
+
339
360
  /** What a pulled Action needs here: it is the workspace's as pulled, so a semantic-type conflict is inherited. */
340
361
  export function actionNeeds(doc: Doc, semantic?: SemanticFacts): Need[] {
341
362
  const conflicts = semantic ? actionSemanticConflicts(doc, semantic) : []
@@ -0,0 +1,26 @@
1
+ import { PlatformApi } from '../api/platform-api'
2
+ import { CliError } from '../errors'
3
+ import type { LocalCredentials } from './local-state'
4
+
5
+ /**
6
+ * Publish an Agent's draft on the local runtime (Forge improvement plan L2,
7
+ * T12; P3 F8). A public website key, a Chat App and a channel talk to an
8
+ * Agent's published version, and publishing one locally took a raw REST
9
+ * call. Local only: the workspace's Agent is published by a person, through
10
+ * a change request or on the platform.
11
+ */
12
+ export async function publishLocalAgent(
13
+ link: { serviceUrl: string; creds: LocalCredentials },
14
+ slug: string,
15
+ notes: string | undefined,
16
+ deps: { api?: Pick<PlatformApi, 'allAgents' | 'publishAgent'> } = {},
17
+ ): Promise<{ agent: string; id: string; published: unknown }> {
18
+ const api = deps.api ?? new PlatformApi(link.serviceUrl, link.creds.workspaceKey ?? link.creds.orgKey, link.creds.workspaceId)
19
+ const listed = (await api.allAgents()) as Array<{ id?: string; agentId?: string }>
20
+ const row = listed.find((a) => a.agentId === slug)
21
+ if (!row?.id) {
22
+ throw new CliError(`the local runtime has no Agent ${slug}`, { code: 'NOT_FOUND', hint: `apply it first: \`frontera local dev --once\` (agents/${slug}/agent.json)` })
23
+ }
24
+ const published = await api.publishAgent(row.id, { notes: notes ?? 'Published on the local runtime.' })
25
+ return { agent: slug, id: row.id, published }
26
+ }