agent-nuvira 3.3.13 → 3.3.14

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 (387) hide show
  1. package/README.md +67 -1
  2. package/dist/agents/agents/planner.d.ts.map +1 -1
  3. package/dist/agents/agents/planner.js +9 -0
  4. package/dist/agents/agents/planner.js.map +1 -1
  5. package/dist/agents/orchestrator.d.ts +31 -0
  6. package/dist/agents/orchestrator.d.ts.map +1 -1
  7. package/dist/agents/orchestrator.js +74 -126
  8. package/dist/agents/orchestrator.js.map +1 -1
  9. package/dist/cli/chat.d.ts +89 -0
  10. package/dist/cli/chat.d.ts.map +1 -1
  11. package/dist/cli/chat.js +765 -33
  12. package/dist/cli/chat.js.map +1 -1
  13. package/dist/cli/cli-program.d.ts.map +1 -1
  14. package/dist/cli/cli-program.js +12 -0
  15. package/dist/cli/cli-program.js.map +1 -1
  16. package/dist/cli/config.d.ts +12 -0
  17. package/dist/cli/config.d.ts.map +1 -1
  18. package/dist/cli/config.js +86 -0
  19. package/dist/cli/config.js.map +1 -1
  20. package/dist/cli/decisions.d.ts +28 -0
  21. package/dist/cli/decisions.d.ts.map +1 -0
  22. package/dist/cli/decisions.js +140 -0
  23. package/dist/cli/decisions.js.map +1 -0
  24. package/dist/cli/doctor.d.ts +6 -0
  25. package/dist/cli/doctor.d.ts.map +1 -1
  26. package/dist/cli/doctor.js +46 -0
  27. package/dist/cli/doctor.js.map +1 -1
  28. package/dist/cli/failover-runner.d.ts.map +1 -1
  29. package/dist/cli/failover-runner.js +19 -1
  30. package/dist/cli/failover-runner.js.map +1 -1
  31. package/dist/cli/feedback.d.ts.map +1 -1
  32. package/dist/cli/feedback.js +5 -1
  33. package/dist/cli/feedback.js.map +1 -1
  34. package/dist/cli/hooks.d.ts +30 -0
  35. package/dist/cli/hooks.d.ts.map +1 -0
  36. package/dist/cli/hooks.js +224 -0
  37. package/dist/cli/hooks.js.map +1 -0
  38. package/dist/cli/knowledge.d.ts +9 -1
  39. package/dist/cli/knowledge.d.ts.map +1 -1
  40. package/dist/cli/knowledge.js +93 -2
  41. package/dist/cli/knowledge.js.map +1 -1
  42. package/dist/cli/loop-executor.d.ts.map +1 -1
  43. package/dist/cli/loop-executor.js +47 -12
  44. package/dist/cli/loop-executor.js.map +1 -1
  45. package/dist/cli/model.d.ts +20 -0
  46. package/dist/cli/model.d.ts.map +1 -1
  47. package/dist/cli/model.js +294 -14
  48. package/dist/cli/model.js.map +1 -1
  49. package/dist/cli/rate.d.ts +78 -0
  50. package/dist/cli/rate.d.ts.map +1 -0
  51. package/dist/cli/rate.js +294 -0
  52. package/dist/cli/rate.js.map +1 -0
  53. package/dist/config/process-env.d.ts.map +1 -1
  54. package/dist/config/process-env.js +10 -0
  55. package/dist/config/process-env.js.map +1 -1
  56. package/dist/enterprise/vault.d.ts.map +1 -1
  57. package/dist/enterprise/vault.js +5 -21
  58. package/dist/enterprise/vault.js.map +1 -1
  59. package/dist/gateway/hook-contract.d.ts +33 -0
  60. package/dist/gateway/hook-contract.d.ts.map +1 -1
  61. package/dist/gateway/hook-contract.js +97 -0
  62. package/dist/gateway/hook-contract.js.map +1 -1
  63. package/dist/inference/gemini-adapter.d.ts.map +1 -1
  64. package/dist/inference/gemini-adapter.js +4 -0
  65. package/dist/inference/gemini-adapter.js.map +1 -1
  66. package/dist/inference/interface.d.ts +37 -0
  67. package/dist/inference/interface.d.ts.map +1 -1
  68. package/dist/inference/native-tools.d.ts +16 -1
  69. package/dist/inference/native-tools.d.ts.map +1 -1
  70. package/dist/inference/native-tools.js +34 -10
  71. package/dist/inference/native-tools.js.map +1 -1
  72. package/dist/inference/route-resolver.d.ts +30 -0
  73. package/dist/inference/route-resolver.d.ts.map +1 -1
  74. package/dist/inference/route-resolver.js +158 -1
  75. package/dist/inference/route-resolver.js.map +1 -1
  76. package/dist/inference/tools.d.ts +16 -1
  77. package/dist/inference/tools.d.ts.map +1 -1
  78. package/dist/inference/tools.js +51 -18
  79. package/dist/inference/tools.js.map +1 -1
  80. package/dist/learning/acceptance-model.d.ts +247 -0
  81. package/dist/learning/acceptance-model.d.ts.map +1 -0
  82. package/dist/learning/acceptance-model.js +708 -0
  83. package/dist/learning/acceptance-model.js.map +1 -0
  84. package/dist/learning/auto-router.d.ts +68 -24
  85. package/dist/learning/auto-router.d.ts.map +1 -1
  86. package/dist/learning/auto-router.js +301 -96
  87. package/dist/learning/auto-router.js.map +1 -1
  88. package/dist/learning/autonomy-policy.d.ts +12 -16
  89. package/dist/learning/autonomy-policy.d.ts.map +1 -1
  90. package/dist/learning/autonomy-policy.js +50 -48
  91. package/dist/learning/autonomy-policy.js.map +1 -1
  92. package/dist/learning/behavioural-labels.d.ts +86 -0
  93. package/dist/learning/behavioural-labels.d.ts.map +1 -0
  94. package/dist/learning/behavioural-labels.js +166 -0
  95. package/dist/learning/behavioural-labels.js.map +1 -0
  96. package/dist/learning/capability-evidence.d.ts +167 -0
  97. package/dist/learning/capability-evidence.d.ts.map +1 -0
  98. package/dist/learning/capability-evidence.js +210 -0
  99. package/dist/learning/capability-evidence.js.map +1 -0
  100. package/dist/learning/capability-readiness.d.ts +64 -0
  101. package/dist/learning/capability-readiness.d.ts.map +1 -0
  102. package/dist/learning/capability-readiness.js +74 -0
  103. package/dist/learning/capability-readiness.js.map +1 -0
  104. package/dist/learning/capability-types.d.ts +169 -0
  105. package/dist/learning/capability-types.d.ts.map +1 -0
  106. package/dist/learning/capability-types.js +20 -0
  107. package/dist/learning/capability-types.js.map +1 -0
  108. package/dist/learning/catalog-feed.d.ts +89 -0
  109. package/dist/learning/catalog-feed.d.ts.map +1 -0
  110. package/dist/learning/catalog-feed.js +211 -0
  111. package/dist/learning/catalog-feed.js.map +1 -0
  112. package/dist/learning/cli-intent-effects.d.ts +60 -0
  113. package/dist/learning/cli-intent-effects.d.ts.map +1 -0
  114. package/dist/learning/cli-intent-effects.js +153 -0
  115. package/dist/learning/cli-intent-effects.js.map +1 -0
  116. package/dist/learning/command-adaptation.d.ts +41 -0
  117. package/dist/learning/command-adaptation.d.ts.map +1 -0
  118. package/dist/learning/command-adaptation.js +122 -0
  119. package/dist/learning/command-adaptation.js.map +1 -0
  120. package/dist/learning/consent-picture.d.ts +42 -0
  121. package/dist/learning/consent-picture.d.ts.map +1 -0
  122. package/dist/learning/consent-picture.js +53 -0
  123. package/dist/learning/consent-picture.js.map +1 -0
  124. package/dist/learning/context-budget.d.ts +37 -6
  125. package/dist/learning/context-budget.d.ts.map +1 -1
  126. package/dist/learning/context-budget.js +38 -9
  127. package/dist/learning/context-budget.js.map +1 -1
  128. package/dist/learning/decision-log.d.ts +114 -0
  129. package/dist/learning/decision-log.d.ts.map +1 -0
  130. package/dist/learning/decision-log.js +296 -0
  131. package/dist/learning/decision-log.js.map +1 -0
  132. package/dist/learning/deliverable-class.d.ts.map +1 -1
  133. package/dist/learning/deliverable-class.js +19 -0
  134. package/dist/learning/deliverable-class.js.map +1 -1
  135. package/dist/learning/deliverable-corpus.d.ts +117 -0
  136. package/dist/learning/deliverable-corpus.d.ts.map +1 -0
  137. package/dist/learning/deliverable-corpus.js +176 -0
  138. package/dist/learning/deliverable-corpus.js.map +1 -0
  139. package/dist/learning/error-repair.d.ts +22 -1
  140. package/dist/learning/error-repair.d.ts.map +1 -1
  141. package/dist/learning/error-repair.js +37 -0
  142. package/dist/learning/error-repair.js.map +1 -1
  143. package/dist/learning/exhaustion-report.d.ts +136 -0
  144. package/dist/learning/exhaustion-report.d.ts.map +1 -0
  145. package/dist/learning/exhaustion-report.js +239 -0
  146. package/dist/learning/exhaustion-report.js.map +1 -0
  147. package/dist/learning/failure-bookkeeping.d.ts +16 -0
  148. package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
  149. package/dist/learning/failure-bookkeeping.js +51 -5
  150. package/dist/learning/failure-bookkeeping.js.map +1 -1
  151. package/dist/learning/gate-friction.d.ts +39 -0
  152. package/dist/learning/gate-friction.d.ts.map +1 -0
  153. package/dist/learning/gate-friction.js +91 -0
  154. package/dist/learning/gate-friction.js.map +1 -0
  155. package/dist/learning/harness-narration.d.ts +34 -0
  156. package/dist/learning/harness-narration.d.ts.map +1 -0
  157. package/dist/learning/harness-narration.js +58 -0
  158. package/dist/learning/harness-narration.js.map +1 -0
  159. package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
  160. package/dist/learning/hub-skill-catalog.js +1 -2
  161. package/dist/learning/hub-skill-catalog.js.map +1 -1
  162. package/dist/learning/hybrid-router.d.ts +65 -0
  163. package/dist/learning/hybrid-router.d.ts.map +1 -1
  164. package/dist/learning/hybrid-router.js +186 -0
  165. package/dist/learning/hybrid-router.js.map +1 -1
  166. package/dist/learning/knowledge-base.d.ts +229 -3
  167. package/dist/learning/knowledge-base.d.ts.map +1 -1
  168. package/dist/learning/knowledge-base.js +593 -24
  169. package/dist/learning/knowledge-base.js.map +1 -1
  170. package/dist/learning/knowledge-eval.d.ts +171 -0
  171. package/dist/learning/knowledge-eval.d.ts.map +1 -0
  172. package/dist/learning/knowledge-eval.js +196 -0
  173. package/dist/learning/knowledge-eval.js.map +1 -0
  174. package/dist/learning/knowledge-turn.d.ts +92 -0
  175. package/dist/learning/knowledge-turn.d.ts.map +1 -0
  176. package/dist/learning/knowledge-turn.js +190 -0
  177. package/dist/learning/knowledge-turn.js.map +1 -0
  178. package/dist/learning/learned-commands.d.ts +64 -0
  179. package/dist/learning/learned-commands.d.ts.map +1 -0
  180. package/dist/learning/learned-commands.js +108 -0
  181. package/dist/learning/learned-commands.js.map +1 -0
  182. package/dist/learning/lexical-search.d.ts +50 -0
  183. package/dist/learning/lexical-search.d.ts.map +1 -0
  184. package/dist/learning/lexical-search.js +111 -0
  185. package/dist/learning/lexical-search.js.map +1 -0
  186. package/dist/learning/machine-facts.d.ts +71 -0
  187. package/dist/learning/machine-facts.d.ts.map +1 -0
  188. package/dist/learning/machine-facts.js +164 -0
  189. package/dist/learning/machine-facts.js.map +1 -0
  190. package/dist/learning/markdown-outline.d.ts +73 -0
  191. package/dist/learning/markdown-outline.d.ts.map +1 -0
  192. package/dist/learning/markdown-outline.js +136 -0
  193. package/dist/learning/markdown-outline.js.map +1 -0
  194. package/dist/learning/model-first-router.d.ts +33 -1
  195. package/dist/learning/model-first-router.d.ts.map +1 -1
  196. package/dist/learning/model-first-router.js +123 -9
  197. package/dist/learning/model-first-router.js.map +1 -1
  198. package/dist/learning/model-identity.d.ts +80 -0
  199. package/dist/learning/model-identity.d.ts.map +1 -0
  200. package/dist/learning/model-identity.js +115 -0
  201. package/dist/learning/model-identity.js.map +1 -0
  202. package/dist/learning/model-registry.d.ts +141 -2
  203. package/dist/learning/model-registry.d.ts.map +1 -1
  204. package/dist/learning/model-registry.js +324 -38
  205. package/dist/learning/model-registry.js.map +1 -1
  206. package/dist/learning/model-scoring.d.ts.map +1 -1
  207. package/dist/learning/model-scoring.js +8 -1
  208. package/dist/learning/model-scoring.js.map +1 -1
  209. package/dist/learning/model-selection.d.ts.map +1 -1
  210. package/dist/learning/model-selection.js +41 -0
  211. package/dist/learning/model-selection.js.map +1 -1
  212. package/dist/learning/outcome-observation.d.ts +49 -0
  213. package/dist/learning/outcome-observation.d.ts.map +1 -0
  214. package/dist/learning/outcome-observation.js +95 -0
  215. package/dist/learning/outcome-observation.js.map +1 -0
  216. package/dist/learning/pair-entitlement.d.ts +184 -0
  217. package/dist/learning/pair-entitlement.d.ts.map +1 -0
  218. package/dist/learning/pair-entitlement.js +214 -0
  219. package/dist/learning/pair-entitlement.js.map +1 -0
  220. package/dist/learning/provider-fallback.d.ts +23 -1
  221. package/dist/learning/provider-fallback.d.ts.map +1 -1
  222. package/dist/learning/provider-fallback.js +24 -0
  223. package/dist/learning/provider-fallback.js.map +1 -1
  224. package/dist/learning/reasoning-trace.d.ts +65 -1
  225. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  226. package/dist/learning/reasoning-trace.js +123 -1
  227. package/dist/learning/reasoning-trace.js.map +1 -1
  228. package/dist/learning/requirement-probe.d.ts +55 -0
  229. package/dist/learning/requirement-probe.d.ts.map +1 -0
  230. package/dist/learning/requirement-probe.js +77 -0
  231. package/dist/learning/requirement-probe.js.map +1 -0
  232. package/dist/learning/resilient-call.d.ts +20 -1
  233. package/dist/learning/resilient-call.d.ts.map +1 -1
  234. package/dist/learning/resilient-call.js +42 -4
  235. package/dist/learning/resilient-call.js.map +1 -1
  236. package/dist/learning/response-usability.d.ts +85 -0
  237. package/dist/learning/response-usability.d.ts.map +1 -0
  238. package/dist/learning/response-usability.js +135 -0
  239. package/dist/learning/response-usability.js.map +1 -0
  240. package/dist/learning/router-bandit.d.ts +132 -0
  241. package/dist/learning/router-bandit.d.ts.map +1 -1
  242. package/dist/learning/router-bandit.js +253 -0
  243. package/dist/learning/router-bandit.js.map +1 -1
  244. package/dist/learning/routing-history.d.ts +41 -2
  245. package/dist/learning/routing-history.d.ts.map +1 -1
  246. package/dist/learning/routing-history.js.map +1 -1
  247. package/dist/learning/run-trace.d.ts +21 -0
  248. package/dist/learning/run-trace.d.ts.map +1 -1
  249. package/dist/learning/run-trace.js +38 -0
  250. package/dist/learning/run-trace.js.map +1 -1
  251. package/dist/learning/session-grant.d.ts +75 -0
  252. package/dist/learning/session-grant.d.ts.map +1 -0
  253. package/dist/learning/session-grant.js +112 -0
  254. package/dist/learning/session-grant.js.map +1 -0
  255. package/dist/learning/task-model-continuity.d.ts +73 -0
  256. package/dist/learning/task-model-continuity.d.ts.map +1 -0
  257. package/dist/learning/task-model-continuity.js +87 -0
  258. package/dist/learning/task-model-continuity.js.map +1 -0
  259. package/dist/learning/turn-feedback.d.ts +93 -0
  260. package/dist/learning/turn-feedback.d.ts.map +1 -0
  261. package/dist/learning/turn-feedback.js +127 -0
  262. package/dist/learning/turn-feedback.js.map +1 -0
  263. package/dist/learning/turn-report.d.ts +104 -2
  264. package/dist/learning/turn-report.d.ts.map +1 -1
  265. package/dist/learning/turn-report.js +161 -6
  266. package/dist/learning/turn-report.js.map +1 -1
  267. package/dist/learning/verdict-routing.d.ts +51 -0
  268. package/dist/learning/verdict-routing.d.ts.map +1 -0
  269. package/dist/learning/verdict-routing.js +122 -0
  270. package/dist/learning/verdict-routing.js.map +1 -0
  271. package/dist/mcp/client.d.ts.map +1 -1
  272. package/dist/mcp/client.js +5 -0
  273. package/dist/mcp/client.js.map +1 -1
  274. package/dist/mcp/manager.d.ts +10 -0
  275. package/dist/mcp/manager.d.ts.map +1 -1
  276. package/dist/mcp/manager.js +20 -0
  277. package/dist/mcp/manager.js.map +1 -1
  278. package/dist/mcp/mcp-discovery.d.ts +34 -0
  279. package/dist/mcp/mcp-discovery.d.ts.map +1 -0
  280. package/dist/mcp/mcp-discovery.js +71 -0
  281. package/dist/mcp/mcp-discovery.js.map +1 -0
  282. package/dist/mcp/mcp-schema-cache.d.ts +17 -0
  283. package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
  284. package/dist/mcp/mcp-schema-cache.js +47 -3
  285. package/dist/mcp/mcp-schema-cache.js.map +1 -1
  286. package/dist/mcp/types.d.ts +28 -0
  287. package/dist/mcp/types.d.ts.map +1 -1
  288. package/dist/mcp/types.js.map +1 -1
  289. package/dist/tools/ask-user.d.ts.map +1 -1
  290. package/dist/tools/ask-user.js +4 -0
  291. package/dist/tools/ask-user.js.map +1 -1
  292. package/dist/tools/capability-registry.d.ts +150 -0
  293. package/dist/tools/capability-registry.d.ts.map +1 -0
  294. package/dist/tools/capability-registry.js +400 -0
  295. package/dist/tools/capability-registry.js.map +1 -0
  296. package/dist/tools/child-agent-runtime.d.ts.map +1 -1
  297. package/dist/tools/child-agent-runtime.js +19 -5
  298. package/dist/tools/child-agent-runtime.js.map +1 -1
  299. package/dist/tools/coding-tools.d.ts +17 -5
  300. package/dist/tools/coding-tools.d.ts.map +1 -1
  301. package/dist/tools/coding-tools.js +80 -11
  302. package/dist/tools/coding-tools.js.map +1 -1
  303. package/dist/tools/command-memo.d.ts +116 -0
  304. package/dist/tools/command-memo.d.ts.map +1 -0
  305. package/dist/tools/command-memo.js +202 -0
  306. package/dist/tools/command-memo.js.map +1 -0
  307. package/dist/tools/git-tool.d.ts.map +1 -1
  308. package/dist/tools/git-tool.js +13 -3
  309. package/dist/tools/git-tool.js.map +1 -1
  310. package/dist/tools/harness-directive.d.ts +42 -0
  311. package/dist/tools/harness-directive.d.ts.map +1 -0
  312. package/dist/tools/harness-directive.js +45 -0
  313. package/dist/tools/harness-directive.js.map +1 -0
  314. package/dist/tools/loop-skill-hint.d.ts +74 -174
  315. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  316. package/dist/tools/loop-skill-hint.js +110 -536
  317. package/dist/tools/loop-skill-hint.js.map +1 -1
  318. package/dist/tools/mcp-client-tool.d.ts +2 -0
  319. package/dist/tools/mcp-client-tool.d.ts.map +1 -1
  320. package/dist/tools/mcp-client-tool.js +3 -0
  321. package/dist/tools/mcp-client-tool.js.map +1 -1
  322. package/dist/tools/modality/shared.d.ts +1 -4
  323. package/dist/tools/modality/shared.d.ts.map +1 -1
  324. package/dist/tools/modality/shared.js +7 -22
  325. package/dist/tools/modality/shared.js.map +1 -1
  326. package/dist/tools/plan-store.d.ts +20 -2
  327. package/dist/tools/plan-store.d.ts.map +1 -1
  328. package/dist/tools/plan-store.js +51 -7
  329. package/dist/tools/plan-store.js.map +1 -1
  330. package/dist/tools/registry.d.ts +76 -3
  331. package/dist/tools/registry.d.ts.map +1 -1
  332. package/dist/tools/registry.js +486 -26
  333. package/dist/tools/registry.js.map +1 -1
  334. package/dist/tools/remediation-ladder.d.ts.map +1 -1
  335. package/dist/tools/remediation-ladder.js +8 -0
  336. package/dist/tools/remediation-ladder.js.map +1 -1
  337. package/dist/tools/run-cli.d.ts.map +1 -1
  338. package/dist/tools/run-cli.js +29 -4
  339. package/dist/tools/run-cli.js.map +1 -1
  340. package/dist/tools/run-terminal.d.ts +11 -0
  341. package/dist/tools/run-terminal.d.ts.map +1 -1
  342. package/dist/tools/run-terminal.js +265 -10
  343. package/dist/tools/run-terminal.js.map +1 -1
  344. package/dist/tools/tool-loop.d.ts +321 -13
  345. package/dist/tools/tool-loop.d.ts.map +1 -1
  346. package/dist/tools/tool-loop.js +867 -66
  347. package/dist/tools/tool-loop.js.map +1 -1
  348. package/dist/utils/atomic-store.d.ts +53 -0
  349. package/dist/utils/atomic-store.d.ts.map +1 -0
  350. package/dist/utils/atomic-store.js +178 -0
  351. package/dist/utils/atomic-store.js.map +1 -0
  352. package/dist/utils/binary-probe.d.ts +25 -0
  353. package/dist/utils/binary-probe.d.ts.map +1 -0
  354. package/dist/utils/binary-probe.js +48 -0
  355. package/dist/utils/binary-probe.js.map +1 -0
  356. package/dist/utils/workspace-path.d.ts +50 -0
  357. package/dist/utils/workspace-path.d.ts.map +1 -0
  358. package/dist/utils/workspace-path.js +86 -0
  359. package/dist/utils/workspace-path.js.map +1 -0
  360. package/dist/web-dashboard/chat-console.d.ts +24 -0
  361. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  362. package/dist/web-dashboard/chat-console.js +38 -0
  363. package/dist/web-dashboard/chat-console.js.map +1 -1
  364. package/dist/web-dashboard/project-context.d.ts +27 -2
  365. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  366. package/dist/web-dashboard/project-context.js +32 -2
  367. package/dist/web-dashboard/project-context.js.map +1 -1
  368. package/dist/web-dashboard/server.d.ts +11 -0
  369. package/dist/web-dashboard/server.d.ts.map +1 -1
  370. package/dist/web-dashboard/server.js +362 -11
  371. package/dist/web-dashboard/server.js.map +1 -1
  372. package/dist/web-dashboard/src/types.d.ts +170 -1
  373. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  374. package/dist/web-dashboard/workspace-guard.d.ts +11 -2
  375. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  376. package/dist/web-dashboard/workspace-guard.js +24 -12
  377. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  378. package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
  379. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
  380. package/dist/web-dashboard/workspace-resolution.js +105 -0
  381. package/dist/web-dashboard/workspace-resolution.js.map +1 -0
  382. package/package.json +1 -1
  383. package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
  384. package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
  385. package/src/web-dashboard/public/index.html +1 -1
  386. package/src/web-dashboard/public/assets/index-DhiuR4S2.js +0 -210
  387. package/src/web-dashboard/public/assets/index-DhiuR4S2.js.map +0 -1
