agent-nuvira 3.3.12 → 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 (458) 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/agents/reasoner.d.ts.map +1 -1
  6. package/dist/agents/agents/reasoner.js +13 -0
  7. package/dist/agents/agents/reasoner.js.map +1 -1
  8. package/dist/agents/orchestrator.d.ts +31 -0
  9. package/dist/agents/orchestrator.d.ts.map +1 -1
  10. package/dist/agents/orchestrator.js +103 -135
  11. package/dist/agents/orchestrator.js.map +1 -1
  12. package/dist/cli/chat.d.ts +89 -0
  13. package/dist/cli/chat.d.ts.map +1 -1
  14. package/dist/cli/chat.js +798 -39
  15. package/dist/cli/chat.js.map +1 -1
  16. package/dist/cli/cli-program.d.ts.map +1 -1
  17. package/dist/cli/cli-program.js +20 -0
  18. package/dist/cli/cli-program.js.map +1 -1
  19. package/dist/cli/config.d.ts +12 -0
  20. package/dist/cli/config.d.ts.map +1 -1
  21. package/dist/cli/config.js +107 -3
  22. package/dist/cli/config.js.map +1 -1
  23. package/dist/cli/decisions.d.ts +28 -0
  24. package/dist/cli/decisions.d.ts.map +1 -0
  25. package/dist/cli/decisions.js +140 -0
  26. package/dist/cli/decisions.js.map +1 -0
  27. package/dist/cli/doctor.d.ts +6 -0
  28. package/dist/cli/doctor.d.ts.map +1 -1
  29. package/dist/cli/doctor.js +46 -0
  30. package/dist/cli/doctor.js.map +1 -1
  31. package/dist/cli/execute.d.ts.map +1 -1
  32. package/dist/cli/execute.js +6 -0
  33. package/dist/cli/execute.js.map +1 -1
  34. package/dist/cli/failover-runner.d.ts.map +1 -1
  35. package/dist/cli/failover-runner.js +19 -1
  36. package/dist/cli/failover-runner.js.map +1 -1
  37. package/dist/cli/feedback.d.ts.map +1 -1
  38. package/dist/cli/feedback.js +5 -1
  39. package/dist/cli/feedback.js.map +1 -1
  40. package/dist/cli/hooks.d.ts +30 -0
  41. package/dist/cli/hooks.d.ts.map +1 -0
  42. package/dist/cli/hooks.js +224 -0
  43. package/dist/cli/hooks.js.map +1 -0
  44. package/dist/cli/knowledge.d.ts +31 -0
  45. package/dist/cli/knowledge.d.ts.map +1 -0
  46. package/dist/cli/knowledge.js +223 -0
  47. package/dist/cli/knowledge.js.map +1 -0
  48. package/dist/cli/loop-executor.d.ts.map +1 -1
  49. package/dist/cli/loop-executor.js +92 -18
  50. package/dist/cli/loop-executor.js.map +1 -1
  51. package/dist/cli/model.d.ts +20 -0
  52. package/dist/cli/model.d.ts.map +1 -1
  53. package/dist/cli/model.js +294 -14
  54. package/dist/cli/model.js.map +1 -1
  55. package/dist/cli/omniroute-control.d.ts +88 -0
  56. package/dist/cli/omniroute-control.d.ts.map +1 -0
  57. package/dist/cli/omniroute-control.js +179 -0
  58. package/dist/cli/omniroute-control.js.map +1 -0
  59. package/dist/cli/omniroute.d.ts +21 -0
  60. package/dist/cli/omniroute.d.ts.map +1 -0
  61. package/dist/cli/omniroute.js +67 -0
  62. package/dist/cli/omniroute.js.map +1 -0
  63. package/dist/cli/rate.d.ts +78 -0
  64. package/dist/cli/rate.d.ts.map +1 -0
  65. package/dist/cli/rate.js +294 -0
  66. package/dist/cli/rate.js.map +1 -0
  67. package/dist/config/process-env.d.ts.map +1 -1
  68. package/dist/config/process-env.js +10 -0
  69. package/dist/config/process-env.js.map +1 -1
  70. package/dist/config/types.d.ts +8 -0
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/history.d.ts +21 -0
  73. package/dist/context/history.d.ts.map +1 -1
  74. package/dist/context/history.js +50 -0
  75. package/dist/context/history.js.map +1 -1
  76. package/dist/enterprise/vault.d.ts.map +1 -1
  77. package/dist/enterprise/vault.js +5 -21
  78. package/dist/enterprise/vault.js.map +1 -1
  79. package/dist/gateway/hook-contract.d.ts +206 -0
  80. package/dist/gateway/hook-contract.d.ts.map +1 -0
  81. package/dist/gateway/hook-contract.js +449 -0
  82. package/dist/gateway/hook-contract.js.map +1 -0
  83. package/dist/gateway/hooks.d.ts.map +1 -1
  84. package/dist/gateway/hooks.js +14 -0
  85. package/dist/gateway/hooks.js.map +1 -1
  86. package/dist/inference/gemini-adapter.d.ts.map +1 -1
  87. package/dist/inference/gemini-adapter.js +4 -0
  88. package/dist/inference/gemini-adapter.js.map +1 -1
  89. package/dist/inference/interface.d.ts +54 -0
  90. package/dist/inference/interface.d.ts.map +1 -1
  91. package/dist/inference/model-id-validation.d.ts +57 -0
  92. package/dist/inference/model-id-validation.d.ts.map +1 -0
  93. package/dist/inference/model-id-validation.js +127 -0
  94. package/dist/inference/model-id-validation.js.map +1 -0
  95. package/dist/inference/native-tools.d.ts +16 -1
  96. package/dist/inference/native-tools.d.ts.map +1 -1
  97. package/dist/inference/native-tools.js +34 -10
  98. package/dist/inference/native-tools.js.map +1 -1
  99. package/dist/inference/openai-compat-adapter.d.ts.map +1 -1
  100. package/dist/inference/openai-compat-adapter.js +12 -1
  101. package/dist/inference/openai-compat-adapter.js.map +1 -1
  102. package/dist/inference/provider-catalog.d.ts +11 -0
  103. package/dist/inference/provider-catalog.d.ts.map +1 -1
  104. package/dist/inference/provider-catalog.js +80 -0
  105. package/dist/inference/provider-catalog.js.map +1 -1
  106. package/dist/inference/route-resolver.d.ts +32 -8
  107. package/dist/inference/route-resolver.d.ts.map +1 -1
  108. package/dist/inference/route-resolver.js +175 -1
  109. package/dist/inference/route-resolver.js.map +1 -1
  110. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  111. package/dist/inference/tool-call-utils.js +10 -8
  112. package/dist/inference/tool-call-utils.js.map +1 -1
  113. package/dist/inference/tools.d.ts +17 -1
  114. package/dist/inference/tools.d.ts.map +1 -1
  115. package/dist/inference/tools.js +69 -19
  116. package/dist/inference/tools.js.map +1 -1
  117. package/dist/learning/acceptance-model.d.ts +247 -0
  118. package/dist/learning/acceptance-model.d.ts.map +1 -0
  119. package/dist/learning/acceptance-model.js +708 -0
  120. package/dist/learning/acceptance-model.js.map +1 -0
  121. package/dist/learning/auto-router.d.ts +121 -24
  122. package/dist/learning/auto-router.d.ts.map +1 -1
  123. package/dist/learning/auto-router.js +374 -96
  124. package/dist/learning/auto-router.js.map +1 -1
  125. package/dist/learning/autonomy-policy.d.ts +12 -16
  126. package/dist/learning/autonomy-policy.d.ts.map +1 -1
  127. package/dist/learning/autonomy-policy.js +50 -48
  128. package/dist/learning/autonomy-policy.js.map +1 -1
  129. package/dist/learning/behavioural-labels.d.ts +86 -0
  130. package/dist/learning/behavioural-labels.d.ts.map +1 -0
  131. package/dist/learning/behavioural-labels.js +166 -0
  132. package/dist/learning/behavioural-labels.js.map +1 -0
  133. package/dist/learning/capability-evidence.d.ts +167 -0
  134. package/dist/learning/capability-evidence.d.ts.map +1 -0
  135. package/dist/learning/capability-evidence.js +210 -0
  136. package/dist/learning/capability-evidence.js.map +1 -0
  137. package/dist/learning/capability-readiness.d.ts +64 -0
  138. package/dist/learning/capability-readiness.d.ts.map +1 -0
  139. package/dist/learning/capability-readiness.js +74 -0
  140. package/dist/learning/capability-readiness.js.map +1 -0
  141. package/dist/learning/capability-types.d.ts +169 -0
  142. package/dist/learning/capability-types.d.ts.map +1 -0
  143. package/dist/learning/capability-types.js +20 -0
  144. package/dist/learning/capability-types.js.map +1 -0
  145. package/dist/learning/catalog-feed.d.ts +89 -0
  146. package/dist/learning/catalog-feed.d.ts.map +1 -0
  147. package/dist/learning/catalog-feed.js +211 -0
  148. package/dist/learning/catalog-feed.js.map +1 -0
  149. package/dist/learning/cli-intent-effects.d.ts +60 -0
  150. package/dist/learning/cli-intent-effects.d.ts.map +1 -0
  151. package/dist/learning/cli-intent-effects.js +153 -0
  152. package/dist/learning/cli-intent-effects.js.map +1 -0
  153. package/dist/learning/command-adaptation.d.ts +41 -0
  154. package/dist/learning/command-adaptation.d.ts.map +1 -0
  155. package/dist/learning/command-adaptation.js +122 -0
  156. package/dist/learning/command-adaptation.js.map +1 -0
  157. package/dist/learning/consent-picture.d.ts +42 -0
  158. package/dist/learning/consent-picture.d.ts.map +1 -0
  159. package/dist/learning/consent-picture.js +53 -0
  160. package/dist/learning/consent-picture.js.map +1 -0
  161. package/dist/learning/context-budget.d.ts +37 -6
  162. package/dist/learning/context-budget.d.ts.map +1 -1
  163. package/dist/learning/context-budget.js +38 -9
  164. package/dist/learning/context-budget.js.map +1 -1
  165. package/dist/learning/decision-log.d.ts +114 -0
  166. package/dist/learning/decision-log.d.ts.map +1 -0
  167. package/dist/learning/decision-log.js +296 -0
  168. package/dist/learning/decision-log.js.map +1 -0
  169. package/dist/learning/deliverable-class.d.ts.map +1 -1
  170. package/dist/learning/deliverable-class.js +19 -0
  171. package/dist/learning/deliverable-class.js.map +1 -1
  172. package/dist/learning/deliverable-corpus.d.ts +117 -0
  173. package/dist/learning/deliverable-corpus.d.ts.map +1 -0
  174. package/dist/learning/deliverable-corpus.js +176 -0
  175. package/dist/learning/deliverable-corpus.js.map +1 -0
  176. package/dist/learning/error-repair.d.ts +45 -2
  177. package/dist/learning/error-repair.d.ts.map +1 -1
  178. package/dist/learning/error-repair.js +95 -0
  179. package/dist/learning/error-repair.js.map +1 -1
  180. package/dist/learning/exhaustion-report.d.ts +136 -0
  181. package/dist/learning/exhaustion-report.d.ts.map +1 -0
  182. package/dist/learning/exhaustion-report.js +239 -0
  183. package/dist/learning/exhaustion-report.js.map +1 -0
  184. package/dist/learning/failure-bookkeeping.d.ts +16 -0
  185. package/dist/learning/failure-bookkeeping.d.ts.map +1 -1
  186. package/dist/learning/failure-bookkeeping.js +51 -5
  187. package/dist/learning/failure-bookkeeping.js.map +1 -1
  188. package/dist/learning/gate-friction.d.ts +39 -0
  189. package/dist/learning/gate-friction.d.ts.map +1 -0
  190. package/dist/learning/gate-friction.js +91 -0
  191. package/dist/learning/gate-friction.js.map +1 -0
  192. package/dist/learning/harness-narration.d.ts +34 -0
  193. package/dist/learning/harness-narration.d.ts.map +1 -0
  194. package/dist/learning/harness-narration.js +58 -0
  195. package/dist/learning/harness-narration.js.map +1 -0
  196. package/dist/learning/hub-skill-catalog.d.ts.map +1 -1
  197. package/dist/learning/hub-skill-catalog.js +1 -2
  198. package/dist/learning/hub-skill-catalog.js.map +1 -1
  199. package/dist/learning/hybrid-router.d.ts +65 -0
  200. package/dist/learning/hybrid-router.d.ts.map +1 -1
  201. package/dist/learning/hybrid-router.js +186 -0
  202. package/dist/learning/hybrid-router.js.map +1 -1
  203. package/dist/learning/knowledge-base.d.ts +350 -0
  204. package/dist/learning/knowledge-base.d.ts.map +1 -0
  205. package/dist/learning/knowledge-base.js +906 -0
  206. package/dist/learning/knowledge-base.js.map +1 -0
  207. package/dist/learning/knowledge-eval.d.ts +171 -0
  208. package/dist/learning/knowledge-eval.d.ts.map +1 -0
  209. package/dist/learning/knowledge-eval.js +196 -0
  210. package/dist/learning/knowledge-eval.js.map +1 -0
  211. package/dist/learning/knowledge-turn.d.ts +92 -0
  212. package/dist/learning/knowledge-turn.d.ts.map +1 -0
  213. package/dist/learning/knowledge-turn.js +190 -0
  214. package/dist/learning/knowledge-turn.js.map +1 -0
  215. package/dist/learning/learned-commands.d.ts +64 -0
  216. package/dist/learning/learned-commands.d.ts.map +1 -0
  217. package/dist/learning/learned-commands.js +108 -0
  218. package/dist/learning/learned-commands.js.map +1 -0
  219. package/dist/learning/lexical-search.d.ts +50 -0
  220. package/dist/learning/lexical-search.d.ts.map +1 -0
  221. package/dist/learning/lexical-search.js +111 -0
  222. package/dist/learning/lexical-search.js.map +1 -0
  223. package/dist/learning/machine-facts.d.ts +71 -0
  224. package/dist/learning/machine-facts.d.ts.map +1 -0
  225. package/dist/learning/machine-facts.js +164 -0
  226. package/dist/learning/machine-facts.js.map +1 -0
  227. package/dist/learning/markdown-outline.d.ts +73 -0
  228. package/dist/learning/markdown-outline.d.ts.map +1 -0
  229. package/dist/learning/markdown-outline.js +136 -0
  230. package/dist/learning/markdown-outline.js.map +1 -0
  231. package/dist/learning/model-first-router.d.ts +33 -1
  232. package/dist/learning/model-first-router.d.ts.map +1 -1
  233. package/dist/learning/model-first-router.js +123 -9
  234. package/dist/learning/model-first-router.js.map +1 -1
  235. package/dist/learning/model-identity.d.ts +80 -0
  236. package/dist/learning/model-identity.d.ts.map +1 -0
  237. package/dist/learning/model-identity.js +115 -0
  238. package/dist/learning/model-identity.js.map +1 -0
  239. package/dist/learning/model-registry.d.ts +141 -2
  240. package/dist/learning/model-registry.d.ts.map +1 -1
  241. package/dist/learning/model-registry.js +324 -38
  242. package/dist/learning/model-registry.js.map +1 -1
  243. package/dist/learning/model-scoring.d.ts.map +1 -1
  244. package/dist/learning/model-scoring.js +8 -1
  245. package/dist/learning/model-scoring.js.map +1 -1
  246. package/dist/learning/model-selection.d.ts +2 -1
  247. package/dist/learning/model-selection.d.ts.map +1 -1
  248. package/dist/learning/model-selection.js +76 -2
  249. package/dist/learning/model-selection.js.map +1 -1
  250. package/dist/learning/outcome-observation.d.ts +49 -0
  251. package/dist/learning/outcome-observation.d.ts.map +1 -0
  252. package/dist/learning/outcome-observation.js +95 -0
  253. package/dist/learning/outcome-observation.js.map +1 -0
  254. package/dist/learning/pair-entitlement.d.ts +184 -0
  255. package/dist/learning/pair-entitlement.d.ts.map +1 -0
  256. package/dist/learning/pair-entitlement.js +214 -0
  257. package/dist/learning/pair-entitlement.js.map +1 -0
  258. package/dist/learning/project-docs.d.ts +83 -0
  259. package/dist/learning/project-docs.d.ts.map +1 -0
  260. package/dist/learning/project-docs.js +132 -0
  261. package/dist/learning/project-docs.js.map +1 -0
  262. package/dist/learning/provider-fallback.d.ts +23 -1
  263. package/dist/learning/provider-fallback.d.ts.map +1 -1
  264. package/dist/learning/provider-fallback.js +24 -0
  265. package/dist/learning/provider-fallback.js.map +1 -1
  266. package/dist/learning/quota-ledger.d.ts +13 -0
  267. package/dist/learning/quota-ledger.d.ts.map +1 -1
  268. package/dist/learning/quota-ledger.js +28 -0
  269. package/dist/learning/quota-ledger.js.map +1 -1
  270. package/dist/learning/reasoning-trace.d.ts +65 -1
  271. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  272. package/dist/learning/reasoning-trace.js +123 -1
  273. package/dist/learning/reasoning-trace.js.map +1 -1
  274. package/dist/learning/requirement-probe.d.ts +55 -0
  275. package/dist/learning/requirement-probe.d.ts.map +1 -0
  276. package/dist/learning/requirement-probe.js +77 -0
  277. package/dist/learning/requirement-probe.js.map +1 -0
  278. package/dist/learning/resilient-call.d.ts +20 -1
  279. package/dist/learning/resilient-call.d.ts.map +1 -1
  280. package/dist/learning/resilient-call.js +42 -4
  281. package/dist/learning/resilient-call.js.map +1 -1
  282. package/dist/learning/response-usability.d.ts +85 -0
  283. package/dist/learning/response-usability.d.ts.map +1 -0
  284. package/dist/learning/response-usability.js +135 -0
  285. package/dist/learning/response-usability.js.map +1 -0
  286. package/dist/learning/router-bandit.d.ts +132 -0
  287. package/dist/learning/router-bandit.d.ts.map +1 -1
  288. package/dist/learning/router-bandit.js +253 -0
  289. package/dist/learning/router-bandit.js.map +1 -1
  290. package/dist/learning/routing-history.d.ts +41 -2
  291. package/dist/learning/routing-history.d.ts.map +1 -1
  292. package/dist/learning/routing-history.js.map +1 -1
  293. package/dist/learning/run-trace.d.ts +21 -0
  294. package/dist/learning/run-trace.d.ts.map +1 -1
  295. package/dist/learning/run-trace.js +38 -0
  296. package/dist/learning/run-trace.js.map +1 -1
  297. package/dist/learning/seeded-benchmark.d.ts +14 -0
  298. package/dist/learning/seeded-benchmark.d.ts.map +1 -1
  299. package/dist/learning/seeded-benchmark.js +43 -4
  300. package/dist/learning/seeded-benchmark.js.map +1 -1
  301. package/dist/learning/session-grant.d.ts +75 -0
  302. package/dist/learning/session-grant.d.ts.map +1 -0
  303. package/dist/learning/session-grant.js +112 -0
  304. package/dist/learning/session-grant.js.map +1 -0
  305. package/dist/learning/task-model-continuity.d.ts +73 -0
  306. package/dist/learning/task-model-continuity.d.ts.map +1 -0
  307. package/dist/learning/task-model-continuity.js +87 -0
  308. package/dist/learning/task-model-continuity.js.map +1 -0
  309. package/dist/learning/turn-feedback.d.ts +93 -0
  310. package/dist/learning/turn-feedback.d.ts.map +1 -0
  311. package/dist/learning/turn-feedback.js +127 -0
  312. package/dist/learning/turn-feedback.js.map +1 -0
  313. package/dist/learning/turn-report.d.ts +104 -2
  314. package/dist/learning/turn-report.d.ts.map +1 -1
  315. package/dist/learning/turn-report.js +161 -6
  316. package/dist/learning/turn-report.js.map +1 -1
  317. package/dist/learning/verdict-routing.d.ts +51 -0
  318. package/dist/learning/verdict-routing.d.ts.map +1 -0
  319. package/dist/learning/verdict-routing.js +122 -0
  320. package/dist/learning/verdict-routing.js.map +1 -0
  321. package/dist/mcp/client.d.ts.map +1 -1
  322. package/dist/mcp/client.js +5 -0
  323. package/dist/mcp/client.js.map +1 -1
  324. package/dist/mcp/manager.d.ts +10 -0
  325. package/dist/mcp/manager.d.ts.map +1 -1
  326. package/dist/mcp/manager.js +20 -0
  327. package/dist/mcp/manager.js.map +1 -1
  328. package/dist/mcp/mcp-discovery.d.ts +34 -0
  329. package/dist/mcp/mcp-discovery.d.ts.map +1 -0
  330. package/dist/mcp/mcp-discovery.js +71 -0
  331. package/dist/mcp/mcp-discovery.js.map +1 -0
  332. package/dist/mcp/mcp-schema-cache.d.ts +17 -0
  333. package/dist/mcp/mcp-schema-cache.d.ts.map +1 -1
  334. package/dist/mcp/mcp-schema-cache.js +47 -3
  335. package/dist/mcp/mcp-schema-cache.js.map +1 -1
  336. package/dist/mcp/types.d.ts +28 -0
  337. package/dist/mcp/types.d.ts.map +1 -1
  338. package/dist/mcp/types.js.map +1 -1
  339. package/dist/security/secret-scan.d.ts +156 -0
  340. package/dist/security/secret-scan.d.ts.map +1 -0
  341. package/dist/security/secret-scan.js +525 -0
  342. package/dist/security/secret-scan.js.map +1 -0
  343. package/dist/tools/ask-user.d.ts.map +1 -1
  344. package/dist/tools/ask-user.js +4 -0
  345. package/dist/tools/ask-user.js.map +1 -1
  346. package/dist/tools/capability-registry.d.ts +150 -0
  347. package/dist/tools/capability-registry.d.ts.map +1 -0
  348. package/dist/tools/capability-registry.js +400 -0
  349. package/dist/tools/capability-registry.js.map +1 -0
  350. package/dist/tools/child-agent-runtime.d.ts.map +1 -1
  351. package/dist/tools/child-agent-runtime.js +19 -5
  352. package/dist/tools/child-agent-runtime.js.map +1 -1
  353. package/dist/tools/coding-tools.d.ts +17 -5
  354. package/dist/tools/coding-tools.d.ts.map +1 -1
  355. package/dist/tools/coding-tools.js +80 -11
  356. package/dist/tools/coding-tools.js.map +1 -1
  357. package/dist/tools/command-memo.d.ts +116 -0
  358. package/dist/tools/command-memo.d.ts.map +1 -0
  359. package/dist/tools/command-memo.js +202 -0
  360. package/dist/tools/command-memo.js.map +1 -0
  361. package/dist/tools/git-tool.d.ts.map +1 -1
  362. package/dist/tools/git-tool.js +13 -3
  363. package/dist/tools/git-tool.js.map +1 -1
  364. package/dist/tools/harness-directive.d.ts +42 -0
  365. package/dist/tools/harness-directive.d.ts.map +1 -0
  366. package/dist/tools/harness-directive.js +45 -0
  367. package/dist/tools/harness-directive.js.map +1 -0
  368. package/dist/tools/loop-skill-hint.d.ts +74 -174
  369. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  370. package/dist/tools/loop-skill-hint.js +110 -536
  371. package/dist/tools/loop-skill-hint.js.map +1 -1
  372. package/dist/tools/mcp-client-tool.d.ts +2 -0
  373. package/dist/tools/mcp-client-tool.d.ts.map +1 -1
  374. package/dist/tools/mcp-client-tool.js +3 -0
  375. package/dist/tools/mcp-client-tool.js.map +1 -1
  376. package/dist/tools/modality/shared.d.ts +1 -4
  377. package/dist/tools/modality/shared.d.ts.map +1 -1
  378. package/dist/tools/modality/shared.js +7 -22
  379. package/dist/tools/modality/shared.js.map +1 -1
  380. package/dist/tools/plan-store.d.ts +44 -4
  381. package/dist/tools/plan-store.d.ts.map +1 -1
  382. package/dist/tools/plan-store.js +93 -9
  383. package/dist/tools/plan-store.js.map +1 -1
  384. package/dist/tools/registry.d.ts +76 -3
  385. package/dist/tools/registry.d.ts.map +1 -1
  386. package/dist/tools/registry.js +623 -15
  387. package/dist/tools/registry.js.map +1 -1
  388. package/dist/tools/remediation-ladder.d.ts +91 -0
  389. package/dist/tools/remediation-ladder.d.ts.map +1 -0
  390. package/dist/tools/remediation-ladder.js +316 -0
  391. package/dist/tools/remediation-ladder.js.map +1 -0
  392. package/dist/tools/run-cli.d.ts.map +1 -1
  393. package/dist/tools/run-cli.js +29 -4
  394. package/dist/tools/run-cli.js.map +1 -1
  395. package/dist/tools/run-terminal.d.ts +11 -0
  396. package/dist/tools/run-terminal.d.ts.map +1 -1
  397. package/dist/tools/run-terminal.js +289 -10
  398. package/dist/tools/run-terminal.js.map +1 -1
  399. package/dist/tools/skills-hub.d.ts +7 -0
  400. package/dist/tools/skills-hub.d.ts.map +1 -1
  401. package/dist/tools/skills-hub.js +11 -2
  402. package/dist/tools/skills-hub.js.map +1 -1
  403. package/dist/tools/step-artifact.d.ts +35 -0
  404. package/dist/tools/step-artifact.d.ts.map +1 -0
  405. package/dist/tools/step-artifact.js +154 -0
  406. package/dist/tools/step-artifact.js.map +1 -0
  407. package/dist/tools/tool-loop.d.ts +327 -13
  408. package/dist/tools/tool-loop.d.ts.map +1 -1
  409. package/dist/tools/tool-loop.js +891 -66
  410. package/dist/tools/tool-loop.js.map +1 -1
  411. package/dist/tools/toolsets.js +4 -4
  412. package/dist/tools/toolsets.js.map +1 -1
  413. package/dist/tools/worktree.d.ts +11 -2
  414. package/dist/tools/worktree.d.ts.map +1 -1
  415. package/dist/tools/worktree.js +11 -2
  416. package/dist/tools/worktree.js.map +1 -1
  417. package/dist/utils/atomic-store.d.ts +53 -0
  418. package/dist/utils/atomic-store.d.ts.map +1 -0
  419. package/dist/utils/atomic-store.js +178 -0
  420. package/dist/utils/atomic-store.js.map +1 -0
  421. package/dist/utils/binary-probe.d.ts +25 -0
  422. package/dist/utils/binary-probe.d.ts.map +1 -0
  423. package/dist/utils/binary-probe.js +48 -0
  424. package/dist/utils/binary-probe.js.map +1 -0
  425. package/dist/utils/workspace-path.d.ts +50 -0
  426. package/dist/utils/workspace-path.d.ts.map +1 -0
  427. package/dist/utils/workspace-path.js +86 -0
  428. package/dist/utils/workspace-path.js.map +1 -0
  429. package/dist/web-dashboard/chat-console.d.ts +24 -0
  430. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  431. package/dist/web-dashboard/chat-console.js +38 -0
  432. package/dist/web-dashboard/chat-console.js.map +1 -1
  433. package/dist/web-dashboard/project-context.d.ts +27 -2
  434. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  435. package/dist/web-dashboard/project-context.js +32 -2
  436. package/dist/web-dashboard/project-context.js.map +1 -1
  437. package/dist/web-dashboard/server.d.ts +40 -0
  438. package/dist/web-dashboard/server.d.ts.map +1 -1
  439. package/dist/web-dashboard/server.js +733 -15
  440. package/dist/web-dashboard/server.js.map +1 -1
  441. package/dist/web-dashboard/src/types.d.ts +236 -1
  442. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  443. package/dist/web-dashboard/workspace-guard.d.ts +18 -2
  444. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  445. package/dist/web-dashboard/workspace-guard.js +94 -12
  446. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  447. package/dist/web-dashboard/workspace-resolution.d.ts +87 -0
  448. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -0
  449. package/dist/web-dashboard/workspace-resolution.js +105 -0
  450. package/dist/web-dashboard/workspace-resolution.js.map +1 -0
  451. package/package.json +1 -1
  452. package/src/web-dashboard/public/assets/index-tK8Y2qHB.css +1 -0
  453. package/src/web-dashboard/public/assets/index-zjbdJnO-.js +210 -0
  454. package/src/web-dashboard/public/assets/index-zjbdJnO-.js.map +1 -0
  455. package/src/web-dashboard/public/index.html +2 -2
  456. package/src/web-dashboard/public/assets/index-BrZcYhy6.js +0 -207
  457. package/src/web-dashboard/public/assets/index-BrZcYhy6.js.map +0 -1
  458. package/src/web-dashboard/public/assets/index-DsWczTa6.css +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,16 +1173,32 @@ 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).
