@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
@@ -29,19 +29,36 @@ import type { LocalCredentials } from './local-state'
29
29
  */
30
30
 
31
31
  /** A check graded in code, as the platform grades an item's `expectations` (`expectation-grading.ts`). */
32
+ /**
33
+ * Which turn a check reads (Forge improvement plan L3, T10): a person turn by
34
+ * its number (1 is the first), `last`, `any` (one turn passes) or `all`
35
+ * (every turn passes). A reply check reads the last reply unless it says; a
36
+ * tool check reads every turn's calls (`all`) unless it says.
37
+ */
38
+ export type CheckTurn = number | 'last' | 'any' | 'all'
39
+
32
40
  export type TestCheck =
33
- | { type: 'contains'; value: string; text?: string }
34
- | { type: 'regex'; value: string; flags?: string; text?: string }
41
+ | { type: 'contains'; value: string; text?: string; turn?: CheckTurn }
42
+ /** The reply must not say this (any case): a phrase the Agent must never use. */
43
+ | { type: 'not_contains'; value: string; text?: string; turn?: CheckTurn }
44
+ | { type: 'regex'; value: string; flags?: string; text?: string; turn?: CheckTurn }
35
45
  | {
36
46
  type: 'tool_called' | 'tool_not_called'
37
47
  tool: string
48
+ /** Arguments the call carries; `{ absent: true }` as a value says that argument must not be there (L3). */
38
49
  argsSubset?: Record<string, unknown>
39
50
  mode?: 'exact' | 'ignore' | 'subset' | 'superset'
40
51
  text?: string
41
- /** The calls of every turn (`all`, the default) or only of the last one, whose reply the case grades. */
42
- turn?: 'all' | 'last'
52
+ /** The calls of every turn (`all`, the default, or `any`), only of the last one, or of turn n. */
53
+ turn?: CheckTurn
43
54
  }
44
55
 
56
+ /** One person turn as the case ran it: the reply and the calls it made. */
57
+ export interface TurnSeen {
58
+ reply: string
59
+ toolCalls: ToolCall[]
60
+ }
61
+
45
62
  /**
46
63
  * One turn of a case's conversation: a person's message (sent), or an
47
64
  * assistant's (kept for whoever reads the case). `waitSeconds` on a person's
@@ -60,9 +77,15 @@ export interface TestCase {
60
77
  expected?: string
61
78
  /** Pass when the reply contains each of these, instead of matching exactly. */
62
79
  expectedContains?: string[]
80
+ /** Phrases the last reply must not contain, any case (L3). */
81
+ notContains?: string[]
63
82
  /** Checks on the reply and the tools called; every one must pass too. */
64
83
  checks?: TestCheck[]
65
- /** Expectations only the platform's judge grades, in words: kept, not graded here. */
84
+ /**
85
+ * Expectations in words, graded by the expectation judge with the grading
86
+ * model, on the local runtime as on the platform (X1), after every other
87
+ * check passed.
88
+ */
66
89
  judged?: string[]
67
90
  /** Human-certified on the platform. */
68
91
  gold?: boolean
@@ -115,6 +138,8 @@ export interface CaseResult {
115
138
  startedAt?: string
116
139
  /** How many tools the Agent called, for efficiency against a baseline. */
117
140
  toolCalls?: number
141
+ /** The tools it called, by name, once each: what tells a failure on a local stand-in from the Agent's (X2). */
142
+ toolNames?: string[]
118
143
  /**
119
144
  * The waits the case kept between turns (`waitSeconds`), in seconds, in
120
145
  * order: said in the run's output, since such a case takes that long.
@@ -133,6 +158,21 @@ export interface CaseResult {
133
158
  * failing, and never recorded as the case's last outcome.
134
159
  */
135
160
  notRun?: CaseNotRun
161
+ /**
162
+ * The case's `judged` expectations, graded on the local runtime by the
163
+ * expectation judge with the grading model (`local model-default --for
164
+ * tests`), each with the judge's evidence. Absent when the case has none,
165
+ * or failed its other checks first.
166
+ */
167
+ judged?: Array<{ text: string; passed: boolean; evidence: string }>
168
+ /** Why its `judged` expectations weren't graded (no grading model, the runtime refused); the rest of the case was. */
169
+ judgedNotGraded?: string
170
+ /**
171
+ * `--repeat N`: how the case's runs went. A case that passes some runs and
172
+ * fails others is `flaky`; it passes when its pass rate is at least the
173
+ * run's threshold, and fails below it. Runs that couldn't run don't count.
174
+ */
175
+ repeat?: { runs: number; passes: number; passRate: number; flaky: boolean; notRun?: number }
136
176
  }
137
177
 
