@mjasnikovs/pi-task 0.38.29 → 0.38.31

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 (373) hide show
  1. package/dist/config/config.d.ts +70 -70
  2. package/dist/config/config.js +26 -35
  3. package/dist/config/extension-list.d.ts +6 -5
  4. package/dist/config/extension-list.js +3 -2
  5. package/dist/config/reasoning-args.d.ts +9 -7
  6. package/dist/config/reasoning-args.js +12 -10
  7. package/dist/config/reasoning.d.ts +44 -105
  8. package/dist/config/reasoning.js +27 -704
  9. package/dist/config/register.d.ts +34 -48
  10. package/dist/config/register.js +41 -51
  11. package/dist/config/tool-list.d.ts +16 -16
  12. package/dist/config/tool-list.js +1 -1
  13. package/dist/index.js +2 -0
  14. package/dist/remote/bridge.d.ts +19 -10
  15. package/dist/remote/bridge.js +3 -2
  16. package/dist/remote/broadcast.js +3 -1
  17. package/dist/remote/events.js +12 -11
  18. package/dist/remote/history.d.ts +1 -1
  19. package/dist/remote/protocol.d.ts +6 -3
  20. package/dist/remote/protocol.js +2 -1
  21. package/dist/remote/push.d.ts +16 -16
  22. package/dist/remote/push.js +27 -27
  23. package/dist/remote/register.d.ts +3 -3
  24. package/dist/remote/register.js +17 -19
  25. package/dist/remote/server.d.ts +9 -8
  26. package/dist/remote/server.js +15 -14
  27. package/dist/remote/session-state.d.ts +5 -4
  28. package/dist/remote/session-state.js +8 -5
  29. package/dist/remote/sw.d.ts +7 -6
  30. package/dist/remote/sw.js +7 -6
  31. package/dist/remote/tailscale.d.ts +4 -2
  32. package/dist/remote/tailscale.js +4 -2
  33. package/dist/remote/ui-highlight.js +6 -5
  34. package/dist/remote/ui-render.js +4 -4
  35. package/dist/remote/ui-script.js +24 -24
  36. package/dist/remote/ui-styles.d.ts +1 -1
  37. package/dist/remote/ui-styles.js +10 -13
  38. package/dist/remote/ui-tools.js +9 -6
  39. package/dist/shared/child-extensions.d.ts +29 -17
  40. package/dist/shared/child-extensions.js +29 -17
  41. package/dist/shared/child-output.d.ts +30 -24
  42. package/dist/shared/child-output.js +25 -17
  43. package/dist/shared/child-process.d.ts +47 -40
  44. package/dist/shared/child-process.js +50 -59
  45. package/dist/shared/command-watchdog.d.ts +85 -16
  46. package/dist/shared/command-watchdog.js +115 -21
  47. package/dist/shared/fs-text.d.ts +16 -10
  48. package/dist/shared/fs-text.js +16 -10
  49. package/dist/shared/git-runner.d.ts +25 -25
  50. package/dist/shared/git-runner.js +25 -25
  51. package/dist/shared/leaked-tool-call.d.ts +17 -11
  52. package/dist/shared/leaked-tool-call.js +23 -15
  53. package/dist/shared/model-endpoint.d.ts +29 -16
  54. package/dist/shared/model-endpoint.js +33 -21
  55. package/dist/shared/pi-invocation.d.ts +7 -4
  56. package/dist/shared/pi-invocation.js +12 -7
  57. package/dist/shared/pkg-version.d.ts +13 -5
  58. package/dist/shared/pkg-version.js +13 -5
  59. package/dist/shared/reasoning-capability.d.ts +35 -24
  60. package/dist/shared/reasoning-capability.js +35 -24
  61. package/dist/shared/stream-watchdog.d.ts +60 -44
  62. package/dist/shared/stream-watchdog.js +62 -45
  63. package/dist/task/accept-debt.d.ts +41 -43
  64. package/dist/task/accept-debt.js +73 -65
  65. package/dist/task/api-synthesis.d.ts +24 -21
  66. package/dist/task/api-synthesis.js +32 -26
  67. package/dist/task/apis-contract.d.ts +32 -64
  68. package/dist/task/apis-contract.js +32 -64
  69. package/dist/task/artifact-closure.d.ts +27 -13
  70. package/dist/task/artifact-closure.js +95 -67
  71. package/dist/task/auto-commit.d.ts +46 -35
  72. package/dist/task/auto-commit.js +51 -38
  73. package/dist/task/auto-io.d.ts +45 -25
  74. package/dist/task/auto-io.js +57 -29
  75. package/dist/task/auto-orchestrator.d.ts +26 -24
  76. package/dist/task/auto-orchestrator.js +192 -165
  77. package/dist/task/auto-prompts.d.ts +36 -24
  78. package/dist/task/auto-prompts.js +40 -26
  79. package/dist/task/autofix-ledger.d.ts +27 -25
  80. package/dist/task/autofix-ledger.js +29 -26
  81. package/dist/task/batch-test-task.d.ts +20 -12
  82. package/dist/task/batch-test-task.js +67 -60
  83. package/dist/task/boot-probe.d.ts +60 -44
  84. package/dist/task/boot-probe.js +91 -72
  85. package/dist/task/cancel-input.d.ts +30 -16
  86. package/dist/task/cancel-input.js +20 -11
  87. package/dist/task/cancel-points.d.ts +27 -20
  88. package/dist/task/cancel-points.js +30 -22
  89. package/dist/task/child-runner.d.ts +124 -55
  90. package/dist/task/child-runner.js +298 -90
  91. package/dist/task/child-status.d.ts +23 -16
  92. package/dist/task/child-status.js +23 -16
  93. package/dist/task/clamp-output.js +12 -5
  94. package/dist/task/command-run.d.ts +31 -28
  95. package/dist/task/command-run.js +44 -35
  96. package/dist/task/command-shrink.d.ts +25 -18
  97. package/dist/task/command-shrink.js +37 -31
  98. package/dist/task/command-watchdog.d.ts +9 -6
  99. package/dist/task/command-watchdog.js +21 -15
  100. package/dist/task/context-attribution.d.ts +34 -26
  101. package/dist/task/context-attribution.js +34 -26
  102. package/dist/task/context-silence.d.ts +39 -29
  103. package/dist/task/context-silence.js +35 -25
  104. package/dist/task/context-usage.d.ts +16 -9
  105. package/dist/task/context-usage.js +16 -9
  106. package/dist/task/contracts.d.ts +8 -4
  107. package/dist/task/contracts.js +25 -17
  108. package/dist/task/coverage-loop.d.ts +22 -18
  109. package/dist/task/coverage-loop.js +35 -30
  110. package/dist/task/critique-probes.d.ts +13 -14
  111. package/dist/task/critique-probes.js +50 -39
  112. package/dist/task/debug-log.d.ts +13 -5
  113. package/dist/task/debug-log.js +32 -20
  114. package/dist/task/decompose-fidelity.d.ts +11 -9
  115. package/dist/task/decompose-fidelity.js +38 -33
  116. package/dist/task/decompose-granularity.d.ts +41 -38
  117. package/dist/task/decompose-granularity.js +41 -38
  118. package/dist/task/deep-render-check.d.ts +22 -14
  119. package/dist/task/deep-render-check.js +40 -31
  120. package/dist/task/dropped-input.d.ts +12 -7
  121. package/dist/task/dropped-input.js +5 -2
  122. package/dist/task/enforce-attribution.d.ts +38 -47
  123. package/dist/task/enforce-attribution.js +46 -52
  124. package/dist/task/enforce-guidelines.d.ts +31 -20
  125. package/dist/task/enforce-guidelines.js +32 -21
  126. package/dist/task/enrichment.d.ts +7 -2
  127. package/dist/task/enrichment.js +26 -14
  128. package/dist/task/env-notes.d.ts +16 -7
  129. package/dist/task/env-notes.js +48 -31
  130. package/dist/task/env-template-closure.d.ts +4 -4
  131. package/dist/task/env-template-closure.js +42 -34
  132. package/dist/task/external-context.d.ts +28 -21
  133. package/dist/task/external-context.js +17 -12
  134. package/dist/task/failure-classifier.d.ts +4 -5
  135. package/dist/task/failure-classifier.js +30 -8
  136. package/dist/task/file-inventory.d.ts +15 -11
  137. package/dist/task/file-inventory.js +25 -22
  138. package/dist/task/final-gate-fix.d.ts +74 -86
  139. package/dist/task/final-gate-fix.js +97 -116
  140. package/dist/task/final-gate-progress.d.ts +29 -46
  141. package/dist/task/final-gate-progress.js +40 -51
  142. package/dist/task/final-gate.d.ts +64 -97
  143. package/dist/task/final-gate.js +192 -199
  144. package/dist/task/fix-child.d.ts +21 -27
  145. package/dist/task/fix-child.js +21 -27
  146. package/dist/task/foreign-path.d.ts +6 -5
  147. package/dist/task/foreign-path.js +0 -0
  148. package/dist/task/frozen-conflict.d.ts +9 -10
  149. package/dist/task/frozen-conflict.js +61 -64
  150. package/dist/task/frozen-path-guard.d.ts +35 -14
  151. package/dist/task/frozen-path-guard.js +56 -39
  152. package/dist/task/gate-child.d.ts +27 -28
  153. package/dist/task/gate-child.js +36 -35
  154. package/dist/task/gate-deps.d.ts +34 -27
  155. package/dist/task/gate-deps.js +169 -159
  156. package/dist/task/gate-tally.d.ts +77 -80
  157. package/dist/task/gate-tally.js +65 -68
  158. package/dist/task/git-state-guard.d.ts +15 -11
  159. package/dist/task/git-state-guard.js +76 -66
  160. package/dist/task/impl-widget.d.ts +25 -16
  161. package/dist/task/impl-widget.js +27 -17
  162. package/dist/task/implementation-guards.d.ts +26 -0
  163. package/dist/task/implementation-guards.js +177 -0
  164. package/dist/task/implementation-thinking.d.ts +33 -31
  165. package/dist/task/implementation-thinking.js +5 -6
  166. package/dist/task/implementation-turn.d.ts +39 -31
  167. package/dist/task/implementation-turn.js +41 -28
  168. package/dist/task/inline-markdown.d.ts +20 -7
  169. package/dist/task/inline-markdown.js +15 -6
  170. package/dist/task/launch-config-gap.js +25 -39
  171. package/dist/task/launch-contract.d.ts +18 -21
  172. package/dist/task/launch-contract.js +28 -30
  173. package/dist/task/launch-manifest.d.ts +6 -2
  174. package/dist/task/launch-manifest.js +35 -34
  175. package/dist/task/ledger.js +16 -14
  176. package/dist/task/lint-fix.d.ts +6 -8
  177. package/dist/task/lint-fix.js +67 -69
  178. package/dist/task/loop-detector.d.ts +27 -8
  179. package/dist/task/loop-detector.js +38 -14
  180. package/dist/task/mid-run-input.d.ts +17 -15
  181. package/dist/task/mid-run-input.js +17 -15
  182. package/dist/task/orchestrator.d.ts +24 -28
  183. package/dist/task/orchestrator.js +89 -66
  184. package/dist/task/orientation.d.ts +18 -23
  185. package/dist/task/orientation.js +24 -31
  186. package/dist/task/owned-freeze-conflict.d.ts +21 -20
  187. package/dist/task/owned-freeze-conflict.js +52 -85
  188. package/dist/task/owned-freeze-reassign.d.ts +40 -60
  189. package/dist/task/owned-freeze-reassign.js +41 -61
  190. package/dist/task/parsers.d.ts +4 -2
  191. package/dist/task/parsers.js +4 -4
  192. package/dist/task/phases.d.ts +41 -48
  193. package/dist/task/phases.js +196 -252
  194. package/dist/task/plan-io.d.ts +6 -7
  195. package/dist/task/plan-io.js +6 -7
  196. package/dist/task/plan-orchestrator.d.ts +10 -8
  197. package/dist/task/plan-orchestrator.js +14 -10
  198. package/dist/task/plan-prompts.d.ts +6 -5
  199. package/dist/task/plan-prompts.js +6 -5
  200. package/dist/task/plan-readonly.d.ts +4 -5
  201. package/dist/task/plan-readonly.js +4 -5
  202. package/dist/task/plan-rounds.d.ts +17 -29
  203. package/dist/task/plan-rounds.js +21 -34
  204. package/dist/task/plan-session.d.ts +58 -72
  205. package/dist/task/plan-session.js +61 -83
  206. package/dist/task/probe-gaming.d.ts +28 -27
  207. package/dist/task/probe-gaming.js +0 -0
  208. package/dist/task/prohibition-probe.d.ts +14 -16
  209. package/dist/task/prompts.d.ts +3 -4
  210. package/dist/task/prompts.js +17 -26
  211. package/dist/task/qa-transcript.d.ts +15 -22
  212. package/dist/task/qa-transcript.js +15 -21
  213. package/dist/task/question-box.d.ts +17 -13
  214. package/dist/task/question-box.js +19 -15
  215. package/dist/task/question-dedup.d.ts +6 -7
  216. package/dist/task/question-dedup.js +13 -14
  217. package/dist/task/question-dialog.d.ts +22 -32
  218. package/dist/task/question-dialog.js +22 -32
  219. package/dist/task/question-source.d.ts +18 -44
  220. package/dist/task/question-source.js +22 -51
  221. package/dist/task/refuted-constraint.d.ts +11 -31
  222. package/dist/task/refuted-constraint.js +27 -51
  223. package/dist/task/regenerable-artifacts.d.ts +12 -31
  224. package/dist/task/regenerable-artifacts.js +12 -31
  225. package/dist/task/render-check.d.ts +11 -22
  226. package/dist/task/render-check.js +33 -46
  227. package/dist/task/repo-health-check.d.ts +10 -14
  228. package/dist/task/repo-health-check.js +17 -23
  229. package/dist/task/requirements.d.ts +38 -71
  230. package/dist/task/requirements.js +78 -126
  231. package/dist/task/research-fanout-budget.d.ts +51 -88
  232. package/dist/task/research-fanout-budget.js +51 -88
  233. package/dist/task/research-worker.d.ts +29 -39
  234. package/dist/task/research-worker.js +37 -61
  235. package/dist/task/resume-gap.d.ts +14 -15
  236. package/dist/task/root-cause-repair.d.ts +9 -9
  237. package/dist/task/root-cause-repair.js +28 -40
  238. package/dist/task/run-bracket.d.ts +10 -13
  239. package/dist/task/run-end.d.ts +12 -22
  240. package/dist/task/run-end.js +8 -16
  241. package/dist/task/run-final-gate.d.ts +19 -21
  242. package/dist/task/run-final-gate.js +62 -80
  243. package/dist/task/runner-globs.d.ts +12 -13
  244. package/dist/task/runner-globs.js +12 -13
  245. package/dist/task/runner-resolve.d.ts +9 -9
  246. package/dist/task/runner-resolve.js +22 -23
  247. package/dist/task/script-escape.d.ts +10 -12
  248. package/dist/task/script-escape.js +13 -14
  249. package/dist/task/serve-entry.d.ts +1 -1
  250. package/dist/task/serve-entry.js +22 -25
  251. package/dist/task/service-blocks.js +4 -2
  252. package/dist/task/shipped-source.d.ts +11 -29
  253. package/dist/task/shipped-source.js +11 -29
  254. package/dist/task/skip-escape.js +10 -14
  255. package/dist/task/spec-urls.d.ts +26 -65
  256. package/dist/task/spec-urls.js +26 -65
  257. package/dist/task/spec-validation.d.ts +17 -20
  258. package/dist/task/spec-validation.js +17 -20
  259. package/dist/task/stall-detector.d.ts +23 -30
  260. package/dist/task/stall-detector.js +23 -30
  261. package/dist/task/stream-watchdog.d.ts +14 -12
  262. package/dist/task/stream-watchdog.js +14 -12
  263. package/dist/task/substitution-probe.d.ts +17 -20
  264. package/dist/task/substitution-probe.js +17 -20
  265. package/dist/task/task-gates.d.ts +36 -41
  266. package/dist/task/task-gates.js +95 -106
  267. package/dist/task/task-io.d.ts +4 -4
  268. package/dist/task/task-io.js +4 -4
  269. package/dist/task/task-parsers.js +4 -3
  270. package/dist/task/task-provenance.d.ts +2 -2
  271. package/dist/task/task-provenance.js +11 -13
  272. package/dist/task/task-types.d.ts +4 -3
  273. package/dist/task/terminal-outcome.d.ts +14 -16
  274. package/dist/task/terminal-outcome.js +12 -14
  275. package/dist/task/test-assembly.d.ts +13 -20
  276. package/dist/task/test-assembly.js +13 -20
  277. package/dist/task/timings.d.ts +5 -3
  278. package/dist/task/timings.js +5 -3
  279. package/dist/task/title-label.d.ts +9 -4
  280. package/dist/task/title-label.js +9 -4
  281. package/dist/task/type-only-answer.d.ts +44 -52
  282. package/dist/task/type-only-answer.js +44 -52
  283. package/dist/task/unfailable-command.d.ts +18 -24
  284. package/dist/task/unfailable-command.js +21 -27
  285. package/dist/task/unknown-routing.d.ts +10 -4
  286. package/dist/task/unknown-routing.js +10 -4
  287. package/dist/task/user-directives.d.ts +5 -8
  288. package/dist/task/user-directives.js +5 -8
  289. package/dist/task/verify-quality.d.ts +18 -22
  290. package/dist/task/verify-quality.js +45 -46
  291. package/dist/task/verify-reconcile.d.ts +15 -10
  292. package/dist/task/verify-reconcile.js +45 -43
  293. package/dist/task/verify-resolution.d.ts +24 -20
  294. package/dist/task/verify-resolution.js +51 -50
  295. package/dist/task/verify-work.d.ts +59 -66
  296. package/dist/task/verify-work.js +101 -138
  297. package/dist/task/widget.d.ts +15 -14
  298. package/dist/task/widget.js +22 -17
  299. package/dist/task/wiring-claims.d.ts +25 -32
  300. package/dist/task/wiring-claims.js +30 -35
  301. package/dist/task/write-guard.d.ts +39 -39
  302. package/dist/task/write-guard.js +48 -51
  303. package/dist/task/yolo.d.ts +34 -30
  304. package/dist/task/yolo.js +42 -37
  305. package/dist/workers/abstention.d.ts +21 -41
  306. package/dist/workers/abstention.js +27 -48
  307. package/dist/workers/brave-search.d.ts +4 -3
  308. package/dist/workers/brave-search.js +5 -2
  309. package/dist/workers/brave-warning.d.ts +7 -4
  310. package/dist/workers/brave-warning.js +19 -7
  311. package/dist/workers/ddg-search.d.ts +6 -6
  312. package/dist/workers/ddg-search.js +18 -12
  313. package/dist/workers/docs-cache.js +5 -2
  314. package/dist/workers/docs-chunk.d.ts +30 -37
  315. package/dist/workers/docs-chunk.js +37 -41
  316. package/dist/workers/docs-core.d.ts +28 -44
  317. package/dist/workers/docs-core.js +25 -44
  318. package/dist/workers/docs-index.js +4 -3
  319. package/dist/workers/docs-lookup.d.ts +15 -22
  320. package/dist/workers/docs-lookup.js +12 -21
  321. package/dist/workers/docs-project.d.ts +15 -9
  322. package/dist/workers/docs-project.js +17 -10
  323. package/dist/workers/docs-resolve.d.ts +19 -20
  324. package/dist/workers/docs-resolve.js +35 -32
  325. package/dist/workers/docs-retrieve.d.ts +5 -6
  326. package/dist/workers/docs-retrieve.js +18 -15
  327. package/dist/workers/exa-search.d.ts +9 -6
  328. package/dist/workers/exa-search.js +23 -12
  329. package/dist/workers/fetch-core.d.ts +13 -16
  330. package/dist/workers/fetch-core.js +23 -23
  331. package/dist/workers/focused-extractor.d.ts +13 -12
  332. package/dist/workers/focused-extractor.js +27 -19
  333. package/dist/workers/html-clean.js +24 -14
  334. package/dist/workers/http-request.d.ts +28 -20
  335. package/dist/workers/http-request.js +22 -17
  336. package/dist/workers/npm-version.d.ts +28 -11
  337. package/dist/workers/npm-version.js +24 -15
  338. package/dist/workers/phantom-imports.d.ts +15 -12
  339. package/dist/workers/phantom-imports.js +30 -24
  340. package/dist/workers/pi-worker-core.d.ts +65 -96
  341. package/dist/workers/pi-worker-core.js +93 -181
  342. package/dist/workers/pi-worker-docs.d.ts +24 -19
  343. package/dist/workers/pi-worker-docs.js +67 -76
  344. package/dist/workers/pi-worker-fetch.d.ts +7 -3
  345. package/dist/workers/pi-worker-fetch.js +27 -19
  346. package/dist/workers/pi-worker-search.js +12 -8
  347. package/dist/workers/pi-worker.d.ts +9 -4
  348. package/dist/workers/pi-worker.js +21 -14
  349. package/dist/workers/reasoning-warning.d.ts +18 -17
  350. package/dist/workers/reasoning-warning.js +22 -20
  351. package/dist/workers/research-cache.js +50 -78
  352. package/dist/workers/search-core.js +7 -5
  353. package/dist/workers/search-types.d.ts +10 -9
  354. package/dist/workers/search-types.js +9 -8
  355. package/dist/workers/session-hint.d.ts +13 -14
  356. package/dist/workers/session-hint.js +8 -9
  357. package/dist/workers/shared.d.ts +21 -25
  358. package/dist/workers/shared.js +0 -0
  359. package/dist/workers/single-read-extension.d.ts +14 -7
  360. package/dist/workers/single-read-extension.js +14 -7
  361. package/dist/workers/single-read-guard.d.ts +27 -30
  362. package/dist/workers/single-read-guard.js +36 -36
  363. package/dist/workers/typeonly-log.d.ts +12 -9
  364. package/dist/workers/typeonly-log.js +29 -33
  365. package/dist/workers/worker-channels.d.ts +15 -23
  366. package/dist/workers/worker-channels.js +15 -23
  367. package/dist/workers/worker-failure.d.ts +38 -46
  368. package/dist/workers/worker-failure.js +31 -39
  369. package/dist/workers/worker-kill.d.ts +25 -26
  370. package/dist/workers/worker-kill.js +16 -19
  371. package/dist/workers/worker-profiles.d.ts +54 -56
  372. package/dist/workers/worker-profiles.js +63 -39
  373. package/package.json +10 -8