980
1194
  if (deps.isThinkOnly ? deps.isThinkOnly(response.content) : isThinkOnlyResponse(response.content)) {
981
1195
  // Feed an empty assistant step so the model continues in-context.
982
- thread.push({ role: 'assistant', content: response.content });
1196
+ thread.push({
1197
+ role: 'assistant',
1198
+ content: response.content,
1199
+ // Keep the thinking model's reasoning with the turn it belongs to.
1200
+ ...(response.reasoningContent ? { reasoningContent: response.reasoningContent } : {}),
1201
+ });
983
1202
  thinkContinues += 1;
984
1203
  // AN EMPTY RESPONSE IS A FAILURE, NOT REASONING. `isThinkOnlyResponse('')`
985
1204
  // returns true, so a provider returning nothing (the loop arm falls over
@@ -989,6 +1208,86 @@ async function runToolLoopInner(opts, progress) {
989
1208
  // is the exact class of defect this work exists to remove, so the two
990
1209
  // cases are named apart even though they are bounded together.
991
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;
992
1291
  // BOUNDED (see THINK_ONLY_ESCALATION). Continuing on reasoning is right
993
1292
  // for a `<think>`-then-answer model and catastrophic without a limit:
994
1293
  // two live eval runs produced 31 reasoning-only steps, one tool call and
@@ -1019,13 +1318,18 @@ async function runToolLoopInner(opts, progress) {
1019
1318
  : `the model produced reasoning-only output ${thinkContinues} times with no tool call and no answer — ` +
1020
1319
  'one bounded escalation to act or answer',
1021
1320
  });
1022
- thread.push({ role: 'user', content: THINK_ONLY_ESCALATION });
1321
+ harness.push(thread, 'think-only', THINK_ONLY_ESCALATION);
1023
1322
  continue;
1024
1323
  }