138
178
  export interface CaseFile {
@@ -236,11 +276,13 @@ function normalise(text: string): string {
236
276
  }
237
277
 
238
278
  /** `lastTurnCalls`: the last turn's calls, for a check scoped `turn: last`; every call when not given. */
239
- export function judge(c: TestCase, reply: string, toolCalls: ToolCall[] = [], lastTurnCalls?: ToolCall[]): { passed: boolean; reason?: string } {
279
+ export function judge(c: TestCase, reply: string, toolCalls: ToolCall[] = [], lastTurnCalls?: ToolCall[], turns?: TurnSeen[]): { passed: boolean; reason?: string } {
240
280
  const answer = judgeAnswer(c, reply)
241
281
  if (!answer.passed) return answer
282
+ const said = (c.notContains ?? []).filter((s) => typeof s === 'string' && s && normalise(reply).toLowerCase().includes(normalise(s).toLowerCase()))
283
+ if (said.length) return { passed: false, reason: `reply says what the case doesn't allow: ${said.join(', ')}` }
242
284
  for (const check of c.checks ?? []) {
243
- const failed = runCheck(check, reply, toolCalls, lastTurnCalls)
285
+ const failed = runCheck(check, reply, toolCalls, lastTurnCalls, turns)
244
286
  if (failed) return { passed: false, reason: failed }
245
287
  }
246
288
  return { passed: true }
@@ -251,8 +293,8 @@ function judgeAnswer(c: TestCase, reply: string): { passed: boolean; reason?: st
251
293
  const missing = c.expectedContains.filter((s) => !normalise(reply).toLowerCase().includes(normalise(s).toLowerCase()))
252
294
  return missing.length ? { passed: false, reason: `reply does not mention: ${missing.join(', ')}` } : { passed: true }
253
295
  }
254
- // Checks alone are an expectation.
255
- if (c.expected === undefined) return c.checks?.length ? { passed: true } : { passed: false, reason: 'the case has no `expected`, `expectedContains` or `checks`' }
296
+ // Checks alone are an expectation, and so are phrases it must not say.
297
+ if (c.expected === undefined) return c.checks?.length || c.notContains?.length ? { passed: true } : { passed: false, reason: 'the case has no `expected`, `expectedContains`, `notContains` or `checks`' }
256
298
  return normalise(reply) === normalise(c.expected) ? { passed: true } : { passed: false, reason: 'reply differs from `expected`' }
257
299
  }
258
300
 
@@ -263,7 +305,12 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
263
305
 
264
306
  const deepEqual = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b) || (isRecord(a) && isRecord(b) && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => deepEqual(a[k], b[k])))
265
307
 
266
- const containsAll = (haystack: Record<string, unknown>, needles: Record<string, unknown>) => Object.entries(needles).every(([k, v]) => k in haystack && deepEqual(haystack[k], v))
308
+ /** `{ absent: true }` in a check's arguments: the call must not carry that argument (an empty value counts as not there). */
309
+ const isAbsentMarker = (v: unknown) => isRecord(v) && v.absent === true && Object.keys(v).length === 1
310
+ const isEmpty = (v: unknown) => v === undefined || v === null || v === ''
311
+
312
+ const containsAll = (haystack: Record<string, unknown>, needles: Record<string, unknown>) =>
313
+ Object.entries(needles).every(([k, v]) => (isAbsentMarker(v) ? !(k in haystack) || isEmpty(haystack[k]) : k in haystack && deepEqual(haystack[k], v)))
267
314
 
268
315
  function argsMatch(check: Extract<TestCheck, { tool: string }>, call: ToolCall): boolean {
269
316
  const mode = check.mode ?? 'superset'
@@ -273,20 +320,45 @@ function argsMatch(check: Extract<TestCheck, { tool: string }>, call: ToolCall):
273
320
  const { action: _narration, ...rest } = call.args
274
321
  const args = 'action' in check.argsSubset ? call.args : rest
275
322
  if (mode === 'superset') return containsAll(args, check.argsSubset)
276
- if (mode === 'subset') return containsAll(check.argsSubset, args)
277
- return containsAll(args, check.argsSubset) && Object.keys(args).length === Object.keys(check.argsSubset).length
323
+ const present = Object.fromEntries(Object.entries(check.argsSubset).filter(([, v]) => !isAbsentMarker(v)))
324
+ const absent = Object.fromEntries(Object.entries(check.argsSubset).filter(([, v]) => isAbsentMarker(v)))
325
+ if (mode === 'subset') return containsAll(present, Object.fromEntries(Object.entries(args).filter(([k]) => !(k in absent)))) && containsAll(args, absent)
326
+ // Exact: the same arguments, and nothing besides; an absent-marked one may be there only empty.
327
+ return containsAll(args, check.argsSubset) && Object.keys(args).every((k) => k in check.argsSubset!)
328
+ }
329
+
330
+ /** A check's `turn` as a turn number (1-based), `last`, `any` or `all`; null when it is none of them. */
331
+ function turnOf(turn: unknown): CheckTurn | null {
332
+ if (turn === undefined) return null
333
+ if (turn === 'last' || turn === 'any' || turn === 'all') return turn
334
+ return typeof turn === 'number' && Number.isInteger(turn) && turn >= 1 ? turn : null
278
335
  }
279
336
 
280
337
  /** Why a check failed, or null when it passed. */
