@namzu/sdk 62.11.0 → 62.13.0

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 (192) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/connector/index.d.ts +1 -1
  3. package/dist/connector/index.d.ts.map +1 -1
  4. package/dist/connector/index.js +1 -1
  5. package/dist/connector/index.js.map +1 -1
  6. package/dist/connector/mcp/client.d.ts +96 -0
  7. package/dist/connector/mcp/client.d.ts.map +1 -1
  8. package/dist/connector/mcp/client.js +488 -13
  9. package/dist/connector/mcp/client.js.map +1 -1
  10. package/dist/connector/mcp/index.d.ts +1 -0
  11. package/dist/connector/mcp/index.d.ts.map +1 -1
  12. package/dist/connector/mcp/index.js +1 -0
  13. package/dist/connector/mcp/index.js.map +1 -1
  14. package/dist/connector/mcp/tasks.d.ts +40 -0
  15. package/dist/connector/mcp/tasks.d.ts.map +1 -0
  16. package/dist/connector/mcp/tasks.js +55 -0
  17. package/dist/connector/mcp/tasks.js.map +1 -0
  18. package/dist/constants/mcp/index.d.ts +1 -1
  19. package/dist/constants/mcp/index.d.ts.map +1 -1
  20. package/dist/constants/mcp/index.js +5 -0
  21. package/dist/constants/mcp/index.js.map +1 -1
  22. package/dist/eval/bootstrap.d.ts +122 -0
  23. package/dist/eval/bootstrap.d.ts.map +1 -0
  24. package/dist/eval/bootstrap.js +227 -0
  25. package/dist/eval/bootstrap.js.map +1 -0
  26. package/dist/eval/compare.d.ts +109 -0
  27. package/dist/eval/compare.d.ts.map +1 -0
  28. package/dist/eval/compare.js +324 -0
  29. package/dist/eval/compare.js.map +1 -0
  30. package/dist/eval/disclosure.d.ts +70 -0
  31. package/dist/eval/disclosure.d.ts.map +1 -0
  32. package/dist/eval/disclosure.js +64 -0
  33. package/dist/eval/disclosure.js.map +1 -0
  34. package/dist/eval/experiment.d.ts +23 -0
  35. package/dist/eval/experiment.d.ts.map +1 -1
  36. package/dist/eval/experiment.js +87 -15
  37. package/dist/eval/experiment.js.map +1 -1
  38. package/dist/eval/from-turn.d.ts.map +1 -1
  39. package/dist/eval/from-turn.js +5 -0
  40. package/dist/eval/from-turn.js.map +1 -1
  41. package/dist/eval/index.d.ts +13 -2
  42. package/dist/eval/index.d.ts.map +1 -1
  43. package/dist/eval/index.js +6 -1
  44. package/dist/eval/index.js.map +1 -1
  45. package/dist/eval/report-stats.d.ts +77 -0
  46. package/dist/eval/report-stats.d.ts.map +1 -0
  47. package/dist/eval/report-stats.js +184 -0
  48. package/dist/eval/report-stats.js.map +1 -0
  49. package/dist/eval/run-options.d.ts +30 -0
  50. package/dist/eval/run-options.d.ts.map +1 -0
  51. package/dist/eval/run-options.js +20 -0
  52. package/dist/eval/run-options.js.map +1 -0
  53. package/dist/eval/run-summary.d.ts +36 -0
  54. package/dist/eval/run-summary.d.ts.map +1 -0
  55. package/dist/eval/run-summary.js +57 -0
  56. package/dist/eval/run-summary.js.map +1 -0
  57. package/dist/eval/scorers.d.ts +32 -0
  58. package/dist/eval/scorers.d.ts.map +1 -1
  59. package/dist/eval/scorers.js +77 -0
  60. package/dist/eval/scorers.js.map +1 -1
  61. package/dist/eval/types.d.ts +74 -0
  62. package/dist/eval/types.d.ts.map +1 -1
  63. package/dist/manager/session/turn-recorder.d.ts +3 -0
  64. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  65. package/dist/manager/session/turn-recorder.js +20 -5
  66. package/dist/manager/session/turn-recorder.js.map +1 -1
  67. package/dist/public-runtime.d.ts +5 -2
  68. package/dist/public-runtime.d.ts.map +1 -1
  69. package/dist/public-runtime.js +7 -2
  70. package/dist/public-runtime.js.map +1 -1
  71. package/dist/public-types.d.ts +2 -0
  72. package/dist/public-types.d.ts.map +1 -1
  73. package/dist/registry/command/kernel-commands.d.ts +3 -0
  74. package/dist/registry/command/kernel-commands.d.ts.map +1 -1
  75. package/dist/registry/command/kernel-commands.js +76 -5
  76. package/dist/registry/command/kernel-commands.js.map +1 -1
  77. package/dist/runtime/query/abandon-turn.d.ts.map +1 -1
  78. package/dist/runtime/query/abandon-turn.js +2 -2
  79. package/dist/runtime/query/abandon-turn.js.map +1 -1
  80. package/dist/runtime/query/index.d.ts.map +1 -1
  81. package/dist/runtime/query/index.js +15 -0
  82. package/dist/runtime/query/index.js.map +1 -1
  83. package/dist/runtime/query/iteration/index.d.ts +18 -1
  84. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  85. package/dist/runtime/query/iteration/index.js +80 -1
  86. package/dist/runtime/query/iteration/index.js.map +1 -1
  87. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  88. package/dist/runtime/query/iteration/phases/tool-review.js +18 -1
  89. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  90. package/dist/runtime/query/prelude-lease.d.ts.map +1 -1
  91. package/dist/runtime/query/prelude-lease.js +2 -2
  92. package/dist/runtime/query/prelude-lease.js.map +1 -1
  93. package/dist/runtime/query/turn/answer-claims.d.ts +52 -0
  94. package/dist/runtime/query/turn/answer-claims.d.ts.map +1 -0
  95. package/dist/runtime/query/turn/answer-claims.js +348 -0
  96. package/dist/runtime/query/turn/answer-claims.js.map +1 -0
  97. package/dist/runtime/query/turn/outcome-ledger.d.ts +7 -0
  98. package/dist/runtime/query/turn/outcome-ledger.d.ts.map +1 -1
  99. package/dist/runtime/query/turn/outcome-ledger.js +13 -0
  100. package/dist/runtime/query/turn/outcome-ledger.js.map +1 -1
  101. package/dist/store/goal/index.d.ts +3 -1
  102. package/dist/store/goal/index.d.ts.map +1 -1
  103. package/dist/store/goal/index.js +6 -0
  104. package/dist/store/goal/index.js.map +1 -1
  105. package/dist/store/session-log/index.d.ts +1 -0
  106. package/dist/store/session-log/index.d.ts.map +1 -1
  107. package/dist/store/session-log/index.js +1 -0
  108. package/dist/store/session-log/index.js.map +1 -1
  109. package/dist/store/session-log/turn-holder.d.ts +26 -0
  110. package/dist/store/session-log/turn-holder.d.ts.map +1 -0
  111. package/dist/store/session-log/turn-holder.js +79 -0
  112. package/dist/store/session-log/turn-holder.js.map +1 -0
  113. package/dist/telemetry/runtime-accessors.d.ts +14 -0
  114. package/dist/telemetry/runtime-accessors.d.ts.map +1 -1
  115. package/dist/telemetry/runtime-accessors.js +32 -0
  116. package/dist/telemetry/runtime-accessors.js.map +1 -1
  117. package/dist/tools/goal/confirmation.d.ts +154 -0
  118. package/dist/tools/goal/confirmation.d.ts.map +1 -0
  119. package/dist/tools/goal/confirmation.js +290 -0
  120. package/dist/tools/goal/confirmation.js.map +1 -0
  121. package/dist/tools/goal/index.d.ts +4 -1
  122. package/dist/tools/goal/index.d.ts.map +1 -1
  123. package/dist/tools/goal/index.js +27 -2
  124. package/dist/tools/goal/index.js.map +1 -1
  125. package/dist/tools/hand-back/index.d.ts +48 -0
  126. package/dist/tools/hand-back/index.d.ts.map +1 -0
  127. package/dist/tools/hand-back/index.js +129 -0
  128. package/dist/tools/hand-back/index.js.map +1 -0
  129. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -1
  130. package/dist/tools/schedules/schedule-tool.js +15 -1
  131. package/dist/tools/schedules/schedule-tool.js.map +1 -1
  132. package/dist/tools/schedules/types.d.ts +10 -0
  133. package/dist/tools/schedules/types.d.ts.map +1 -1
  134. package/dist/types/connector/mcp.d.ts +18 -0
  135. package/dist/types/connector/mcp.d.ts.map +1 -1
  136. package/dist/types/goal/index.d.ts +12 -0
  137. package/dist/types/goal/index.d.ts.map +1 -1
  138. package/dist/types/session/config.d.ts +8 -0
  139. package/dist/types/session/config.d.ts.map +1 -1
  140. package/dist/types/session/hand-back.d.ts +29 -0
  141. package/dist/types/session/hand-back.d.ts.map +1 -0
  142. package/dist/types/session/hand-back.js +11 -0
  143. package/dist/types/session/hand-back.js.map +1 -0
  144. package/dist/types/session/outcome.d.ts +37 -0
  145. package/dist/types/session/outcome.d.ts.map +1 -1
  146. package/dist/types/session/outcome.js.map +1 -1
  147. package/dist/types/session/turn.d.ts +19 -1
  148. package/dist/types/session/turn.d.ts.map +1 -1
  149. package/dist/types/session/turn.js.map +1 -1
  150. package/package.json +1 -1
  151. package/src/connector/index.ts +1 -0
  152. package/src/connector/mcp/client.ts +531 -14
  153. package/src/connector/mcp/index.ts +1 -0
  154. package/src/connector/mcp/tasks.ts +81 -0
  155. package/src/constants/mcp/index.ts +6 -1
  156. package/src/eval/bootstrap.ts +314 -0
  157. package/src/eval/compare.ts +473 -0
  158. package/src/eval/disclosure.ts +136 -0
  159. package/src/eval/experiment.ts +127 -15
  160. package/src/eval/from-turn.ts +5 -0
  161. package/src/eval/index.ts +51 -0
  162. package/src/eval/report-stats.ts +257 -0
  163. package/src/eval/run-options.ts +49 -0
  164. package/src/eval/run-summary.ts +104 -0
  165. package/src/eval/scorers.ts +83 -0
  166. package/src/eval/types.ts +76 -0
  167. package/src/manager/session/turn-recorder.ts +25 -4
  168. package/src/public-runtime.ts +53 -0
  169. package/src/public-types.ts +9 -0
  170. package/src/registry/command/kernel-commands.ts +94 -6
  171. package/src/runtime/query/abandon-turn.ts +2 -1
  172. package/src/runtime/query/index.ts +18 -0
  173. package/src/runtime/query/iteration/index.ts +85 -1
  174. package/src/runtime/query/iteration/phases/tool-review.ts +24 -1
  175. package/src/runtime/query/prelude-lease.ts +7 -2
  176. package/src/runtime/query/turn/answer-claims.ts +413 -0
  177. package/src/runtime/query/turn/outcome-ledger.ts +12 -0
  178. package/src/store/goal/index.ts +11 -0
  179. package/src/store/session-log/index.ts +8 -0
  180. package/src/store/session-log/turn-holder.ts +95 -0
  181. package/src/telemetry/runtime-accessors.ts +29 -0
  182. package/src/tools/goal/confirmation.ts +375 -0
  183. package/src/tools/goal/index.ts +37 -1
  184. package/src/tools/hand-back/index.ts +150 -0
  185. package/src/tools/schedules/schedule-tool.ts +19 -1
  186. package/src/tools/schedules/types.ts +10 -0
  187. package/src/types/connector/mcp.ts +18 -0
  188. package/src/types/goal/index.ts +13 -0
  189. package/src/types/session/config.ts +8 -0
  190. package/src/types/session/hand-back.ts +31 -0
  191. package/src/types/session/outcome.ts +42 -0
  192. package/src/types/session/turn.ts +19 -1