1025
1324
  // Still spinning after the escalation: END the turn rather than burn the
1026
1325
  // rest of the budget. `bounded` is set so every surface reads this as
1027
- // "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).
1028
1331
  bounded = true;
1332
+ exitTermination = emptyResponse ? 'no-capable-candidate' : 'reasoning-spin';
1029
1333
  deps.onEvent?.(emptyResponse
1030
1334
  ? ' ⚠️ Empty responses kept coming — ending the turn instead of spinning. This is a PROVIDER failure, not the agent thinking.'
1031
1335
  : ' 🧠 Reasoning-only output kept repeating — ending the turn instead of spinning.');
@@ -1066,11 +1370,8 @@ async function runToolLoopInner(opts, progress) {
1066
1370
  // would outrank and suppress the nudge entirely.
1067
1371
  lastContent = '';
1068
1372
  thread.push({ role: 'assistant', content: response.content });
1069
- thread.push({
1070
- role: 'user',
1071
- content: 'You announced an action you were about to take, but no tool was called and nothing was done. ' +
1072
- 'Either carry it out now with the tools available, or reply with the actual answer — do not end the turn on a promise.',
1073
- });
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.');
1074
1375
  continue;
1075
1376
  }
1076
1377
  // ── G13 — AUTHORIZED-WORK nudge (bounded, once) ──────────────────────
