@frontera-sdk/cli 1.52.38 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -0,0 +1,409 @@
1
+ import type { toTrace } from '../local/local-traces'
2
+ import { studioLink, type RuntimeLink } from './mcp-runtime'
3
+
4
+ /**
5
+ * Traces for the coding agent, read from the runtime's local trace store
6
+ * (the Langfuse-compatible API Studio's trace views use) and cut down to
7
+ * what an agent reasons about: the span tree, tool calls, tokens, errors.
8
+ */
9
+
10
+ type LocalTrace = ReturnType<typeof toTrace>
11
+ type Observation = LocalTrace['observations'][number]
12
+
13
+ export interface TraceSummary {
14
+ id: string
15
+ name: string
16
+ at: string
17
+ conversationId: string | null
18
+ level: string
19
+ seconds: number
20
+ tokens: number
21
+ link: string
22
+ }
23
+
24
+ export interface TraceNode {
25
+ name: string
26
+ type: string
27
+ ms: number | null
28
+ model?: string
29
+ tokens?: { input: number; output: number; total: number }
30
+ error?: string
31
+ input?: string
32
+ output?: string
33
+ /** A \`reply.blocked\` span: which check fired and its outcome; the trace's \`replyChecks\` has the findings and both drafts. */
34
+ replyCheck?: string
35
+ children?: TraceNode[]
36
+ }
37
+
38
+ const CLIP = 1_500
39
+
40
+ function clip(value: unknown): string | undefined {
41
+ if (value === null || value === undefined) return undefined
42
+ const text = typeof value === 'string' ? value : JSON.stringify(value)
43
+ return text.length > CLIP ? `${text.slice(0, CLIP)}… (${text.length - CLIP} more characters)` : text
44
+ }
45
+
46
+ /** Studio's page for one trace. */
47
+ export function traceLink(link: RuntimeLink, id: string): string {
48
+ return studioLink(link, `/traces?trace=${encodeURIComponent(id)}`)
49
+ }
50
+
51
+ async function tracesCall<T>(link: RuntimeLink, path: string): Promise<T | null> {
52
+ const res = await fetch(new URL(path, link.tracesUrl), { signal: AbortSignal.timeout(15_000) })
53
+ if (res.status === 404) return null
54
+ if (!res.ok) throw new Error(`local traces answered ${res.status}`)
55
+ return (await res.json()) as T
56
+ }
57
+
58
+ function tokensOf(trace: LocalTrace): number {
59
+ return trace.observations.reduce((n, o) => n + (o.usage?.total ?? 0), 0)
60
+ }
61
+
62
+ export function summarize(link: RuntimeLink, trace: LocalTrace): TraceSummary {
63
+ return {
64
+ id: trace.id,
65
+ name: trace.name,
66
+ at: trace.timestamp,
67
+ conversationId: trace.sessionId,
68
+ level: trace.level,
69
+ seconds: Math.round(trace.latency * 100) / 100,
70
+ tokens: tokensOf(trace),
71
+ link: traceLink(link, trace.id),
72
+ }
73
+ }
74
+
75
+ /** Recent traces, newest first, narrowed in this process: the store filters only by time, metadata and level. */
76
+ export async function listTraces(link: RuntimeLink, opts: { limit: number; conversationId?: string; errorsOnly?: boolean; name?: string; since?: string }): Promise<TraceSummary[]> {
77
+ const filter = opts.since ? [{ type: 'datetime', column: 'timestamp', operator: '>=', value: opts.since }] : []
78
+ const page = await tracesCall<{ data: LocalTrace[] }>(link, `/api/public/traces?limit=100&filter=${encodeURIComponent(JSON.stringify(filter))}`)
79
+ const traces = (page?.data ?? []).filter(
80
+ (t) =>
81
+ (!opts.conversationId || t.sessionId === opts.conversationId) &&
82
+ (!opts.errorsOnly || t.level === 'ERROR') &&
83
+ (!opts.name || t.name.toLowerCase().includes(opts.name.toLowerCase())),
84
+ )
85
+ return traces.slice(0, opts.limit).map((t) => summarize(link, t))
86
+ }
87
+
88
+ export async function getTrace(link: RuntimeLink, id: string): Promise<LocalTrace | null> {
89
+ return tracesCall<LocalTrace>(link, `/api/public/traces/${encodeURIComponent(id)}`)
90
+ }
91
+
92
+ function node(o: Observation, children: Map<string, Observation[]>, detail: boolean): TraceNode {
93
+ const kids = (children.get(o.id) ?? []).map((c) => node(c, children, detail))
94
+ return {
95
+ name: o.name,
96
+ type: o.type.toLowerCase(),
97
+ ms: o.latency === null ? null : Math.round(o.latency * 1000),
98
+ ...(o.model ? { model: o.model } : {}),
99
+ ...(o.usage ? { tokens: { input: o.usage.input, output: o.usage.output, total: o.usage.total } } : {}),
100
+ ...(o.level === 'ERROR' ? { error: o.statusMessage ?? 'failed' } : {}),
101
+ ...(detail ? { input: clip(o.input), output: clip(o.output) } : {}),
102
+ ...(isReplyBlocked(o.name) ? { replyCheck: `${String(o.metadata?.guardrail ?? o.metadata?.check ?? 'a check')} fired, outcome ${String(o.metadata?.outcome ?? 'unknown')}: replyChecks has what it found and both drafts` } : {}),
103
+ ...(kids.length ? { children: kids } : {}),
104
+ }
105
+ }
106
+
107
+ /** A trace as the agent reads it: the tree, the tool calls in order, totals and every error. */
108
+ export function readTrace(link: RuntimeLink, trace: LocalTrace, opts: { detail: boolean }) {
109
+ const ids = new Set(trace.observations.map((o) => o.id))
110
+ const children = new Map<string, Observation[]>()
111
+ const roots: Observation[] = []
112
+ for (const o of trace.observations) {
113
+ if (o.parentObservationId && ids.has(o.parentObservationId)) {
114
+ const list = children.get(o.parentObservationId) ?? []
115
+ list.push(o)
116
+ children.set(o.parentObservationId, list)
117
+ } else roots.push(o)
118
+ }
119
+ const tools = trace.observations
120
+ .filter((o) => o.type === 'TOOL' || /^(ai\.toolCall|tool[:.\s])/i.test(o.name))
121
+ .map((o) => ({ name: o.name.replace(/^(ai\.toolCall|tool)[:.\s]*/i, '') || o.name, ms: o.latency === null ? null : Math.round(o.latency * 1000), ok: o.level !== 'ERROR', input: clip(o.input), output: clip(o.output) }))
122
+ const errors = trace.observations.filter((o) => o.level === 'ERROR').map((o) => ({ span: o.name, message: o.statusMessage ?? 'failed' }))
123
+ const procedures = proceduresOf(trace.observations)
124
+ const replyChecks = replyChecksOfTrace(trace.observations)
125
+ const toolsOffered = toolsOfferedOf(trace.observations)
126
+ const dataScopes = dataScopesOf(trace.observations)
127
+ const version = agentVersionOf(trace)
128
+ return {
129
+ ...summarize(link, trace),
130
+ ...version,
131
+ ...(procedures ? { procedures } : {}),
132
+ // The tools each model call was offered (`pi.turn <n>`): what the Agent could have called, beside what it did.
133
+ ...(toolsOffered.length ? { toolsOffered } : {}),
134
+ // Each data scope the turn's Blueprint reads were narrowed by: the type, property, variable and whether it had a value.
135
+ ...(dataScopes.length ? { dataScopes } : {}),
136
+ // Each time the reply check changed a reply: the check, what it found, what the person got, both drafts.
137
+ ...(replyChecks.length ? { replyChecks } : {}),
138
+ totals: {
139
+ spans: trace.observations.length,
140
+ generations: trace.observations.filter((o) => o.type === 'GENERATION').length,
141
+ toolCalls: tools.length,
142
+ tokens: tokensOf(trace),
143
+ cost: trace.totalCost || undefined,
144
+ },
145
+ input: clip(trace.input),
146
+ output: clip(trace.output),
147
+ errors,
148
+ toolCalls: tools,
149
+ tree: roots.map((r) => node(r, children, opts.detail)),
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The tool set each model call of the turn was offered, from the generations
155
+ * named `pi.turn <n>` (`metadata.toolsOffered`, `toolCount`). A call offered
156
+ * the same set as the one before it is folded into it (`turns`). A compact
157
+ * run lists `activate_capability` for what it has not loaded yet.
158
+ */
159
+ export function toolsOfferedOf(observations: Observation[]): Array<{ turns: string[]; count: number; tools: string[]; note?: string }> {
160
+ const out: Array<{ turns: string[]; count: number; tools: string[]; note?: string }> = []
161
+ const ordered = [...observations].filter((o) => /^pi\.turn\b/.test(o.name)).sort((a, b) => Date.parse(String(a.startTime ?? '')) - Date.parse(String(b.startTime ?? '')))
162
+ for (const o of ordered) {
163
+ const offered = o.metadata?.toolsOffered
164
+ if (!Array.isArray(offered)) continue
165
+ const tools = offered.filter((t): t is string => typeof t === 'string')
166
+ const count = typeof o.metadata?.toolCount === 'number' ? o.metadata.toolCount : tools.length
167
+ const last = out[out.length - 1]
168
+ if (last && last.tools.join('\n') === tools.join('\n')) {
169
+ last.turns.push(o.name)
170
+ continue
171
+ }
172
+ out.push({ turns: [o.name], count, tools, ...(tools.includes('activate_capability') ? { note: 'activate_capability stands for capabilities not loaded yet: the Agent loads one by calling it, and the next call is offered its tools' } : {}) })
173
+ }
174
+ return out
175
+ }
176
+
177
+ /** The data scopes a turn's Blueprint reads ran under (`blueprint.data-scope` spans, `metadata.scopes`). */
178
+ export function dataScopesOf(observations: Observation[]): Array<Record<string, unknown>> {
179
+ return observations
180
+ .filter((o) => o.name === 'blueprint.data-scope')
181
+ .flatMap((o) => (Array.isArray(o.metadata?.scopes) ? (o.metadata.scopes as unknown[]) : []))
182
+ .filter((x): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x))
183
+ }
184
+
185
+ /** Which version of the Agent ran: the draft (and whose draft) when the trace says so, as a Function's `ctx.agent()` on the local runtime does. */
186
+ function agentVersionOf(trace: LocalTrace): { agentVersion?: string; draftOf?: string } {
187
+ const meta = (trace as { metadata?: Record<string, unknown> }).metadata ?? {}
188
+ const version = typeof meta.agentVersion === 'string' ? meta.agentVersion : undefined
189
+ const draftOf = typeof meta.draftOf === 'string' ? meta.draftOf : undefined
190
+ return { ...(version ? { agentVersion: version } : {}), ...(draftOf ? { draftOf } : {}) }
191
+ }
192
+
193
+ /** A conversation's traces, newest first, whole: what a turn's trace is chosen from. */
194
+ export async function conversationTraces(link: RuntimeLink, conversationId: string, since?: string): Promise<LocalTrace[]> {
195
+ const filter = since ? [{ type: 'datetime', column: 'timestamp', operator: '>=', value: since }] : []
196
+ const page = await tracesCall<{ data: LocalTrace[] }>(link, `/api/public/traces?limit=100&filter=${encodeURIComponent(JSON.stringify(filter))}`)
197
+ return (page?.data ?? []).filter((t) => t.sessionId === conversationId)
198
+ }
199
+
200
+ /** The trace's root span (no parent at all) has ended: every span under it ended before it, so the trace is whole. */
201
+ export function traceComplete(trace: LocalTrace): boolean {
202
+ return trace.observations.some((o) => !o.parentObservationId && o.endTime !== null)
203
+ }
204
+
205
+ const hasUsage = (trace: LocalTrace) => trace.observations.some((o) => o.type === 'GENERATION' && o.usage !== null)
206
+ const hasGeneration = (trace: LocalTrace) => trace.observations.some((o) => o.type === 'GENERATION')
207
+
208
+ /**
209
+ * The trace of ONE turn: among the conversation's traces that were not there
210
+ * before the turn was sent, the earliest that called a model (a title or a
211
+ * summary the runtime writes afterwards is a trace of its own, and later).
212
+ */
213
+ export function pickTurnTrace(traces: LocalTrace[], before: ReadonlySet<string>): LocalTrace | null {
214
+ const fresh = traces.filter((t) => !before.has(t.id)).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
215
+ return fresh.find(hasGeneration) ?? fresh[0] ?? null
216
+ }
217
+
218
+ export interface TurnTrace {
219
+ summary: TraceSummary
220
+ /** The models the turn's generations ran on, as the provider named them. */
221
+ models: string[]
222
+ /** Not every span had arrived, or no model call reported usage: `tokens` is a floor, not the total. */
223
+ tokensPartial: boolean
224
+ trace: LocalTrace
225
+ }
226
+
227
+ /**
228
+ * The trace a turn produced. Spans are exported in batches, so a trace
229
+ * shows up in pieces a few seconds after the reply: wait until its root span
230
+ * has ended (the whole turn is in) for at most `waitMs`, then sum what is
231
+ * there and say whether that is all of it.
232
+ */
233
+ export async function awaitTurnTrace(
234
+ link: RuntimeLink,
235
+ conversationId: string,
236
+ opts: { before: ReadonlySet<string>; since: string; waitMs?: number; pollMs?: number },
237
+ ): Promise<TurnTrace | null> {
238
+ const deadline = Date.now() + (opts.waitMs ?? 20_000)
239
+ let last: LocalTrace | null = null
240
+ for (;;) {
241
+ const traces = await conversationTraces(link, conversationId, opts.since).catch(() => [] as LocalTrace[])
242
+ last = pickTurnTrace(traces, opts.before) ?? last
243
+ // Children end before their root, so a trace whose root has ended is whole.
244
+ if ((last && traceComplete(last)) || Date.now() > deadline) break
245
+ await new Promise((r) => setTimeout(r, opts.pollMs ?? 750))
246
+ }
247
+ if (!last) return null
248
+ const models = [...new Set(last.observations.filter((o) => o.type === 'GENERATION' && o.model).map((o) => o.model!))]
249
+ return { summary: summarize(link, last), models, tokensPartial: !traceComplete(last) || !hasUsage(last), trace: last }
250
+ }
251
+
252
+ const PROCEDURE_REASONS: Record<string, string> = {
253
+ start: "it is the conversation's start procedure",
254
+ keyword: 'a message contained one of its keywords',
255
+ active: 'it was already active from an earlier turn',
256
+ none: 'no procedure applies to this turn',
257
+ }
258
+
259
+ export interface ProcedureTurn {
260
+ id: string | null
261
+ name: string | null
262
+ reason: string
263
+ why: string
264
+ keywords?: unknown
265
+ alsoTriggered?: unknown
266
+ tools?: unknown
267
+ handover?: unknown
268
+ handsOver?: unknown
269
+ }
270
+
271
+ /**
272
+ * Which procedure each model turn ran under and why (`procedure.active`
273
+ * spans, one per pi turn of an Agent with procedures), and the procedures
274
+ * the model loaded through `skill` (`procedure.loaded`). Null when the trace
275
+ * has neither: an Agent without procedures, or a runtime that does not
276
+ * record them.
277
+ */
278
+ export function proceduresOf(observations: Array<{ name: string; startTime?: string | null; metadata?: Record<string, unknown> | null }>): { active: ProcedureTurn | null; turns?: ProcedureTurn[]; loaded?: Array<Record<string, unknown>> } | null {
279
+ const ordered = [...observations].sort((a, b) => Date.parse(a.startTime ?? '') - Date.parse(b.startTime ?? ''))
280
+ const turns = ordered
281
+ .filter((o) => o.name === 'procedure.active')
282
+ .map((o): ProcedureTurn => {
283
+ const m = o.metadata ?? {}
284
+ const reason = typeof m.reason === 'string' ? m.reason : 'unknown'
285
+ const has = (v: unknown) => v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0)
286
+ return {
287
+ id: typeof m.procedureId === 'string' ? m.procedureId : null,
288
+ name: typeof m.procedureName === 'string' ? m.procedureName : null,
289
+ reason,
290
+ why: PROCEDURE_REASONS[reason] ?? 'the runtime gave no reason it knows',
291
+ ...(has(m.keywords) ? { keywords: m.keywords } : {}),
292
+ ...(has(m.alsoTriggered) ? { alsoTriggered: m.alsoTriggered } : {}),
293
+ ...(has(m.tools) ? { tools: m.tools } : {}),
294
+ ...(has(m.handover) ? { handover: m.handover } : {}),
295
+ ...(has(m.procedureHandsOver) ? { handsOver: m.procedureHandsOver } : {}),
296
+ }
297
+ })
298
+ const loaded = ordered.filter((o) => o.name === 'procedure.loaded').map((o) => ({ ...(o.metadata ?? {}) }))
299
+ if (!turns.length && !loaded.length) return null
300
+ const differ = new Set(turns.map((t) => `${t.id}:${t.reason}`)).size > 1
301
+ return { active: turns.at(-1) ?? null, ...(differ ? { turns } : {}), ...(loaded.length ? { loaded } : {}) }
302
+ }
303
+
304
+ /* ------------------------------------------------------------------ *
305
+ * The reply check
306
+ * ------------------------------------------------------------------ */
307
+
308
+ /** What a claim kind means: the sentence the reply was read as saying. */
309
+ const CLAIM_KINDS: Record<string, string> = {
310
+ action: 'that an action was, is being or will be carried out',
311
+ escalation: 'that the request was passed on to someone, or that a person or team will take it up',
312
+ channel: 'a way to reach someone (a contact, a page, a desk or a sign-in route; the finding names the phrases) that nothing the Agent was given mentions',
313
+ }
314
+
315
+ /** What the person read instead of the first draft, by the check's outcome. */
316
+ const OUTCOMES: Record<string, { got: string; fixedReply: boolean }> = {
317
+ regenerated: { got: 'the second draft: the Agent rewrote the reply after the correction, and the rewrite passed', fixedReply: false },
318
+ template: { got: "the fixed safe reply (\"Sorry, I can't answer that right now…\" or its translation): the rewrite failed the check too, so neither draft was sent", fixedReply: true },
319
+ deferred: { got: 'the reply the Agent wrote after the tools it called beside this text had run (checked again then); this text was held back', fixedReply: false },
320
+ 'sends-dropped': { got: 'neither draft: the rewrite failed too, so its messages were dropped, and the tools called beside them still ran', fixedReply: false },
321
+ }
322
+
323
+ const DRAFT_CLIP = 800
324
+
325
+ function clipDraft(value: unknown): string | undefined {
326
+ if (typeof value !== 'string' || !value) return undefined
327
+ return value.length > DRAFT_CLIP ? `${value.slice(0, DRAFT_CLIP)}… (${value.length - DRAFT_CLIP} more characters)` : value
328
+ }
329
+
330
+ const RESERVED = new Set(['guardrail', 'check', 'outcome', 'harness', 'correction', 'firstDraft', 'secondDraft', 'drafts', 'draft', 'secondGuardrail', 'secondDetail', 'secondCheck', 'reason'])
331
+
332
+ /** What one check found, in words, from its name and findings. */
333
+ function foundBy(check: string, findings: Record<string, unknown>, reason: unknown): string {
334
+ const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : [])
335
+ if (check === 'claims') {
336
+ const kinds = list(findings.kinds)
337
+ const read = kinds.map((k) => `${k}: it says ${CLAIM_KINDS[k] ?? 'something the check does not name'}`)
338
+ return `The reply claims something no tool did in this conversation${read.length ? ` (${read.join('; ')})` : ''}.`
339
+ }
340
+ // The figures check is gone; older sessions' traces still carry it.
341
+ if (check === 'figures') return `The reply states ${list(findings.figures).join(', ') || 'figures'}, which nothing the Agent was given (instructions, messages, tool results, its own call arguments) contains.`
342
+ if (check === 'output') return `The reply uses "${String(findings.term ?? '?')}", a term this Agent must not say.`
343
+ if (check === 'length') return `A message the Agent was sending is ${String(findings.characters ?? '?')} characters, over the Agent's limit.`
344
+ if (check === 'empty') return typeof findings.reason === 'string' ? findings.reason : 'The model wrote no reply text.'
345
+ return typeof reason === 'string' && reason ? reason : `The ${check} check failed.`
346
+ }
347
+
348
+ export interface ReplyCheck {
349
+ /** The check that failed: claims, output (a blocked term), length, empty; figures in older traces. */
350
+ check: string
351
+ found: string
352
+ findings: Record<string, unknown>
353
+ outcome: string
354
+ /** What the person read, in words. */
355
+ personGot: string
356
+ /** The person read the fixed safe reply, not anything the Agent wrote. */
357
+ replacedWithFixedReply: boolean
358
+ correction?: string
359
+ firstDraft?: string
360
+ secondDraft?: string
361
+ /** The rewrite failed a check too: which, and what it found. */
362
+ secondCheck?: { check: string; found: string; findings: Record<string, unknown> }
363
+ at?: string
364
+ }
365
+
366
+ /**
367
+ * A `reply.blocked` record — the session event's payload or the span's
368
+ * metadata, which carry the same fields — read defensively: the check, what
369
+ * it found, what the person got instead, and both drafts, clipped.
370
+ */
371
+ export function replyCheckOf(raw: Record<string, unknown> | null | undefined, at?: string | null): ReplyCheck {
372
+ const r = raw ?? {}
373
+ const check = String(r.guardrail ?? r.check ?? 'unknown')
374
+ const findings: Record<string, unknown> = {}
375
+ for (const [k, v] of Object.entries(r)) if (!RESERVED.has(k)) findings[k] = v
376
+ // `empty` keeps its reason as its finding.
377
+ if (check === 'empty' && typeof r.reason === 'string') findings.reason = r.reason
378
+ const outcome = String(r.outcome ?? 'unknown')
379
+ const known = OUTCOMES[outcome]
380
+ const drafts = Array.isArray(r.drafts) ? r.drafts : []
381
+ const firstDraft = clipDraft(r.firstDraft ?? r.draft ?? drafts[0])
382
+ const secondDraft = clipDraft(r.secondDraft ?? drafts[1])
383
+ const second = (r.secondCheck && typeof r.secondCheck === 'object' ? r.secondCheck : null) as Record<string, unknown> | null
384
+ const secondName = r.secondGuardrail ?? second?.guardrail ?? second?.check
385
+ const secondFindings = ((r.secondDetail ?? second?.findings ?? second?.detail) as Record<string, unknown> | undefined) ?? {}
386
+ return {
387
+ check,
388
+ found: foundBy(check, findings, r.reason),
389
+ findings,
390
+ outcome,
391
+ personGot: known?.got ?? `the runtime recorded the outcome "${outcome}"`,
392
+ replacedWithFixedReply: known?.fixedReply ?? false,
393
+ ...(typeof r.correction === 'string' ? { correction: r.correction } : {}),
394
+ ...(firstDraft ? { firstDraft } : {}),
395
+ ...(secondDraft ? { secondDraft } : {}),
396
+ ...(secondName ? { secondCheck: { check: String(secondName), found: foundBy(String(secondName), secondFindings, undefined), findings: secondFindings } } : {}),
397
+ ...(at ? { at } : {}),
398
+ }
399
+ }
400
+
401
+ const isReplyBlocked = (name: string) => /reply[._ -]?blocked/i.test(name)
402
+
403
+ /** The reply checks a trace recorded (its `reply.blocked` spans), in order. */
404
+ export function replyChecksOfTrace(observations: Array<{ name: string; startTime?: string | null; metadata?: Record<string, unknown> | null; statusMessage?: string | null }>): ReplyCheck[] {
405
+ return [...observations]
406
+ .filter((o) => isReplyBlocked(o.name))
407
+ .sort((a, b) => Date.parse(a.startTime ?? '') - Date.parse(b.startTime ?? ''))
408
+ .map((o) => replyCheckOf({ ...(o.statusMessage ? { reason: o.statusMessage } : {}), ...(o.metadata ?? {}) }, o.startTime))
409
+ }
@@ -0,0 +1,193 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, relative } from 'node:path'
3
+
4
+ import { RENDER_EVIDENCE_PATH, RENDER_THUMBNAIL_PATH } from '../render-evidence'
5
+ import type { A11yResult } from './a11y-scan'
6
+
7
+ /**
8
+ * What a page showed and said once it settled: the one shape both browsers
9
+ * answer with, Forge's Browser (the person watches it there) and the
10
+ * headless fallback. `app_render` turns it into render evidence.
11
+ */
12
+
13
+ export interface ObserveRequest {
14
+ url: string
15
+ width?: number
16
+ height?: number
17
+ settleMs?: number
18
+ waitForText?: string
19
+ fullPage?: boolean
20
+ /** axe-core's script, to scan the page once it settles; no scan without it. */
21
+ a11yScript?: string | null
22
+ /** The expression that runs the scan, after the script (`AXE_RUN`). */
23
+ a11yRun?: string
24
+ }
25
+
26
+ export interface PageObservation {
27
+ /** The accessibility scan, when one ran. */
28
+ a11y?: A11yResult | null
29
+ url: string
30
+ title: string
31
+ /** The page's visible text, clipped. */
32
+ text: string
33
+ console: Array<{ level: 'error' | 'warning'; text: string }>
34
+ failedRequests: Array<{ url: string; method: string; reason: string }>
35
+ requests: Array<{ url: string; method: string; status: number; type?: string }>
36
+ navigationError: string | null
37
+ /** PNG, base64. */
38
+ screenshot: string
39
+ where: 'forge' | 'headless'
40
+ /** Forge's tab, so the person can find the page the agent rendered. */
41
+ tabId?: string
42
+ }
43
+
44
+ /** The Blueprint reads and governed Action requests the page made, from its requests. */
45
+ export function platformCalls(requests: PageObservation['requests']) {
46
+ const path = (url: string) => {
47
+ try {
48
+ return new URL(url).pathname
49
+ } catch {
50
+ return url
51
+ }
52
+ }
53
+ const blueprint = requests
54
+ .filter((r) => /\/v1\/blueprint\//.test(path(r.url)) && !/\/governed-actions\//.test(path(r.url)))
55
+ .map((r) => ({ method: r.method, path: path(r.url), status: r.status }))
56
+ const actions = requests
57
+ .filter((r) => r.method === 'POST' && /\/v1\/blueprint\/governed-actions\/actions\/[^/]+\/requests$/.test(path(r.url)))
58
+ .map((r) => ({ action: decodeURIComponent(path(r.url).split('/').at(-2) ?? ''), status: r.status }))
59
+ const functions = requests
60
+ .filter((r) => /\/v1\/(functions|automations)\/[^/]+\/(invoke|run)/.test(path(r.url)))
61
+ .map((r) => ({ method: r.method, path: path(r.url), status: r.status }))
62
+ return { blueprint, actions, functions }
63
+ }
64
+
65
+ export interface RenderRecord {
66
+ renderedAt: string
67
+ renderedAtMs: number
68
+ path: string
69
+ title: string
70
+ consoleErrors: number
71
+ failedRequests: number
72
+ /** Where it rendered and against what: the evidence a reviewer weighs. */
73
+ source: 'frontera-mcp'
74
+ browser: 'forge' | 'headless'
75
+ runtime: { kind: 'local'; url: string; version: string | null } | { kind: 'url'; url: string }
76
+ /**
77
+ * What was rendered, for a deploy to check it is evidence of this App: its
78
+ * dev server, its own preview (`/apps/<slug>`), or an address that is
79
+ * neither (`elsewhere`), which a deploy does not attach.
80
+ */
81
+ target?: RenderTarget
82
+ blueprintCalls: number
83
+ actionRequests: number
84
+ /** The basic accessibility scan, when one ran: what a deployment's Accessibility check reads. */
85
+ a11y?: A11yResult
86
+ }
87
+
88
+ export interface RenderTarget {
89
+ of: 'dev-server' | 'preview' | 'elsewhere'
90
+ /** The address opened, without any session token. */
91
+ url: string
92
+ /** The App's slug it was rendered for. */
93
+ app: string
94
+ }
95
+
96
+ /**
97
+ * Whether an address `app_render` was given is this App's: under its running
98
+ * dev server, or one of its preview links (`/apps/<slug>`, any deployment).
99
+ */
100
+ export function renderTargetOf(url: string, app: string, devServerUrl: string | null): RenderTarget {
101
+ let at: URL
102
+ try {
103
+ at = new URL(url)
104
+ } catch {
105
+ return { of: 'elsewhere', url, app }
106
+ }
107
+ if (devServerUrl) {
108
+ try {
109
+ if (new URL(devServerUrl).origin === at.origin) return { of: 'dev-server', url, app }
110
+ } catch {
111
+ // Not a URL: no dev server to compare with.
112
+ }
113
+ }
114
+ const segments = at.pathname.split('/').filter(Boolean)
115
+ const i = segments.indexOf('apps')
116
+ if (i >= 0 && decodeURIComponent(segments[i + 1] ?? '') === app) return { of: 'preview', url, app }
117
+ return { of: 'elsewhere', url, app }
118
+ }
119
+
120
+ const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, '-')
121
+
122
+ /**
123
+ * Write what `frontera app deploy` reads (`.frontera/last-render.json` and
124
+ * the screenshot beside it, in the App's folder) and keep a copy in the
125
+ * project's evidence folder, per session, for the person and a change
126
+ * request. Returns the evidence paths relative to the solution.
127
+ */
128
+ export function writeRenderEvidence(args: {
129
+ appRoot: string
130
+ solutionRoot: string
131
+ session: string | null
132
+ slug: string
133
+ path: string
134
+ observation: PageObservation
135
+ runtime: RenderRecord['runtime']
136
+ target?: RenderTarget
137
+ now?: Date
138
+ }): { record: RenderRecord; screenshot: string; evidence: string } {
139
+ const now = args.now ?? new Date()
140
+ const calls = platformCalls(args.observation.requests)
141
+ const record: RenderRecord = {
142
+ renderedAt: now.toISOString(),
143
+ renderedAtMs: now.getTime(),
144
+ path: args.path,
145
+ title: args.observation.title,
146
+ consoleErrors: args.observation.console.filter((c) => c.level === 'error').length,
147
+ failedRequests: args.observation.failedRequests.length,
148
+ source: 'frontera-mcp',
149
+ browser: args.observation.where,
150
+ runtime: args.runtime,
151
+ ...(args.target ? { target: args.target } : {}),
152
+ blueprintCalls: calls.blueprint.length,
153
+ actionRequests: calls.actions.length,
154
+ ...(args.observation.a11y ? { a11y: args.observation.a11y } : {}),
155
+ }
156
+ const png = Buffer.from(args.observation.screenshot, 'base64')
157
+ const evidenceFile = join(args.appRoot, RENDER_EVIDENCE_PATH)
158
+ mkdirSync(dirname(evidenceFile), { recursive: true })
159
+ writeFileSync(evidenceFile, `${JSON.stringify(record, null, 2)}\n`)
160
+ writeFileSync(join(args.appRoot, RENDER_THUMBNAIL_PATH), png)
161
+
162
+ const folder = evidenceDir(args.solutionRoot, args.session)
163
+ mkdirSync(folder, { recursive: true })
164
+ const base = `${stamp(now)}-${args.slug.replace(/[^a-z0-9-]+/gi, '-')}-render`
165
+ writeFileSync(join(folder, `${base}.png`), png)
166
+ writeFileSync(join(folder, `${base}.json`), `${JSON.stringify({ ...record, url: args.observation.url, console: args.observation.console, failed: args.observation.failedRequests, calls }, null, 2)}\n`)
167
+ return { record, screenshot: relative(args.solutionRoot, join(folder, `${base}.png`)), evidence: relative(args.solutionRoot, join(folder, `${base}.json`)) }
168
+ }
169
+
170
+ /** `.frontera/evidence/<session>/`: screenshots and render results, gitignored with the rest of `.frontera`'s local state. */
171
+ export function evidenceDir(solutionRoot: string, session: string | null): string {
172
+ ignoreEvidence(solutionRoot)
173
+ return join(solutionRoot, '.frontera', 'evidence', (session ?? 'cli').replace(/[^a-z0-9-]+/gi, '-'))
174
+ }
175
+
176
+ /** Screenshots can show real records: they never reach git. */
177
+ function ignoreEvidence(root: string): void {
178
+ const ignore = join(root, '.frontera', '.gitignore')
179
+ mkdirSync(dirname(ignore), { recursive: true })
180
+ const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : ''
181
+ if (text.split(/\r?\n/).some((l) => l.trim() === 'evidence/' || l.trim() === 'evidence')) return
182
+ if (text) appendFileSync(ignore, `${text.endsWith('\n') ? '' : '\n'}evidence/\n`)
183
+ else writeFileSync(ignore, 'local/\nevidence/\n')
184
+ }
185
+
186
+ /** Save a screenshot into the session's evidence folder; returns its path from the solution root. */
187
+ export function saveScreenshot(solutionRoot: string, session: string | null, png: string, label: string, now = new Date()): string {
188
+ const folder = evidenceDir(solutionRoot, session)
189
+ mkdirSync(folder, { recursive: true })
190
+ const file = join(folder, `${stamp(now)}-${label.replace(/[^a-z0-9-]+/gi, '-').slice(0, 40) || 'page'}.png`)
191
+ writeFileSync(file, Buffer.from(png, 'base64'))
192
+ return relative(solutionRoot, file)
193
+ }