@@ -2,41 +2,43 @@
2
2
  * git-state-guard — deterministic repo-state snapshot/reconcile around the
3
3
  * read-only gate children (verify, recommend).
4
4
  *
5
- * The failure this closes (proven twice on mx5): those children hold a `read,bash`
6
- * contract whose "never modify the tree" clause is prompt-level only, and the live
7
- * local model breaks it. Run 6's verify child ran `git stash; git checkout HEAD~1;
8
- * tsc; git checkout HEAD` with NO pop the task's whole uncommitted implementation
9
- * vanished into a stash, the verify judged an empty tree (a full re-implementation
10
- * was burned), and the orphaned stash detonated two days later when a later impl
11
- * turn popped it onto a 14-commits-newer HEAD (unresolvable UU conflict, the
12
- * /task-auto checklist reverted to a stale state). The same child has been observed
13
- * running `eslint --fix .` mid-verification and ad-hoc DDL against the test DB.
5
+ * Those children hold a `read,bash` contract whose "never modify the tree" clause
6
+ * is PROMPT-LEVEL ONLY: `bash` can stash, check out, commit and rewrite files, and
7
+ * nothing but the prompt says not to. A child that stashes the task's uncommitted
8
+ * work and never pops it leaves the verify judging an empty tree AND leaves an
9
+ * orphan stash that only detonates when something later pops it onto a moved HEAD.
10
+ * So this is capability-shaped rather than prompt-shaped: snapshot the repo state
11
+ * BEFORE the child runs, deterministically restore whatever it moved afterwards,
12
+ * no model in the loop.
14
13
  *
15
- * Prompt rules are evidence-insufficient for this class (FROZEN-CONTRACT framing
16
- * A/B'd 0–1/5 compliance), so this is a capability-shaped fix: snapshot the repo
17
- * state BEFORE the child runs, and afterwards deterministically restore anything
18
- * it moved no model in the loop.
19
- *
20
- * What is captured / reconciled:
21
- * - HEAD (sha + symbolic branch ref): a child that checked out another commit
22
- * and never came back is checked back out.
23
- * - The WORKTREE CONTENT as a git tree object, built through a temporary index
24
- * (`read-tree --empty` + `add -A` + `write-tree`, excluding .pi-tasks — the
25
- * gate's own debug logs land there DURING the run). This snapshots tracked
26
- * *and* untracked (non-ignored) files without touching the real index or the
27
- * stash. Restoration re-materialises every changed/deleted file from the
28
- * snapshot tree and deletes files the child created.
14
+ * What is captured and reconciled each one run against a real repo:
15
+ * - HEAD (sha + symbolic branch ref). A child that checked out another commit,
16
+ * detached, is put back on its branch AND its sha. LIMIT: a child that COMMITS
17
+ * on the current branch is NOT undone the restore checks out
18
+ * `before.branchRef`, and that branch now points at the child's commit, so the
19
+ * `checked HEAD back out to <branch>` line is honest about the ref and says
20
+ * nothing about the sha. The move is still classed verdict-tainting, so the
21
+ * verdict is discarded; the commit stays.
22
+ * - The WORKTREE CONTENT as a git tree object, built through a THROWAWAY index
23
+ * (`read-tree --empty` + `add -A` + `write-tree`, excluding `.pi-tasks` — the
24
+ * gate's own debug logs land there DURING the run). Measured on a repo with a
25
+ * staged file, an untracked file, a gitignored file and a `.pi-tasks/` log: the
26
+ * tree holds the tracked and untracked files and neither of the other two, and
27
+ * the REAL index still shows the same staged path afterwards. Restoration
28
+ * re-materialises every changed or deleted file and deletes what the child
29
+ * created.
29
30
  * - The STASH ref: entries the child pushed are dropped AFTER the worktree is
30
- * restored from the snapshot (the snapshot, not the stash, is the source of
31
- * truth), so no landmine stash survives the reconcile.
31
+ * restored from the snapshot the snapshot, not the stash, is the source of
32
+ * truth so no orphan stash survives the reconcile.
32
33
  *
33
34
  * The real index's staging state is deliberately NOT restored: pre-commit gate
34
35
  * children run against a tree whose work is unstaged, and the auto-commit that
35
- * follows re-stages everything with `add -A` anyway.
36
+ * follows re-stages everything with `git add -A` (auto-commit.ts:192).
36
37
  *
37
- * Everything is best-effort: a repo where git itself fails (not a work tree, git
38
- * missing) disables the guard (capture returns ok:false and reconcile no-ops)
39
- * the gate must keep working in non-git projects exactly as before.
38
+ * Everything is best-effort: a repo where git fails disables the guard capture
39
+ * returns `ok: false` and reconcile no-ops. Measured: a fresh `git init` with no
40
+ * commits answers non-zero to `rev-parse -q --verify HEAD`, which is the unborn-HEAD
41
+ * case the capture bails on.
40
42
  */
41
43
  import { readFileSync } from 'node:fs';
42
44
  import * as fsp from 'node:fs/promises';
@@ -48,29 +50,30 @@ import { isRegenerableArtifact } from './regenerable-artifacts.js';
48
50
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
49
51
  /**
50
52
  * Untracked paths that are regenerable test/build OUTPUT, not graded source. A gate
51
- * child creating or rewriting one of these has not mutated the work under judgement,
52
- * so its verdict stands. Gitignored files never reach the snapshot (git add -A skips
53
- * them); this list is for the ones a typical project leaves UNIGNORED — Playwright's
54
- * `test-results/` and `playwright-report/` above all, the exact churn that discarded
55
- * verify verdicts across mx5 run 9. Kept deliberately narrow: anything not matched
56
- * here that a child modifies/deletes is treated as graded state (verdict-tainting).
53
+ * child creating or rewriting one of these has not mutated the work under
54
+ * judgement, so its verdict stands. Gitignored files never reach the snapshot at
55
+ * all — measured, `add -A` skips them — so this list is for the ones a typical
56
+ * project leaves UNIGNORED, `test-results/` and `playwright-report/` above all.
57
+ * Kept deliberately narrow: anything NOT matched here that a child modifies or
58
+ * deletes is graded state, and taints the verdict.
57
59
  *
58
- * The list itself now lives in `regenerable-artifacts.ts` the deletion guard and
59
- * the per-task commit need the same knowledge, and three private copies of it is
60
- * how mx5 run 20 spent two thirds of its repair budget on three screenshots.
60
+ * The list lives in `regenerable-artifacts.ts` because three call sites need the
61
+ * same knowledge this guard, the write-guard's deletion check, and the per-task
62
+ * commit and three private copies would drift.
61
63
  */
62
64
  const isBenignArtifact = isRegenerableArtifact;
63
65
  /**
64
- * Regenerable machine state that is benign EVEN WHEN TRACKED a project that
65
- * mistakenly commits it (mx5 run 10 does exactly this) must not have a gate child's
66
- * incidental rewrite of it discard the verdict. Two classes:
67
- * - Playwright component-test build cache (`ctCacheDir` run 10 committed 60+
68
- * `.playwright-cache/assets/*.js` bundles; a `test:ct` run rewrites them every
69
- * time), and
70
- * - the test runner's `.last-run.json` run-state file.
71
- * DELIBERATELY narrow: snapshot BASELINE images (`*-snapshots/*.png`) are NOT here —
66
+ * Regenerable machine state that is benign EVEN WHEN TRACKED, so a project that
67
+ * commits it does not have a child's incidental rewrite discard the verdict. Two
68
+ * classes: the component-test build cache under `ctCacheDir`, which a component
69
+ * test run rewrites every time, and the test runner's `.last-run.json` run-state
70
+ * file.
71
+ *
72
+ * DELIBERATELY narrow. Snapshot BASELINE images (`*-snapshots/*.png`) are NOT here:
72
73
  * a child that rewrites a baseline to make a screenshot test pass is the real
73
- * mutate-to-pass catch (run 10's other half), so those stay verdict-tainting.
74
+ * mutate-to-pass catch. Measured on a real repo a tracked file under a custom
75
+ * `ctCacheDir` and a tracked `.last-run.json` both restore WITHOUT tainting, while
76
+ * a tracked `tests/a-snapshots/x.png` taints.
74
77
  */