@@ -1108,7 +1409,7 @@ async function runToolLoopInner(opts, progress) {
1108
1409
  lastContent = stripTrailingPermissionSeek(response.content.length >= lastContent.length ? response.content : lastContent);
1109
1410
  }
1110
1411
  thread.push({ role: 'assistant', content: response.content });
1111
- thread.push({ role: 'user', content: AUTHORIZED_WORK_NUDGE });
1412
+ harness.push(thread, 'permission', AUTHORIZED_WORK_NUDGE);
1112
1413
  continue;
1113
1414
  }
1114
1415
  // ── Stage 2 — REPETITION nudge (bounded, once) ───────────────────────
@@ -1141,7 +1442,7 @@ async function runToolLoopInner(opts, progress) {
1141
1442
  lastContent = stripTrailingPermissionSeek(closing);
1142
1443
  }
1143
1444
  thread.push({ role: 'assistant', content: response.content });
1144
- thread.push({ role: 'user', content: repeatNudge(closing, runTrace.priorAnswer(closing)) });
1445
+ harness.push(thread, 'repeat', repeatNudge(closing, runTrace.priorAnswer(closing)));
1145
1446
  continue;
1146
1447
  }
1147
1448
  // ── ZERO-ACTION gate (bounded, once) ────────────────────────────────