@@ -15,7 +15,8 @@
15
15
  * - JSON fallback: the model emits `{"tool":"<name>","arguments":{...}}`
16
16
  * blocks after its response text (contract in TOOL_CONTRACT_JSON).
17
17
  */
18
- import { getTool, toolJsonSchemas } from './registry.js';
18
+ import { getTool, listTools, toolJsonSchemas } from './registry.js';
19
+ import { effectClassOfTool } from './capability-registry.js';
19
20
  // A1 — build-command recognition, shared with effect verification so "a build"
20
21
  // means the same thing to both the launch check and the honesty guard.
21
22
  import { isBuildCommand } from '../utils/effect-verification.js';
@@ -29,6 +30,10 @@ import { runBeforeToolHooks, runToolOutcomeHooks, toolHookRefusalText, } from '.
29
30
  // when the run is a resume; `stepDigest` is over the whole input, so a step whose
30
31
  // thread or tool schema changed MISSES and is paid for again (see the module).
31
32
  import { stepDigest } from '../learning/step-checkpoint.js';
33
+ // P7 — the identical-command guard's memory. The loop drops it when a WRITE is
34
+ // applied, so a repaired project can re-run its own check (see the context below).
35
+ import { resetTerminalFailureStreaks } from './run-terminal.js';
36
+ import { createRunCommandMemo } from './command-memo.js';
32
37
  // WS6 (#28) — the declared fault seam. `faultAt` is a null check when this
33
38
  // process declared no fault (`NUVIRA_INJECT_FAULT`), so an ordinary turn is
34
39
  // unaffected.