75
78
  const ALWAYS_REGENERABLE_PATTERNS = [/(?:^|\/)\.last-run\.json$/];
76
79
  /** Playwright config files that may declare a custom `ctCacheDir`. */
@@ -80,7 +83,10 @@ const CT_CONFIG_FILES = [
80
83
  'playwright.config.ts',
81
84
  'playwright.config.js'
82
85
  ];
83
- /** ctCacheDir defaults Playwright uses when a config does not override it. */
86
+ /** The ctCacheDir values assumed when no config declares one. Playwright is not a
87
+ * dependency here, so these are not verifiable against an installed package —
88
+ * what IS verified is that a config declaring `ctCacheDir: './custom-cache/'` is
89
+ * parsed and its directory treated as regenerable. */
84
90
  const DEFAULT_CT_CACHE_DIRS = ['.playwright-cache', 'playwright/.cache'];
85
91
  /**
86
92
  * The component-test cache dir(s) for this project: the `ctCacheDir` any Playwright
@@ -183,14 +189,15 @@ function pushCapped(actions, verb, paths) {
183
189
  }
184
190
  /**
185
191
  * Restore every file recorded in `beforeTree` (content + deletions) and remove
186
- * files that exist in `afterTree` but not in `beforeTree` (files the child
187
- * created). Uses a throwaway index seeded from the snapshot tree; `checkout-index
192
+ * files that exist in `afterTree` but not in `beforeTree` the ones the child
193
+ * created. Uses a throwaway index seeded from the snapshot tree; `checkout-index
188
194
  * -a -f` re-materialises the snapshot verbatim.
189
195
  *
190
- * Returns whether any restored change was *verdict-tainting* a modified/deleted
191
- * path that is tracked-in-HEAD or an untracked non-artifact (see isBenignArtifact).
192
- * Creations and test-runner-artifact churn restore identically but do NOT taint.
193
- * Each changed path is itemised (capped) so the gate trail says WHICH files moved.
196
+ * Returns whether any restored change was *verdict-tainting*: a modified or
197
+ * deleted path that is tracked-in-HEAD, or an untracked non-artifact (see
198
+ * isBenignArtifact). Creations and test-runner-artifact churn restore identically
199
+ * but do NOT taint. Each changed path is itemised, capped, so the gate trail says
200
+ * WHICH files moved.
194
201
  */