@@ -40,6 +40,11 @@ import { getTracer } from '../../telemetry/runtime-accessors.js'
40
40
  import { buildAdvisoryTools } from '../../tools/advisory/index.js'
41
41
  import { SearchToolsTool } from '../../tools/builtins/search-tools.js'
42
42
  import { createStructuredOutputTool } from '../../tools/builtins/structuredOutput.js'
43
+ import {
44
+ HAND_BACK_GUIDANCE,
45
+ HAND_BACK_GUIDANCE_CONTRIBUTION_ID,
46
+ buildHandBackTool,
47
+ } from '../../tools/hand-back/index.js'
43
48
  import { buildTaskTools } from '../../tools/task/index.js'
44
49
  import { ToolManager } from '../../toolsets/manager.js'
45
50
  import { toolset } from '../../toolsets/toolset.js'
@@ -1283,6 +1288,10 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1283
1288
  for (const tool of taskTools) addRuntimeTool(tool, 'deferred')
1284
1289
  }
1285
1290
 
1291
+ // The way out of a task that cannot be done as specified. Only when the host
1292
+ // turned it on; with it off the tool is absent and the prompt is as before.
1293
+ if (turnConfig.handBack === true) addRuntimeTool(buildHandBackTool(), 'active')
1294
+
1286
1295
  // `search_tools` is added only when something could actually be