281
- export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[], lastTurnCalls?: ToolCall[]): string | null {
338
+ export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[], lastTurnCalls?: ToolCall[], turns?: TurnSeen[]): string | null {
282
339
  const label = check.text ? ` (${check.text})` : ''
283
- const lastOnly = 'turn' in check && check.turn === 'last'
284
- const toolCalls = lastOnly && lastTurnCalls ? lastTurnCalls : allCalls
340
+ const turn = turnOf((check as { turn?: unknown }).turn)
341
+ if ((check as { turn?: unknown }).turn !== undefined && turn === null) return `the check's \`turn\` is a turn number from 1, last, any or all (it is ${JSON.stringify((check as { turn?: unknown }).turn)})${label}`
342
+ // A turn the case never reached fails the check by name, rather than reading another turn.
343
+ if (typeof turn === 'number' && turns && turn > turns.length) return `the case has no turn ${turn} (it ran ${turns.length})${label}`
285
344
  // The format says text; a number or a missing value is refused, not thrown on.
286
- if ((check.type === 'contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) {
345
+ if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) {
287
346
  return `the ${check.type} check's \`value\` must be text (it is ${check.value === undefined ? 'missing' : JSON.stringify(check.value)}); quote it in the file${label}`
288
347
  }
348
+ // A reply check on several turns: any one passing, or every one (L3).
349
+ if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && (turn === 'any' || turn === 'all') && turns?.length) {
350
+ const failures = turns.map((t, i) => [i + 1, runCheck({ ...check, turn: 'last' } as TestCheck, t.reply, t.toolCalls, t.toolCalls)] as const)
351
+ if (turn === 'any') return failures.some(([, f]) => f === null) ? null : `no turn passes; the last: ${failures[failures.length - 1]![1]}`
352
+ const failed = failures.find(([, f]) => f !== null)
353
+ return failed ? `turn ${failed[0]}: ${failed[1]}` : null
354
+ }
355
+ if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && typeof turn === 'number' && turns) {
356
+ return runCheck({ ...check, turn: 'last' } as TestCheck, turns[turn - 1]!.reply, turns[turn - 1]!.toolCalls, turns[turn - 1]!.toolCalls)?.replace(/^/, `turn ${turn}: `) ?? null
357
+ }
358
+ const lastOnly = turn === 'last'
359
+ const toolCalls = typeof turn === 'number' && turns ? turns[turn - 1]!.toolCalls : lastOnly && lastTurnCalls ? lastTurnCalls : allCalls
289
360
  if (check.type === 'contains') return reply.toLowerCase().includes(check.value.toLowerCase()) ? null : `reply does not mention: ${check.value}${label}`