@@ -1167,7 +1468,7 @@ async function runToolLoopInner(opts, progress) {
1167
1468
  // The non-answer must not become the delivered answer either way.
1168
1469
  lastContent = '';
1169
1470
  thread.push({ role: 'assistant', content: response.content });
1170
- thread.push({ role: 'user', content: zeroActionNudge(currentAsk(opts)) });
1471
+ harness.push(thread, 'action', zeroActionNudge(currentAsk(opts)));
1171
1472
  continue;
1172
1473
  }
1173
1474
  // S1 (both exits): the MOST SUBSTANTIVE content seen wins here too —
@@ -1192,7 +1493,7 @@ async function runToolLoopInner(opts, progress) {
1192
1493
  : 'an authored deliverable was requested and no file was written — one bounded nudge to produce it',
1193
1494
  });
1194
1495
  thread.push({ role: 'assistant', content: response.content });
1195
- thread.push({ role: 'user', content: deliverableNudge(authorization.requestedPath) });
1496
+ harness.push(thread, 'deliverable', deliverableNudge(authorization.requestedPath));
1196
1497
  continue;
1197
1498
  }
1198
1499
  //
@@ -1220,10 +1521,7 @@ async function runToolLoopInner(opts, progress) {
1220
1521
  // preference order. A live turn reached for `node -c` because the nudge
1221
1522
  // left the choice to the model; `verificationNudgeFor` reads the
1222
1523
  // workspace and asks for the real command, so there is nothing to guess.
1223
- thread.push({
1224
- role: 'user',
1225
- content: verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths),
1226
- });
1524
+ harness.push(thread, 'verification', verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths));
1227
1525
  continue;