@@ -37,6 +42,8 @@ import { detectPermissionSeeking, isAffirmativeReply, replyAsksTheReader, reques
37
42
  import { envelopeFromPlan, envelopeFromRequest, getEnvelope, grantEnvelope, isEnvelopeKey, } from '../learning/intent-envelope.js';
38
43
  import { detectProcessComplaint, isTraceKey, noProgressNudge, repeatNudge, repeatedFailureNudge, runTraceFor, RunTrace, } from '../learning/run-trace.js';
39
44
  import { wantsAuthoredArtifact } from '../learning/deliverable-class.js';
45
+ import { isAuthoredReadBack } from '../learning/turn-report.js';
46
+ import { parseLongFormTarget, countWords } from '../learning/long-form.js';
40
47
  import { normalizeFollowups } from './followup-utils.js';
41
48
  // The capability mode (balanced | max) — `max` widens the loop's own reasoning
42
49
  // budget as well as routing, so "cost is not a concern" means the agent may
@@ -46,6 +53,7 @@ import { isMaxCapability } from '../config/capability-mode.js';
46
53
  // compaction digest is injected in every mode, only under `max`, or never.
47
54
  import { isWorkDigestEnabled } from '../config/work-digest.js';
48
55
  import { assessEditActivity, detectUnverifiedEditClaim, isVerificationTool, verificationNudgeFor, THINK_ONLY_ESCALATION, AUTHORIZED_WORK_NUDGE, deliverableNudge, } from './edit-verification.js';
56
+ import { createHarnessDirectiveSink } from './harness-directive.js';
49
57
  import { effectiveToolJsonSchemas, coreToolJsonSchemas, isToolEnabled, toolsetForTool } from './toolsets.js';
50
58
  import { deliverablesNamedIn, recordStepHandoff } from '../agents/step-handoff.js';
51
59
  import { fenceUntrustedToolOutput } from './untrusted-content.js';
@@ -254,6 +262,15 @@ export function extractFallbackToolCalls(content) {
254
262
  // A QUOTED tool block with no closing brace: the model was cut off
255
263
  // mid-call. Everything from the marker on is leaked scaffolding, not
256
264
  // answer text — strip it and stop (nothing valid can follow).
265
+ //
266
+ // Item 6 — surface it as an UNPARSEABLE call when we saw the tool's name,
267
+ // so the loop refuses it with the truncation cause and the sectioned path.
268
+ // Dropping it silently (the old behaviour) is how a cut-off delivery failed
269
+ // with nothing to tell the model why.
270
+ const name = /^\s*\(?\s*\{\s*"tool"\s*:\s*"([^"]+)"/.exec(cleaned.slice(m.index))?.[1];
271
+ if (name) {
272
+ calls.push({ id: `call_${calls.length + 1}`, name, arguments: {}, argumentsError: 'unparseable' });
273
+ }
257
274
  cleaned = cleaned.slice(0, m.index);
258
275
  strippedAny = true;
259
276
  break;
@@ -269,15 +286,25 @@ export function extractFallbackToolCalls(content) {
269
286
  try {
270
287
  const parsed = JSON.parse(block);
271
288
  if (parsed.tool && typeof parsed.tool === 'string') {
289
+ // Item 6 — the fallback transport is TEXT, so "did the arguments arrive?"
290
+ // is decided HERE. A block that parses but carries no `arguments` object
291
+ // is the empty-payload signature; flagging it lets the loop refuse it with
292
+ // the REAL cause instead of executing `{}` and answering "path is required".
293
+ const rawArgs = parsed.arguments;
294
+ const isPlainObject = rawArgs !== null && typeof rawArgs === 'object' && !Array.isArray(rawArgs);
272
295
  calls.push({
273
296
  id: `call_${calls.length + 1}`,
274
297
  name: parsed.tool,
275
- arguments: parsed.arguments && typeof parsed.arguments === 'object' ? parsed.arguments : {},
298
+ arguments: isPlainObject ? rawArgs : {},
299
+ ...(isPlainObject ? {} : { argumentsError: rawArgs === undefined ? 'empty' : 'unparseable' }),
276
300
  });
277
301
  }
278
302
  }
279
303
  catch {
280
- // Unparseable block — dropped from the answer, no tool call.
304
+ // A brace-MATCHED but unparseable block (the model's own JSON typo, e.g. a
305
+ // trailing comma) is dropped from the answer, no call — a truncation has no
306
+ // closing brace and is handled above. Item 6 covers the truncated case there;
307
+ // this stays silent by design so a typo cannot become a phantom refusal.
281
308
  }
282
309
  }
283
310
  // ── Pass 2: our tool's ARGUMENTS keyed by our own tool NAME ─────────────
@@ -387,31 +414,51 @@ function findMatchingBrace(text, start) {
387
414
  }
388
415
  // ─── Parallel read-only execution (W2) ──────────────────────────────────────
389
416
  /**
390
- * Tools that are READ-ONLY and therefore safe to execute concurrently when the
391
- * model emits several of them in ONE step. Everything else — writes, terminal
392
- * commands, pipeline dispatch, delegation, `tool_search` (it mutates the
393
- * tiering state), `plan_todo`, `ask_user` (it blocks on user input) — stays
394
- * strictly serial and in the model's original order.
417
+ * Tools that READ the world but must still run SERIALLY, because they change
418
+ * HARNESS state or block on input rather than changing the world.
395
419
  *
396
- * Why this matters: tool results are fed back one step at a time and the step
397
- * budget is bounded (16), so N independent reads used to cost N sequential
398
- * round-trips of wall-clock. Reading several files/searches is the single most
399
- * common investigation pattern, so it becomes one bounded fan-out.
420
+ * `effectClass: 'read'` is NECESSARY but NOT SUFFICIENT for safe fan-out — this is
421
+ * the second half of the fact, and it is a property of the HARNESS, not of the
422
+ * effect. `tool_search` mutates the tiering state that decides which tools exist
423
+ * in the NEXT step; `ask_user` blocks on the human. Fanning either out would let
424
+ * two siblings disagree about the world they are reading.
400
425
  *
401
- * The list is deliberately an ALLOWLIST (never a denylist): a tool added to
402
- * the registry later is SERIAL by default until it is reviewed as read-only.
403
- */
404
- export const PARALLEL_SAFE_TOOL_NAMES = new Set([
405
- 'read_file',
406
- 'list_dir',
407
- 'glob',
408
- 'code_search',
409
- 'web_search',
410
- 'read_page',
426
+ * Pinned by `parallel-tool-exec.test.ts`, which asserts each of these is serial
427
+ * WHILE its declared effect is genuinely `read`.
428
+ */
429
+ const READ_BUT_SERIAL = new Set([
430
+ 'tool_search', // mutates the tiering state
431
+ 'ask_user', // blocks on user input
411
432
  ]);
412
- /** Whether a tool may run concurrently with sibling calls in the same step. */
433
+ /**
434
+ * Whether a tool may run concurrently with sibling calls in the same step.
435
+ *
436
+ * DERIVED FROM THE CAPABILITY REGISTRY, not a hand-maintained allowlist. This used
437
+ * to be a six-name list that had to be reviewed by hand whenever a read tool was
438
+ * added — so any new read tool, and every read-only MCP tool discovered at
439
+ * runtime, silently lost the fan-out. The effect class answers it now.
440
+ *
441
+ * THE SAFETY DIRECTION IS UNCHANGED, which is the part that matters: a tool the
442
+ * registry does not describe maps to `local-state`, so a newly registered tool is
443
+ * still SERIAL until someone declares it a read. `READ_BUT_SERIAL` is the one
444
+ * hand-written exception, and it is a harness fact rather than an effect guess.
445
+ */
413
446
  export function isParallelSafeTool(name) {
414
- return PARALLEL_SAFE_TOOL_NAMES.has(name);
447
+ if (READ_BUT_SERIAL.has(name))
448
+ return false;
449
+ return effectClassOfTool(name) === 'read';
450
+ }
451
+ let _parallelSafeNames = null;
452
+ /**
453
+ * Every REGISTRY tool that currently qualifies, derived by filtering with
454
+ * `isParallelSafeTool` — so the set and the predicate can never disagree. Built
455
+ * lazily, because the registry must be fully loaded before it can be enumerated.
456
+ */
457
+ export function parallelSafeToolNames() {
458
+ if (!_parallelSafeNames) {
459
+ _parallelSafeNames = new Set(listTools().map((t) => t.name).filter(isParallelSafeTool));
460
+ }
461
+ return _parallelSafeNames;
415
462
  }
416
463
  /** Max concurrent read-only calls per step (bounded fan-out) when the model
417
464
  * has no harness opinion — see `ModelHarnessProfile.maxParallelReads`, which
@@ -434,6 +481,36 @@ const MAX_PARALLEL_READS = 4;
434
481
  * were well able to do (see `THINK_ONLY_ESCALATION`).
435
482
  */
436
483
  export const MAX_THINK_CONTINUES = 3;
484
+ /**
485
+ * P2 — how many times the SAME model may answer with nothing before the loop
486
+ * insists on a handoff. One, deliberately: a single empty completion can be a
487
+ * blip at a shared endpoint, but a model that resolves with nothing twice has
488
+ * demonstrated it is not answering this step, and re-calling it is the loop that
489
+ * killed a real turn (five consecutive empties, then the turn reported itself
490
+ * bounded with zero tool calls).
491
+ */
492
+ export const MAX_SAME_MODEL_EMPTY_RETRIES = 1;
493
+ /**
494
+ * P2 — ceiling on mid-turn handoffs, so a caller whose pool churns through
495
+ * unusable models cannot spin. Each handoff is a genuinely different candidate
496
+ * (the caller must refuse to hand back the same pair), so this only bounds a
497
+ * pathological pool; a real one is exhausted well before it.
498
+ */
499
+ export const MAX_MODEL_HANDOFFS_PER_TURN = 5;
500
+ /**
501
+ * P3 — the turn's ATTEMPT budget, kept separate from its STEP budget.
502
+ *
503
+ * A mid-turn handoff spends model calls that produced nothing: the empty
504
+ * completion that triggered it, and the first call to the replacement (which
505
+ * may itself come back empty). Charging those to the same budget that governs
506
+ * the actual WORK means a run against two or three dead models exhausts its
507
+ * road before it has done anything — the exact way a task gets abandoned while
508
+ * capable models are still queued. Each handoff therefore GRANTS two extra
509
+ * steps of credit (capped), so hand-offs cannot be cut short by step
510
+ * accounting; everything else — the step budget, the continuation budget, the
511
+ * think-only cap — is untouched.
512
+ */
513
+ export const MAX_HANDOFF_ATTEMPT_CREDIT = MAX_MODEL_HANDOFFS_PER_TURN * 2;
437
514
  /**
438
515
  * Consecutive steps that RAN tools and had NO success before the loop asks the
439
516
  * model to diagnose the stall. This generalises {@link RunTrace.repeatedFailure}
@@ -533,6 +610,21 @@ async function runToolLoopInner(opts, progress) {
533
610
  // Bounded think-only continuation (see THINK_ONLY_ESCALATION). Counts the
534
611
  // consecutive reasoning-only steps so the loop cannot spin on them.
535
612
  let thinkContinues = 0;
613
+ // P2 — empty-response accounting. `emptyRetriesOnCurrentModel` is the streak
614
+ // of usable-nothing responses from the model currently serving the turn; it
615
+ // resets the moment that model produces anything usable (or is replaced), so
616
+ // "twice in a row" means twice in a row from THAT model. `modelHandoffs`
617
+ // bounds how many times the turn may move.
618
+ let emptyRetriesOnCurrentModel = 0;
619
+ let modelHandoffs = 0;
620
+ // P3 — the ATTEMPT budget (see MAX_HANDOFF_ATTEMPT_CREDIT): step credit
621
+ // granted by mid-turn handoffs, so a run against several dead models cannot
622
+ // exhaust its road before it has done any work.
623
+ let handoffAttemptCredit = 0;
624
+ // Why the turn is ending. The two `break`s in the empty/think-only path mean
625
+ // opposite things (a model shortage vs. one model spinning), so the reason is
626
+ // recorded explicitly rather than re-derived at the exit.
627
+ let exitTermination = null;
536
628
  // G13b — bounded "the request asked for a file and none was written" nudges
537
629
  // (see wantsAuthoredArtifact).
538
630
  let deliverableNudges = 0;
@@ -561,6 +653,12 @@ async function runToolLoopInner(opts, progress) {
561
653
  const maxParallelReads = Math.max(1, opts.maxParallelReads ?? MAX_PARALLEL_READS);
562
654
  const followups = [];
563
655
  const toolCallsRun = [];
656
+ // A call whose arguments never ARRIVED is not a call the model made: it is a
657
+ // payload that did not fit in one model output (measured: 59 of them in one
658
+ // turn). Counted per turn so the loop can escalate, and once-nudged so the
659
+ // escalation itself cannot loop.
660
+ let malformedToolCalls = 0;
661
+ let malformedCallNudges = 0;
564
662
  // S5 — the plan_todo loop guard is split by ACTION (see the guard below):
565
663
  // repeated CREATES were the observed planner loop; UPDATES are the tracking
566
664
  // that keeps the user's checklist honest. Counted across the whole turn, like
@@ -633,8 +731,31 @@ async function runToolLoopInner(opts, progress) {
633
731
  // so a `finding` in the same step can check its cited command/path against
634
732
  // what really happened (see `enforceEvidenceProvenance`).
635
733
  const executedActions = progress.executedActions;
734
+ // P7 — a WRITE makes the world different, so a command that already failed may
735
+ // now succeed. `run_terminal`'s identical-command guard exists to stop blind
736
+ // repeats (the live run burned three back-to-back attempts), but a repaired
737
+ // project re-running its own check is the ONE case that must never be refused:
738
+ // with the guard's cap, that call would be rejected before it could ever
739
+ // succeed and clear the streak — a deadlock where a fixed project could not
740
+ // run its check again. Every tool that mutates the workspace announces it on
741
+ // `autonomy:write-applied`, so the streak is dropped exactly when something
742
+ // changed, and a blind repeat at the same timeout still counts.
743
+ //
744
+ // `run_terminal` announces on the SAME event when IT proceeds autonomously, but
745
+ // that is a statement about its own command, not a change to the world — letting
746
+ // it invalidate the streak would mean the guard could never accumulate on the
747
+ // exact commands it exists for. Payloads name their emitter, so its event is
748
+ // excluded by name while any other write (write_file, edit_file, git, …) counts.
749
+ const baseEmit = context.emit;
750
+ const emitWithWriteInvalidation = (event, data, source) => {
751
+ if (event === 'autonomy:write-applied' && data?.tool !== 'run_terminal') {
752
+ resetTerminalFailureStreaks();
753
+ }
754
+ baseEmit?.(event, data, source);
755
+ };
636
756
  const ctx = {
637
757
  ...context,
758
+ emit: emitWithWriteInvalidation,
638
759
  followups: context.followups || sink,
639
760
  executedActions,
640
761
  writesAuthorized: authorization,
@@ -648,6 +769,15 @@ async function runToolLoopInner(opts, progress) {
648
769
  // The run's own behaviour, so a gate can refuse to RE-ASK a question the
649
770
  // user already answered (see learning/run-trace.ts).
650
771
  runTrace,
772
+ // C3 — the commands THIS run already answered.
773
+ //
774
+ // Created here, so its lifetime is exactly the tool loop's: one answer's
775
+ // worth of steps, discarded after. That is the honest granularity — a probe
776
+ // (`node --version`) is a fact about this run's environment, while
777
+ // idempotent setup (`mkdir -p out`) is only safely skippable while the run
778
+ // that created the directory is still going. A caller may inject its own
779
+ // memo (a test, or a longer-lived session scope), and then it is honoured.
780
+ commandMemo: context.commandMemo ?? createRunCommandMemo(),
651
781
  };
652
782
  // Resolve the tool set — stable JSON schemas for every native step
653
783
  // (derived from the registry's zod schemas, single source of truth).
@@ -703,8 +833,29 @@ async function runToolLoopInner(opts, progress) {
703
833
  return added;
704
834
  };
705
835
  const thread = [...messages];
836
+ // Every harness-authored nudge is delivered through ONE channel: a marked
837
+ // `system` directive that the model ACTS ON, deduped by gate within the turn,
838
+ // instead of a `user` message it answers (see tools/harness-directive.ts).
839
+ const harness = createHarnessDirectiveSink();
706
840
  /** Serving pairs the loop has already told the model about, oldest first. */
707
841
  const routeFeedState = { pairs: [] };
842
+ // ── C4 residual: the plan the conversation ALREADY has is shown, not hidden ──
843
+ // The C4 experiment measured the shape of this failure: the plan store keeps the
844
+ // plan (and now carries its progress across a re-declaration), but the model was
845
+ // never SHOWN it — the store was read only to answer "does a plan exist". So a
846
+ // later turn could not reference "step 3 is done" (which the tool's own
847
+ // description promises) and re-declared the plan instead. Handing the plan back
848
+ // at the top of every turn makes the carried progress visible and names the one
849
+ // correct move (update), instead of relying on the model to guess that a plan is
850
+ // already tracked.
851
+ //
852
+ // Bounded, and only when there is work left: a finished plan is not re-shown, and
853
+ // a long plan is capped rather than allowed to dominate the context it exists to
854
+ // protect.
855
+ const existingPlan = context.planStore?.snapshot?.() ?? null;
856
+ if (existingPlan && existingPlan.steps.length > 0 && existingPlan.steps.some((s) => s.status !== 'done')) {
857
+ thread.push({ role: 'system', content: planContextBlock(existingPlan) });
858
+ }
708
859
  // ── Stage 2: answer a question ABOUT the run from the run ────────────────
709
860
  // The live audit's worst moment was the user asking "why are you asking me
710
861
  // this again and again?" and the turn replying with an edit plan — not through
@@ -727,7 +878,9 @@ async function runToolLoopInner(opts, progress) {
727
878
  'instead. Do not answer a question about your own process with a plan for the work.',
728
879
  });
729
880
  }
730
- const budgetChars = opts.threadBudgetChars ?? DEFAULT_THREAD_BUDGET_CHARS;
881
+ // C5 — `let`, not `const`: a mid-turn handoff can install a different model, and
882
+ // the budget must follow it (see `threadBudgetAfterSwitch`).
883
+ let budgetChars = opts.threadBudgetChars ?? DEFAULT_THREAD_BUDGET_CHARS;
731
884
  // P3d — per-turn parallel suggester: after 2+ successful independent gather
732
885
  // steps, one advisory delegate suggestion fires (bounded, deterministic).
733
886
  const parallel = makeParallelSuggester();
@@ -744,6 +897,38 @@ async function runToolLoopInner(opts, progress) {
744
897
  // clobber it — otherwise the delivered answer is lost and the turn ends
745
898
  // with an empty/bounded response (the "where is the essay?" bug).
746
899
  let lastContent = '';
900
+ /**
901
+ * How many PRODUCTIVE actions had SUCCEEDED when `lastContent` was written.
902
+ *
903
+ * WHY THIS EXISTS (D1 — the delivered answer contradicted the run's own
904
+ * evidence). `lastContent` used to be replaced only when the new text was at
905
+ * least as long, so the delivered answer was the LONGEST text of the turn —
906
+ * and the model's own later correction could never take over. Measured in
907
+ * `trace-1791300903944-upblb7` (an 82-step run): the longest step was **seq 65
908
+ * (4,024 chars)** — the "Built and verified. Here's the rundown." draft — while
909
+ * seq 80 ("Hold on — that README note claims `sentence-transformers` needs a
910
+ * `torch` build, but I never actually tested that install. Let me check rather
911
+ * than assert.") and seq 82 ("My README claim was **wrong** — torch-2.14.1 and
912
+ * sentence-transformers-6.1.0 do resolve…") are 155 and 165 chars. Both are
913
+ * corrections of the draft, both are shorter, so the run delivered the draft
914
+ * and the user read an assertion the agent had already disproved itself.
915
+ *
916
+ * The rule the length check was reaching for is "a trailing WRAPPER must not
917
+ * clobber the essay". Length is the wrong proxy for that, because a correction
918
+ * is also short. What actually separates them is whether the turn DID something
919
+ * in between: text written after real work is an account of a LATER state and
920
+ * supersedes; a closing paragraph written with no work since is presentation.
921
+ * So a later answer wins when real work happened after the stored one.
922
+ *
923
+ * The presentation calls in `NON_PRODUCTIVE_TOOLS` are excluded on purpose —
924
+ * `suggest_followups` is what the end-of-response contract forces, it changes
925
+ * nothing, and it is precisely the call that produces the trailing wrapper
926
+ * this rule must still ignore (the same set `hasProductiveAction` uses, so the
927
+ * two cannot drift).
928
+ */
929
+ let workCallsBeforeLastContent = 0;
930
+ /** A capture happened this step; stamp it at the top of the NEXT one (see below). */
931
+ let pendingContentStamp = false;
747
932
  let bounded = false;
748
933
  // D2.2 — has this turn already pre-flighted the project's build prerequisites?
749
934
  // Checked once, before the FIRST build command the turn plans to run.
@@ -784,7 +969,7 @@ async function runToolLoopInner(opts, progress) {
784
969
  // many-file edit). Extend the budget a bounded number of times and keep
785
970
  // the SAME thread — completed tool calls are never re-run — instead of
786
971
  // telling the user "I reached my step limit" with the task unfinished.
787
- if (steps >= stepLimit) {
972
+ if (steps >= stepLimit + handoffAttemptCredit) {
788
973
  if (continuations < maxContinuations) {
789
974
  continuations += 1;
790
975
  stepLimit += continuationSteps;
@@ -796,9 +981,23 @@ async function runToolLoopInner(opts, progress) {
796
981
  // P4 — check cancellation BEFORE each step (a pre-aborted signal never
797
982
  // spends a model call) and after tool executions (below).
798
983
  if (opts.signal?.aborted) {
799
- return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true };
984
+ return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true, termination: 'cancelled' };
800
985
  }
801
986
  steps += 1;
987
+ // D1 — the work completed BEFORE this step's response is written. Snapshotted
988
+ // at the TOP of the iteration, where the previous step's tool calls have
989
+ // already run and been counted, so a step's OWN tool calls can never make its
990
+ // own answer look superseded by the next step's closing wrapper. See
991
+ // `workCallsBeforeLastContent`.
992
+ const workAtIterationStart = countProductiveWork(progress);
993
+ // Stamp an answer captured LAST step with the work count as of the END of that
994
+ // step. Doing it here (rather than at capture) is what keeps a step's OWN tool
995
+ // calls from making its own answer look superseded: the essay step that also
996
+ // wrote a file must not be replaced by the closing paragraph written next.
997
+ if (pendingContentStamp) {
998
+ workCallsBeforeLastContent = workAtIterationStart;
999
+ pendingContentStamp = false;
1000
+ }
802
1001
  // Mechanical thread budget: trim BEFORE the model call so a provider
803
1002
  // request never exceeds the window (deterministic — no LLM summarizer,
804
1003
  // no latency, no drift; see trimThreadBudget).
@@ -871,7 +1070,7 @@ async function runToolLoopInner(opts, progress) {
871
1070
  // generation failure: the caller discards the turn. No error text, no
872
1071
  // fallback — the fetch itself aborted on the caller's signal.
873
1072
  if (opts.signal?.aborted) {
874
- return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true };
1073
+ return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true, termination: 'cancelled' };
875
1074
  }
876
1075
  // Generation failure — surface what we have rather than crash the turn.
877
1076
  // generationFailed is TRUE only when NOTHING happened yet (no content, no
@@ -936,6 +1135,10 @@ async function runToolLoopInner(opts, progress) {
936
1135
  ...(opts.resume ? { replayedSteps } : {}),
937
1136
  bounded: false,
938
1137
  continuations,
1138
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
1139
+ // Nothing to deliver means the provider walk itself gave up — every
1140
+ // candidate was tried and none answered. That IS the evidenced shortage.
1141
+ termination: delivered === '' ? 'no-capable-candidate' : 'delivered',
939
1142
  generationFailed: delivered === '' || !madeProgress,
940
1143
  };
941
1144
  }
@@ -970,10 +1173,21 @@ async function runToolLoopInner(opts, progress) {
970
1173
  const responseThinkOnly = deps.isThinkOnly
971
1174
  ? deps.isThinkOnly(response.content)
972
1175
  : isThinkOnlyResponse(response.content);
973
- if (response.content.trim() && !responseThinkOnly && response.content.length >= lastContent.length) {
1176
+ const responseIsSubstantive = response.content.trim() !== '' && !responseThinkOnly && !isBareAcknowledgment(response.content);
1177
+ const workSinceLastContent = workAtIterationStart > workCallsBeforeLastContent;
1178
+ if (responseIsSubstantive && (response.content.length >= lastContent.length || workSinceLastContent)) {
974
1179
  lastContent = response.content;
1180
+ // Stamp what this answer is an account OF, so the next step can tell an
1181
+ // update from a wrapper (see `workCallsBeforeLastContent`). Deferred to the
1182
+ // top of the next iteration so it includes THIS step's own work.
1183
+ pendingContentStamp = true;
975
1184
  }
976
1185
  const { toolCalls } = response;
1186
+ // P2 — the model just produced something usable (text, or at least one tool
1187
+ // call), so its empty-response streak is over: a later blip starts a NEW
1188
+ // streak rather than inheriting one from twenty steps ago.
1189
+ if (toolCalls.length > 0 || !responseThinkOnly)
1190
+ emptyRetriesOnCurrentModel = 0;
977
1191
  if (toolCalls.length === 0) {
978
1192
  // No tools → end turn UNLESS the content is think-only (
979
1193
  // isThinkOnlyResponse: continue instead of ending).
@@ -994,6 +1208,86 @@ async function runToolLoopInner(opts, progress) {
994
1208
  // is the exact class of defect this work exists to remove, so the two
995
1209
  // cases are named apart even though they are bounded together.
996
1210
  const emptyResponse = response.content.trim().length === 0;
1211
+ // ── P2 — MID-TURN MODEL HANDOFF ────────────────────────────────────
1212
+ // An empty completion is a PROVIDER failure, not a state of mind, and
1213
+ // re-asking the model that just returned nothing is what killed a real
1214
+ // run: five consecutive empties from one model, then `bounded: true`
1215
+ // with zero tool calls, while healthy models sat configured and unused.
1216
+ // So once THIS model has had its single retry, the loop asks the caller
1217
+ // for a different candidate. The caller owns the pool (exclusions,
1218
+ // credentials, cooldowns) and refuses to hand back the same pair; a
1219
+ // `false` means there is genuinely nothing else, and we fall through to
1220
+ // the bounded escalation below — unchanged behaviour for a turn with no
1221
+ // alternative (and for every caller that supplies no hook at all).
1222
+ if (emptyResponse &&
1223
+ deps.requestModelSwitch &&
1224
+ modelHandoffs < MAX_MODEL_HANDOFFS_PER_TURN &&
1225
+ emptyRetriesOnCurrentModel >= MAX_SAME_MODEL_EMPTY_RETRIES) {
1226
+ let switched = false;
1227
+ try {
1228
+ switched = (await deps.requestModelSwitch('empty')) === true;
1229
+ }
1230
+ catch {
1231
+ // A broken switch hook must never kill the turn — fall through to the
1232
+ // bounded path exactly as if it had answered `false`.
1233
+ switched = false;
1234
+ }
1235
+ if (switched) {
1236
+ modelHandoffs += 1;
1237
+ // ── C5 — RE-FIT THE THREAD TO THE MODEL THAT NOW ANSWERS ──────
1238
+ // `budgetChars` was resolved before this loop started, from the pair
1239
+ // the turn BEGAN on — and the handoff worth catching is the one to a
1240
+ // SMALLER window, which is precisely the case that overflows. The
1241
+ // caller knows the session, so it re-resolves; `undefined` keeps the
1242
+ // budget (an unknown window must not silently shrink the thread).
1243
+ // The trim runs at the TOP of the next iteration, before the call, so
1244
+ // a NEW budget is applied to the very next request — and when it is
1245
+ // smaller, that trim is what makes the handoff fit.
1246
+ const refitted = deps.threadBudgetAfterSwitch?.();
1247
+ if (typeof refitted === 'number' && refitted > 0 && refitted !== budgetChars) {
1248
+ deps.onEvent?.(` 📐 thread budget re-fitted for the handoff model: ` +
1249
+ `${Math.round(budgetChars / 1000)}K → ${Math.round(refitted / 1000)}K chars.`);
1250
+ budgetChars = refitted;
1251
+ }
1252
+ // P3 — the ATTEMPT budget: the empty completion that triggered this
1253
+ // handoff, and the first call to the replacement model, are not the
1254
+ // user's work — grant the step credit so a run against a couple of
1255
+ // dead models cannot exhaust its road before it has done anything.
1256
+ handoffAttemptCredit = Math.min(MAX_HANDOFF_ATTEMPT_CREDIT, handoffAttemptCredit + 2);
1257
+ // The streak and the think streak belong to the model that failed.
1258
+ emptyRetriesOnCurrentModel = 0;
1259
+ thinkContinues = 0;
1260
+ // ── DROP THE FAILED MODEL'S PRIVATE REASONING ─────────────────
1261
+ // Every assistant turn records `reasoningContent`, and the wire
1262
+ // layer echoes it back on that assistant message — which is
1263
+ // REQUIRED when the SAME model retries (some reasoning models fail
1264
+ // the request without their own prior reasoning; see
1265
+ // `reasoning-cache.ts`). After a HANDOFF the next model is a
1266
+ // DIFFERENT model, and replaying a stranger's chain-of-thought
1267
+ // either gets the request rejected by the target API (foreign
1268
+ // `reasoning_content` / tool-call ids) or anchors the new model to
1269
+ // the reasoning that already failed. Strip it on a pair change; a
1270
+ // same-model retry keeps it untouched, which is where the
1271
+ // requirement actually comes from.
1272
+ // NOTE: `thread` (the loop's own working copy, line ~1398), not
1273
+ // `messages` — the assistant turns live here.
1274
+ for (const prior of thread) {
1275
+ if (prior.role === 'assistant' && prior.reasoningContent) {
1276
+ delete prior.reasoningContent;
1277
+ }
1278
+ }
1279
+ deps.onEvent?.(` 🔀 That model returned nothing usable — handing the turn to a different model (handoff ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}).`);
1280
+ traceEvent({
1281
+ kind: 'gate',
1282
+ gate: 'handoff',
1283
+ summary: `the provider returned an empty response twice — the turn handed off to a different model ` +
1284
+ `(handoff ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}) instead of re-asking the same one`,
1285
+ });
1286
+ continue;
1287
+ }
1288
+ }
1289
+ if (emptyResponse)
1290
+ emptyRetriesOnCurrentModel += 1;
997
1291
  // BOUNDED (see THINK_ONLY_ESCALATION). Continuing on reasoning is right
998
1292
  // for a `<think>`-then-answer model and catastrophic without a limit:
999
1293
  // two live eval runs produced 31 reasoning-only steps, one tool call and
@@ -1024,13 +1318,18 @@ async function runToolLoopInner(opts, progress) {
1024
1318
  : `the model produced reasoning-only output ${thinkContinues} times with no tool call and no answer — ` +
1025
1319
  'one bounded escalation to act or answer',
1026
1320
  });
1027
- thread.push({ role: 'user', content: THINK_ONLY_ESCALATION });
1321
+ harness.push(thread, 'think-only', THINK_ONLY_ESCALATION);
1028
1322
  continue;
1029
1323
  }
1030
1324
  // Still spinning after the escalation: END the turn rather than burn the
1031
1325
  // rest of the budget. `bounded` is set so every surface reads this as
1032
- // "stopped before finishing", never as a completed answer.
1326
+ // "stopped before finishing", never as a completed answer — and P3 names
1327
+ // WHICH kind of stop this is, because the two demand opposite responses:
1328
+ // every model that answered returned nothing (a model shortage, which
1329
+ // the caller must report as such) versus one model that kept thinking
1330
+ // (a bounded stop that says nothing about the other models).
1033
1331
  bounded = true;
1332
+ exitTermination = emptyResponse ? 'no-capable-candidate' : 'reasoning-spin';
1034
1333
  deps.onEvent?.(emptyResponse
1035
1334
  ? ' ⚠️ Empty responses kept coming — ending the turn instead of spinning. This is a PROVIDER failure, not the agent thinking.'
1036
1335
  : ' 🧠 Reasoning-only output kept repeating — ending the turn instead of spinning.');
@@ -1071,11 +1370,8 @@ async function runToolLoopInner(opts, progress) {
1071
1370
  // would outrank and suppress the nudge entirely.
1072
1371
  lastContent = '';
1073
1372
  thread.push({ role: 'assistant', content: response.content });
1074
- thread.push({
1075
- role: 'user',
1076
- content: 'You announced an action you were about to take, but no tool was called and nothing was done. ' +
1077
- 'Either carry it out now with the tools available, or reply with the actual answer — do not end the turn on a promise.',
1078
- });
1373
+ harness.push(thread, 'promise', 'You announced an action you were about to take, but no tool was called and nothing was done. ' +
1374
+ 'Either carry it out now with the tools available, or reply with the actual answer — do not end the turn on a promise.');
1079
1375
  continue;
1080
1376
  }
1081
1377
  // ── G13 — AUTHORIZED-WORK nudge (bounded, once) ──────────────────────
@@ -1113,7 +1409,7 @@ async function runToolLoopInner(opts, progress) {
1113
1409
  lastContent = stripTrailingPermissionSeek(response.content.length >= lastContent.length ? response.content : lastContent);
1114
1410
  }
1115
1411
  thread.push({ role: 'assistant', content: response.content });
1116
- thread.push({ role: 'user', content: AUTHORIZED_WORK_NUDGE });
1412
+ harness.push(thread, 'permission', AUTHORIZED_WORK_NUDGE);
1117
1413
  continue;
1118
1414
  }
1119
1415
  // ── Stage 2 — REPETITION nudge (bounded, once) ───────────────────────
@@ -1146,7 +1442,7 @@ async function runToolLoopInner(opts, progress) {
1146
1442
  lastContent = stripTrailingPermissionSeek(closing);
1147
1443
  }
1148
1444
  thread.push({ role: 'assistant', content: response.content });
1149
- thread.push({ role: 'user', content: repeatNudge(closing, runTrace.priorAnswer(closing)) });
1445
+ harness.push(thread, 'repeat', repeatNudge(closing, runTrace.priorAnswer(closing)));
1150
1446
  continue;
1151
1447
  }
1152
1448
  // ── ZERO-ACTION gate (bounded, once) ────────────────────────────────
@@ -1172,7 +1468,7 @@ async function runToolLoopInner(opts, progress) {
1172
1468
  // The non-answer must not become the delivered answer either way.
1173
1469
  lastContent = '';
1174
1470
  thread.push({ role: 'assistant', content: response.content });
1175
- thread.push({ role: 'user', content: zeroActionNudge(currentAsk(opts)) });
1471
+ harness.push(thread, 'action', zeroActionNudge(currentAsk(opts)));
1176
1472
  continue;
1177
1473
  }
1178
1474
  // S1 (both exits): the MOST SUBSTANTIVE content seen wins here too —
@@ -1197,7 +1493,7 @@ async function runToolLoopInner(opts, progress) {
1197
1493
  : 'an authored deliverable was requested and no file was written — one bounded nudge to produce it',
1198
1494
  });
1199
1495
  thread.push({ role: 'assistant', content: response.content });
1200
- thread.push({ role: 'user', content: deliverableNudge(authorization.requestedPath) });
1496
+ harness.push(thread, 'deliverable', deliverableNudge(authorization.requestedPath));
1201
1497
  continue;
1202
1498
  }
1203
1499
  //
@@ -1225,10 +1521,7 @@ async function runToolLoopInner(opts, progress) {
1225
1521
  // preference order. A live turn reached for `node -c` because the nudge
1226
1522
  // left the choice to the model; `verificationNudgeFor` reads the
1227
1523
  // workspace and asks for the real command, so there is nothing to guess.
1228
- thread.push({
1229
- role: 'user',
1230
- content: verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths),
1231
- });
1524
+ harness.push(thread, 'verification', verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths));
1232
1525
  continue;
1233
1526
  }