1287
1296
  // deferred — one of the runtime tools above, or a caller toolset that
1288
1297
  // declared itself `deferred(...)`. Nothing deferred means nothing for
@@ -1789,6 +1798,15 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1789
1798
  // The turn's own registry when the host passed none, so a hook's
1790
1799
  // annotation has somewhere to land.
1791
1800
  const promptContributions = params.promptContributions ?? new PromptContributionRegistry()
1801
+ if (turnConfig.handBack === true) {
1802
+ promptContributions.replace({
1803
+ id: HAND_BACK_GUIDANCE_CONTRIBUTION_ID,
1804
+ placement: 'static',
1805
+ render: () => HAND_BACK_GUIDANCE,
1806
+ })
1807
+ } else {
1808
+ promptContributions.unregister(HAND_BACK_GUIDANCE_CONTRIBUTION_ID)
1809
+ }
1792
1810
 
1793
1811
  const promptBuilder = new PromptBuilder({
1794
1812
  systemPrompt: params.systemPrompt,
@@ -29,6 +29,8 @@ import {
29
29
  } from '../../../telemetry/attributes.js'
30
30
  import { getTracer } from '../../../telemetry/runtime-accessors.js'
31
31
  import { STRUCTURED_OUTPUT_TOOL_NAME } from '../../../tools/builtins/structuredOutput.js'
32
+ import { SESSION_GOAL_TOOL_NAMES } from '../../../tools/goal/index.js'
33
+ import { HAND_BACK_TOOL_NAME, parseHandBack } from '../../../tools/hand-back/index.js'
32
34
  import type { ToolManager } from '../../../toolsets/manager.js'
33
35
  import { RECALLED_MEMORY_ENTRY_KEY, recalledMemoryIds } from '../../../turn/memory-recall.js'
34
36
  import { type CostInfo, type TokenUsage, promptSizeOf } from '../../../types/common/index.js'
@@ -48,6 +50,7 @@ import {
48
50
  import { classifyProviderError, readOutputOverflow } from '../../../types/provider/errors.js'
49
51
  import type { ChatCompletionResponse, ReasoningEffort } from '../../../types/provider/index.js'
50
52
  import type { AnswerReview, AnswerReviewContext } from '../../../types/session/answer-review.js'
53
+ import type { HandBack } from '../../../types/session/hand-back.js'
51
54
  import type {
52
55
  RecalledMemory,
53
56
  SessionEvent,
@@ -136,6 +139,9 @@ import {
136
139
  } from './step-shaping.js'
137
140
  import { streamProviderTurn } from './stream-turn.js'
138
141
 
142
+ /** Notes a host may add before a turn ends (`TurnConfig.outcome.beforeEnd`) in one turn. */
143
+ const MAX_BEFORE_END_NOTES = 16
144
+
139
145
  /** Capped steps in a row after which the per-step cap stops applying for the turn's next step. */
140
146
  const MAX_CAPPED_STREAK = 3
141
147
 
@@ -339,6 +345,7 @@ export class IterationOrchestrator {
339
345
  private readonly completionAudit?: CompletionAudit
340
346
  /** What the turn changed and what checked it; see {@link OutcomeLedger}. Kept whether or not the host asked. */
341
347
  private readonly outcomeLedger: OutcomeLedger
348
+ private beforeEndNotes = 0
342
349
  private outcomeHydrated = false
343
350
  /** The next step must call a tool, at low effort: set by the stall monitor or a capped step. */
344
351
  private forceActionNextStep = false
@@ -2016,6 +2023,14 @@ export class IterationOrchestrator {
2016
2023
  recorder.pushMessage(createRuntimeContextMessage(auditReminder, 'progress-nudge'))
2017
2024
  continue
2018
2025
  }
2026
+ const endNote =
2027
+ !forceFinalize && closingStopReason === undefined
2028
+ ? await this.askBeforeEnd()
2029
+ : undefined
2030
+ if (endNote !== undefined) {
2031
+ recorder.pushMessage(createRuntimeContextMessage(endNote, 'progress-nudge'))
2032
+ continue
2033
+ }
2019
2034
  if (!forceFinalize && closingStopReason === undefined) {
2020
2035
  const feedback = yield* this.askStopHooks()
2021
2036
  const stoppedByHook = this.ctx.toolExecutor.takeHalt?.()
@@ -2074,6 +2089,28 @@ export class IterationOrchestrator {
2074
2089
  if (reviewOutcome.decision === 'stop') {
2075
2090
  return
2076
2091
  }
2092
+
2093
+ // The model handed the task back to a person. Nothing else ran in
2094
+ // this step (its sibling calls were refused), and no further model
2095
+ // call is made: the turn ends here, as a turn that ended on its own.
2096
+ const handedBack = this.takeHandBack(reviewOutcome.results, response)
2097
+ if (handedBack !== undefined) {
2098
+ this.ctx.log.info('The model handed the task back — ending turn', {
2099
+ [NAMZU.TURN_ID]: recorder.turnId,
2100
+ [NAMZU.ITERATION]: iterationNum,
2101
+ 'namzu.hand_back.kind': handedBack.kind,
2102
+ })
2103
+ recorder.setHandedBack(handedBack)
2104
+ recorder.setStopReason('end_turn')
2105
+ await this.ctx.emitEvent({
2106
+ type: 'iteration_completed',
2107
+ turnId: recorder.turnId,
2108
+ iteration: iterationNum,
2109
+ hasToolCalls: true,
2110
+ })
2111
+ yield* this.ctx.drainPending()
2112
+ break
2113
+ }
2077
2114
  const needsCorrection = await this.recordToolStructuredFailure(
2078
2115
  reviewOutcome.results,
2079
2116
  iterationNum,
@@ -3406,8 +3443,14 @@ export class IterationOrchestrator {
3406
3443
  else this.outcomeLedger.restore(restored.snapshot)
3407
3444
  }
3408
3445
 
3409
- /** Whether a call is one the outcome record may treat as only looking. */
3446
+ /**
3447
+ * Whether a call is one the outcome record may treat as only looking. The
3448
+ * goal tools write the goal's own record, never the workspace; counting a
3449
+ * report of completion as a possible change would make every check before it
3450
+ * read as not known to be fresh.
3451
+ */
3410
3452
  private lookedOnly(toolName: string, input: unknown): boolean {
3453
+ if ((SESSION_GOAL_TOOL_NAMES as readonly string[]).includes(toolName)) return true
3411
3454
  try {
3412
3455
  return !callChangedState(toolName, this.ctx.tools.get(toolName)?.isReadOnly?.(input), false)
3413
3456
  } catch {
@@ -3474,6 +3517,47 @@ export class IterationOrchestrator {
3474
3517
  }
3475
3518
  }
3476
3519
 
3520
+ /**
3521
+ * The host's say before the turn ends (`TurnConfig.outcome.beforeEnd`): a note
3522
+ * for the model, or `undefined` to let the turn end. Bounded, and a hook that
3523
+ * fails never fails the turn.
3524
+ */
3525
+ async askBeforeEnd(): Promise<string | undefined> {
3526
+ const hook = this.ctx.turnConfig.outcome?.beforeEnd
3527
+ if (hook === undefined || this.beforeEndNotes >= MAX_BEFORE_END_NOTES) return undefined
3528
+ try {
3529
+ const note = await hook(async () => {
3530
+ this.hydrateOutcome()
3531
+ return await this.outcomeLedger.preview()
3532
+ })
3533
+ if (typeof note !== 'string' || note.trim() === '') return undefined
3534
+ this.beforeEndNotes += 1
3535
+ return note
3536
+ } catch (err) {
3537
+ this.ctx.log.warn('The host hook before the turn ends failed; the turn ends', {
3538
+ 'exception.message': toErrorMessage(err),
3539
+ })
3540
+ return undefined
3541
+ }
3542
+ }
3543
+
3544
+ /**
3545
+ * The hand-back this step made, when the setting is on and `hand_back_task`
3546
+ * succeeded; the first one when the model made several (the rest were refused).
3547
+ */
3548
+ private takeHandBack(
3549
+ results: readonly ToolCallOutcome[],
3550
+ response: ChatCompletionResponse,
3551
+ ): HandBack | undefined {
3552
+ if (this.ctx.turnConfig.handBack !== true) return undefined
3553
+ const hit = results.find(
3554
+ (r) => r.toolName === HAND_BACK_TOOL_NAME && r.isError !== true && r.skipped !== true,
3555
+ )
3556
+ if (!hit) return undefined
3557
+ const call = (response.message.toolCalls ?? []).find((c) => c.id === hit.toolCallId)
3558
+ return call ? parseHandBack(call.function.arguments) : undefined
3559
+ }
3560
+
3477
3561
  /** The outcome record as the turn leaves it; `undefined` when none was kept. */
3478
3562
  async takeOutcome(): Promise<TurnOutcome | undefined> {
3479
3563
  try {
@@ -1,5 +1,10 @@
1
1
  import { allowRuleScope } from '../../../../authorization/gate.js'
2
2
  import type { AuthorizationGate } from '../../../../authorization/index.js'
3
+ import {
4
+ HAND_BACK_SIBLING_REFUSAL,
5
+ HAND_BACK_TOOL_NAME,
6
+ parseHandBack,
7
+ } from '../../../../tools/hand-back/index.js'
3
8
  import { isTrustedReadOnly } from '../../../../tools/trusted-read-only.js'
4
9
  import type { ToolSourceRef } from '../../../../toolsets/types.js'
5
10
  import type {
@@ -259,7 +264,25 @@ export async function* runToolReview(
259
264
  * with the tracker's refusal instead of being run — the one denial the
260
265
  * tracker makes, on top of whatever the caller already denied.
261
266
  */
262
- const withRepeatRefusals = (denials?: ToolCallDenials): ToolCallDenials | undefined => {
267
+ const withRepeatRefusals = (given?: ToolCallDenials): ToolCallDenials | undefined => {
268
+ // A step that hands the task back runs nothing else: the first hand-back
269
+ // call is the step, every other call in it is refused.
270
+ let denials = given
271
+ if (ctx.turnConfig?.handBack === true) {
272
+ const handBack = toolCallSummaries.find(
273
+ (summary) =>
274
+ summary.name === HAND_BACK_TOOL_NAME && parseHandBack(summary.input) !== undefined,
275
+ )
276
+ if (handBack) {
277
+ let siblings: Map<string, string> | undefined
278
+ for (const summary of toolCallSummaries) {
279
+ if (summary.id === handBack.id || given?.has(summary.id)) continue
280
+ siblings ??= new Map(given ?? [])
281
+ siblings.set(summary.id, HAND_BACK_SIBLING_REFUSAL)
282
+ }
283
+ denials = siblings ?? given
284
+ }
285
+ }
263
286
  const tracker = ctx.repeatCalls
264
287
  if (!tracker) return denials
265
288
  let merged: Map<string, string> | undefined
@@ -1,5 +1,10 @@
1
1
  import { DEFAULT_TURN_LEASE_TTL_MS } from '../../manager/session/turn-recorder.js'
2
- import { type SessionLease, type SessionLog, isLeaseLive } from '../../store/session-log/index.js'
2
+ import {
3
+ type SessionLease,
4
+ type SessionLog,
5
+ isLeaseLive,
6
+ turnHolder,
7
+ } from '../../store/session-log/index.js'
3
8
  import { NamzuError } from '../../types/errors/index.js'
4
9
  import type { SessionId, TurnId } from '../../types/ids/index.js'
5
10
  import { TurnInProgressError } from '../../types/session/turn.js'
@@ -34,7 +39,7 @@ export class PreludeSessionLease {
34
39
  ): Promise<PreludeSessionLease> {
35
40
  if (supplied) return new PreludeSessionLease(log, supplied, false)
36
41
  const lease = await log.claim({
37
- holder: `namzu:${process.pid}:${turnId}`,
42
+ holder: turnHolder(turnId),
38
43
  ttlMs: DEFAULT_TURN_LEASE_TTL_MS,
39
44
  repairTornTail: false,
40
45
  })
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Compare what an answer claims about checks with what the turn's outcome
3
+ * record holds.
4
+ *
5
+ * A pure function: no model call, no clock, no I/O. The model's words only
6
+ * select which statement to check; the statement the kernel then makes is
7
+ * about the record, so it never claims more than the record establishes.
8
+ *
9
+ * It is built to stay silent. A notice that is wrong teaches people to ignore
10
+ * the line, so every doubt resolves to nothing: a negation, a hedge, a
11
+ * condition, a question, a quotation, code, a report of what someone else
12
+ * said, a statement about the past, a qualified claim ("all but one"), and any
13
+ * language but English. The claim wording and the words that cancel it are data
14
+ * below, each with the reason it is there.
15
+ *
16
+ * It looks at the record of THIS turn only. A turn whose record says nothing
17
+ * changed is never reported: "the tests pass" may be true of an earlier turn.
18
+ */
19
+
20
+ import type {
21
+ AnswerClaimKind,
22
+ AnswerClaimMismatch,
23
+ AnswerClaimRecord,
24
+ TurnOutcome,
25
+ } from '../../../types/session/outcome.js'
26
+ import { auditCommandText } from './completion-audit.js'
27
+
28
+ /** Answers longer than this are read only up to it, so cost stays bounded. */
29
+ export const ANSWER_CLAIM_MAX_CHARS = 100_000
30
+
31
+ /** The longest quotation of the answer a mismatch carries, in characters. */
32
+ export const ANSWER_CLAIM_MAX_QUOTE_CHARS = 160
33
+
34
+ /** Words that may sit between a subject and its verb without changing the claim. */
35
+ const FILLER =
36
+ '(?:\\s+(?:all|now|also|still|again|both|are|were|is|have|has|been|currently|locally|fully))*'
37
+
38
+ /** Verbs that say a check came out well. */
39
+ const PASSED = '(?:pass(?:es|ed|ing)?|green|succeed(?:s|ed)?)\\b'
40
+
41
+ /** Determiners and kinds of test that may precede `tests`. */
42
+ const TEST_WORDS =
43
+ '(?:(?:all(?:\\s+of)?|the|my|our)\\s+)?(?:(?:unit|integration|e2e|existing|new|relevant|related|automated)\\s+)*'
44
+
45
+ interface ClaimPattern {
46
+ readonly kind: AnswerClaimKind
47
+ /** The claim in the kernel's words. */
48
+ readonly said: string
49
+ /** Matches the claim inside one clause. */
50
+ readonly re: RegExp
51
+ /** Why this wording, and why it is safe. */
52
+ readonly why: string
53
+ }
54
+
55
+ /**
56
+ * Plain positive claims only. Each pattern needs the subject, at most a few
57
+ * filler words, and the verb, in that order, so a negation or a modal between
58
+ * them ("did not", "should") breaks the match by itself.
59
+ */
60
+ export const ANSWER_CLAIM_PATTERNS: readonly ClaimPattern[] = [
61
+ {
62
+ kind: 'tests',
63
+ said: 'the tests pass',
64
+ re: new RegExp(`\\b${TEST_WORDS}tests\\b${FILLER}\\s+${PASSED}`, 'i'),
65
+ why: 'The plural "tests" as the subject. The singular is left out: "the test now passes" is usually about the one test just fixed, which says nothing about the run that failed.',
66
+ },
67
+ {
68
+ kind: 'tests',
69
+ said: 'the test suite passes',
70
+ re: new RegExp(
71
+ `\\b(?:test\\s+suite|the\\s+suite|(?:whole|full|entire)\\s+(?:test\\s+)?suite|test\\s+run)\\b${FILLER}\\s+${PASSED}`,
72
+ 'i',
73
+ ),
74
+ why: 'The suite as a whole is the same claim as "the tests pass", also as "the whole suite". A bare "suite" is not enough: it also names a benchmark suite or a suite of rooms.',
75
+ },
76
+ {
77
+ kind: 'tests',
78
+ said: 'no test failures',
79
+ re: /\b(?:no|zero|0)\s+(?:test\s+failures|failing\s+tests|failed\s+tests|test\s+errors)\b/i,
80
+ why: 'The same claim said by counting failures. It is matched whole, so the word "no" in it is not read as a negation.',
81
+ },
82
+ {
83
+ kind: 'tests',
84
+ said: 'no test failures',
85
+ re: /\b(?:no\s+tests?\s+(?:are\s+|is\s+)?(?:fail(?:s|ing|ed)?)|tests?\s+(?:are\s+not|aren['\u2019]t|do\s+not|don['\u2019]t|did\s+not|didn['\u2019]t)\s+(?:fail|failing))\b/i,
86
+ why: 'The same claim said as a negated failure: "no tests are failing", "the tests do not fail". The pattern holds its own negation, and the failure word in it is not read as a failure.',
87
+ },
88
+ {
89
+ kind: 'tests',
90
+ said: 'the tests pass',
91
+ re: /\b(?:ran|run|running)\s+(?:(?:all\s+)?(?:of\s+)?(?:the|my|our)\s+)?(?:\w+\s+){0,2}(?:tests|test\s+suite|suite)\s+and\s+(?:they|it)\s+(?:all\s+)?(?:pass(?:es|ed)?|are\s+(?:green|passing))\b/i,
92
+ why: 'The pronoun form, the commonest plain phrasing: "I ran the tests and they all pass". The run and its result are in one clause, and a negation between them ("they did not pass") breaks the match.',
93
+ },
94
+ {
95
+ kind: 'checks',
96
+ said: 'the checks pass',
97
+ re: /^\W*(?:it['\u2019]?s\s+|it\s+is\s+|everything\s+is\s+)?all\s+green\b/i,
98
+ why: 'The shortest summary: "All green." Anchored to the start of the sentence, so "not all green" never matches.',
99
+ },
100
+ {
101
+ kind: 'build',
102
+ said: 'the build succeeds',
103
+ re: /\b(?:it|everything)\s+compiles\b/i,
104
+ why: 'A bare "it compiles". The pronoun or "everything" is required: "the compiles list" and similar nouns are not claims.',
105
+ },
106
+ {
107
+ kind: 'build',
108
+ said: 'the build succeeds',
109
+ re: new RegExp(
110
+ `\\b(?:build|compilation|compile)\\b${FILLER}\\s+(?:succeed(?:s|ed)?|pass(?:es|ed)?|green|completes?|completed|works|worked|is\\s+clean)\\b`,
111
+ 'i',
112
+ ),
113
+ why: 'The build as the subject with a success verb. "The build" alone, as in "the build script", never matches because the verb must follow.',
114
+ },
115
+ {
116
+ kind: 'build',
117
+ said: 'the build succeeds',
118
+ re: /\b(?:builds?|compiles?)\s+(?:cleanly|successfully|fine)\b/i,
119
+ why: 'The adverb form: "it builds cleanly", "everything compiles successfully".',
120
+ },
121
+ {
122
+ kind: 'lint',
123
+ said: 'lint is clean',
124
+ re: new RegExp(
125
+ `\\blint(?:ing|er)?\\b${FILLER}\\s+(?:is\\s+|are\\s+)?(?:clean|pass(?:es|ed|ing)?|green|succeed(?:s|ed)?|ok)\\b`,
126
+ 'i',
127
+ ),
128
+ why: 'Lint as the subject with a clean or passing verb.',
129
+ },
130
+ {
131
+ kind: 'lint',
132
+ said: 'lint is clean',
133
+ re: /\bno\s+lint(?:ing)?\s+(?:errors?|warnings?|issues?|problems?)\b/i,
134
+ why: 'The same claim by counting: "no lint errors". Matched whole, so its own "no" is not a negation.',
135
+ },
136
+ {
137
+ kind: 'typecheck',
138
+ said: 'type checking passes',
139
+ re: new RegExp(
140
+ `\\b(?:type[\\s-]?check(?:ing|s)?|typecheck|tsc)\\b${FILLER}\\s+(?:is\\s+|are\\s+)?(?:clean|pass(?:es|ed|ing)?|green|succeed(?:s|ed)?|ok)\\b`,
141
+ 'i',
142
+ ),
143
+ why: 'Type checking, the one-word spelling and the compiler named for it, as the subject. "Type checker" is left out: "the type checker passes the argument on" names what a checker does, not a result.',
144
+ },
145
+ {
146
+ kind: 'typecheck',
147
+ said: 'type checking passes',
148
+ re: /\bno\s+type\s+errors\b/i,
149
+ why: 'The same claim by counting: "no type errors".',
150
+ },
151
+ {
152
+ kind: 'checks',
153
+ said: 'the checks pass',
154
+ re: new RegExp(
155
+ `\\b(?:all\\s+(?:of\\s+)?(?:the\\s+)?checks|the\\s+(?:ci\\s+)?checks|ci\\s+checks|everything)\\b${FILLER}\\s+${PASSED}`,
156
+ 'i',
157
+ ),
158
+ why: 'The catch-all a summary reaches for: "all checks pass", "the CI checks pass", "everything is green".',
159
+ },
160
+ {
161
+ kind: 'tests',
162
+ said: 'the tests pass',
163
+ re: /^\W*(?:\*\*)?(?:tests?|test\s+suite)(?:\*\*)?\s*[:\-\u2013]\s*(?:\*\*)?\s*(?:all\s+)?(?:\d+\s+)?(?:pass(?:es|ed|ing)?|green|success(?:ful)?|ok|\u2705|\u2714)/i,
164
+ why: 'The label form of a summary list: "Tests: passing". Anchored to the start of the line, where a label stands.',
165
+ },
166
+ {
167
+ kind: 'build',
168
+ said: 'the build succeeds',
169
+ re: /^\W*(?:\*\*)?build(?:\*\*)?\s*[:\-–]\s*(?:\*\*)?\s*(?:pass(?:es|ed|ing)?|green|success(?:ful)?|ok|clean|✅|✔)/i,
170
+ why: 'The label form: "Build: success".',
171
+ },
172
+ {
173
+ kind: 'lint',
174
+ said: 'lint is clean',
175
+ re: /^\W*(?:\*\*)?lint(?:ing)?(?:\*\*)?\s*[:\-–]\s*(?:\*\*)?\s*(?:pass(?:es|ed|ing)?|green|success(?:ful)?|ok|clean|✅|✔)/i,
176
+ why: 'The label form: "Lint: clean".',
177
+ },
178
+ {
179
+ kind: 'typecheck',
180
+ said: 'type checking passes',
181
+ re: /^\W*(?:\*\*)?(?:type[\s-]?check(?:ing)?|typecheck)(?:\*\*)?\s*[:\-–]\s*(?:\*\*)?\s*(?:pass(?:es|ed|ing)?|green|success(?:ful)?|ok|clean|✅|✔)/i,
182
+ why: 'The label form: "Typecheck: passing".',
183
+ },
184
+ ]
185
+
186
+ /** Words that turn the claim around. Looked for in the few words before it. */
187
+ const NEGATION =
188
+ /\b(?:not|no|never|none|nothing|nobody|zero|scarcely|neither|nor|cannot|unable|without|barely|hardly|n't|isn|aren|doesn|don|didn|wasn|weren|won|couldn|can't|won't)\b|n['’]t\b/i
189
+
190
+ /** Words that make it a wish, a plan, a condition, a request or a guess. Looked for in the few words before it. */
191
+ const HEDGE =
192
+ /\b(?:if|once|when|whenever|unless|until|assuming|provided|whether|in\s+case|so\s+that|ensure|ensures|ensuring|make\s+sure|makes\s+sure|make|makes|get|gets|keep|keeps|verify|verifying|confirm|confirming|check\s+(?:that|whether)|hope|hopes|hoping|hopefully|expect|expects|expecting|expected|want|wants|need|needs|must|should|would|could|might|may|can|will|probably|likely|maybe|perhaps|presumably|supposedly|apparently|ideally|seems?|appears?|think|believe|assume|guess|suppose|imagine|wonder|let|try|please|goal|aim|aims|target)\b|'ll\b/i
193
+
194
+ /** Words that report what someone or something else said. Looked for in the whole clause. */
195
+ const REPORTING =
196
+ /\b(?:said|says|say|saying|claims?|claimed|claiming|reports?|reported|reporting|according\s+to|states?|stated|mentions?|mentioned|noted|told|tells|asked|asks|promises?|promised|advertises?|advertised)\b/i
197
+
198
+ /** Words that put the claim in the past, before this turn's change. Looked for in the whole clause. */
199
+ const EARLIER =
200
+ /\b(?:before|earlier|previously|prior|originally|beforehand|initially|yesterday|formerly|used\s+to|at\s+first|last\s+time|last\s+week|until\s+now|in\s+the\s+past|back\s+then|had\s+been)\b/i
201
+
202
+ /**
203
+ * Words that make the claim partial, a guess or someone else's. Looked for in
204
+ * the few words after it. The last few are the plain negation of other languages
205
+ * ("değil", "nicht", "pas") for a clause that holds an English claim.
206
+ */
207
+ const QUALIFIER =
208
+ /\b(?:except|besides|apart|aside|other\s+than|but\s+for|barring|mostly|partly|partially|mainly|almost|nearly|only|if|unless|provided|once|when|until|assuming|so\s+far\s+as|aside\s+from|on\s+ci|in\s+theory|apparently|supposedly|allegedly|presumably|seemingly|reportedly|hopefully|probably|maybe|perhaps|goal|aim|target|de[g\u011f]il|nicht|nein|pas)\b/i
209
+
210
+ /** A failure named anywhere else in the sentence: the sentence is reporting one, not claiming a pass. */
211
+ const FAILURE =
212
+ /\b(?:fail|fails|failed|failing|failure|failures|error|errors|broken|breaks|red|regress(?:ed|es|ion)?)\b/i
213
+
214
+ /**
215
+ * A word anywhere in the sentence that makes every claim in it a guess, or a
216
+ * claim in another language's negation. It sits at sentence level because a
217
+ * comma often separates it from the claim: "The tests pass, apparently."
218
+ */
219
+ const SENTENCE_HEDGE =
220
+ /\b(?:apparently|supposedly|allegedly|presumably|seemingly|reportedly|hopefully|probably|maybe|perhaps|de[g\u011f]il|nicht|nein)\b/i
221
+
222
+ /** Words a sentence may start with that make everything after it conditional. */
223
+ const CONDITIONAL_START =
224
+ /^\W*(?:if|once|when|whenever|unless|assuming|provided|in\s+case|should|were|had)\b/i
225
+
226
+ /** How many words before and after a claim are read for what cancels it. */
227
+ const WORDS_BEFORE = 6
228
+ const WORDS_AFTER = 6
229
+
230
+ /** What a verb that takes an object starts with: "the type checker passes the argument on" is not a result. */
231
+ const OBJECT_AFTER =
232
+ /^\W*(?:the|a|an|its|their|his|her|them|it|this|that|these|those|each|every|any|some|through|along)\b/i
233
+
234
+ /** A claim worded as a subject ("tests passing is the goal"): the verb is a gerund that a copula follows. */
235
+ const COPULA_AFTER = /^\W*(?:is|are|was|were|means|requires|needs)\b/i
236
+
237
+ /** What is left of an answer once the places a claim does not count are taken out. */
238
+ function readableText(answer: string): string {
239
+ return (
240
+ answer
241
+ .slice(0, ANSWER_CLAIM_MAX_CHARS)
242
+ // A fenced block, or one the answer never closed.
243
+ .replace(/(```|~~~)[\s\S]*?(?:\1|$)/g, ' _ ')
244
+ // A block quotation: its lines are somebody else's words.
245
+ .replace(/^[ \t]*>.*$/gm, ' ')
246
+ // An inline code span names a command or a symbol, not a claim.
247
+ .replace(/`[^`\n]{0,400}`/g, ' _ ')
248
+ // A quotation in either kind of double quotes.
249
+ .replace(/"[^"\n]{0,400}"/g, ' _ ')
250
+ .replace(/“[^”\n]{0,400}”/g, ' _ ')
251
+ )
252
+ }
253
+
254
+ /** Sentences, and within one the clauses a claim and its hedge do not cross. */
255
+ function clausesOf(text: string): { sentenceEndsWithQuestion: boolean; clauses: string[] }[] {
256
+ const out: { sentenceEndsWithQuestion: boolean; clauses: string[] }[] = []
257
+ // A semicolon ends a statement, so a failure named in one half does not cancel a claim in the other.
258
+ for (const raw of text.split(/(?<=[.!?])\s+|\n+|;\s*/)) {
259
+ const sentence = raw.trim()
260
+ if (sentence === '') continue
261
+ if (CONDITIONAL_START.test(sentence) || SENTENCE_HEDGE.test(sentence)) continue
262
+ // What the claim patterns themselves match ("no test failures") is not a failure.
263
+ let rest = sentence
264
+ for (const pattern of ANSWER_CLAIM_PATTERNS) {
265
+ rest = rest.replace(new RegExp(pattern.re.source, 'gi'), ' ')
266
+ }
267
+ // "no errors", "without failures": a count of none is not a failure.
268
+ rest = rest.replace(/\b(?:no|zero|0|without)\s+(?:\w+\s+)?(?:errors?|failures?)\b/gi, ' ')
269
+ if (FAILURE.test(rest)) continue
270
+ out.push({
271
+ sentenceEndsWithQuestion: /\?\W*$/.test(sentence),
272
+ // A comma, a semicolon, a dash, a bracket, or a word that turns the sentence around.
273
+ clauses: sentence.split(
274
+ /[,;()—]|\s-\s|\s–\s|\b(?:but|however|although|though|while|whereas|yet|because|since|so)\b/i,
275
+ ),
276
+ })
277
+ }
278
+ return out
279
+ }
280
+
281
+ function wordsBefore(clause: string, at: number): string {
282
+ return clause.slice(0, at).trim().split(/\s+/).slice(-WORDS_BEFORE).join(' ')
283
+ }
284
+
285
+ function wordsAfter(clause: string, to: number): string {
286
+ return clause.slice(to).trim().split(/\s+/).slice(0, WORDS_AFTER).join(' ')
287
+ }
288
+
289
+ /** The claim in this clause, when it is a plain, present, positive one. */
290
+ function claimIn(clause: string): ClaimPattern | undefined {
291
+ if (REPORTING.test(clause) || EARLIER.test(clause)) return undefined
292
+ for (const pattern of ANSWER_CLAIM_PATTERNS) {
293
+ const found = pattern.re.exec(clause)
294
+ if (!found) continue
295
+ const before = wordsBefore(clause, found.index)
296
+ if (NEGATION.test(before) || HEDGE.test(before)) continue
297
+ const after = wordsAfter(clause, found.index + found[0].length)
298
+ if (QUALIFIER.test(after)) continue
299
+ if (/\bpass(?:es)?$/i.test(found[0]) && OBJECT_AFTER.test(after)) continue
300
+ if (/\bpassing$/i.test(found[0]) && COPULA_AFTER.test(after)) continue
301
+ if (/\d+\s+of\s+\d+/.test(clause)) continue
302
+ return pattern
303
+ }
304
+ return undefined
305
+ }
306
+
307
+ /** The kind of check a command line is, when it says so. */
308
+ const COMMAND_KINDS: readonly { kind: AnswerClaimKind; re: RegExp }[] = [
309
+ {
310
+ kind: 'lint',
311
+ re: /\b(?:lint|eslint|biome|prettier|ruff|flake8|rubocop|clippy|golangci-lint)\b/i,
312
+ },
313
+ { kind: 'typecheck', re: /\b(?:typecheck|tsc|mypy|pyright)\b/i },
314
+ { kind: 'tests', re: /\b(?:test|tests|vitest|jest|pytest|mocha|phpunit|spec)\b/i },
315
+ { kind: 'build', re: /\b(?:build|compile|bundle)\b/i },
316
+ ]
317
+
318
+ /** A type check is a compile, so a claim about either is about both. */
319
+ function sameKind(a: AnswerClaimKind, b: AnswerClaimKind): boolean {
320
+ if (a === b) return true
321
+ return (a === 'build' && b === 'typecheck') || (a === 'typecheck' && b === 'build')
322
+ }
323
+
324
+ /** Every kind a command line is: a chained `pnpm lint && pnpm test` is both. */
325
+ function kindsOfCommand(command: string): AnswerClaimKind[] {
326
+ return COMMAND_KINDS.filter((k) => k.re.test(command)).map((k) => k.kind)
327
+ }
328
+
329
+ /** The failed check the claim is about, or `undefined` when every failed check is plainly another kind. */
330
+ function failedCheckFor(
331
+ record: TurnOutcome,
332
+ claim: AnswerClaimKind,
333
+ ): { command?: string } | undefined {
334
+ const checks = Array.isArray(record.checks) ? record.checks : []
335
+ const failed = checks.filter((c) => c?.result === 'failed')
336
+ if (failed.length === 0) return {}
337
+ const related = failed.filter((c) => {
338
+ const kinds = kindsOfCommand(typeof c.command === 'string' ? c.command : '')
339
+ return claim === 'checks' || kinds.length === 0 || kinds.some((kind) => sameKind(kind, claim))
340
+ })
341
+ if (related.length === 0) return undefined
342
+ const only = related.length === 1 ? related[0]?.command : undefined
343
+ return typeof only === 'string' ? { command: only } : {}
344
+ }
345
+
346
+ /**
347
+ * The one mismatch between a plain claim in `answer` and this turn's `outcome`,
348
+ * or `undefined`.
349
+ *
350
+ * Reports only when the record says a check failed, or says files changed and
351
+ * no check ran or none established a pass. Never when the record says nothing
352
+ * changed, that the checks passed, or that it cannot tell: in those the claim
353
+ * may be true. English only: a claim in another language is not found.
354
+ */
355
+ export function compareAnswerToOutcome(
356
+ answer: string | undefined | null,
357
+ outcome: TurnOutcome | undefined,
358
+ ): AnswerClaimMismatch | undefined {
359
+ // A notice is never worth failing a turn for: input that does not have the
360
+ // expected shape (a hook-supplied record) is read as "nothing to say".
361
+ try {
362
+ return compare(answer, outcome)
363
+ } catch {
364
+ return undefined
365
+ }
366
+ }
367
+
368
+ function compare(
369
+ answer: string | undefined | null,
370
+ outcome: TurnOutcome | undefined,
371
+ ): AnswerClaimMismatch | undefined {
372
+ if (typeof answer !== 'string' || answer === '' || !outcome) return undefined
373
+ const verdict = outcome.verdict
374
+ if (verdict !== 'failing' && verdict !== 'unchecked' && verdict !== 'inconclusive') {
375
+ return undefined
376
+ }
377
+ for (const { sentenceEndsWithQuestion, clauses } of clausesOf(readableText(answer))) {
378
+ if (sentenceEndsWithQuestion) continue
379
+ for (const clause of clauses) {
380
+ const pattern = claimIn(clause)
381
+ if (!pattern) continue
382
+ let record: AnswerClaimRecord
383
+ let recordSays: string
384
+ if (verdict === 'failing') {
385
+ const failed = failedCheckFor(outcome, pattern.kind)
386
+ if (!failed) continue
387
+ record = 'check-failed'
388
+ recordSays = failed.command
389
+ ? `${auditCommandText(failed.command)} failed`
390
+ : 'a check failed'
391
+ } else if (verdict === 'unchecked') {
392
+ record = 'unchecked'
393
+ recordSays = 'files changed and no check ran'
394
+ } else {
395
+ record = 'unreadable'
396
+ recordSays = 'files changed and no check gave a result that could be read'
397
+ }
398
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what this removes
399
+ const folded = clause.replace(/[\u0000-\u001f\u007f-\u009f\s]+/g, ' ').trim()
400
+ const quote =
401
+ folded.length <= ANSWER_CLAIM_MAX_QUOTE_CHARS
402
+ ? folded
403
+ : `${folded.slice(0, ANSWER_CLAIM_MAX_QUOTE_CHARS - 3)}...`
404
+ return { claim: pattern.kind, said: pattern.said, quote, record, recordSays }
405
+ }
406
+ }
407
+ return undefined
408
+ }
409
+
410
+ /** The mismatch as one plain line, for a person: `The answer says the tests pass. The record: ...` */
411
+ export function describeAnswerClaimMismatch(mismatch: AnswerClaimMismatch): string {
412
+ return `The answer says ${mismatch.said}. The record: ${mismatch.recordSays}.`
413
+ }
@@ -539,6 +539,18 @@ export class OutcomeLedger {
539
539
  return this.compute(null)
540
540
  }
541
541
 
542
+ /**
543
+ * The record as it stands now, with the workspace asked once, and without
544
+ * settling: the turn may go on and `finish` still answers once at the end.
545
+ * Used by a host that wants the whole record before it lets a turn end. A
546
+ * record that is not whole is `undefined`, as in `finish`.
547
+ */
548
+ async preview(): Promise<TurnOutcome | undefined> {
549
+ if (this.unrecorded) return undefined
550
+ if (this.settled !== undefined) return this.settled
551
+ return this.compute(this.started ? await this.detect() : null)
552
+ }
553
+
542
554
  private compute(endFingerprint: string | null): TurnOutcome {
543
555
  const workspace = this.started && this.startFingerprint !== null && endFingerprint !== null
544
556
  const moved = workspace && this.startFingerprint !== endFingerprint