1228
1526
  }
1229
1527
  // SELF-REVIEW (no-tools exit) — a substantial, already-verified turn is
@@ -1239,7 +1537,7 @@ async function runToolLoopInner(opts, progress) {
1239
1537
  summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
1240
1538
  });
1241
1539
  thread.push({ role: 'assistant', content: response.content });
1242
- thread.push({ role: 'user', content: review });
1540
+ harness.push(thread, 'self-review', review);
1243
1541
  continue;
1244
1542
  }
1245
1543
  return {
@@ -1250,6 +1548,8 @@ async function runToolLoopInner(opts, progress) {
1250
1548
  ...(opts.resume ? { replayedSteps } : {}),
1251
1549
  bounded: false,
1252
1550
  continuations,
1551
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
1552
+ termination: 'delivered',
1253
1553
  };
1254
1554
  }
1255
1555
  // ── Execute tool calls (results fed back, next step) ──────────────
@@ -1266,8 +1566,25 @@ async function runToolLoopInner(opts, progress) {
1266
1566
  arguments: JSON.stringify(tc.arguments),
1267
1567
  ...(tc.providerMeta ? { providerMeta: tc.providerMeta } : {}),
1268
1568
  })),
1569
+ // Thinking-mode reasoning is echoed back with the assistant turn that
1570
+ // carried these calls — DeepSeek v4 rejects the next request without it.
1571
+ ...(response.reasoningContent ? { reasoningContent: response.reasoningContent } : {}),
1269
1572
  });
1270
1573
  let endedAfterConcluding = false;