195
202
  async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, ctCacheDirs, actions) {
196
203
  const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
@@ -209,8 +216,11 @@ async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, ctCache
209
216
  const trimmed = line.trim();
210
217
  if (trimmed.length === 0)
211
218
  continue;
212
- // "M\tpath", "A\tpath", "D\tpath", "T\tpath" — no -M, so renames show
213
- // as a D + an A pair; both get classified on their own merits.
219
+ // "M\tpath", "A\tpath", "D\tpath", "T\tpath" — no -M, so a rename
220
+ // shows as a D + an A pair and each half is classified on its own
221
+ // merits. Measured: renaming a tracked `a.txt` to `b.txt` yields
222
+ // "removed child-created file b.txt" plus "restored deleted file
223
+ // a.txt", and taints.
214
224
  const tab = trimmed.indexOf('\t');
215
225
  if (tab < 0)
216
226
  continue;
@@ -225,7 +235,7 @@ async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, ctCache
225
235
  || (isBenignArtifact(name) && !tracked.has(name))) {
226
236
  // Regenerable test/build output — not graded work. Either an
227
237
  // always-regenerable class (ct cache / run-state, benign even when
228
- // tracked — mx5 run 10) or untracked test-runner output.
238
+ // tracked) or untracked test-runner output.
229
239
  artifactChanges.push(name);
230
240
  }