1234
1527
  // SELF-REVIEW (no-tools exit) — a substantial, already-verified turn is
@@ -1244,7 +1537,7 @@ async function runToolLoopInner(opts, progress) {
1244
1537
  summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
1245
1538
  });
1246
1539
  thread.push({ role: 'assistant', content: response.content });
1247
- thread.push({ role: 'user', content: review });
1540
+ harness.push(thread, 'self-review', review);
1248
1541
  continue;
1249
1542
  }
1250
1543
  return {
@@ -1255,6 +1548,8 @@ async function runToolLoopInner(opts, progress) {
1255
1548
  ...(opts.resume ? { replayedSteps } : {}),
1256
1549
  bounded: false,
1257
1550
  continuations,
1551
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
1552
+ termination: 'delivered',
1258
1553
  };
1259
1554
  }
1260
1555
  // ── Execute tool calls (results fed back, next step) ──────────────
@@ -1286,6 +1581,9 @@ async function runToolLoopInner(opts, progress) {
1286
1581
  // their nudge into this queue and it is flushed after the results, keeping
1287
1582
  // the assistant→tool group intact. (Without this, a pinned/strict run died
1288
1583
  // on the third model call and looked like a provider fault.)
1584
+ // Planning-time directives, flushed as harness directives once the step's
1585
+ // tool-call group is complete. Kept as {gate, content} so the same channel
1586
+ // (and the same per-turn dedupe) covers them.
1289
1587
  const deferredUserNudges = [];
1290
1588
  const plans = toolCalls.map((call) => {
1291
1589
  const tool = getTool(call.name);
@@ -1361,6 +1659,24 @@ async function runToolLoopInner(opts, progress) {
1361
1659
  `Call tool_search with {"action":"load","toolset":"${ownerToolset?.name ?? ''}"} first — its tools become callable immediately.`,
1362
1660
  };
1363
1661
  }
1662
+ if (call.argumentsError &&
1663
+ // 'unparseable' is unambiguous: argument bytes ARRIVED and could not be
1664
+ // used, which is the shape of a truncation. 'empty' is only a defect
1665
+ // when the tool actually REQUIRES an argument — a schema with no required
1666
+ // field is legitimately callable with none, and the Gemini wire omits an
1667
+ // empty `args` object, so a blanket refusal there would be a FALSE refusal
1668
+ // of a real no-argument call (git, plan_todo). Item 6.
1669
+ (call.argumentsError === 'unparseable' || toolRequiresArguments(call.name))) {
1670
+ // F1 — the call was never made: the argument payload did not arrive.
1671
+ // Refusing here is the whole point: executing it as `{}` reported a tool
1672
+ // call that "ran" with no input, hid the real cause (an output-budget
1673
+ // truncation) from the model, and burned 59 round trips in one turn.
1674
+ malformedToolCalls += 1;
1675
+ return {
1676
+ call,
1677
+ refuse: malformedToolCallRefusal({ name: call.name, argumentsError: call.argumentsError }, { finishReason: response.finishReason, attempt: malformedToolCalls }),
1678
+ };
1679
+ }
1364
1680
  if (!isToolEnabled(call.name, context.configManager)) {
1365
1681
  // I1 execution gate: a disabled tool is rejected at runtime even if
1366
1682
  // the model hallucinated its name — the toggle is never cosmetic.
@@ -1551,7 +1867,7 @@ async function runToolLoopInner(opts, progress) {
1551
1867
  try {
1552
1868
  const findings = checkProjectPrerequisites(createNodePrereqFs(opts.context?.cwd || process.cwd()));
1553
1869
  if (findings.length > 0) {
1554
- deferredUserNudges.push(formatPreflightFindings(findings));
1870
+ deferredUserNudges.push({ gate: 'prerequisite', content: formatPreflightFindings(findings) });
1555
1871
  deps.onEvent?.(` 🧱 pre-flight: ${findings.length} missing project prerequisite(s) — handed the exact fix before building.`);
1556
1872
  traceEvent({
1557
1873
  kind: 'gate',
@@ -1635,7 +1951,7 @@ async function runToolLoopInner(opts, progress) {
1635
1951
  ? `a workspace-directing turn was about to mutate without a plan — the first mutation batch (${blocked.join(', ')}) was refused once and the model asked to declare a plan first`
1636
1952
  : 'a workspace-directing turn was about to mutate without a plan — one bounded nudge to declare one first',
1637
1953
  });
1638
- deferredUserNudges.push(planRequiredNudge(currentAsk(opts)));
1954
+ deferredUserNudges.push({ gate: 'plan', content: planRequiredNudge(currentAsk(opts)) });
1639
1955
  }
1640
1956
  const executed = new Array(plans.length).fill('');
1641
1957
  for (let i = 0; i < plans.length;) {
@@ -1710,6 +2026,21 @@ async function runToolLoopInner(opts, progress) {
1710
2026
  const p = a?.path ?? a?.file_path ?? a?.file;
1711
2027
  if (typeof p === 'string' && p) {
1712
2028
  progress.mutatedPaths.push(p);
2029
+ // Bundle 19 — keep what was WRITTEN, so the turn can be judged on its
2030
+ // artifact. Capped: a turn cannot grow the loop's memory without bound.
2031
+ if (progress.authoredArtifacts.length < AUTHORED_ARTIFACT_MAX) {
2032
+ const written = extractWrittenText(call.arguments);
2033
+ progress.authoredArtifacts.push({
2034
+ tool: call.name,
2035
+ path: p,
2036
+ // The TEXT is capped for memory, but the WORD COUNT is taken from the
2037
+ // FULL text: a long document must be measured whole. Measuring the
2038
+ // first 20k characters instead under-counts a long-with-long-words
2039
+ // document and FALSELY flags it as short — see `detectArtifactShortfall`.
2040
+ content: written.slice(0, AUTHORED_ARTIFACT_MAX_CHARS),
2041
+ words: countWords(written),
2042
+ });
2043
+ }
1713
2044
  // Stage 2 — the run's own record of what it CHANGED, so a self-report
1714
2045
  // can answer "what have you been doing" from data rather than from a
1715
2046
  // re-read of the transcript.
@@ -1860,7 +2191,7 @@ async function runToolLoopInner(opts, progress) {
1860
2191
  // complete (every call id has a `tool` result). This is the first point a
1861
2192
  // user/system message may safely follow the batch.
1862
2193
  for (const nudge of deferredUserNudges)
1863
- thread.push({ role: 'user', content: nudge });
2194
+ harness.push(thread, nudge.gate, nudge.content);
1864
2195
  // Track the generalized stall: a step that RAN tools but succeeded at none
1865
2196
  // of them extends the streak; any success clears it. A text-only step (no
1866
2197
  // tools) leaves it as it was — the model is thinking, not failing.
@@ -1878,6 +2209,21 @@ async function runToolLoopInner(opts, progress) {
1878
2209
  // right after the step's results are in the thread, and the next model
1879
2210
  // step sees it. Bounded once per turn and keyed on the action so the same
1880
2211
  // repeat cannot trigger it twice.
2212
+ // ── A payload that does not fit in one output ────────────────────────────
2213
+ // The refusal above already says so per call; this adds ONE mid-turn nudge
2214
+ // naming the delivery strategy, because a model stuck re-emitting a too-large
2215
+ // call needs the alternative, not a third identical error.
2216
+ if (malformedToolCalls >= 3 && malformedCallNudges < 1) {
2217
+ malformedCallNudges += 1;
2218
+ stepLimit += 1;
2219
+ deps.onEvent?.(` 📦 ${malformedToolCalls} calls arrived with no usable arguments — telling the model to build the artifact in sections.`);
2220
+ traceEvent({
2221
+ kind: 'gate',
2222
+ gate: 'malformed-call',
2223
+ summary: `${malformedToolCalls} tool calls this turn carried no usable arguments (payload larger than one model output) — one bounded nudge to deliver in sections`,
2224
+ });
2225
+ harness.push(thread, 'malformed-call', malformedCallNudge(malformedToolCalls));
2226
+ }
1881
2227
  if (diagnosisNudges < 1) {
1882
2228
  const repeated = runTrace.repeatedFailure();
1883
2229
  if (repeated && repeated.times >= 2) {
@@ -1890,7 +2236,7 @@ async function runToolLoopInner(opts, progress) {
1890
2236
  summary: `the same action failed ${repeated.times} times (${repeated.tool}: ${repeated.action.slice(0, 120)}) — ` +
1891
2237
  'one bounded nudge to diagnose the root cause and change the approach',
1892
2238
  });
1893
- thread.push({ role: 'user', content: repeatedFailureNudge(repeated) });
2239
+ harness.push(thread, 'diagnosis', repeatedFailureNudge(repeated));
1894
2240
  }
1895
2241
  else if (failedToolSteps >= NO_PROGRESS_STALL_STEPS) {
1896
2242
  // Stage 2 — no SINGLE action repeated, but the last N steps each ran
@@ -1904,7 +2250,7 @@ async function runToolLoopInner(opts, progress) {
1904
2250
  summary: `${failedToolSteps} consecutive steps ran tools and none succeeded — ` +
1905
2251
  'one bounded nudge to diagnose the shared cause and change the approach',
1906
2252
  });
1907
- thread.push({ role: 'user', content: noProgressNudge(runTrace.recentFailures(3), failedToolSteps) });
2253
+ harness.push(thread, 'diagnosis', noProgressNudge(runTrace.recentFailures(3), failedToolSteps));
1908
2254
  }
1909
2255
  }
1910
2256
  // Tiered exposure: after EVERY executed tool call, union any newly
@@ -1935,7 +2281,7 @@ async function runToolLoopInner(opts, progress) {
1935
2281
  // P4 — cancellation during/after tool execution: do NOT request another
1936
2282
  // model step on a cancelled turn (the user already walked away).
1937
2283
  if (opts.signal?.aborted) {
1938
- return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true };
2284
+ return { content: '', followups, toolCalls: toolCallsRun, steps, ...(opts.resume ? { replayedSteps } : {}), bounded: false, cancelled: true, termination: 'cancelled' };
1939
2285
  }
1940
2286
  // ── endsAgentStep — a successful dispenser's RESULT is the answer ────────
1941
2287
  // `build`/`resume`/`repair`/`document`/`website`/`analyze`/`test`/`publish`
@@ -1965,6 +2311,8 @@ async function runToolLoopInner(opts, progress) {
1965
2311
  ...(opts.resume ? { replayedSteps } : {}),
1966
2312
  bounded: false,
1967
2313
  continuations,
2314
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
2315
+ termination: 'delivered',
1968
2316
  };
1969
2317
  }
1970
2318
  // End-of-response semantics (the contract: "END EVERY RESPONSE by
@@ -2011,10 +2359,7 @@ async function runToolLoopInner(opts, progress) {
2011
2359
  // preference order. A live turn reached for `node -c` because the nudge
2012
2360
  // left the choice to the model; `verificationNudgeFor` reads the
2013
2361
  // workspace and asks for the real command, so there is nothing to guess.
2014
- thread.push({
2015
- role: 'user',
2016
- content: verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths),
2017
- });
2362
+ harness.push(thread, 'verification', verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths));
2018
2363
  continue;
2019
2364
  }
2020
2365
  // G13b — DELIVERABLE GATE (concluding path). AFTER the verification gate
@@ -2032,7 +2377,7 @@ async function runToolLoopInner(opts, progress) {
2032
2377
  : 'an authored deliverable was requested and no file was written — one bounded nudge to produce it',
2033
2378
  });
2034
2379
  thread.push({ role: 'assistant', content: response.content });
2035
- thread.push({ role: 'user', content: deliverableNudge(authorization.requestedPath) });
2380
+ harness.push(thread, 'deliverable', deliverableNudge(authorization.requestedPath));
2036
2381
  continue;
2037
2382
  }
2038
2383
  // ZERO-ACTION gate (concluding exit) — the same bounded pass as the
@@ -2050,7 +2395,7 @@ async function runToolLoopInner(opts, progress) {
2050
2395
  });
2051
2396
  lastContent = '';
2052
2397
  thread.push({ role: 'assistant', content: response.content });
2053
- thread.push({ role: 'user', content: zeroActionNudge(currentAsk(opts)) });
2398
+ harness.push(thread, 'action', zeroActionNudge(currentAsk(opts)));
2054
2399
  continue;
2055
2400
  }
2056
2401
  // SELF-REVIEW (concluding exit) — same bounded pass, placed AFTER the
@@ -2066,7 +2411,7 @@ async function runToolLoopInner(opts, progress) {
2066
2411
  summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
2067
2412
  });
2068
2413
  thread.push({ role: 'assistant', content: response.content });
2069
- thread.push({ role: 'user', content: review });
2414
+ harness.push(thread, 'self-review', review);
2070
2415
  continue;
2071
2416
  }