1574
+ // ── Provider-protocol safety: nudges decided while PLANNING must queue ──
1575
+ // An assistant message that carries `tool_calls` MUST be followed
1576
+ // immediately by a `tool` result for EVERY call id, before any other role.
1577
+ // A strict OpenAI-compatible API (DeepSeek native) rejects the NEXT request
1578
+ // otherwise: "An assistant message with 'tool_calls' must be followed by
1579
+ // tool messages responding to each 'tool_call_id'." The pre-flight and plan
1580
+ // gates decide during planning — before the results exist — so they push
1581
+ // their nudge into this queue and it is flushed after the results, keeping
1582
+ // the assistant→tool group intact. (Without this, a pinned/strict run died
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.
1587
+ const deferredUserNudges = [];
1271
1588
  const plans = toolCalls.map((call) => {
1272
1589
  const tool = getTool(call.name);
1273
1590
  const priorSameTool = toolCallsRun.filter((t) => t === call.name).length;
@@ -1342,6 +1659,24 @@ async function runToolLoopInner(opts, progress) {
1342
1659
  `Call tool_search with {"action":"load","toolset":"${ownerToolset?.name ?? ''}"} first — its tools become callable immediately.`,
1343
1660
  };
1344
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
+ }
1345
1680
  if (!isToolEnabled(call.name, context.configManager)) {
1346
1681
  // I1 execution gate: a disabled tool is rejected at runtime even if
1347
1682
  // the model hallucinated its name — the toggle is never cosmetic.
@@ -1532,7 +1867,7 @@ async function runToolLoopInner(opts, progress) {
1532
1867
  try {
1533
1868
  const findings = checkProjectPrerequisites(createNodePrereqFs(opts.context?.cwd || process.cwd()));
1534
1869
  if (findings.length > 0) {
1535
- thread.push({ role: 'user', content: formatPreflightFindings(findings) });
1870
+ deferredUserNudges.push({ gate: 'prerequisite', content: formatPreflightFindings(findings) });
1536
1871
  deps.onEvent?.(` 🧱 pre-flight: ${findings.length} missing project prerequisite(s) — handed the exact fix before building.`);
1537
1872
  traceEvent({
1538
1873
  kind: 'gate',
@@ -1616,7 +1951,7 @@ async function runToolLoopInner(opts, progress) {
1616
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`
1617
1952
  : 'a workspace-directing turn was about to mutate without a plan — one bounded nudge to declare one first',
1618
1953
  });
1619
- thread.push({ role: 'user', content: planRequiredNudge(currentAsk(opts)) });
1954
+ deferredUserNudges.push({ gate: 'plan', content: planRequiredNudge(currentAsk(opts)) });
1620
1955
  }
1621
1956
  const executed = new Array(plans.length).fill('');
1622
1957
  for (let i = 0; i < plans.length;) {
@@ -1691,6 +2026,21 @@ async function runToolLoopInner(opts, progress) {
1691
2026
  const p = a?.path ?? a?.file_path ?? a?.file;
1692
2027
  if (typeof p === 'string' && p) {
1693
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
+ }
1694
2044
  // Stage 2 — the run's own record of what it CHANGED, so a self-report
1695
2045
  // can answer "what have you been doing" from data rather than from a
1696
2046
  // re-read of the transcript.
@@ -1837,6 +2187,11 @@ async function runToolLoopInner(opts, progress) {
1837
2187
  });
1838
2188
  thread.push({ role: 'tool', toolCallId: call.id, content: resultText });
1839
2189
  }
2190
+ // Flush the planning-time nudges now that the assistant's tool_calls group is
2191
+ // complete (every call id has a `tool` result). This is the first point a
2192
+ // user/system message may safely follow the batch.
2193
+ for (const nudge of deferredUserNudges)
2194
+ harness.push(thread, nudge.gate, nudge.content);
1840
2195
  // Track the generalized stall: a step that RAN tools but succeeded at none
1841
2196
  // of them extends the streak; any success clears it. A text-only step (no
1842
2197
  // tools) leaves it as it was — the model is thinking, not failing.
@@ -1854,6 +2209,21 @@ async function runToolLoopInner(opts, progress) {
1854
2209
  // right after the step's results are in the thread, and the next model
1855
2210
  // step sees it. Bounded once per turn and keyed on the action so the same
1856
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
+ }
1857
2227
  if (diagnosisNudges < 1) {
1858
2228
  const repeated = runTrace.repeatedFailure();
1859
2229
  if (repeated && repeated.times >= 2) {
@@ -1866,7 +2236,7 @@ async function runToolLoopInner(opts, progress) {
1866
2236
  summary: `the same action failed ${repeated.times} times (${repeated.tool}: ${repeated.action.slice(0, 120)}) — ` +
1867
2237
  'one bounded nudge to diagnose the root cause and change the approach',
1868
2238
  });
1869
- thread.push({ role: 'user', content: repeatedFailureNudge(repeated) });
2239
+ harness.push(thread, 'diagnosis', repeatedFailureNudge(repeated));
1870
2240
  }
1871
2241
  else if (failedToolSteps >= NO_PROGRESS_STALL_STEPS) {
1872
2242
  // Stage 2 — no SINGLE action repeated, but the last N steps each ran
@@ -1880,7 +2250,7 @@ async function runToolLoopInner(opts, progress) {
1880
2250
  summary: `${failedToolSteps} consecutive steps ran tools and none succeeded — ` +
1881
2251
  'one bounded nudge to diagnose the shared cause and change the approach',
1882
2252
  });
1883
- thread.push({ role: 'user', content: noProgressNudge(runTrace.recentFailures(3), failedToolSteps) });
2253
+ harness.push(thread, 'diagnosis', noProgressNudge(runTrace.recentFailures(3), failedToolSteps));
1884
2254
  }
1885
2255
  }
1886
2256
  // Tiered exposure: after EVERY executed tool call, union any newly
@@ -1911,7 +2281,7 @@ async function runToolLoopInner(opts, progress) {
1911
2281
  // P4 — cancellation during/after tool execution: do NOT request another
1912
2282
  // model step on a cancelled turn (the user already walked away).
1913
2283
  if (opts.signal?.aborted) {
1914
- 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' };
1915
2285
  }
1916
2286
  // ── endsAgentStep — a successful dispenser's RESULT is the answer ────────
1917
2287
  // `build`/`resume`/`repair`/`document`/`website`/`analyze`/`test`/`publish`
@@ -1941,6 +2311,8 @@ async function runToolLoopInner(opts, progress) {
1941
2311
  ...(opts.resume ? { replayedSteps } : {}),
1942
2312
  bounded: false,
1943
2313
  continuations,
2314
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
2315
+ termination: 'delivered',
1944
2316
  };
1945
2317
  }
1946
2318
  // End-of-response semantics (the contract: "END EVERY RESPONSE by
@@ -1987,10 +2359,7 @@ async function runToolLoopInner(opts, progress) {
1987
2359
  // preference order. A live turn reached for `node -c` because the nudge
1988
2360
  // left the choice to the model; `verificationNudgeFor` reads the
1989
2361
  // workspace and asks for the real command, so there is nothing to guess.
1990
- thread.push({
1991
- role: 'user',
1992
- content: verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths),
1993
- });
2362
+ harness.push(thread, 'verification', verificationNudgeFor(ctx.cwd ?? process.cwd(), progress.mutatedPaths));
1994
2363
  continue;
1995
2364
  }
1996
2365
  // G13b — DELIVERABLE GATE (concluding path). AFTER the verification gate
@@ -2008,7 +2377,7 @@ async function runToolLoopInner(opts, progress) {
2008
2377
  : 'an authored deliverable was requested and no file was written — one bounded nudge to produce it',
2009
2378
  });
2010
2379
  thread.push({ role: 'assistant', content: response.content });
2011
- thread.push({ role: 'user', content: deliverableNudge(authorization.requestedPath) });
2380
+ harness.push(thread, 'deliverable', deliverableNudge(authorization.requestedPath));
2012
2381
  continue;
2013
2382
  }
2014
2383
  // ZERO-ACTION gate (concluding exit) — the same bounded pass as the
@@ -2026,7 +2395,7 @@ async function runToolLoopInner(opts, progress) {
2026
2395
  });
2027
2396
  lastContent = '';
2028
2397
  thread.push({ role: 'assistant', content: response.content });
2029
- thread.push({ role: 'user', content: zeroActionNudge(currentAsk(opts)) });
2398
+ harness.push(thread, 'action', zeroActionNudge(currentAsk(opts)));
2030
2399
  continue;
2031
2400
  }
2032
2401
  // SELF-REVIEW (concluding exit) — same bounded pass, placed AFTER the
@@ -2042,7 +2411,7 @@ async function runToolLoopInner(opts, progress) {
2042
2411
  summary: 'a substantial turn that changed files reached its end — one bounded nudge to check the result against the original ask',
2043
2412
  });
2044
2413
  thread.push({ role: 'assistant', content: response.content });
2045
- thread.push({ role: 'user', content: review });
2414
+ harness.push(thread, 'self-review', review);
2046
2415
  continue;
2047
2416
  }
2048
2417
  const content = response.content.length >= lastContent.length ? response.content : lastContent;
@@ -2054,17 +2423,52 @@ async function runToolLoopInner(opts, progress) {
2054
2423
  ...(opts.resume ? { replayedSteps } : {}),
2055
2424
  bounded: false,
2056
2425
  continuations,
2426
+ ...(modelHandoffs > 0 ? { modelHandoffs } : {}),
2427
+ termination: 'delivered',
2057
2428
  };
2058
2429
  }
2059
2430
  }
2060
2431
  // Reaching here means the continuation budget is spent (the in-loop check
2061
2432
  // extends the bound otherwise) — the turn is honestly bounded.
2062
2433
  bounded = true;
2063
- 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
+ }
2064
2465
  traceEvent({
2065
2466
  kind: 'decision',
2066
2467
  gate: 'budget',
2067
- 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`,
2068
2472
  });
2069
2473
  return {
2070
2474
  content: lastContent || 'I reached my step limit for this request.',
@@ -2074,6 +2478,11 @@ async function runToolLoopInner(opts, progress) {
2074
2478
  ...(opts.resume ? { replayedSteps } : {}),
2075
2479
  bounded: true,
2076
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',
2077
2486
  };
2078
2487
  }
2079
2488
  // ─── Honest-answer guard ────────────────────────────────────────────────────
@@ -2131,12 +2540,129 @@ export function detectUnverifiedDeliveryClaim(content, toolsRun) {
2131
2540
  }
2132
2541
  return false;
2133
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
+ }
2134
2610
  /**
2135
2611
  * Tool-shaped actions a first-person imminent promise can drop. Matched by
2136
2612
  * STEM (`\w*` suffix) so "scaffold" covers "scaffolding", "write" covers
2137
2613
  * "writing", etc. — the promise is usually phrased as a gerund ("start by
2138
2614
  * scaffolding").
2139
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
+ }
2140
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)';
2141
2667
  /**
2142
2668
  * A first-person IMMINENT promise to act. Immediacy is required, because that
@@ -2201,6 +2727,82 @@ export function detectUnfulfilledIntentPromise(content) {
2201
2727
  }
2202
2728
  return false;
2203
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
+ }
2204
2806
  // ─── Build honesty (A3 Part 2) ──────────────────────────────────────────────
2205
2807
  /**
2206
2808
  * Past-tense sentences that ASSERT a BUILD, or its artifact, came out good.
@@ -2342,6 +2944,7 @@ export async function runToolLoop(opts) {
2342
2944
  successfulToolCalls: [],
2343
2945
  deliveryConfirmed: false,
2344
2946
  mutatedPaths: [],
2947
+ authoredArtifacts: [],
2345
2948
  verificationEvidence: [],
2346
2949
  executedActions: [],
2347
2950
  };
@@ -2382,12 +2985,66 @@ export async function runToolLoop(opts) {
2382
2985
  // runs whether or not the nudge was enabled, so the flag is never a
2383
2986
  // function of configuration.
2384
2987
  const activity = assessEditActivity(result.successfulToolCalls, progress.verificationEvidence, progress.mutatedPaths);
2385
- 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) {
2386
2999
  result.unverifiedEdit = true;
2387
3000
  }
2388
- if (detectUnverifiedEditClaim(result.content, activity.mutations, activity.verifications)) {
3001
+ if (detectUnverifiedEditClaim(result.content, activity.mutations, activity.verifications) &&
3002
+ !authoredReadBack) {
2389
3003
  result.unverifiedEditClaim = true;
2390
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
+ }
2391
3048
  // G13b — DELIVERABLE honesty, the same way and for the same reason: the
2392
3049
  // flag is a function of what the turn DID, never of configuration. A turn
2393
3050
  // that was asked for a file and wrote none must not be readable as
@@ -2400,6 +3057,18 @@ export async function runToolLoop(opts) {
2400
3057
  wantsAuthoredArtifact(lastUserText(opts.messages))) {
2401
3058
  result.undeliveredArtifact = true;
2402
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
+ }
2403
3072
  // ZERO-ACTION honesty — a request that DIRECTED work on the workspace was
2404
3073
  // answered with nothing at all: no tool succeeded, nothing was written. The
2405
3074
  // flag is a function of what the turn DID, never of configuration, so a
@@ -2503,6 +3172,22 @@ const NON_PRODUCTIVE_TOOLS = new Set(['suggest_followups']);
2503
3172
  function hasProductiveAction(progress) {
2504
3173
  return progress.successfulToolCalls.some((name) => !NON_PRODUCTIVE_TOOLS.has(name));
2505
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
+ }
2506
3191
  /**
2507
3192
  * Does the ZERO-ACTION gate apply to this turn?
2508
3193
  *
@@ -2614,6 +3299,107 @@ function previewToolResult(result, max = 200) {
2614
3299
  * Returns null when the result is an ordinary tool/runtime error, so the caller
2615
3300
  * keeps its own wording rather than inventing a cause.
2616
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
+ }
2617
3403
  export function classifyToolRefusal(result) {
2618
3404
  const text = result || '';
2619
3405
  if (/needs explicit confirmation|requires confirmation|retry with confirm|call ask_user/i.test(text) ||
@@ -2653,11 +3439,50 @@ export function classifyToolRefusal(result) {
2653
3439
  return null;
2654
3440
  }
2655
3441
  /** Compact argument preview for the event line. */
2656
- function summarizeArgs(args) {
2657
- const first = Object.entries(args).slice(0, 1)[0];
2658
- 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)
2659
3483
  return '{}';
2660
- 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;
2661
3486
  const v = typeof value === 'string' ? value : JSON.stringify(value);
2662
3487
  return `{${key}: ${v.length > 40 ? v.slice(0, 40) + '…' : v}}`;
2663
3488
  }