231
241
  else if (code === 'D') {
@@ -268,11 +278,11 @@ async function restoreWorktree(cwd, git, beforeTree, afterTree, tracked, ctCache
268
278
  * whatever a gate child moved. Ordering matters:
269
279
  *
270
280
  * 1. HEAD first — a child parked on another commit must be back on the original
271
- * ref before the worktree comparison/restore makes sense.
272
- * 2. Worktree content from the snapshot TREE (not from any stash the child may
273
- * have pushed the snapshot is the authoritative "as the child found it").
274
- * 3. Child-pushed stash entries are dropped LAST, once the work they swallowed
275
- * is already restored — this is exactly the orphan that detonated mx5 run 6.
281
+ * ref before the worktree comparison and restore make sense.
282
+ * 2. Worktree content from the snapshot TREE, never from a stash the child may
283
+ * have pushed: the snapshot is the authoritative "as the child found it".
284
+ * 3. Child-pushed stash entries are dropped LAST, once the work they swallowed is
285
+ * already restored — dropping first would destroy the only other copy.
276
286
  *
277
287
  * Never throws; failures degrade to actions[] lines so the caller can log them.
278
288
  */
@@ -308,9 +318,9 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
308
318
  }
309
319
  }
310
320
  // 3. Stash entries the child pushed. Drop stash@{0} until the ref matches the