361
+ if (check.type === 'not_contains') return reply.toLowerCase().includes(check.value.toLowerCase()) ? `reply says ${check.value}, which the case doesn't allow${label}` : null
290
362
  if (check.type === 'regex') {
291
363
  if (check.value.length > 500 || NESTED_QUANTIFIER.test(check.value)) return `the pattern ${check.value} can't run safely${label}`
292
364
  let pattern: RegExp
@@ -302,7 +374,7 @@ export function runCheck(check: TestCheck, reply: string, allCalls: ToolCall[],
302
374
  const matched = candidates.some((c) => c.name === check.tool && argsMatch(check, c))
303
375
  const said = (c: ToolCall) => `${c.name}${c.parked ? ' (waiting on approval)' : !c.ok ? ' (did not run)' : ''}`
304
376
  const made = toolCalls.length ? `calls made: ${[...new Set(toolCalls.map(said))].join(', ')}` : 'no tools were called'
305
- const where = lastOnly ? ' in the last turn' : ''
377
+ const where = lastOnly ? ' in the last turn' : typeof turn === 'number' ? ` in turn ${turn}` : ''
306
378
  if (check.type === 'tool_called') return matched ? null : `no matching call to ${check.tool}${where}; ${made}${label}`
307
379
  return matched ? `${check.tool} was called${where}, which the case does not allow${label}` : null
308
380
  }
@@ -619,10 +691,22 @@ export async function runCases(args: {
619
691
  runOne?: (api: Api, target: CaseTarget, c: TestCase, opts?: ConversationCaseOptions) => Promise<CaseResult>
620
692
  /** The Agent a file's cases talk to (`chatTarget`; tests pass a stand-in). */
621
693
  target?: (api: Api, slug: string, live: boolean) => Promise<{ agentId: string; agentName: string }>
694
+ /**
695
+ * `--repeat N`: each conversation case runs N times, and its result says how
696
+ * many passed (`combineRuns`). A scenario case runs once: the evaluation
697
+ * runner judges it as a whole.
698
+ */
699
+ repeat?: number
700
+ /** The pass rate a repeated, flaky case needs to pass (default `DEFAULT_PASS_RATE`). */
701
+ passRate?: number
702
+ /** How a case's `judged` expectations are graded (tests pass a stand-in). */
703
+ grade?: typeof gradeJudged
622
704
  }): Promise<CaseResult[]> {
623
705
  const api: Api = { serviceUrl: args.serviceUrl, creds: args.creds }
624
706
  const results: CaseResult[] = []
625
- const queued: Array<{ target: CaseTarget; c: TestCase }> = []
707
+ const queued: Array<{ target: CaseTarget; c: TestCase; group: number }> = []
708
+ const repeat = Math.max(1, Math.min(10, Math.floor(args.repeat ?? 1)))
709
+ let groups = 0
626
710
  // A credit refusal names a model the runtime offers directly from another provider whose key it holds
627
711
  // (the providers the runtime applied keys for; never a key), and how to make it the default.
628
712
  const told = (r: CaseResult): CaseResult => (r.notRun?.kind === 'credits' ? { ...r, notRun: withDirectModel(r.notRun, directModelFor(args.creds.modelProviders, r.notRun.provider)) } : r)
@@ -666,7 +750,10 @@ export async function runCases(args: {
666
750
  }
667
751
  }
668
752
  const target: CaseTarget = { agent: file.agent, file: file.file, agentName, ...(agentId ? { agentId } : {}), ...(setupError ? { setupError } : {}), workspaceId: args.creds.workspaceId }
669
- for (const c of file.cases) queued.push({ target, c })
753
+ for (const c of file.cases) {
754
+ const group = groups++
755
+ for (let n = 0; n < repeat; n++) queued.push({ target, c, group })
756
+ }
670
757
  }
671
758
  // The conversation cases, a few at a time; each retried once, after a pause, when the provider refused it for its limits.
672
759
  const runOne = args.runOne ?? runConversationCase
@@ -678,7 +765,8 @@ export async function runCases(args: {
678
765
  let stopped: { notRun: CaseNotRun; by: string } | null = null
679
766
  const once = async ({ target, c }: { target: CaseTarget; c: TestCase }): Promise<CaseResult> => {
680
767
  if (stopped) return { agent: target.agent, file: target.file, name: c.name, passed: false, reply: '', reason: `not run: ${stopped.notRun.what} at ${stopped.by}, so the cases after it were not sent`, ms: 0, notRun: { ...stopped.notRun, skipped: true } }
681
- const attempt = () => (usesRealActions(c) ? writes(() => runOne(api, target, c, { wait })) : runOne(api, target, c, { wait }))
768
+ const options = { wait, ...(args.grade ? { grade: args.grade } : {}) }
769
+ const attempt = () => (usesRealActions(c) ? writes(() => runOne(api, target, c, options)) : runOne(api, target, c, options))
682
770
  const first = await attempt()
683
771
  if (first.passed || !first.reason || !isProviderLimit(first.reason)) return first
684
772
  await wait(delay)
@@ -686,13 +774,22 @@ export async function runCases(args: {
686
774
  const said = first.reason.replace(/\s+/g, ' ').slice(0, 200)
687
775
  return { ...second, retried: said, ...(second.passed ? {} : { reason: `${second.reason ?? 'failed'} (run twice: the provider refused the first try for its limits: ${said})` }) }
688
776
  }
689
- const ran = await pool(queued, Math.max(1, Math.floor(args.concurrency ?? DEFAULT_CASE_CONCURRENCY)), async (q) => {
777
+ // A case's runs, as they come back: said as one result once the last of them is in.
778
+ const runsOf = new Map<number, CaseResult[]>()
779
+ const combined = new Map<number, CaseResult>()
780
+ await pool(queued, Math.max(1, Math.floor(args.concurrency ?? DEFAULT_CASE_CONCURRENCY)), async (q) => {
690
781
  const r = told(await once(q))
691
782
  if (!stopped && r.notRun && !r.notRun.skipped && stopsTheRun(r.notRun)) stopped = { notRun: r.notRun, by: `${r.agent} · ${r.name}` }
692
- args.onResult(r)
783
+ const runs = [...(runsOf.get(q.group) ?? []), r]
784
+ runsOf.set(q.group, runs)
785
+ if (runs.length === repeat) {
786
+ const one = combineRuns(runs, args.passRate ?? DEFAULT_PASS_RATE)
787
+ combined.set(q.group, one)
788
+ args.onResult(one)
789
+ }
693
790
  return r
694
791
  })
695
- return [...results, ...ran]
792
+ return [...results, ...[...combined.entries()].sort((a, b) => a[0] - b[0]).map(([, r]) => r)]
696
793
  }
697
794
 
698
795
  /** Where a file's conversation cases go: the Agent, the name a conversation addresses, and why they can't run when they can't. */
@@ -708,6 +805,97 @@ export interface CaseTarget {
708
805
  export interface ConversationCaseOptions {
709
806
  /** How a turn's `waitSeconds` is waited out (tests pass a quick one). */
710
807
  wait?: (ms: number) => Promise<void>
808
+ /** How a case's `judged` expectations are graded (`gradeJudged`; tests pass a stand-in). */
809
+ grade?: typeof gradeJudged
810
+ }
811
+
812
+ /**
813
+ * Grade a reply against expectations in words with the local runtime's
814
+ * expectation judge (`POST /v1/eval/expectations/grade`), the judge a
815
+ * platform evaluation run uses, on the grading model the runtime has
816
+ * (`frontera local model-default <model> --for tests`).
817
+ */
818
+ export async function gradeJudged(api: Api, input: { expectations: string[]; conversation: string; reply: string; toolCalls: ToolCall[] }): Promise<{ results: Array<{ text: string; passed: boolean; evidence: string }> }> {
819
+ return call(api, '/v1/eval/expectations/grade', {
820
+ body: {
821
+ expectations: input.expectations,
822
+ conversation: input.conversation.slice(0, 200_000),
823
+ reply: input.reply.slice(0, 200_000),
824
+ toolCalls: input.toolCalls.slice(0, 200).map((c) => ({ name: c.name, ...(c.args === undefined ? {} : { args: c.args }), ok: c.ok })),
825
+ },
826
+ })
827
+ }
828
+
829
+ /** Why grading couldn't run, in words, with what fixes it. */
830
+ export function judgedNotGradedWords(message: string): string {
831
+ if (/answered 404/.test(message)) return 'this runtime is older than judged grading: `frontera local start --update`, then test again'
832
+ if (/answered 409|not configured|no .*model|judge/i.test(message)) return `the runtime has no grading model it can use (${message.replace(/\s+/g, ' ').slice(0, 160)}): choose one with \`frontera local model-default <model> --for tests\``
833
+ return message.replace(/\s+/g, ' ').slice(0, 240)
834
+ }
835
+
836
+ /** `--repeat` as given: a whole number from 1 to 10. Undefined when not given. */
837
+ export function caseRepeat(raw: string | number | undefined): number | undefined {
838
+ if (raw === undefined || raw === '') return undefined
839
+ const n = Number(raw)
840
+ if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error('--repeat takes a whole number from 1 to 10, e.g. `--repeat 5` to run each case five times and report its pass rate')
841
+ return n
842
+ }
843
+
844
+ /** The pass rate a repeated case needs to pass, by default: 4 of 5 runs. */
845
+ export const DEFAULT_PASS_RATE = 0.8
846
+
847
+ /** `--pass-rate` as given: a share from 0 to 1, or a percentage (`80%`). Undefined when not given. */
848
+ export function passRateThreshold(raw: string | number | undefined): number | undefined {
849
+ if (raw === undefined || raw === '') return undefined
850
+ const text = String(raw).trim()
851
+ const n = text.endsWith('%') ? Number(text.slice(0, -1)) / 100 : Number(text)
852
+ if (!Number.isFinite(n) || n <= 0 || n > 1) throw new Error('--pass-rate takes a share above 0 and up to 1 (`0.8`) or a percentage (`80%`): the share of runs a repeated case must pass')
853
+ return n
854
+ }
855
+
856
+ /**
857
+ * One case's repeated runs as one result (`--repeat N`): passed when every
858
+ * run that ran passed, or when it is flaky and its pass rate is at least
859
+ * `threshold`; the first failing run's reason and conversation otherwise, with
860
+ * how many runs passed. Runs that couldn't run aren't counted; when none ran,
861
+ * the case couldn't run.
862
+ */
863
+ export function combineRuns(runs: CaseResult[], threshold = DEFAULT_PASS_RATE): CaseResult {
864
+ if (runs.length === 1) return runs[0]!
865
+ const ran = runs.filter((r) => !r.notRun)
866
+ if (!ran.length) return runs[0]!
867
+ const passes = ran.filter((r) => r.passed).length
868
+ const passRate = passes / ran.length
869
+ const flaky = passes > 0 && passes < ran.length
870
+ const passed = passes === ran.length || (flaky && passRate >= threshold)
871
+ const shown = ran.find((r) => !r.passed) ?? ran[ran.length - 1]!
872
+ const mean = (values: Array<number | undefined>) => {
873
+ const known = values.filter((v): v is number => typeof v === 'number')
874
+ return known.length ? Math.round(known.reduce((a, b) => a + b, 0) / known.length) : undefined
875
+ }
876
+ const counted = `passed ${passes} of ${ran.length} runs`
877
+ const toolCalls = mean(ran.map((r) => r.toolCalls))
878
+ return {
879
+ ...shown,
880
+ passed,
881
+ ms: mean(ran.map((r) => r.ms)) ?? shown.ms,
882
+ ...(toolCalls === undefined ? {} : { toolCalls }),
883
+ reason: passed ? (flaky ? `flaky: ${counted}, at or above the ${Math.round(threshold * 100)}% it needs; one failed: ${shown.reason ?? 'failed'}` : undefined) : `${shown.reason ?? 'failed'} (${counted}${flaky ? `, flaky, below the ${Math.round(threshold * 100)}% it needs` : ''})`,
884
+ repeat: { runs: ran.length, passes, passRate, flaky, ...(runs.length > ran.length ? { notRun: runs.length - ran.length } : {}) },
885
+ }
886
+ }
887
+
888
+ /** What a repeated case says after its name: `(passed 4 of 5 runs, flaky)`. */
889
+ export function repeatWords(r: CaseResult): string {
890
+ if (!r.repeat) return ''
891
+ return ` (passed ${r.repeat.passes} of ${r.repeat.runs} runs${r.repeat.flaky ? ', flaky' : ''}${r.repeat.notRun ? `; ${r.repeat.notRun} couldn’t run` : ''})`
892
+ }
893
+
894
+ /** What a case's `judged` expectations came to, after its name: ` (judged: 2 of 2 met)`, or why they weren't graded. */
895
+ export function judgedWords(r: CaseResult): string {
896
+ if (r.judgedNotGraded) return ` (judged expectations not graded: ${r.judgedNotGraded})`
897
+ if (!r.judged?.length) return ''
898
+ return ` (judged: ${r.judged.filter((j) => j.passed).length} of ${r.judged.length} met)`
711
899
  }
712
900
 
713
901
  /** A turn's `waitSeconds` as the runner keeps it: a whole number of seconds from 0 to `MAX_TURN_WAIT_SECONDS`. */
@@ -742,6 +930,10 @@ export async function runConversationCase(api: Api, target: CaseTarget, c: TestC
742
930
  let waited: PendingDecision | undefined
743
931
  const decide = c.decision === 'approve' || c.decision === 'deny' ? c.decision : undefined
744
932
  let userTurn = 0
933
+ // The conversation as the judge reads it, for the case's `judged` expectations.
934
+ const said: string[] = []
935
+ // Each person turn's reply and calls, for checks on a turn (L3).
936
+ const seen: TurnSeen[] = []
745
937
  for (const turn of c.conversation ?? []) {
746
938
  if (typeof turn.user !== 'string') continue
747
939
  userTurn++
@@ -752,6 +944,8 @@ export async function runConversationCase(api: Api, target: CaseTarget, c: TestC
752
944
  gaps.push({ turn: userTurn, seconds })
753
945
  }
754
946
  const answer = await sendTurn(api, session.id, target.agentName, turn.user, decide)
947
+ seen.push({ reply: answer.reply, toolCalls: answer.toolCalls })
948
+ said.push(`user: ${turn.user}`, `assistant: ${answer.reply}`)
755
949
  reply = answer.reply
756
950
  toolCalls.push(...answer.toolCalls)
757
951
  lastTurnCalls = answer.toolCalls
@@ -766,8 +960,21 @@ export async function runConversationCase(api: Api, target: CaseTarget, c: TestC
766
960
  ? { passed: false, reason: waited.approvalId ? `turn waits on an approval for ${waited.tool}; decide it (\`decision: approve\` or \`deny\`) or mark the case \`decision: expect\`` : `turn waits on ${waited.tool}; mark the case \`decision: expect\`` }
767
961
  : !waited && c.decision === 'expect'
768
962
  ? { passed: false, reason: 'the case expects a decision, and no turn waited for one' }
769
- : judge(c, reply, toolCalls, lastTurnCalls)
770
- return { agent: target.agent, file: target.file, name: c.name, passed: verdict.passed, reply, reason: verdict.reason, ms: Date.now() - started, startedAt: new Date(started).toISOString(), agentId, conversationId, toolCalls: toolCalls.length, ...waitedField() }
963
+ : judge(c, reply, toolCalls, lastTurnCalls, seen)
964
+ // The case's `judged` expectations, graded by the runtime's judge once everything else passed.
965
+ let judgedField: Pick<CaseResult, 'judged' | 'judgedNotGraded'> = {}
966
+ const judgedTexts = (c.judged ?? []).filter((j): j is string => typeof j === 'string' && j.trim() !== '')
967
+ if (verdict.passed && judgedTexts.length && !waited) {
968
+ try {
969
+ const graded = await (opts.grade ?? gradeJudged)(api, { expectations: judgedTexts, conversation: said.join('\n'), reply, toolCalls })
970
+ judgedField = { judged: graded.results.map((g) => ({ text: g.text, passed: g.passed, evidence: g.evidence })) }
971
+ const missed = judgedField.judged!.filter((g) => !g.passed)
972
+ if (missed.length) Object.assign(verdict, { passed: false, reason: `judged: ${missed.map((g) => `“${g.text}” not met (${g.evidence})`).join('; ')}` })
973
+ } catch (err) {
974
+ judgedField = { judgedNotGraded: judgedNotGradedWords((err as Error).message) }
975
+ }
976
+ }
977
+ return { agent: target.agent, file: target.file, name: c.name, passed: verdict.passed, reply, reason: verdict.reason, ms: Date.now() - started, startedAt: new Date(started).toISOString(), agentId, conversationId, toolCalls: toolCalls.length, ...(toolCalls.length ? { toolNames: [...new Set(toolCalls.map((t) => t.name))] } : {}), ...waitedField(), ...judgedField }
771
978
  } catch (err) {
772
979
  const reason = (err as Error).message
773
980
  const notRun = caseCouldNotRun(reason)
@@ -0,0 +1,174 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join, normalize } from 'node:path'
3
+
4
+ import { loadBuild } from './local-applied'
5
+ import type { Unchanged } from './local-check-inherited'
6
+ import type { LocalProject } from './local-project'
7
+ import { hash, readLock } from './local-sync-plan'
8
+ import { readBase } from './local-sync-snapshot'
9
+ import { findCaseFiles, type CaseFile } from './local-test-runner'
10
+
11
+ /**
12
+ * What a test run left out and why, and why each Agent's cases are in it
13
+ * (Forge improvement plan T9, X1). Before, a gate's count dropped from 13 to
14
+ * 5 with nothing said, and `local check` ran every Agent's cases, so a rename
15
+ * turned another Agent's flaky case into a blocker.
16
+ */
17
+
18
+ /** Cases a run did not take, with why: `3 cases in agents/desk/evals/x.yaml: not marked gate: true`. */
19
+ export interface SkippedCases {
20
+ reason: string
21
+ cases: number
22
+ }
23
+
24
+ /**
25
+ * The cases under the run's scope that it leaves out, by reason: a file with
26
+ * no cases, cases not marked `gate: true` in a gate run, files that aren't a
27
+ * regression dataset in a `--regression` run, and other Agents' cases when
28
+ * the run names Agents (`local check --agent`).
29
+ */
30
+ export function skippedCases(root: string, scope: { agents?: readonly string[]; file?: string; regression?: boolean; gate?: boolean } = {}): SkippedCases[] {
31
+ const file = scope.file?.replace(/^\.\//, '')
32
+ const all = findCaseFiles(root).filter((f) => !file || f.file === file)
33
+ const out: SkippedCases[] = []
34
+ const add = (reason: string, cases: number) => out.push({ reason, cases })
35
+ const named = scope.agents?.length ? new Set(scope.agents) : null
36
+ if (named) {
37
+ const others = all.filter((f) => !named.has(f.agent))
38
+ const count = others.reduce((n, f) => n + f.cases.length, 0)
39
+ if (count) add(`other Agents' cases (${[...new Set(others.map((f) => f.agent))].sort().join(', ')}): not named with --agent`, count)
40
+ }
41
+ for (const f of all.filter((f) => !named || named.has(f.agent))) {
42
+ if (!f.cases.length && !f.problem) {
43
+ add(`${f.file} has no cases`, 0)
44
+ continue
45
+ }
46
+ if (scope.regression && !f.regression) {
47
+ add(`${f.file}: not a regression dataset (\`regression: true\`), and the run takes only those`, f.cases.length)
48
+ continue
49
+ }
50
+ const ungated = scope.gate ? f.cases.filter((c) => c.gate !== true).length : 0
51
+ if (ungated) add(`${f.file}: not marked \`gate: true\`, and the run takes only gate cases`, ungated)
52
+ }
53
+ return out
54
+ }
55
+
56
+ /** The skipped cases in one line: `5 skipped (3 agents/x/evals/a.yaml: not marked gate: true …; 2 …)`. */
57
+ export function skippedWords(skipped: readonly SkippedCases[]): string {
58
+ const counted = skipped.filter((s) => s.cases > 0)
59
+ const total = counted.reduce((n, s) => n + s.cases, 0)
60
+ const empty = skipped.filter((s) => s.cases === 0)
61
+ return [total ? `${total} skipped (${counted.map((s) => (counted.length === 1 ? s.reason : `${s.cases} ${s.reason}`)).join('; ')})` : '', ...empty.map((s) => s.reason)].filter(Boolean).join('; ')
62
+ }
63
+
64
+ /** Why an Agent's cases are in a `local check`, and whether its failures fail it. */
65
+ export interface AgentScope {
66
+ agent: string
67
+ cases: number
68
+ why: string
69
+ /**
70
+ * Its failures are inherited from the workspace: it is unchanged since the
71
+ * pull, or only its name changed. They pass unless `--strict`, as every
72
+ * other step's inherited failures do.
73
+ */
74
+ inherited: boolean
75
+ }
76
+
77
+ const parseJson = (text: string | null): unknown => {
78
+ if (text === null) return undefined
79
+ try {
80
+ return JSON.parse(text)
81
+ } catch {
82
+ return undefined
83
+ }
84
+ }
85
+
86
+ /** An agent.json without its name, for telling a rename from a change in what the Agent does. */
87
+ function withoutName(doc: unknown): unknown {
88
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc
89
+ const { name: _name, ...rest } = doc as Record<string, unknown>
90
+ const config = rest.config
91
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return rest
92
+ const { name: _configName, ...configRest } = config as Record<string, unknown>
93
+ return { ...rest, config: configRest }
94
+ }
95
+
96
+ const canonical = (value: unknown): string => JSON.stringify(value, (_k, v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : v))
97
+
98
+ /**
99
+ * Whether the only change to an Agent since the pull is its name: every one
100
+ * of its files the pull wrote is as written, except agent.json, which differs
101
+ * from the pulled copy only in `config.name` (or a top-level `name`).
102
+ */
103
+ export function nameOnlyChange(root: string, slug: string): boolean {
104
+ const entry = readLock(root)?.items[`agent/${slug}`]
105
+ if (!entry) return false
106
+ const agentJson = `agents/${slug}/agent.json`
107
+ let renamed = false
108
+ for (const [path, written] of Object.entries(entry.files ?? {})) {
109
+ const full = join(root, path)
110
+ const now = existsSync(full) ? readFileSync(full, 'utf8') : null
111
+ if (now !== null && hash(now) === written) continue
112
+ if (normalize(path) !== normalize(agentJson) || now === null) return false
113
+ const before = parseJson(readBase(root, path))
114
+ const after = parseJson(now)
115
+ if (before === undefined || after === undefined || canonical(withoutName(before)) !== canonical(withoutName(after))) return false
116
+ renamed = true
117
+ }
118
+ return renamed
119
+ }
120
+
121
+ /**
122
+ * Each Agent with cases in a check, and why it is in it. Named with
123
+ * `--agent`: those only. Otherwise every Agent; in a pulled project one
124
+ * unchanged since the pull, its cases included, or changed only in its name,
125
+ * has its failures inherited (they pass unless `--strict`).
126
+ */
127
+ export function agentScope(root: string, files: readonly CaseFile[], unchanged: Unchanged, named: readonly string[] = []): AgentScope[] {
128
+ const pulled = !!readLock(root)
129
+ const bySlug = new Map<string, number>()
130
+ for (const f of files) bySlug.set(f.agent, (bySlug.get(f.agent) ?? 0) + f.cases.length)
131
+ return [...bySlug.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([agent, cases]) => {
132
+ if (named.includes(agent)) return { agent, cases, why: 'named with --agent', inherited: false }
133
+ if (!pulled) return { agent, cases, why: 'every Agent with cases is checked (`--agent <slug>` checks one)', inherited: false }
134
+ const caseFilesSame = files.filter((f) => f.agent === agent).every((f) => unchanged.files.has(normalize(f.file)))
135
+ if (!readLock(root)?.items[`agent/${agent}`]) return { agent, cases, why: 'added here, not pulled', inherited: false }
136
+ if (!caseFilesSame) return { agent, cases, why: 'its test cases changed since the pull', inherited: false }
137
+ if (unchanged.refs.has(`agent/${agent}`)) return { agent, cases, why: 'unchanged since the pull: its failures are inherited unless --strict', inherited: true }
138
+ if (nameOnlyChange(root, agent)) return { agent, cases, why: 'only its name changed since the pull, which is not a change in what it does: its failures are inherited unless --strict', inherited: true }
139
+ return { agent, cases, why: 'its files changed since the pull', inherited: false }
140
+ })
141
+ }
142
+
143
+ /**
144
+ * The Agents that run on another model here than their files name, as the
145
+ * apply said (`it uses <model> here`), one line each: a pass or a failure
146
+ * says less about the model the workspace runs.
147
+ */
148
+ export function modelSubstitutions(project: LocalProject, organizationId: string | null, slugs: readonly string[]): string[] {
149
+ const build = loadBuild(project, organizationId)
150
+ const out: string[] = []
151
+ for (const slug of [...new Set(slugs)].sort()) {
152
+ const entry = build[`agent:${slug}`]
153
+ for (const a of entry?.adaptations ?? []) if (/\bmodel\b/i.test(a) && /\buses\b/i.test(a)) out.push(`Agent ${slug}: ${a.replace(/^Agent \S+ /, '')}`)
154
+ }
155
+ return out
156
+ }
157
+
158
+ /**
159
+ * The Agents the project's files make customer-facing: each with an
160
+ * `operating.json` (Forge improvement plan X4, T7; P5 A15). Behavior settings
161
+ * are what make an Agent serve customers, and the built-in Chat neither
162
+ * offers nor runs one; that is inferred, not chosen, until an Agent's
163
+ * audience is its own field (the plan's open question 5). `local check` says
164
+ * which, so a team's own Agent with an `operating.json` is noticed.
165
+ */
166
+ export function customerFacingAgents(root: string): string[] {
167
+ const dir = join(root, 'agents')
168
+ if (!existsSync(dir)) return []
169
+ return readdirSync(dir).filter((slug) => existsSync(join(dir, slug, 'agent.json')) && existsSync(join(dir, slug, 'operating.json'))).sort()
170
+ }
171
+
172
+ export function customerFacingWords(slugs: readonly string[]): string {
173
+ return `${slugs.join(', ')} ${slugs.length === 1 ? 'serves' : 'serve'} customers, so the built-in Chat neither offers nor runs ${slugs.length === 1 ? 'it' : 'them'}: ${slugs.length === 1 ? 'it has' : 'each has'} behavior settings (operating.json). For an Agent your team uses in Chat, delete its operating.json; in the workspace, \`frontera agent behavior clear <agent>\` and publish`
174
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+
4
+ import { collectAppContract } from '../app-contract'
5
+ import { readSnapshot } from './local-sync-snapshot'
6
+
7
+ /**
8
+ * What the project's workflows and Apps read that the linked workspace isn't
9
+ * granted (Forge improvement plan X2, T12 step 3). The local runtime grants
10
+ * the workspace every object type, so a workflow or App reading a type the
11
+ * project adds works here. In the workspace it reads only its slice: a change
12
+ * request that creates the type grants it, and a Blueprint published by hand
13
+ * grants nothing. Said as a warning when the Blueprint applies, for a project
14
+ * linked to a workspace; nothing for one that isn't.
15
+ */
16
+ export interface UngrantedRead {
17
+ objectType: string
18
+ /** `workflow/intake`, `app/desk`. */
19
+ readers: string[]
20
+ }
21
+
22
+ const TYPE_REF = /objectType\/([A-Z][A-Za-z0-9]*)/g
23
+
24
+ function yamlFiles(dir: string): string[] {
25
+ return existsSync(dir) ? readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).map((f) => join(dir, f)) : []
26
+ }
27
+
28
+ export function ungrantedReads(root: string): UngrantedRead[] {
29
+ const slice = readSnapshot(root)?.names?.objectType
30
+ if (!slice) return []
31
+ const granted = new Set(Object.keys(slice))
32
+ const projectTypes = new Set(yamlFiles(join(root, 'blueprint', 'object-types')).map((p) => basename(p).replace(/\.ya?ml$/, '')))
33
+ const readers = new Map<string, Set<string>>()
34
+ const read = (type: string, by: string) => {
35
+ if (!projectTypes.has(type) || granted.has(type)) return
36
+ readers.set(type, (readers.get(type) ?? new Set()).add(by))
37
+ }
38
+ for (const path of yamlFiles(join(root, 'workflows'))) {
39
+ const name = basename(path).replace(/\.ya?ml$/, '')
40
+ for (const m of readFileSync(path, 'utf8').matchAll(TYPE_REF)) read(m[1]!, `workflow/${name}`)
41
+ }
42
+ const apps = join(root, 'apps')
43
+ if (existsSync(apps)) {
44
+ for (const slug of readdirSync(apps)) {
45
+ const dir = join(apps, slug)
46
+ if (!statSync(dir).isDirectory() || !existsSync(join(dir, 'frontera.config.json'))) continue
47
+ for (const type of Object.keys(collectAppContract(dir).objectTypes)) read(type, `app/${slug}`)
48
+ }
49
+ }
50
+ return [...readers].sort(([a], [b]) => a.localeCompare(b)).map(([objectType, by]) => ({ objectType, readers: [...by].sort() }))
51
+ }
52
+
53
+ export function ungrantedReadLines(found: UngrantedRead[]): string[] {
54
+ return found.map((f) => ` warning: ${f.readers.join(', ')} ${f.readers.length === 1 ? 'reads' : 'read'} ${f.objectType}, which the linked workspace isn't granted. A change request that creates ${f.objectType} grants it to the workspace; a Blueprint published by hand (\`frontera blueprint publish\`) grants nothing, and there ${f.readers.length === 1 ? 'it' : 'they'} can't read it`)
55
+ }