2072
2417
  const content = response.content.length >= lastContent.length ? response.content : lastContent;
@@ -2078,17 +2423,52 @@ async function runToolLoopInner(opts, progress) {
2078
2423
  ...(opts.resume ? { replayedSteps } : {}),
2079
2424
  bounded: false,
2080
2425
  continuations,
2426
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
2427
+ termination: 'delivered',
2081
2428
  };
2082
2429
  }
2083
2430
  }
2084
2431
  // Reaching here means the continuation budget is spent (the in-loop check
2085
2432
  // extends the bound otherwise) — the turn is honestly bounded.
2086
2433
  bounded = true;
2087
- deps.onEvent?.(` ⚠️ Tool loop reached its ${stepLimit}-step budget — returning the last response.`);
2434
+ // P3 — the exit SAYS which ending this was. The live trace reported "the step
2435
+ // budget (17) was reached" after FIVE model calls and zero tool calls: the
2436
+ // empty-response path had ended the turn, and this shared tail claimed the road
2437
+ // had run out when it had not. A reader cannot tell a model shortage from a
2438
+ // budget from that line, which is the whole reason `termination` exists.
2439
+ if (exitTermination === 'no-capable-candidate') {
2440
+ deps.onEvent?.(' ⚠️ Every model that answered returned nothing usable — ending the turn and reporting exactly what was tried.');
2441
+ }
2442
+ else if (exitTermination === 'reasoning-spin') {
2443
+ deps.onEvent?.(' 🧠 Reasoning-only output kept repeating — ending the turn instead of spinning.');
2444
+ }
2445
+ else {
2446
+ deps.onEvent?.(` ⚠️ Tool loop reached its ${stepLimit}-step budget — returning the last response.`);
2447
+ }
2448
+ // P3 — BUG SIGNAL. Nothing was delivered and the caller could still have
2449
+ // handed the work to another model, so this ending is a GAP IN THE ROAD, not a
2450
+ // shortage of models. It is said out loud (invariant 2: a failure is never
2451
+ // silent) and named on the result, so the caller's report does not have to
2452
+ // guess which of the two it was serving the user.
2453
+ // `exitTermination === null` is what makes this a BUDGET claim: when the empty
2454
+ // path already chose an ending it said WHY (a model shortage, a reasoning
2455
+ // spin), and re-labelling that as a budget gap would be the exact dishonesty
2456
+ // this work removes — the reason must be the one that actually applied.
2457
+ const budgetGapWithCandidates = exitTermination === null &&
2458
+ lastContent.trim() === '' &&
2459
+ !!deps.requestModelSwitch &&
2460
+ modelHandoffs < MAX_MODEL_HANDOFFS_PER_TURN;
2461
+ if (budgetGapWithCandidates) {
2462
+ logger.warn(` ⚠️ the turn ran out of steps with nothing delivered while a model handoff was still available ` +
2463
+ `(handoffs used ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}) — this is a BUDGET gap, not a model shortage`);
2464
+ }
2088
2465
  traceEvent({
2089
2466
  kind: 'decision',
2090
2467
  gate: 'budget',
2091
- summary: `the step budget (${stepLimit}) was reached — the turn ended on its last response instead of finishing`,
2468
+ summary: budgetGapWithCandidates
2469
+ ? `the step budget (${stepLimit}) was reached with nothing delivered while model handoffs were still available ` +
2470
+ `(handoffs used ${modelHandoffs}/${MAX_MODEL_HANDOFFS_PER_TURN}) — a budget gap, not a model shortage`
2471
+ : `the step budget (${stepLimit}) was reached — the turn ended on its last response instead of finishing`,
2092
2472
  });