311
- // snapshot again (bounded a child pushes at most a handful; 10 is beyond
312
- // anything observed). A stash the child POPPED (ref gone/behind) cannot be
313
- // reconstructed report it instead of guessing.
321
+ // snapshot again, bounded at 10 so a runaway cannot loop here. A stash the
322
+ // child POPPED the ref is gone or no longer contains the snapshot's tip —
323
+ // cannot be reconstructed, so report it instead of guessing.
314
324
  const stashNow = async () => {
315
325
  const s = await git(['rev-parse', '-q', '--verify', 'refs/stash']);
316
326
  return s.exitCode === 0 ? s.stdout.trim() : null;
@@ -318,7 +328,7 @@ export async function reconcileGitState(cwd, before, signal, spawnFn) {
318
328
  let stash = await stashNow();
319
329
  if (stash !== before.stashSha) {
320
330
  // A child that pushed/popped a stash moved graded work in or out of the tree
321
- // (mx5 run 6's stash-and-abandon) — always verdict-tainting.
331
+ // (a stash-and-abandon) — always verdict-tainting.
322
332
  tainted = true;
323
333
  if (before.stashSha === null || (await stashContains(git, stash, before.stashSha))) {
324
334
  let dropped = 0;
@@ -1,24 +1,30 @@
1
1
  /**
2
2
  * Implementation-turn status widget.
3
3
  *
4
- * The phase widget (widget.ts) is disposed at spec-handoff, so the host agent's
5
- * implementation turn the longest, most visible part of a run otherwise shows
6
- * only pi's bare "⠸ Working…" indicator. This module keeps the SAME rich status
7
- * block alive across that turn (task id · implementing/elapsed/context bar · ↳ last
8
- * tool), driven by the host's own live `ctx.getContextUsage()`.
4
+ * `TaskRunner._deliverSpec` is preceded by `_disposeWidget()`, so the phase widget
5
+ * (widget.ts) is gone before the spec is handed off. Without this module the host
6
+ * agent's implementation turn the longest, most visible part of a run — would
7
+ * show only pi's own working indicator, whose default message is the literal
8
+ * `"Working..."`. This keeps the SAME rich status block alive across that turn
9
+ * (task id · implementing/elapsed/context bar · ↳ last tool), driven by the host's
10
+ * own live `ctx.getContextUsage()`, which pi declares as
11
+ * `getContextUsage(): ContextUsage | undefined`.
9
12
  *
10
- * It is event-driven rather than poll-wrapped because the two delivery paths differ:
13
+ * Event-driven rather than poll-wrapped, because the two delivery paths differ:
11
14
  *
12
- * • /task (fire-and-forget): the command returns right after `sendUserMessage`,
13
- * so the host runs the impl turn AFTER our code is gone — only an `agent_start`
14
- * handler can pick it up. Armed one-shot: `agent_end` disarms it.
15
- * /task-auto (awaited): the caller blocks across `waitForIdle` plus any
16
- * compaction-resume / steer turns. Armed sticky: each `agent_start` re-shows the
17
- * widget, `agent_end` only hides it between turns, and the caller disarms once
18
- * the whole implementation phase has settled.
15
+ * • /task (fire-and-forget): the command returns right after
16
+ * `piApi.sendUserMessage(spec, {deliverAs: 'followUp'})`, so the host runs the
17
+ * impl turn AFTER this code is gone and only an `agent_start` handler can pick
18
+ * it up. Armed one-shot, and `agent_end` disarms it.
19
+ * /task-auto (awaited): the caller blocks across `waitForIdle` plus any resume
20
+ * or steer turns. Armed sticky each `agent_start` re-shows the widget,
21
+ * `agent_end` only hides it between turns, and the caller disarms in a
22
+ * `finally` once the whole implementation phase has settled.
19
23
  *
20
- * Only one task runs at a time (single active task), so a single module-level slot
21
- * is sufficient.
24
+ * `_deliverSpec` picks between them with `oneShot: !this._implAwaited`.
25
+ *
26
+ * A single module-level slot is enough because only one task runs at a time —
27
+ * `orchestrator.ts` keeps one module-level `activeTask`.
22
28
  */
23
29
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
24
30
  export interface ImplWidgetMeta {
@@ -36,5 +42,8 @@ export declare function armImplWidget(meta: ImplWidgetMeta, opts: {
36
42
  }): void;
37
43
  /** Tear down the widget and clear the armed slot (sticky/awaited path). */
38
44
  export declare function disarmImplWidget(): void;
39
- /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup. */
45
+ /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup.
46
+ * All three events are pi's own: `agent_start`, `tool_execution_start` and
47
+ * `agent_end` each have an `on()` overload, and ToolExecutionStartEvent carries
48
+ * the `toolName` and `args` read below. */
40
49
  export declare function setupImplWidget(pi: ExtensionAPI): void;
@@ -1,24 +1,30 @@
1
1
  /**
2
2
  * Implementation-turn status widget.
3
3
  *
4
- * The phase widget (widget.ts) is disposed at spec-handoff, so the host agent's
5
- * implementation turn the longest, most visible part of a run otherwise shows
6
- * only pi's bare "⠸ Working…" indicator. This module keeps the SAME rich status
7
- * block alive across that turn (task id · implementing/elapsed/context bar · ↳ last
8
- * tool), driven by the host's own live `ctx.getContextUsage()`.
4
+ * `TaskRunner._deliverSpec` is preceded by `_disposeWidget()`, so the phase widget
5
+ * (widget.ts) is gone before the spec is handed off. Without this module the host
6
+ * agent's implementation turn the longest, most visible part of a run — would
7
+ * show only pi's own working indicator, whose default message is the literal
8
+ * `"Working..."`. This keeps the SAME rich status block alive across that turn
9
+ * (task id · implementing/elapsed/context bar · ↳ last tool), driven by the host's
10
+ * own live `ctx.getContextUsage()`, which pi declares as
11
+ * `getContextUsage(): ContextUsage | undefined`.
9
12
  *
10
- * It is event-driven rather than poll-wrapped because the two delivery paths differ:
13
+ * Event-driven rather than poll-wrapped, because the two delivery paths differ:
11
14
  *
12
- * • /task (fire-and-forget): the command returns right after `sendUserMessage`,
13
- * so the host runs the impl turn AFTER our code is gone — only an `agent_start`
14
- * handler can pick it up. Armed one-shot: `agent_end` disarms it.
15
- * /task-auto (awaited): the caller blocks across `waitForIdle` plus any
16
- * compaction-resume / steer turns. Armed sticky: each `agent_start` re-shows the
17
- * widget, `agent_end` only hides it between turns, and the caller disarms once
18
- * the whole implementation phase has settled.
15
+ * • /task (fire-and-forget): the command returns right after
16
+ * `piApi.sendUserMessage(spec, {deliverAs: 'followUp'})`, so the host runs the
17
+ * impl turn AFTER this code is gone and only an `agent_start` handler can pick
18
+ * it up. Armed one-shot, and `agent_end` disarms it.
19
+ * /task-auto (awaited): the caller blocks across `waitForIdle` plus any resume
20
+ * or steer turns. Armed sticky each `agent_start` re-shows the widget,
21
+ * `agent_end` only hides it between turns, and the caller disarms in a
22
+ * `finally` once the whole implementation phase has settled.
19
23
  *
20
- * Only one task runs at a time (single active task), so a single module-level slot
21
- * is sufficient.
24
+ * `_deliverSpec` picks between them with `oneShot: !this._implAwaited`.
25
+ *
26
+ * A single module-level slot is enough because only one task runs at a time —
27
+ * `orchestrator.ts` keeps one module-level `activeTask`.
22
28
  */
23
29
  import { WIDGET_KEY, WIDGET_REFRESH_MS, buildImplLines, buildImplData } from './widget.js';
24
30
  import { setTaskWidget } from '../remote/session-state.js';
@@ -27,7 +33,8 @@ let lastLine;
27
33
  let timer = null;
28
34
  let activeCtx = null;
29
35
  /** Map the host's live context usage to the widget snapshot, or undefined when the
30
- * token count is unknown (e.g. right after compaction, before the next response). */
36
+ * token count is unknown. pi types `ContextUsage.tokens` as `number | null` and
37
+ * documents the null as "right after compaction, before next LLM response". */
31
38
  function snapshot(ctx) {
32
39
  const u = ctx.getContextUsage?.();
33
40
  if (!u || u.tokens == null)
@@ -100,7 +107,10 @@ export function disarmImplWidget() {
100
107
  activeCtx = null;
101
108
  lastLine = undefined;
102
109
  }
103
- /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup. */
110
+ /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup.
111
+ * All three events are pi's own: `agent_start`, `tool_execution_start` and
112
+ * `agent_end` each have an `on()` overload, and ToolExecutionStartEvent carries
113
+ * the `toolName` and `args` read below. */
104
114
  export function setupImplWidget(pi) {
105
115
  pi.on('agent_start', (_event, ctx) => {
106
116
  if (!armed)
@@ -0,0 +1,26 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
3
+ * event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
4
+ export declare function armImplementationGuard(opts: {
5
+ oneShot: boolean;
6
+ }): void;
7
+ export declare function disarmImplementationGuard(): void;
8
+ /** @internal Test seam: is a turn currently guarded? */
9
+ export declare function implementationGuardArmed(): boolean;
10
+ /**
11
+ * Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
12
+ * about an attempt that does not exist here.
13
+ *
14
+ * It claims nothing about the call's RESULT, which this hook fires too early to
15
+ * see, and it does not offer "change the call" — that is an escape, not advice:
16
+ * one altered byte is a new key and a clean slate on both counters.
17
+ */
18
+ export declare function blockedCallReason(toolName: string, count: number): string;
19
+ /** The reason on the final block, which also ends the turn. */
20
+ export declare function terminalCallReason(): string;
21
+ export declare function consumeGuardTermination(): boolean;
22
+ /**
23
+ * Inert until armed. Registering ANY `tool_call` handler switches on pi's
24
+ * `beforeToolCall` for every call in the session, so the armed check comes first.
25
+ */
26
+ export declare function registerImplementationGuards(pi: ExtensionAPI): void;
@@ -0,0 +1,177 @@
1
+ import { LoopDetector, loopKey, LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from './loop-detector.js';
2
+ /**
3
+ * Runaway guard for the IMPLEMENTATION TURN — the one model surface with none.
4
+ *
5
+ * MEASURED: one turn ran 5h16m and 6,760 tool calls, alternating two
6
+ * byte-identical bash commands 3,300 times each with its output frozen and zero
7
+ * edits. The command watchdog is per call (each took ~2.5s), the stream was never
8
+ * silent, and MAX_COMPACTION_RESUMES counts only compactions that PARK at idle,
9
+ * while all 18 of these were inside the turn. The two detectors are built only in
10
+ * the CHILD spawn paths, and this turn runs in the user's own session.
11
+ *
12
+ * IT BLOCKS RATHER THAN KILLS because there is no re-spawn here — the argument
13
+ * single-read-extension.ts already makes: "detect-and-kill only re-spawns a model
14
+ * that deterministically re-thrashes". pi's ctx.abort() would also empty the
15
+ * queued-message list into the user's editor. That also raises the bar on false
16
+ * positives: a `gate` child killed by mistake costs one attempt of three, this
17
+ * costs the user their turn.
18
+ *
19
+ * WHY PROGRESS IS READ OFF THE CALL, NOT THE RESULT. The gate profile pairs its
20
+ * LoopDetector with a StallDetector, which judges results. That cannot work here:
21
+ * pi's edit tool returns the constant `Successfully replaced N block(s) in <path>.`
22
+ * and puts the diff in `details`, not `content`, so every real edit to one file is
23
+ * byte-identical result text and scores as dead ground. An edit's ARGUMENTS carry
24
+ * the progress its result throws away, so an edit is what resets the window.
25
+ *
26
+ * Known blind spots, all deliberate: a bash-driven mutation (`sed -i`, `>`,
27
+ * `git apply`) does not reset; one varying `write` per iteration buys unlimited
28
+ * immunity; a repeat cycle of LOOP_WINDOW/LOOP_THRESHOLD or longer never fills the
29
+ * window; polling a booting server with an identical curl trips at five; and
30
+ * schema-invalid calls never reach this hook at all, since pi validates first.
31
+ */
32
+ /** Tool names that mutate the tree. pi ships exactly seven core tools, and
33
+ * pi-task's own four (pi-worker, -search, -fetch, -docs) are all read-only. */
34
+ const MUTATING_TOOLS = new Set(['edit', 'write']);
35
+ /**
36
+ * Path-revisit is OFF for both detectors (that is the Infinity). MEASURED: at the
37
+ * default threshold, six DISTINCT edits to one file trip the path rule, because
38
+ * an edit names a `file_path` and no `limit`, so the first one sets the
39
+ * high-water mark and every later one scores as already-covered ground. Six edits
40
+ * to one file is the most ordinary thing an implementation turn does. mx5
41
+ * TASK_0002 is the same lesson from the other side: the rule killed an enforce
42
+ * child that was editing one file as its job.
43
+ */
44
+ function freshDetector() {
45
+ return new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD, Number.POSITIVE_INFINITY);
46
+ }
47
+ /** The armed turn's state, or null outside one. One slot: one task runs at a time. */
48
+ let armed = null;
49
+ /** Built, never spread from the previous state: a leaked `terminating` would
50
+ * block every call for the rest of an awaited run. */
51
+ function freshArmedState(oneShot) {
52
+ return {
53
+ loop: freshDetector(),
54
+ edits: freshDetector(),
55
+ strikes: new Map(),
56
+ terminating: false,
57
+ oneShot
58
+ };
59
+ }
60
+ /** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
61
+ * event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
62
+ export function armImplementationGuard(opts) {
63
+ armed = freshArmedState(opts.oneShot);
64
+ }
65
+ export function disarmImplementationGuard() {
66
+ armed = null;
67
+ }
68
+ /** @internal Test seam: is a turn currently guarded? */
69
+ export function implementationGuardArmed() {
70
+ return armed !== null;
71
+ }
72
+ /**
73
+ * Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
74
+ * about an attempt that does not exist here.
75
+ *
76
+ * It claims nothing about the call's RESULT, which this hook fires too early to
77
+ * see, and it does not offer "change the call" — that is an escape, not advice:
78
+ * one altered byte is a new key and a clean slate on both counters.
79
+ */
80
+ export function blockedCallReason(toolName, count) {
81
+ return (`Blocked: this is the ${count}th identical ${toolName} call in this turn. `
82
+ + `Use what you already have, or do something different, then continue the task.`);
83
+ }
84
+ /** The reason on the final block, which also ends the turn. */
85
+ export function terminalCallReason() {
86
+ return (`Blocked: this turn repeated one call past every warning, so it is being stopped `
87
+ + `here. Nothing further will run.`);
88
+ }
89
+ /**
90
+ * One-shot: the guard ended a turn, and nothing in the session state says so.
91
+ *
92
+ * `terminate` lets the agent loop finish normally — the last assistant message
93
+ * keeps `stopReason: "toolUse"`, so `classifyTurnEnd` reads `'stop'` and the run
94
+ * reports a clean finish over work that was cut off mid-task. Verified against a
95
+ * live model: a real guard-terminated turn ends exactly that way. Same shape as
96
+ * `consumeWatchdogAbort`, and consumed for the same reason — one reader, then it
97
+ * is gone.
98
+ */
99
+ let terminatedTurn = false;
100
+ export function consumeGuardTermination() {
101
+ const hit = terminatedTurn;
102
+ terminatedTurn = false;
103
+ return hit;
104
+ }
105
+ /**
106
+ * Inert until armed. Registering ANY `tool_call` handler switches on pi's
107
+ * `beforeToolCall` for every call in the session, so the armed check comes first.
108
+ */
109
+ export function registerImplementationGuards(pi) {
110
+ pi.on('tool_call', event => {
111
+ const state = armed;
112
+ if (!state)
113
+ return;
114
+ try {
115
+ // Every call, whatever it is: pi terminates only when EVERY finalized
116
+ // result in the batch carries the flag (agent-loop.js
117
+ // shouldTerminateToolBatch). The batch that trips it has already
118
+ // finalized its earlier calls without it and so survives; the next one
119
+ // ends. One batch, and it is the only bound this path has.
120
+ if (state.terminating) {
121
+ return { block: true, terminate: true, reason: terminalCallReason() };
122
+ }
123
+ const call = { name: event.toolName, args: event.input };
124
+ const mutating = MUTATING_TOOLS.has(event.toolName);
125
+ const hit = mutating ? state.edits.record(call) : state.loop.record(call);
126
+ if (!hit) {
127
+ if (mutating) {
128
+ state.loop = freshDetector();
129
+ // Strikes go with the window. Keeping them made a LATER episode
130
+ // terminate on its first hit, skipping both warnings, because an
131
+ // earlier one had part-spent the budget. MEASURED over 494 real
132
+ // turns: this loses no catch — the incident still ends at call
133
+ // 173 of 6,760, both live-model loops still end — and drops one
134
+ // termination of a turn that was editing between episodes.
135
+ // A determined model is still bounded: three hits inside ONE
136
+ // episode terminate, which is the runaway shape (it makes no
137
+ // edits at all).
138
+ state.strikes.clear();
139
+ }
140
+ return;
141
+ }
142
+ const key = loopKey(call);
143
+ const strikes = (state.strikes.get(key) ?? 0) + 1;
144
+ state.strikes.set(key, strikes);
145
+ // Blocking alone does not stop a determined model: nothing prevents the
146
+ // next identical call.
147
+ if (strikes > MAX_LOOP_RESTARTS) {
148
+ state.terminating = true;
149
+ terminatedTurn = true;
150
+ return { block: true, terminate: true, reason: terminalCallReason() };
151
+ }
152
+ return { block: true, reason: blockedCallReason(event.toolName, hit.count) };
153
+ }
154
+ catch {
155
+ // pi does not guard this hook, and a throw here would block a
156
+ // legitimate call. A broken guard must cost nothing.
157
+ return;
158
+ }
159
+ });
160
+ // NOT `agent_end`, which fires again for every auto-retry, every threshold
161
+ // compaction and every queued message — pi drives those with `agent.continue()`,
162
+ // each a fresh agent loop. The measured runaway compacted 18 times INSIDE its
163
+ // turn, so a one-shot disarm on agent_end would have retired the guard after the
164
+ // first ~375 of its 6,760 calls. `agent_settled` is the boundary that means what
165
+ // this needs: no retry, compaction or queued continuation left to run.
166
+ pi.on('agent_settled', () => {
167
+ if (!armed)
168
+ return;
169
+ if (armed.oneShot)
170
+ disarmImplementationGuard();
171
+ // An awaited run spans resume and steer turns. Counters are per TURN, so a
172
+ // fresh one starts clean rather than inheriting the last one's strikes.
173
+ else
174
+ armed = freshArmedState(false);
175
+ });
176
+ pi.on('session_shutdown', disarmImplementationGuard);
177
+ }