2093
2473
  return {
2094
2474
  content: lastContent || 'I reached my step limit for this request.',
@@ -2098,6 +2478,11 @@ async function runToolLoopInner(opts, progress) {
2098
2478
  ...(opts.resume ? { replayedSteps } : {}),
2099
2479
  bounded: true,
2100
2480
  continuations,
2481
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
2482
+ // An exit the empty/think path chose says exactly why; otherwise the road
2483
+ // simply ran out, and `budget-exhausted` admits that rather than dressing it
2484
+ // up as a model shortage.
2485
+ termination: exitTermination ?? 'budget-exhausted',
2101
2486
  };
2102
2487
  }
2103
2488
  // ─── Honest-answer guard ────────────────────────────────────────────────────
@@ -2155,12 +2540,129 @@ export function detectUnverifiedDeliveryClaim(content, toolsRun) {
2155
2540
  }
2156
2541
  return false;
2157
2542
  }
2543
+ /**
2544
+ * Tools whose SUCCESS means a file could have been written, even though the loop
2545
+ * cannot see it: an editor (tracked in `mutatedPaths`), a shell whose command
2546
+ * redirected a file, an executed snippet. Used only to keep the file-claim guard
2547
+ * conservative — if any of these SUCCEEDED, the turn's own record does not
2548
+ * contradict a "I wrote the file" claim, so the guard stays silent.
2549
+ */
2550
+ const FILE_WRITE_CAPABLE_TOOLS = new Set([
2551
+ 'write_file',
2552
+ 'edit_file',
2553
+ 'apply_patch',
2554
+ 'multi_edit',
2555
+ 'patch_file',
2556
+ 'run_terminal',
2557
+ 'run_cli',
2558
+ 'code_execution',
2559
+ ]);
2560
+ /**
2561
+ * Past-tense sentences that ASSERT a FILE was produced. Deliberately about
2562
+ * writing/creating/saving a file — not about reading one, and not about sending
2563
+ * one (`detectUnverifiedDeliveryClaim` covers sends).
2564
+ */
2565
+ const FILE_WRITE_CLAIM_RE = [
2566
+ /\b(?:has|have)\s+been\s+(?:written|saved|created|produced|generated)\b/i,
2567
+ /\bi(?:'ve|\s+have)\s+(?:just\s+|now\s+|successfully\s+)?(?:written|saved|created|produced|generated)\b/i,
2568
+ /\b(?:the\s+)?(?:file|document|guide|report|chapter|artifact|deliverable|write-?up|script|manifest)\b[^.]{0,50}\b(?:now\s+)?contains\b/i,
2569
+ /\b(?:written|saved|created|produced|generated)\s+(?:it\s+)?(?:to|into)\s+(?:disk\b|\S+\.\w{1,6}\b)/i,
2570
+ /\b(?:the\s+)?(?:file|document|report|guide|artifact|deliverable)\b[^.]{0,40}\b(?:is|are)\s+(?:now\s+)?(?:on\s+disk|written|saved|created|ready)\b/i,
2571
+ ];
2572
+ /**
2573
+ * True when the answer CLAIMS a file was produced and the turn's own record says
2574
+ * none was. This is the sibling of `detectUnverifiedDeliveryClaim` (which covers
2575
+ * SENDS), and it closes a gap found live: a turn wrote nothing, then replied
2576
+ * "The guide has been written and saved as GUIDE.md … contains a full ~5,000-word
2577
+ * technical guide", and NO flag could see it.
2578
+ *
2579
+ * Why it is not redundant with `undeliveredArtifact`: that flag is a function of
2580
+ * the ASK (it is gated on the request classifying as an authored deliverable), so
2581
+ * an ask the classifier does not recognise — or a plain code ask that happens to
2582
+ * be answered with a fabricated "I saved it" — produced no disclosure at all. This
2583
+ * one is a function of what the turn DID: a completed file claim, contradicted by
2584
+ * the absence of any write. The two are made mutually exclusive at the call site so
2585
+ * one turn is never reported under two names.
2586
+ *
2587
+ * Conservative by construction, so an honest turn is never flagged: it fires only
2588
+ * when NOTHING was mutated AND no write-capable tool succeeded; a heredoc through
2589
+ * `run_terminal`, an `edit_file`, or any executed snippet therefore exempts the
2590
+ * turn, because the run's record cannot contradict the claim.
2591
+ */
2592
+ export function detectUnverifiedFileClaim(content, evidence) {
2593
+ const text = (content || '').trim();
2594
+ if (!text)
2595
+ return false;
2596
+ if (evidence.mutatedPaths.length > 0)
2597
+ return false;
2598
+ if (evidence.successfulToolCalls.some((n) => FILE_WRITE_CAPABLE_TOOLS.has(n)))
2599
+ return false;
2600
+ const sentences = text.split(/(?<=[.!?\u3002\uff01\uff1f])\s+|\n+/);
2601
+ for (const sentence of sentences) {
2602
+ const s = sentence.trim();
2603
+ if (!s || NON_CLAIM_CONTEXT_RE.test(s))
2604
+ continue;
2605
+ if (FILE_WRITE_CLAIM_RE.some((re) => re.test(s)))
2606
+ return true;
2607
+ }
2608
+ return false;
2609
+ }
2158
2610
  /**
2159
2611
  * Tool-shaped actions a first-person imminent promise can drop. Matched by
2160
2612
  * STEM (`\w*` suffix) so "scaffold" covers "scaffolding", "write" covers
2161
2613
  * "writing", etc. — the promise is usually phrased as a gerund ("start by
2162
2614
  * scaffolding").
2163
2615
  */
2616
+ /**
2617
+ * Bundle 23 (item 13) — how far short of a stated magnitude counts as a miss.
2618
+ * Half, deliberately: "about 5000 words" delivered as 4,200 is a tighter draft,
2619
+ * not a shortfall, and an over-eager version of this flag would be ignored like
2620
+ * every other noisy guard. Under half is materially not what was asked for.
2621
+ */
2622
+ export const ARTIFACT_SHORTFALL_RATIO = 0.5;
2623
+ /**
2624
+ * How much of a delivered artifact travels to the corpus (item 13). Bounded on
2625
+ * purpose: a candidate row is a SAMPLE to fit a signal to, not a copy of the
2626
+ * user's document, and an unbounded row would turn a collection file into a
2627
+ * second copy of everything the agent ever wrote.
2628
+ */
2629
+ export const AUTHORED_DELIVERABLE_EXCERPT_MAX = 2000;
2630
+ /**
2631
+ * Compare a written artifact against an EXPLICIT magnitude the ask named.
2632
+ *
2633
+ * This is item 13's honest half: the delivered artifact is measured AGAINST THE
2634
+ * REQUEST, deterministically, instead of judging quality from a model's
2635
+ * vocabulary. It fires only when the ask states a number ("about 5000 words",
2636
+ * "a 100 page story") — a plain "write a long document" names no magnitude, so
2637
+ * there is nothing to compare against and this must not invent one.
2638
+ *
2639
+ * Returns null unless the file is under `ARTIFACT_SHORTFALL_RATIO` of the target,
2640
+ * so an honest document is never flagged. An empty file returns null too — a
2641
+ * missing artifact is the omission/undelivered gates' job, not a shortfall's.
2642
+ */
2643
+ export function detectArtifactShortfall(ask, artifact) {
2644
+ const target = parseLongFormTarget(ask || '');
2645
+ // Only an EXPLICIT magnitude counts: `source` is the quoted number the parser
2646
+ // matched, while the default-book branch (a book with no number in the ask) has
2647
+ // no quotes — inventing a target there would measure the document against a
2648
+ // number the user never said.
2649
+ if (!target || !target.source.startsWith('"'))
2650
+ return null;
2651
+ // `words` is the count of the FULL artifact when the caller supplies it; counting
2652
+ // `content` (which may be a capped prefix) instead would measure the storage
2653
+ // limit, not the document, and could flag a long deliverable as short.
2654
+ const deliveredWords = artifact.words ?? countWords(artifact.content);
2655
+ if (deliveredWords === 0)
2656
+ return null;
2657
+ if (deliveredWords >= target.wordsTarget * ARTIFACT_SHORTFALL_RATIO)
2658
+ return null;
2659
+ return {
2660
+ path: artifact.path,
2661
+ deliveredWords,
2662
+ targetWords: target.wordsTarget,
2663
+ source: target.source,
2664
+ };
2665
+ }
2164
2666
  const INTENT_ACTION_STEM = '(?:creat|writ|build|scaffold|implement|generat|add|edit|updat|modif|refactor|run|execut|install|configur|read|inspect|send|post|upload|deploy|publish|fetch|search|apply|fix|delet|remov|mov|renam|copi|compil|set\\s+up)';
2165
2667
  /**
2166
2668
  * A first-person IMMINENT promise to act. Immediacy is required, because that
@@ -2225,6 +2727,82 @@ export function detectUnfulfilledIntentPromise(content) {
2225
2727
  }
2226
2728
  return false;
2227
2729
  }
2730
+ // ─── Bundle 19 — an artifact that declares ITSELF incomplete ────────────────
2731
+ /**
2732
+ * Statements an ARTIFACT makes about being incomplete — read from the file the turn
2733
+ * WROTE, never from the model's prose.
2734
+ *
2735
+ * WHY THIS EXISTS (measured 2026-10-07). Asked for a ~2500-word design document, a
2736
+ * real turn wrote 25 words to `DESIGN.md` and reported success. The file contained
2737
+ * `## 1. Introduction ... (full content omitted for brevity) ...` — it announced the
2738
+ * content and then omitted it — and its own verification ("read the file back and
2739
+ * confirm all sections are present") is true of a list of headings. No existing flag
2740
+ * could see it: `unverifiedEdit` needs an unobserved mutation (the file WAS read
2741
+ * back), `undeliveredArtifact` needs nothing written (a file exists), and there is
2742
+ * content, so nothing is empty.
2743
+ *
2744
+ * WHY THIS IS NOT A "PHRASE LIST" IN THE SENSE THIS PROGRAMME REMOVES. Those decide
2745
+ * whether work is GOOD, from a model's vocabulary. This reads a factual statement the
2746
+ * artifact makes about itself — the same distinction as `unfulfilledPromise` (an
2747
+ * announced action) and `detectUnverifiedDeliveryClaim` (a claim contradicted by the
2748
+ * run). A document saying "content omitted" is not a quality judgement; it is the
2749
+ * artifact reporting its own incompleteness.
2750
+ *
2751
+ * DELIBERATELY NARROW. Each pattern requires an OMISSION word bound to content or a
2752
+ * section. The bare word `placeholder` is NOT matched (HTML `placeholder=` attributes
2753
+ * would fire on ordinary code), and neither is a bare `TODO` — a TODO is a plan for
2754
+ * later work, not a claim that this file is finished.
2755
+ */
2756
+ const ARTIFACT_OMISSION_RE = [
2757
+ /\(\s*(?:the\s+)?(?:full\s+)?content\s+omitted/i,
2758
+ /\bcontent\s+(?:is\s+|was\s+)?omitted\s+for\s+(?:brevity|space|length|conciseness|readability)\b/i,
2759
+ /\bomitted\s+for\s+(?:brevity|space|length|conciseness|readability)\b/i,
2760
+ /\b(?:the\s+)?(?:rest|remainder)\s+of\s+(?:this\s+)?(?:document|report|article|file)\s+(?:is|are|was|were)?\s*(?:omitted|not\s+included|intentionally\s+left\s+out)/i,
2761
+ // A section RANGE may sit between the noun and the adverb/verb: the measured
2762
+ // "Sections 2-9 intentionally omitted." failed the adjacency form that only
2763
+ // allowed whitespace there. The range is optional, so "Content omitted." and
2764
+ // "Details omitted." still match; a bare prose "omits" does not.
2765
+ /\b(?:sections?|content|details?)\s+(?:\d+(?:\s*[-–—]\s*\d+)?\s+)?(?:intentionally\s+|deliberately\s+)?omitted\b/i,
2766
+ /\bplaceholder\s+for\s+(?:the\s+)?(?:full|actual|real|complete)\s+(?:content|document|section|report|article)\b/i,
2767
+ ];
2768
+ /**
2769
+ * The statement an artifact makes about its own incompleteness, or `null`.
2770
+ * Returns the MATCHED TEXT so the report can quote what the file said about itself.
2771
+ */
2772
+ export function detectSelfDeclaredOmission(text) {
2773
+ const body = text || '';
2774
+ if (!body)
2775
+ return null;
2776
+ for (const re of ARTIFACT_OMISSION_RE) {
2777
+ const m = body.match(re);
2778
+ if (m?.[0])
2779
+ return m[0].trim();
2780
+ }
2781
+ return null;
2782
+ }
2783
+ /** Cap on the content kept per artifact, and on how many are kept, so a long turn cannot grow the loop's memory. */
2784
+ const AUTHORED_ARTIFACT_MAX_CHARS = 20_000;
2785
+ const AUTHORED_ARTIFACT_MAX = 40;
2786
+ /**
2787
+ * The text a mutation call WROTE, from whichever field carries it. `''` when the call
2788
+ * carries no literal body (an append by path, a patch with no text).
2789
+ */
2790
+ /**
2791
+ * The text a write actually carried — UNCAPPED, so a caller can measure the WHOLE
2792
+ * artifact (word count) and cap only what it stores. Capping here would make the
2793
+ * measurement a property of the storage limit rather than of the file.
2794
+ */
2795
+ function extractWrittenText(args) {
2796
+ if (!args || typeof args !== 'object')
2797
+ return '';
2798
+ const a = args;
2799
+ for (const key of ['content', 'new_string', 'newText', 'text', 'body', 'patch']) {
2800
+ const v = a[key];
2801
+ if (typeof v === 'string' && v)
2802
+ return v;
2803
+ }
2804
+ return '';
2805
+ }
2228
2806
  // ─── Build honesty (A3 Part 2) ──────────────────────────────────────────────
2229
2807
  /**
2230
2808
  * Past-tense sentences that ASSERT a BUILD, or its artifact, came out good.
@@ -2366,6 +2944,7 @@ export async function runToolLoop(opts) {
2366
2944
  successfulToolCalls: [],
2367
2945
  deliveryConfirmed: false,
2368
2946
  mutatedPaths: [],
2947
+ authoredArtifacts: [],
2369
2948
  verificationEvidence: [],
2370
2949
  executedActions: [],
2371
2950
  };
@@ -2406,12 +2985,66 @@ export async function runToolLoop(opts) {
2406
2985
  // runs whether or not the nudge was enabled, so the flag is never a
2407
2986
  // function of configuration.
2408
2987
  const activity = assessEditActivity(result.successfulToolCalls, progress.verificationEvidence, progress.mutatedPaths);
2409
- if (activity.needsVerification) {
2988
+ // Bundle 21/25 — the authored-document demotion, applied WHERE THE FLAGS ARE
2989
+ // SET so every surface agrees without a per-caller conditional: the trace
2990
+ // outcome, the console warning, the working-state ledger and the turn report all
2991
+ // read these two flags. For an authored deliverable that was written and then
2992
+ // READ BACK, the read-back IS the meaningful check — a code-style "no observing
2993
+ // run" verdict would contradict the `delivered-and-read-back` verdict the report
2994
+ // deliberately produces. Measured live: a turn that wrote and read back GUIDE.md
2995
+ // recorded `unverifiedEdit: true` on the trace while its report said
2996
+ // `delivered-and-read-back`.
2997
+ const authoredReadBack = isAuthoredReadBack(lastUserText(opts.messages), result.successfulToolCalls);
2998
+ if (activity.needsVerification && !authoredReadBack) {
2410
2999
  result.unverifiedEdit = true;
2411
3000
  }
2412
- if (detectUnverifiedEditClaim(result.content, activity.mutations, activity.verifications)) {
3001
+ if (detectUnverifiedEditClaim(result.content, activity.mutations, activity.verifications) &&
3002
+ !authoredReadBack) {
2413
3003
  result.unverifiedEditClaim = true;
2414
3004
  }
3005
+ // Bundle 19 — ARTIFACT honesty. A file the turn wrote says its own content was
3006
+ // omitted, so the turn is NOT finished work however complete its prose reads.
3007
+ // Judged on the artifact, never on the model's vocabulary, and recorded with the
3008
+ // artifact's own words so a reader can see exactly what it admitted.
3009
+ for (const artifact of progress.authoredArtifacts) {
3010
+ const statement = detectSelfDeclaredOmission(artifact.content);
3011
+ if (statement) {
3012
+ result.artifactIncomplete = { path: artifact.path, statement };
3013
+ break;
3014
+ }
3015
+ }
3016
+ // Bundle 23 (item 13) — ARTIFACT-VS-ASK honesty. When the ask named an explicit
3017
+ // magnitude and the turn wrote a SINGLE artifact far short of it, the delivered
3018
+ // file is not what was asked for. Skipped when the artifact already admits its
3019
+ // own omission (that is the stronger, self-incriminating signal), and bounded to
3020
+ // one artifact because an append/sectioned delivery cannot be measured here
3021
+ // without risking a false "short".
3022
+ if (!result.artifactIncomplete && progress.authoredArtifacts.length === 1) {
3023
+ const shortfall = detectArtifactShortfall(lastUserText(opts.messages), progress.authoredArtifacts[0]);
3024
+ if (shortfall)
3025
+ result.artifactShortfall = shortfall;
3026
+ }
3027
+ // Item 13 (round 3) — COLLECT the case a quality signal would have to be fit
3028
+ // to, instead of inventing a rule for it. A single authored artifact that
3029
+ // tripped NEITHER check above is the "fluent, on-length" shape: the one no
3030
+ // live run has produced as a miss, and the one no deterministic rule can judge
3031
+ // (judging it from vocabulary is the defect this programme exists to remove).
3032
+ // So the FACTS travel — path, length, a bounded excerpt — and a caller records
3033
+ // them with a later user verdict as the label. No judgement is made here.
3034
+ if (!result.artifactIncomplete && !result.artifactShortfall && progress.authoredArtifacts.length === 1) {
3035
+ const artifact = progress.authoredArtifacts[0];
3036
+ const ask = lastUserText(opts.messages) || '';
3037
+ const target = parseLongFormTarget(ask);
3038
+ result.authoredDeliverable = {
3039
+ path: artifact.path,
3040
+ deliveredWords: artifact.words,
3041
+ // Only an EXPLICIT magnitude is carried — `source` is the quoted number the
3042
+ // parser matched, while the default-book branch invents one. A corpus that
3043
+ // recorded an invented target would fit a signal to a number nobody asked for.
3044
+ ...(target && target.source.startsWith('"') ? { targetWords: target.wordsTarget } : {}),
3045
+ excerpt: artifact.content.slice(0, AUTHORED_DELIVERABLE_EXCERPT_MAX),
3046
+ };
3047
+ }
2415
3048
  // G13b — DELIVERABLE honesty, the same way and for the same reason: the
2416
3049
  // flag is a function of what the turn DID, never of configuration. A turn
2417
3050
  // that was asked for a file and wrote none must not be readable as
@@ -2424,6 +3057,18 @@ export async function runToolLoop(opts) {
2424
3057
  wantsAuthoredArtifact(lastUserText(opts.messages))) {
2425
3058
  result.undeliveredArtifact = true;
2426
3059
  }
3060
+ // Bundle 20 — FILE-CLAIM honesty, the complement of the flag above. That one
3061
+ // is a function of the ASK; this is a function of the ANSWER: a reply that says
3062
+ // a file was produced while the turn mutated nothing. Skipped when
3063
+ // `undeliveredArtifact` already fired, so one turn is never reported under two
3064
+ // names.
3065
+ if (!result.undeliveredArtifact &&
3066
+ detectUnverifiedFileClaim(result.content, {
3067
+ mutatedPaths: progress.mutatedPaths,
3068
+ successfulToolCalls: result.successfulToolCalls,
3069
+ })) {
3070
+ result.unverifiedFileClaim = true;
3071
+ }
2427
3072
  // ZERO-ACTION honesty — a request that DIRECTED work on the workspace was
2428
3073
  // answered with nothing at all: no tool succeeded, nothing was written. The
2429
3074
  // flag is a function of what the turn DID, never of configuration, so a
@@ -2527,6 +3172,22 @@ const NON_PRODUCTIVE_TOOLS = new Set(['suggest_followups']);
2527
3172
  function hasProductiveAction(progress) {
2528
3173
  return progress.successfulToolCalls.some((name) => !NON_PRODUCTIVE_TOOLS.has(name));
2529
3174
  }
3175
+ /**
3176
+ * How many PRODUCTIVE actions have SUCCEEDED this turn — the cardinality of the
3177
+ * very predicate `hasProductiveAction` answers, so the two cannot drift. D1 uses
3178
+ * it to tell a later answer written after real work (an update, which supersedes)
3179
+ * from a closing wrapper written after none (presentation, which must not). It
3180
+ * reads the loop's own success record — `successfulToolCalls`, appended only for
3181
+ * calls that actually ran — so a refused or failed call is never mistaken for
3182
+ * work, and `suggest_followups` (which merely concludes) is never counted.
3183
+ */
3184
+ function countProductiveWork(progress) {
3185
+ let n = 0;
3186
+ for (const name of progress.successfulToolCalls)
3187
+ if (!NON_PRODUCTIVE_TOOLS.has(name))
3188
+ n += 1;
3189
+ return n;
3190
+ }
2530
3191
  /**
2531
3192
  * Does the ZERO-ACTION gate apply to this turn?
2532
3193
  *
@@ -2638,6 +3299,107 @@ function previewToolResult(result, max = 200) {
2638
3299
  * Returns null when the result is an ordinary tool/runtime error, so the caller
2639
3300
  * keeps its own wording rather than inventing a cause.
2640
3301
  */
3302
+ /** How many steps of an existing plan are re-shown to the model (bounded). */
3303
+ export const PLAN_CONTEXT_MAX_STEPS = 12;
3304
+ /**
3305
+ * The block that hands the model the plan its own conversation already tracks.
3306
+ *
3307
+ * Written to make the correct next move obvious rather than merely possible: the
3308
+ * step ids are given verbatim (so `update` can address them), the statuses are the
3309
+ * stored truth, and the one thing a model tends to do instead — declare the plan
3310
+ * again — is named as wrong, because a re-declaration under the same goal now
3311
+ * carries progress but still replaces the step LIST the user is watching.
3312
+ */
3313
+ export function planContextBlock(plan) {
3314
+ const shown = plan.steps.slice(0, PLAN_CONTEXT_MAX_STEPS);
3315
+ const hidden = plan.steps.length - shown.length;
3316
+ const lines = shown.map((s) => ` - ${s.id} [${s.status}] ${s.description}${s.note ? ` — ${s.note}` : ''}`);
3317
+ const open = plan.steps.filter((s) => s.status !== 'done').length;
3318
+ return [
3319
+ `📋 The plan this conversation is already tracking (${plan.steps.length - open}/${plan.steps.length} done, revision ${plan.revision}):`,
3320
+ ` goal: ${plan.goal}`,
3321
+ ...lines,
3322
+ ...(hidden > 0 ? [` … and ${hidden} more step(s).`] : []),
3323
+ `Advance THIS plan: call \`plan_todo\` with action "update", the step id above and its new status. ` +
3324
+ 'Do NOT declare it again with action "create" — the checklist the user is watching is this one, and ' +
3325
+ 're-declaring replaces its steps. Work the next open step.',
3326
+ ].join('\n');
3327
+ }
3328
+ /**
3329
+ * The refusal a tool call gets when its ARGUMENTS never arrived.
3330
+ *
3331
+ * Measured defect (dashboard session `4e2b0e51…-1791349543186`, 2026-10-07): the
3332
+ * goal "deliver complete document" produced **59 `write_file` calls with empty
3333
+ * arguments**, plus 14 `run_terminal` and 2 `code_execution` — 73 of the turn's 81
3334
+ * calls. Each one was executed as `{}`, so the model's feedback was
3335
+ * `write_file: path is required`, which names the wrong problem: the model HAD
3336
+ * asked for a write, with a payload (a whole design document) that could not fit
3337
+ * in one output. It diagnosed "my calls were emitted empty", retried 59 times,
3338
+ * and the turn ended `bounded: true` with no document. Two things were missing
3339
+ * and both are here: the REAL cause, and a way to succeed at all.
3340
+ *
3341
+ * The refusal is `Error:`-prefixed because the call genuinely did not run — the
3342
+ * loop's honest accounting (successful calls, mutations, verification) depends
3343
+ * on that prefix, so a call that never reached a tool is never counted as one.
3344
+ */
3345
+ export function malformedToolCallRefusal(call, opts = {}) {
3346
+ const attempt = opts.attempt ?? 1;
3347
+ const cause = call.argumentsError === 'empty'
3348
+ ? 'the call arrived with NO arguments at all'
3349
+ : "the call's arguments arrived incomplete and could not be parsed";
3350
+ const why = opts.finishReason === 'length'
3351
+ ? 'the provider stopped this step at its output-token limit (finish_reason: "length"), so the payload was cut off before the arguments were complete'
3352
+ : 'this is what a payload too large to emit in ONE step looks like on the wire — nothing was lost on this side';
3353
+ const escape = 'Send the payload in PIECES instead of one call: `write_file` the FIRST section, then call `write_file` again ' +
3354
+ 'with mode:"append" for each further section (or write a smaller file and grow it with `edit_file`). ' +
3355
+ 'Do NOT re-send this call unchanged — it cannot succeed, and every attempt is a full round trip.';
3356
+ const escalation = attempt >= 3
3357
+ ? ` (This is time ${attempt} this turn that a call arrived with no usable arguments — change the SHAPE of the call, not its wording.)`
3358
+ : '';
3359
+ return `Error: ${call.name}: NOT run — ${cause}, so the tool never saw your input. Reason: ${why}. ${escape}${escalation}`;
3360
+ }
3361
+ /**
3362
+ * Whether a tool's schema declares at least one REQUIRED argument (item 6).
3363
+ *
3364
+ * This is what lets the empty-arguments refusal reach the JSON-fallback and
3365
+ * Gemini wires without refusing a legitimate no-argument call: a `git` or
3366
+ * `plan_todo` call with `{}` is a real call, but a `write_file` with `{}` is a
3367
+ * truncated one. Read from the tool's own JSON schema (derived from its zod
3368
+ * schema, never hand-kept), so a tool added later is judged by what it declares.
3369
+ * When the schema cannot be read the answer is conservative (`true`): a
3370
+ * malformed payload is the more likely cause, and the refusal is guidance, not a
3371
+ * wall — the model can still retry with arguments.
3372
+ */
3373
+ function toolRequiresArguments(name) {
3374
+ try {
3375
+ const [schema] = toolJsonSchemas([name]);
3376
+ // An unregistered name is not a call to honour — refuse, as before.
3377
+ if (!schema)
3378
+ return true;
3379
+ const required = schema.parameters.required;
3380
+ // No `required` key means the tool declares no required argument, so an empty
3381
+ // payload IS a legitimate call (list_dir). An empty array means the same.
3382
+ if (Array.isArray(required))
3383
+ return required.length > 0;
3384
+ return false;
3385
+ }
3386
+ catch {
3387
+ // A schema that cannot be read is treated conservatively: a malformed payload
3388
+ // is the more likely cause, and the refusal is guidance the model can act on.
3389
+ return true;
3390
+ }
3391
+ }
3392
+ /**
3393
+ * The one bounded mid-turn nudge for the same defect, pushed after the results of
3394
+ * the step that produced it — so a model that keeps re-sending a payload too large
3395
+ * for one output is told the strategy, not merely that it failed again.
3396
+ */
3397
+ export function malformedCallNudge(attempts) {
3398
+ return (`Your last ${attempts} tool call(s) this turn arrived WITHOUT usable arguments — the payload did not fit in one ` +
3399
+ 'model output and was cut off. Nothing is wrong with your plan; the DELIVERY shape is. Build the artifact in ' +
3400
+ 'sections: `write_file` the first part, then `write_file` with mode:"append" for each next part (keep each call ' +
3401
+ 'under ~120 lines). Do not repeat the single large call.');
3402
+ }
2641
3403
  export function classifyToolRefusal(result) {
2642
3404
  const text = result || '';
2643
3405
  if (/needs explicit confirmation|requires confirmation|retry with confirm|call ask_user/i.test(text) ||
@@ -2677,11 +3439,50 @@ export function classifyToolRefusal(result) {
2677
3439
  return null;
2678
3440
  }
2679
3441
  /** Compact argument preview for the event line. */
2680
- function summarizeArgs(args) {
2681
- const first = Object.entries(args).slice(0, 1)[0];
2682
- if (!first)
3442
+ /**
3443
+ * The argument fields that say WHAT a call acts on — the useful half of a
3444
+ * one-field preview, in preference order.
3445
+ *
3446
+ * MEASURED (2026-10-07, live): a routing-design run printed
3447
+ * `⚙ write_file({confirm: true})` five times and `⚙ edit_file({allow_multiple:
3448
+ * false})` four times on its console. Both calls were COMPLETE — the preview
3449
+ * simply took the FIRST key the model happened to emit, which is a boolean
3450
+ * default, so the one thing a watcher needs (WHICH FILE) was invisible. A
3451
+ * progress line that names `confirm` instead of the path is worse than no line:
3452
+ * it looks like the agent is confirming something it never wrote.
3453
+ *
3454
+ * The fallback stays "first entry" so an unknown tool is still previewed rather
3455
+ * than blanked.
3456
+ */
3457
+ const IDENTIFYING_ARG_KEYS = [
3458
+ 'path',
3459
+ 'filePath',
3460
+ 'file',
3461
+ 'paths',
3462
+ 'command',
3463
+ 'pattern',
3464
+ 'query',
3465
+ 'url',
3466
+ 'goal',
3467
+ 'name',
3468
+ 'question',
3469
+ 'tool',
3470
+ 'action',
3471
+ 'claim',
3472
+ ];
3473
+ /**
3474
+ * A one-field preview of a tool call's arguments, preferring the IDENTIFYING
3475
+ * field ({@link IDENTIFYING_ARG_KEYS}) over whichever key was emitted first.
3476
+ *
3477
+ * Exported for the test that pins the preference — the bug it fixed was not a
3478
+ * crash but a true statement that named the wrong thing.
3479
+ */
3480
+ export function summarizeArgs(args) {
3481
+ const entries = Object.entries(args);
3482
+ if (entries.length === 0)
2683
3483
  return '{}';
2684
- const [key, value] = first;
3484
+ const picked = IDENTIFYING_ARG_KEYS.map((k) => entries.find(([key]) => key === k)).find((e) => e !== undefined) ?? entries[0];
3485
+ const [key, value] = picked;
2685
3486
  const v = typeof value === 'string' ? value : JSON.stringify(value);
2686
3487
  return `{${key}: ${v.length > 40 ? v.slice(0, 40) + '…' : v}}`;
2687
3488
  }