@cleocode/core 2026.10.1 → 2026.10.2

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 (385) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/cloud/journal.d.ts +9 -5
  3. package/dist/cloud/journal.d.ts.map +1 -1
  4. package/dist/cloud/journal.js +29 -8
  5. package/dist/cloud/journal.js.map +1 -1
  6. package/dist/cloud/manifest-check.d.ts +140 -4
  7. package/dist/cloud/manifest-check.d.ts.map +1 -1
  8. package/dist/cloud/manifest-check.js +309 -10
  9. package/dist/cloud/manifest-check.js.map +1 -1
  10. package/dist/cloud/nexus-attach.d.ts +25 -0
  11. package/dist/cloud/nexus-attach.d.ts.map +1 -1
  12. package/dist/cloud/nexus-attach.js +81 -6
  13. package/dist/cloud/nexus-attach.js.map +1 -1
  14. package/dist/cloud/nexus-cloud-activity.d.ts +37 -0
  15. package/dist/cloud/nexus-cloud-activity.d.ts.map +1 -0
  16. package/dist/cloud/nexus-cloud-activity.js +79 -0
  17. package/dist/cloud/nexus-cloud-activity.js.map +1 -0
  18. package/dist/cloud/nexus-cloud-status.d.ts.map +1 -1
  19. package/dist/cloud/nexus-cloud-status.js +36 -0
  20. package/dist/cloud/nexus-cloud-status.js.map +1 -1
  21. package/dist/cloud/nexus-device.d.ts +1 -1
  22. package/dist/cloud/nexus-enrol.d.ts.map +1 -1
  23. package/dist/cloud/nexus-enrol.js +5 -0
  24. package/dist/cloud/nexus-enrol.js.map +1 -1
  25. package/dist/cloud/nexus-home.d.ts +74 -0
  26. package/dist/cloud/nexus-home.d.ts.map +1 -0
  27. package/dist/cloud/nexus-home.js +97 -0
  28. package/dist/cloud/nexus-home.js.map +1 -0
  29. package/dist/cloud/nexus-vault-keys.d.ts +129 -0
  30. package/dist/cloud/nexus-vault-keys.d.ts.map +1 -0
  31. package/dist/cloud/nexus-vault-keys.js +277 -0
  32. package/dist/cloud/nexus-vault-keys.js.map +1 -0
  33. package/dist/cloud/nexus-vault-state.d.ts +121 -0
  34. package/dist/cloud/nexus-vault-state.d.ts.map +1 -0
  35. package/dist/cloud/nexus-vault-state.js +274 -0
  36. package/dist/cloud/nexus-vault-state.js.map +1 -0
  37. package/dist/cloud/nexus-vault.d.ts +160 -0
  38. package/dist/cloud/nexus-vault.d.ts.map +1 -0
  39. package/dist/cloud/nexus-vault.js +1733 -0
  40. package/dist/cloud/nexus-vault.js.map +1 -0
  41. package/dist/cloud/signing.d.ts +24 -8
  42. package/dist/cloud/signing.d.ts.map +1 -1
  43. package/dist/cloud/signing.js +61 -15
  44. package/dist/cloud/signing.js.map +1 -1
  45. package/dist/docs/export-document.js +8137 -3318
  46. package/dist/docs/export-document.js.map +3 -3
  47. package/dist/doctor/migrations.d.ts +75 -0
  48. package/dist/doctor/migrations.d.ts.map +1 -0
  49. package/dist/doctor/migrations.js +136 -0
  50. package/dist/doctor/migrations.js.map +1 -0
  51. package/dist/doctor/sync-sealer.d.ts +48 -0
  52. package/dist/doctor/sync-sealer.d.ts.map +1 -0
  53. package/dist/doctor/sync-sealer.js +109 -0
  54. package/dist/doctor/sync-sealer.js.map +1 -0
  55. package/dist/doctor/sync-triggers.d.ts +53 -0
  56. package/dist/doctor/sync-triggers.d.ts.map +1 -0
  57. package/dist/doctor/sync-triggers.js +157 -0
  58. package/dist/doctor/sync-triggers.js.map +1 -0
  59. package/dist/init.d.ts.map +1 -1
  60. package/dist/init.js +5 -1
  61. package/dist/init.js.map +1 -1
  62. package/dist/internal.d.ts +1 -1
  63. package/dist/internal.d.ts.map +1 -1
  64. package/dist/internal.js +1 -1
  65. package/dist/internal.js.map +1 -1
  66. package/dist/llm/plugin-facade.js +8442 -3623
  67. package/dist/llm/plugin-facade.js.map +3 -3
  68. package/dist/nexus/projects-clean.d.ts +3 -0
  69. package/dist/nexus/projects-clean.d.ts.map +1 -1
  70. package/dist/nexus/projects-clean.js +9 -3
  71. package/dist/nexus/projects-clean.js.map +1 -1
  72. package/dist/nexus/registry-roots.d.ts +3 -1
  73. package/dist/nexus/registry-roots.d.ts.map +1 -1
  74. package/dist/nexus/registry-roots.js +5 -2
  75. package/dist/nexus/registry-roots.js.map +1 -1
  76. package/dist/nexus/registry.d.ts +8 -1
  77. package/dist/nexus/registry.d.ts.map +1 -1
  78. package/dist/nexus/registry.js +24 -10
  79. package/dist/nexus/registry.js.map +1 -1
  80. package/dist/nexus/workspace.d.ts +4 -1
  81. package/dist/nexus/workspace.d.ts.map +1 -1
  82. package/dist/nexus/workspace.js +10 -4
  83. package/dist/nexus/workspace.js.map +1 -1
  84. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  85. package/dist/orchestrate/spawn-ops.js +7 -2
  86. package/dist/orchestrate/spawn-ops.js.map +1 -1
  87. package/dist/orchestrate/worker-verify.d.ts +121 -12
  88. package/dist/orchestrate/worker-verify.d.ts.map +1 -1
  89. package/dist/orchestrate/worker-verify.js +172 -21
  90. package/dist/orchestrate/worker-verify.js.map +1 -1
  91. package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
  92. package/dist/orchestration/spawn-prompt.js +15 -9
  93. package/dist/orchestration/spawn-prompt.js.map +1 -1
  94. package/dist/release/ci-evidence.d.ts +9 -0
  95. package/dist/release/ci-evidence.d.ts.map +1 -1
  96. package/dist/release/ci-evidence.js +9 -2
  97. package/dist/release/ci-evidence.js.map +1 -1
  98. package/dist/resources/backend.d.ts +15 -3
  99. package/dist/resources/backend.d.ts.map +1 -1
  100. package/dist/resources/backend.js +7 -3
  101. package/dist/resources/backend.js.map +1 -1
  102. package/dist/resources/darwin-backend.d.ts +142 -0
  103. package/dist/resources/darwin-backend.d.ts.map +1 -0
  104. package/dist/resources/darwin-backend.js +288 -0
  105. package/dist/resources/darwin-backend.js.map +1 -0
  106. package/dist/resources/governor.d.ts +44 -1
  107. package/dist/resources/governor.d.ts.map +1 -1
  108. package/dist/resources/governor.js +100 -36
  109. package/dist/resources/governor.js.map +1 -1
  110. package/dist/resources/heavy-command.d.ts +132 -0
  111. package/dist/resources/heavy-command.d.ts.map +1 -0
  112. package/dist/resources/heavy-command.js +768 -0
  113. package/dist/resources/heavy-command.js.map +1 -0
  114. package/dist/resources/monitor.d.ts +50 -1
  115. package/dist/resources/monitor.d.ts.map +1 -1
  116. package/dist/resources/monitor.js +83 -1
  117. package/dist/resources/monitor.js.map +1 -1
  118. package/dist/resources/run-admission.d.ts +303 -0
  119. package/dist/resources/run-admission.d.ts.map +1 -0
  120. package/dist/resources/run-admission.js +648 -0
  121. package/dist/resources/run-admission.js.map +1 -0
  122. package/dist/resources/run-class.d.ts +111 -0
  123. package/dist/resources/run-class.d.ts.map +1 -0
  124. package/dist/resources/run-class.js +658 -0
  125. package/dist/resources/run-class.js.map +1 -0
  126. package/dist/resources/run-governed.d.ts +158 -0
  127. package/dist/resources/run-governed.d.ts.map +1 -0
  128. package/dist/resources/run-governed.js +406 -0
  129. package/dist/resources/run-governed.js.map +1 -0
  130. package/dist/resources/slot-holder.d.ts +267 -0
  131. package/dist/resources/slot-holder.d.ts.map +1 -0
  132. package/dist/resources/slot-holder.js +505 -0
  133. package/dist/resources/slot-holder.js.map +1 -0
  134. package/dist/resources/tool-groups.d.ts +74 -0
  135. package/dist/resources/tool-groups.d.ts.map +1 -0
  136. package/dist/resources/tool-groups.js +118 -0
  137. package/dist/resources/tool-groups.js.map +1 -0
  138. package/dist/routing/build-command-groups.d.ts.map +1 -1
  139. package/dist/routing/build-command-groups.js +1 -0
  140. package/dist/routing/build-command-groups.js.map +1 -1
  141. package/dist/selfimprove/fix-gen.js +8446 -3627
  142. package/dist/selfimprove/fix-gen.js.map +3 -3
  143. package/dist/sentient/cross-project-hygiene.d.ts +3 -2
  144. package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
  145. package/dist/sentient/cross-project-hygiene.js +5 -2
  146. package/dist/sentient/cross-project-hygiene.js.map +1 -1
  147. package/dist/sentient/state.d.ts +7 -0
  148. package/dist/sentient/state.d.ts.map +1 -1
  149. package/dist/sentient/state.js.map +1 -1
  150. package/dist/sentient/tick.d.ts +14 -2
  151. package/dist/sentient/tick.d.ts.map +1 -1
  152. package/dist/sentient/tick.js +63 -4
  153. package/dist/sentient/tick.js.map +1 -1
  154. package/dist/store/backup-recover.d.ts.map +1 -1
  155. package/dist/store/backup-recover.js +5 -0
  156. package/dist/store/backup-recover.js.map +1 -1
  157. package/dist/store/data-accessor.d.ts +1 -0
  158. package/dist/store/data-accessor.d.ts.map +1 -1
  159. package/dist/store/data-accessor.js +7 -0
  160. package/dist/store/data-accessor.js.map +1 -1
  161. package/dist/store/dual-scope-db.d.ts +23 -1
  162. package/dist/store/dual-scope-db.d.ts.map +1 -1
  163. package/dist/store/dual-scope-db.js +126 -36
  164. package/dist/store/dual-scope-db.js.map +1 -1
  165. package/dist/store/exodus/recovery.d.ts.map +1 -1
  166. package/dist/store/exodus/recovery.js +15 -0
  167. package/dist/store/exodus/recovery.js.map +1 -1
  168. package/dist/store/legacy-tasks-lineage.d.ts.map +1 -1
  169. package/dist/store/legacy-tasks-lineage.js +5 -0
  170. package/dist/store/legacy-tasks-lineage.js.map +1 -1
  171. package/dist/store/lock.d.ts +8 -1
  172. package/dist/store/lock.d.ts.map +1 -1
  173. package/dist/store/lock.js +10 -2
  174. package/dist/store/lock.js.map +1 -1
  175. package/dist/store/migration-manager.d.ts +5 -0
  176. package/dist/store/migration-manager.d.ts.map +1 -1
  177. package/dist/store/migration-manager.js +11 -0
  178. package/dist/store/migration-manager.js.map +1 -1
  179. package/dist/store/migration-runner.d.ts +158 -0
  180. package/dist/store/migration-runner.d.ts.map +1 -0
  181. package/dist/store/migration-runner.js +493 -0
  182. package/dist/store/migration-runner.js.map +1 -0
  183. package/dist/store/portable-bundle-import.d.ts +15 -1
  184. package/dist/store/portable-bundle-import.d.ts.map +1 -1
  185. package/dist/store/portable-bundle-import.js +24 -4
  186. package/dist/store/portable-bundle-import.js.map +1 -1
  187. package/dist/store/portable-bundle-relocate.d.ts +43 -0
  188. package/dist/store/portable-bundle-relocate.d.ts.map +1 -1
  189. package/dist/store/portable-bundle-relocate.js +79 -5
  190. package/dist/store/portable-bundle-relocate.js.map +1 -1
  191. package/dist/store/portable-bundle-scan.d.ts +20 -0
  192. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  193. package/dist/store/portable-bundle-scan.js +38 -3
  194. package/dist/store/portable-bundle-scan.js.map +1 -1
  195. package/dist/store/portable-bundle.d.ts +33 -0
  196. package/dist/store/portable-bundle.d.ts.map +1 -1
  197. package/dist/store/portable-bundle.js +34 -7
  198. package/dist/store/portable-bundle.js.map +1 -1
  199. package/dist/store/row-identity-registry.d.ts +137 -7
  200. package/dist/store/row-identity-registry.d.ts.map +1 -1
  201. package/dist/store/row-identity-registry.js +364 -9
  202. package/dist/store/row-identity-registry.js.map +1 -1
  203. package/dist/store/row-identity.d.ts.map +1 -1
  204. package/dist/store/row-identity.js +9 -10
  205. package/dist/store/row-identity.js.map +1 -1
  206. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  207. package/dist/store/sqlite-data-accessor.js +10 -0
  208. package/dist/store/sqlite-data-accessor.js.map +1 -1
  209. package/dist/store/sqlite.d.ts +31 -0
  210. package/dist/store/sqlite.d.ts.map +1 -1
  211. package/dist/store/sqlite.js +5 -1
  212. package/dist/store/sqlite.js.map +1 -1
  213. package/dist/store/sync/capture.d.ts +203 -0
  214. package/dist/store/sync/capture.d.ts.map +1 -0
  215. package/dist/store/sync/capture.js +562 -0
  216. package/dist/store/sync/capture.js.map +1 -0
  217. package/dist/store/sync/clock-store.d.ts.map +1 -1
  218. package/dist/store/sync/clock-store.js +2 -0
  219. package/dist/store/sync/clock-store.js.map +1 -1
  220. package/dist/store/sync/fk-orphans.d.ts +133 -0
  221. package/dist/store/sync/fk-orphans.d.ts.map +1 -0
  222. package/dist/store/sync/fk-orphans.js +265 -0
  223. package/dist/store/sync/fk-orphans.js.map +1 -0
  224. package/dist/store/sync/flags.d.ts +7 -0
  225. package/dist/store/sync/flags.d.ts.map +1 -1
  226. package/dist/store/sync/flags.js +16 -0
  227. package/dist/store/sync/flags.js.map +1 -1
  228. package/dist/store/sync/hlc.d.ts.map +1 -1
  229. package/dist/store/sync/hlc.js +4 -0
  230. package/dist/store/sync/hlc.js.map +1 -1
  231. package/dist/store/sync/identity-fill.d.ts +45 -0
  232. package/dist/store/sync/identity-fill.d.ts.map +1 -0
  233. package/dist/store/sync/identity-fill.js +85 -0
  234. package/dist/store/sync/identity-fill.js.map +1 -0
  235. package/dist/store/sync/machinery.d.ts +27 -0
  236. package/dist/store/sync/machinery.d.ts.map +1 -0
  237. package/dist/store/sync/machinery.js +44 -0
  238. package/dist/store/sync/machinery.js.map +1 -0
  239. package/dist/store/sync/netting.d.ts +120 -0
  240. package/dist/store/sync/netting.d.ts.map +1 -0
  241. package/dist/store/sync/netting.js +336 -0
  242. package/dist/store/sync/netting.js.map +1 -0
  243. package/dist/store/sync/replica.d.ts +27 -2
  244. package/dist/store/sync/replica.d.ts.map +1 -1
  245. package/dist/store/sync/replica.js +47 -3
  246. package/dist/store/sync/replica.js.map +1 -1
  247. package/dist/store/sync/schema.d.ts +12 -0
  248. package/dist/store/sync/schema.d.ts.map +1 -1
  249. package/dist/store/sync/schema.js +21 -0
  250. package/dist/store/sync/schema.js.map +1 -1
  251. package/dist/store/sync/seal-backlog.d.ts +21 -0
  252. package/dist/store/sync/seal-backlog.d.ts.map +1 -0
  253. package/dist/store/sync/seal-backlog.js +24 -0
  254. package/dist/store/sync/seal-backlog.js.map +1 -0
  255. package/dist/store/sync/sealer-values.d.ts +34 -0
  256. package/dist/store/sync/sealer-values.d.ts.map +1 -0
  257. package/dist/store/sync/sealer-values.js +53 -0
  258. package/dist/store/sync/sealer-values.js.map +1 -0
  259. package/dist/store/sync/sealer.d.ts +145 -0
  260. package/dist/store/sync/sealer.d.ts.map +1 -0
  261. package/dist/store/sync/sealer.js +906 -0
  262. package/dist/store/sync/sealer.js.map +1 -0
  263. package/dist/store/sync/structural.d.ts +63 -0
  264. package/dist/store/sync/structural.d.ts.map +1 -0
  265. package/dist/store/sync/structural.js +180 -0
  266. package/dist/store/sync/structural.js.map +1 -0
  267. package/dist/store/sync/timestamps.d.ts +92 -0
  268. package/dist/store/sync/timestamps.d.ts.map +1 -0
  269. package/dist/store/sync/timestamps.js +228 -0
  270. package/dist/store/sync/timestamps.js.map +1 -0
  271. package/dist/store/sync/tombstones.d.ts +75 -0
  272. package/dist/store/sync/tombstones.d.ts.map +1 -0
  273. package/dist/store/sync/tombstones.js +103 -0
  274. package/dist/store/sync/tombstones.js.map +1 -0
  275. package/dist/store/sync/trigger-classes.d.ts +221 -0
  276. package/dist/store/sync/trigger-classes.d.ts.map +1 -0
  277. package/dist/store/sync/trigger-classes.js +441 -0
  278. package/dist/store/sync/trigger-classes.js.map +1 -0
  279. package/dist/store/sync/trigger-suspend-at-path.d.ts +23 -0
  280. package/dist/store/sync/trigger-suspend-at-path.d.ts.map +1 -0
  281. package/dist/store/sync/trigger-suspend-at-path.js +44 -0
  282. package/dist/store/sync/trigger-suspend-at-path.js.map +1 -0
  283. package/dist/store/sync/writer-version.d.ts +43 -0
  284. package/dist/store/sync/writer-version.d.ts.map +1 -0
  285. package/dist/store/sync/writer-version.js +90 -0
  286. package/dist/store/sync/writer-version.js.map +1 -0
  287. package/dist/store/table-classification.d.ts.map +1 -1
  288. package/dist/store/table-classification.js +102 -0
  289. package/dist/store/table-classification.js.map +1 -1
  290. package/dist/store/trigger-suspend-schema.d.ts +44 -0
  291. package/dist/store/trigger-suspend-schema.d.ts.map +1 -0
  292. package/dist/store/trigger-suspend-schema.js +25 -0
  293. package/dist/store/trigger-suspend-schema.js.map +1 -0
  294. package/dist/store/vault-manifest.d.ts +365 -0
  295. package/dist/store/vault-manifest.d.ts.map +1 -0
  296. package/dist/store/vault-manifest.js +812 -0
  297. package/dist/store/vault-manifest.js.map +1 -0
  298. package/dist/store/writer-lease.d.ts +31 -0
  299. package/dist/store/writer-lease.d.ts.map +1 -1
  300. package/dist/store/writer-lease.js +65 -2
  301. package/dist/store/writer-lease.js.map +1 -1
  302. package/dist/system/health.d.ts.map +1 -1
  303. package/dist/system/health.js +21 -0
  304. package/dist/system/health.js.map +1 -1
  305. package/dist/system/project-health.d.ts.map +1 -1
  306. package/dist/system/project-health.js +3 -1
  307. package/dist/system/project-health.js.map +1 -1
  308. package/dist/tasks/affected-packages.d.ts +58 -0
  309. package/dist/tasks/affected-packages.d.ts.map +1 -1
  310. package/dist/tasks/affected-packages.js +75 -6
  311. package/dist/tasks/affected-packages.js.map +1 -1
  312. package/dist/tasks/affected-scope.d.ts +150 -23
  313. package/dist/tasks/affected-scope.d.ts.map +1 -1
  314. package/dist/tasks/affected-scope.js +320 -32
  315. package/dist/tasks/affected-scope.js.map +1 -1
  316. package/dist/tasks/change-set.d.ts +96 -0
  317. package/dist/tasks/change-set.d.ts.map +1 -1
  318. package/dist/tasks/change-set.js +169 -4
  319. package/dist/tasks/change-set.js.map +1 -1
  320. package/dist/tasks/complete-ci.d.ts +115 -0
  321. package/dist/tasks/complete-ci.d.ts.map +1 -0
  322. package/dist/tasks/complete-ci.js +171 -0
  323. package/dist/tasks/complete-ci.js.map +1 -0
  324. package/dist/tasks/complete.d.ts.map +1 -1
  325. package/dist/tasks/complete.js +50 -17
  326. package/dist/tasks/complete.js.map +1 -1
  327. package/dist/tasks/compute-task-view.d.ts.map +1 -1
  328. package/dist/tasks/compute-task-view.js +7 -0
  329. package/dist/tasks/compute-task-view.js.map +1 -1
  330. package/dist/tasks/done-plan.d.ts.map +1 -1
  331. package/dist/tasks/done-plan.js +51 -30
  332. package/dist/tasks/done-plan.js.map +1 -1
  333. package/dist/tasks/done-record.d.ts +6 -0
  334. package/dist/tasks/done-record.d.ts.map +1 -1
  335. package/dist/tasks/done-record.js +9 -3
  336. package/dist/tasks/done-record.js.map +1 -1
  337. package/dist/tasks/evidence.d.ts +3 -2
  338. package/dist/tasks/evidence.d.ts.map +1 -1
  339. package/dist/tasks/evidence.js +143 -12
  340. package/dist/tasks/evidence.js.map +1 -1
  341. package/dist/tasks/gate-notes.d.ts +22 -0
  342. package/dist/tasks/gate-notes.d.ts.map +1 -0
  343. package/dist/tasks/gate-notes.js +33 -0
  344. package/dist/tasks/gate-notes.js.map +1 -0
  345. package/dist/tasks/index.d.ts +2 -1
  346. package/dist/tasks/index.d.ts.map +1 -1
  347. package/dist/tasks/index.js +2 -1
  348. package/dist/tasks/index.js.map +1 -1
  349. package/dist/tasks/test-run-binding.d.ts +117 -0
  350. package/dist/tasks/test-run-binding.d.ts.map +1 -0
  351. package/dist/tasks/test-run-binding.js +365 -0
  352. package/dist/tasks/test-run-binding.js.map +1 -0
  353. package/dist/tasks/tool-cache-env.d.ts +123 -0
  354. package/dist/tasks/tool-cache-env.d.ts.map +1 -0
  355. package/dist/tasks/tool-cache-env.js +287 -0
  356. package/dist/tasks/tool-cache-env.js.map +1 -0
  357. package/dist/tasks/tool-cache-failed-first.d.ts +155 -0
  358. package/dist/tasks/tool-cache-failed-first.d.ts.map +1 -0
  359. package/dist/tasks/tool-cache-failed-first.js +285 -0
  360. package/dist/tasks/tool-cache-failed-first.js.map +1 -0
  361. package/dist/tasks/tool-cache.d.ts +375 -55
  362. package/dist/tasks/tool-cache.d.ts.map +1 -1
  363. package/dist/tasks/tool-cache.js +912 -303
  364. package/dist/tasks/tool-cache.js.map +1 -1
  365. package/dist/tasks/tool-resolver.d.ts +4 -3
  366. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  367. package/dist/tasks/tool-resolver.js +15 -4
  368. package/dist/tasks/tool-resolver.js.map +1 -1
  369. package/dist/tasks/tool-semaphore.d.ts +67 -7
  370. package/dist/tasks/tool-semaphore.d.ts.map +1 -1
  371. package/dist/tasks/tool-semaphore.js +134 -56
  372. package/dist/tasks/tool-semaphore.js.map +1 -1
  373. package/dist/upgrade.d.ts.map +1 -1
  374. package/dist/upgrade.js +4 -0
  375. package/dist/upgrade.js.map +1 -1
  376. package/dist/validation/engine-ops.d.ts +6 -0
  377. package/dist/validation/engine-ops.d.ts.map +1 -1
  378. package/dist/validation/engine-ops.js +8 -0
  379. package/dist/validation/engine-ops.js.map +1 -1
  380. package/migrations/drizzle-cleo-project/20260930170000_t12819-trigger-suspend-clause/migration.sql +367 -0
  381. package/migrations/sync-journal/20260930120000_t12343-capture/migration.sql +47 -0
  382. package/migrations/sync-journal/20261001170000_t12984-sealer/migration.sql +101 -0
  383. package/package.json +13 -13
  384. package/templates/CLEO-INJECTION.md +5 -5
  385. package/templates/CLEO-REFERENCE.md +8 -4
@@ -8,15 +8,27 @@
8
8
  *
9
9
  * This module wraps tool execution with:
10
10
  *
11
- * 1. **Content-addressed cache** — keyed on `(canonical, cmd, args, head,
12
- * dirtyFingerprint)`. Cache hits return the prior `exitCode + stdoutTail`
13
- * without spawning the tool.
11
+ * 1. **Content-addressed cache** — keyed on `(canonical, cmd, args,
12
+ * treeHash)`, where `treeHash` is the git tree of the tracked content as
13
+ * it sits in the working tree (T12958, see {@link captureTreeHash}).
14
+ * Cache hits return the prior `exitCode + stdoutTail` without spawning
15
+ * the tool. Two worktrees holding the same content, or a commit, rebase
16
+ * or amend that leaves the tree unchanged, share one result.
14
17
  * 2. **Cross-process semaphore** — when N processes simultaneously miss the
15
18
  * cache, only one runs the tool; the rest block on a `proper-lockfile`
16
19
  * and read the freshly-written cache entry.
17
- * 3. **Automatic invalidation** — entries become stale when `git HEAD`
18
- * changes or when uncommitted-tree fingerprint changes. Stale entries
19
- * are discarded on access (no GC daemon required).
20
+ * 3. **Automatic invalidation** — entries become unreachable as soon as the
21
+ * tracked content changes, because the key moves with it. Nothing is
22
+ * deleted on access (no GC daemon required).
23
+ * 4. **Failed-first reruns** — a failing `test` run remembers its failing
24
+ * test files; the next run re-runs those first and stops if they still
25
+ * fail (T12961, see `tool-cache-failed-first.ts`). A failing run is
26
+ * re-run in full once; a pass then is recorded as `flaky`.
27
+ * 5. **Resource kills are not results** — a run killed by a signal, an
28
+ * exit of 128 + a kill signal, or a heap OOM is returned but never
29
+ * cached, and the key includes the heap and worker limits the run was
30
+ * given, so a retry with more memory always runs (T12989, see
31
+ * {@link resourceKillReason}).
20
32
  *
21
33
  * Cache layout (under `<projectRoot>/.cleo/cache/evidence/`):
22
34
  *
@@ -31,17 +43,29 @@
31
43
  */
32
44
  import { execFileSync, spawn } from 'node:child_process';
33
45
  import { createHash } from 'node:crypto';
34
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
46
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, } from 'node:fs';
47
+ import { constants as osConstants, tmpdir } from 'node:os';
35
48
  import { join, resolve } from 'node:path';
36
49
  import { ExitCode } from '@cleocode/contracts';
37
50
  import { CleoError } from '../errors.js';
38
- import { withLock } from '../store/lock.js';
51
+ import { activeToolGroups, trackToolGroup } from '../resources/tool-groups.js';
52
+ import { isLocked, withLock } from '../store/lock.js';
39
53
  import { heavyToolEnv } from './heavy-tool-env.js';
40
54
  import { confinementStartupFailure, isSystemdRunCommand, withMemoryLimit, } from './heavy-tool-limit.js';
55
+ import { captureEnvFingerprint, captureResourceEnv } from './tool-cache-env.js';
56
+ import { FAILED_FIRST_TOOLS, parseFailingTestFiles, planFocusedRuns, readFailedFirstPointer, reportsNoTestFiles, writeFailedFirstPointer, } from './tool-cache-failed-first.js';
41
57
  import { acquireGlobalSlot } from './tool-semaphore.js';
42
58
  // ---------------------------------------------------------------------------
43
59
  // Wall-clock deadline resolution (T12105 / gh#1193)
44
60
  // ---------------------------------------------------------------------------
61
+ /**
62
+ * Most failing test files a failed `test` run may name and still get its one
63
+ * full flake retry (T12961). More, or none named, is treated as a
64
+ * deterministic failure: no retry.
65
+ *
66
+ * @task T12961
67
+ */
68
+ export const MAX_FLAKE_RETRY_FILES = 3;
45
69
  /**
46
70
  * Default wall-clock deadline for one evidence-tool child process, in ms.
47
71
  *
@@ -200,27 +224,45 @@ export function resolveSpawnTimeoutMs(canonical, env = process.env) {
200
224
  * {@link isEntryUsable} (gh#1380: an unknown outcome must never be cached).
201
225
  * Putting it here would key the cache on its own answer.
202
226
  *
227
+ * `head` and `executionRoot` are recorded on every entry but are no longer
228
+ * identity (T12958). Both name a LOCATION — which commit, which directory —
229
+ * rather than the content measured, so keying on them made every worktree and
230
+ * every commit a miss even when the tracked content was byte-identical.
231
+ * `treeHash` is the content itself; it subsumes the old `dirtyFingerprint`.
232
+ * `envFingerprint` covers the per-checkout state git does not see (installed
233
+ * deps, gitignored build output, `.env*`), so sharing across worktrees
234
+ * happens only when BOTH the source and the environment match.
235
+ * `resourceEnv` (T12989) covers the limits the run was spawned with — heap
236
+ * flags and worker counts — which neither the tree nor the checkout shows: a
237
+ * 3 GB run that ran out of memory and a 6 GB retry of the same code are two
238
+ * runs, and keying them as one is what replayed the OOM.
239
+ *
203
240
  * @task T12190 (gh#1419)
241
+ * @task T12958
242
+ * @task T12989
204
243
  */
205
244
  export const TOOL_RUN_IDENTITY_FIELDS = [
206
245
  'canonical',
207
246
  'cmd',
208
247
  'args',
209
- 'head',
210
- 'dirtyFingerprint',
211
- 'executionRoot',
248
+ 'treeHash',
249
+ 'envFingerprint',
250
+ 'resourceEnv',
212
251
  ];
252
+ /**
253
+ * Current {@link ToolCacheEntry.schemaVersion}. Entries of any other version
254
+ * are refused on read.
255
+ *
256
+ * @task T12958
257
+ * @task T12989
258
+ */
259
+ export const TOOL_CACHE_SCHEMA_VERSION = 4;
213
260
  /**
214
261
  * Normalise an execution root to a stable, comparable absolute path.
215
262
  *
216
- * Symlinks are resolved so that two spellings of one directory produce one
217
- * key — the safe direction, since collapsing an alias can only merge entries
218
- * that genuinely describe the same tree, while failing to collapse it would
219
- * split one tree's cache across spellings. A path that cannot be resolved
220
- * (it has been deleted) falls back to lexical resolution rather than
221
- * throwing: callers of {@link computeCacheKey} must be able to compute the
222
- * key of a run whose directory is already gone, precisely so
223
- * {@link readCacheEntry} can then refuse it.
263
+ * Symlinks are resolved so two spellings of one directory compare equal. A
264
+ * path that cannot be resolved (it has been deleted) falls back to lexical
265
+ * resolution rather than throwing.
224
266
  *
225
267
  * @task T12190 (gh#1419)
226
268
  */
@@ -233,34 +275,40 @@ function normalizeExecutionRoot(executionRoot) {
233
275
  }
234
276
  }
235
277
  /**
236
- * Compute the cache key for a resolved tool command + repo state.
278
+ * Compute the cache key for a resolved tool command + tracked tree content.
237
279
  *
238
280
  * The key covers exactly {@link TOOL_RUN_IDENTITY_FIELDS} — canonical tool
239
- * name, resolved command and args, git HEAD, dirty-tree fingerprint, and the
240
- * normalised execution root. Fields are projected in the order that array
241
- * declares, so the hashed payload is a function of the array and adding a
242
- * field there changes every key by construction.
281
+ * name, resolved command and args, the tree hash, the environment
282
+ * fingerprint and the resource environment. Fields are projected in
283
+ * the order that array declares, so the hashed payload is a function of the
284
+ * array and adding a field there changes every key by construction.
243
285
  *
244
- * `executionRoot` is a REQUIRED parameter, deliberately. A defaulted one
245
- * would let every callsite that was not updated keep computing the old,
246
- * colliding key while the type-checker reported success — which is the exact
247
- * shape of the defect this fixes, since `executionRoot` already existed and
248
- * already reached this function's caller.
286
+ * There is deliberately no directory parameter (T12958): two checkouts with
287
+ * the same tracked content get the same key, which is what lets parallel
288
+ * worktrees and post-rebase re-verifies reuse one run.
249
289
  *
250
290
  * Using `createHash('sha256')` makes the key collision-resistant and bounded
251
291
  * to 32 hex chars regardless of input size.
252
292
  *
293
+ * @param command - Resolved tool command.
294
+ * @param treeHash - {@link captureTreeHash} of the execution root.
295
+ * @param envFingerprint - {@link captureEnvFingerprint} of the execution root.
296
+ * @param resourceEnv - {@link captureResourceEnv} of the spawn environment.
297
+ * @returns 32 hex chars.
298
+ *
253
299
  * @task T1534
254
300
  * @task T12190 (gh#1419)
301
+ * @task T12958
302
+ * @task T12989
255
303
  */
256
- export function computeCacheKey(command, head, dirtyFingerprint, executionRoot) {
304
+ export function computeCacheKey(command, treeHash, envFingerprint, resourceEnv) {
257
305
  const identity = {
258
306
  canonical: command.canonical,
259
307
  cmd: command.cmd,
260
308
  args: command.args,
261
- head,
262
- dirtyFingerprint,
263
- executionRoot: normalizeExecutionRoot(executionRoot),
309
+ treeHash,
310
+ envFingerprint,
311
+ resourceEnv,
264
312
  };
265
313
  // Projected through TOOL_RUN_IDENTITY_FIELDS rather than written as an
266
314
  // object literal: the literal is what drifted from the entry shape before.
@@ -271,18 +319,22 @@ export function computeCacheKey(command, head, dirtyFingerprint, executionRoot)
271
319
  * Whether a cache entry may be SERVED or PERSISTED as a real result.
272
320
  *
273
321
  * One predicate, used by both {@link readCacheEntry} and the persist guard in
274
- * `runToolCached`, so the two cannot drift apart. Two rules:
322
+ * `runToolCached`, so the two cannot drift apart. Three rules:
275
323
  *
276
324
  * 1. every field in {@link TOOL_RUN_IDENTITY_FIELDS} is present and
277
325
  * non-null — an entry that cannot say which run it describes is not
278
326
  * evidence of anything;
279
327
  * 2. `exitCode` is non-null — a run that produced no exit code records that
280
328
  * we do not know what happened, and an unknown must never be cached
281
- * (gh#1380).
329
+ * (gh#1380);
330
+ * 3. `resourceKill` is absent — a run killed for resources (an OOM, a heap
331
+ * limit, exit 137) has a real exit code that says nothing about the code,
332
+ * and caching it replays the kill to every retry, including one given
333
+ * the memory it lacked (T12989).
282
334
  *
283
335
  * Rule 1 subsumes gh#1404's `head === null` clause without naming `head`:
284
- * when the tool runs off a non-git root both git fields are null on EVERY
285
- * run, so the key is a function of the command alone and nothing a developer
336
+ * when the tool runs off a non-git root `treeHash` is null on EVERY run, so
337
+ * the key is a function of the command alone and nothing a developer
286
338
  * does to the source can rotate it. That is not a cache, it is a hardcoded
287
339
  * answer with a filename — and unlike gh#1380 it holds a real exit code,
288
340
  * which can be a PASS. A poisoned red is investigated within minutes; nobody
@@ -293,6 +345,7 @@ export function computeCacheKey(command, head, dirtyFingerprint, executionRoot)
293
345
  * getting valid caching today — they are getting one answer forever.
294
346
  *
295
347
  * @task T12190 (gh#1419)
348
+ * @task T12989
296
349
  */
297
350
  export function isEntryUsable(entry) {
298
351
  for (const field of TOOL_RUN_IDENTITY_FIELDS) {
@@ -300,8 +353,97 @@ export function isEntryUsable(entry) {
300
353
  if (value === null || value === undefined)
301
354
  return false;
302
355
  }
356
+ if (entry.resourceKill !== undefined && entry.resourceKill !== null)
357
+ return false;
303
358
  return entry.exitCode !== null && entry.exitCode !== undefined;
304
359
  }
360
+ /**
361
+ * Signals whose `128 + n` exit status means the run was killed rather than
362
+ * finishing, when a shell, `pnpm` or a wrapper turns the signal into an exit
363
+ * code: termination (`TERM`, `INT`, `HUP`), the kernel or cgroup OOM killer's
364
+ * `KILL`, V8's `abort()` on heap exhaustion (`ABRT`), and the CPU and
365
+ * file-size rlimits. Numbers come from `os.constants.signals`, so they match
366
+ * the platform.
367
+ *
368
+ * `SEGV` and `BUS` are deliberately absent: a crash in native code can be a
369
+ * deterministic bug in the code under test, and such a red stays cacheable.
370
+ *
371
+ * @task T12989
372
+ */
373
+ const KILL_SIGNALS = [
374
+ 'SIGHUP',
375
+ 'SIGINT',
376
+ 'SIGABRT',
377
+ 'SIGKILL',
378
+ 'SIGTERM',
379
+ 'SIGXCPU',
380
+ 'SIGXFSZ',
381
+ ];
382
+ /**
383
+ * Output only a run that ran out of memory prints: V8's fatal heap messages,
384
+ * Node's worker heap limit, the OS refusing an allocation, and vitest's report
385
+ * of a pool worker that vanished — what the kernel OOM-killing one fork looks
386
+ * like from the pool, which then exits 1 like a failing assertion.
387
+ *
388
+ * Matched as literal substrings, and only on a NON-ZERO exit. A false match
389
+ * (a failing test that happens to print one of these) costs a re-run, never a
390
+ * wrong verdict: the exit code is still reported, just not cached.
391
+ *
392
+ * @task T12989
393
+ */
394
+ const RESOURCE_KILL_MARKERS = [
395
+ 'JavaScript heap out of memory',
396
+ 'Reached heap limit',
397
+ 'Ineffective mark-compacts near heap limit',
398
+ 'ERR_WORKER_OUT_OF_MEMORY',
399
+ 'Worker terminated due to reaching memory limit',
400
+ 'Worker exited unexpectedly',
401
+ 'ENOMEM',
402
+ 'Cannot allocate memory',
403
+ ];
404
+ /**
405
+ * Why a finished run was killed for resources, or `null` when its outcome is
406
+ * a real result.
407
+ *
408
+ * Decides from the exit and the captured output, in order:
409
+ *
410
+ * 1. a terminating `signal` — the process did not exit on its own;
411
+ * 2. an exit code of `128 + n` for a {@link KILL_SIGNALS} signal — the same
412
+ * kill, reported by a wrapper (`sh` exits 137 for a `SIGKILL`ed child);
413
+ * 3. a non-zero exit whose output carries a {@link RESOURCE_KILL_MARKERS}
414
+ * marker — vitest catches a worker's heap OOM and exits 1.
415
+ *
416
+ * Exit 0 is always a result, and so is a plain non-zero exit such as a failing
417
+ * assertion's 1: that red stays cacheable for failed-first reruns. A run that
418
+ * never started (`exitCode` and `signal` both null) is not a kill; it is
419
+ * reported, and refused by {@link isEntryUsable}, as an unknown outcome.
420
+ *
421
+ * @param run - The finished run's exit code, signal and captured output.
422
+ * @returns A short reason, e.g. `exit 137 (128 + SIGKILL)`, or `null`.
423
+ *
424
+ * @example
425
+ * ```ts
426
+ * resourceKillReason({ exitCode: 1, signal: null, stdout: '', stderr: 'FAIL a.test.ts' }); // null
427
+ * resourceKillReason({ exitCode: 137, signal: null, stdout: '', stderr: '' });
428
+ * // → 'exit 137 (128 + SIGKILL)'
429
+ * ```
430
+ *
431
+ * @task T12989
432
+ */
433
+ export function resourceKillReason(run) {
434
+ if (run.signal !== null)
435
+ return `killed by ${run.signal}`;
436
+ if (run.exitCode === null || run.exitCode === 0)
437
+ return null;
438
+ for (const name of KILL_SIGNALS) {
439
+ if (run.exitCode === 128 + osConstants.signals[name]) {
440
+ return `exit ${run.exitCode} (128 + ${name})`;
441
+ }
442
+ }
443
+ const output = `${run.stdout}\n${run.stderr}`;
444
+ const marker = RESOURCE_KILL_MARKERS.find((m) => output.includes(m));
445
+ return marker === undefined ? null : `output reports "${marker}"`;
446
+ }
305
447
  /**
306
448
  * Maximum bytes retained from a child's stdout / stderr stream during
307
449
  * spawn. The tool-evidence atom only needs the trailing 512 bytes for
@@ -409,6 +551,77 @@ function killProcessTree(pid, signal) {
409
551
  }
410
552
  }
411
553
  }
554
+ /** Signals whose default action would end cleo and orphan the tool groups it started. */
555
+ const TERMINATION_SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM'];
556
+ let terminationCleanupInstalled = false;
557
+ /** SIGTERM every tool group this process started that is still running. */
558
+ function terminateActiveToolGroups() {
559
+ for (const pgid of activeToolGroups())
560
+ killProcessTree(pgid, 'SIGTERM');
561
+ }
562
+ function onTerminationSignal(signal) {
563
+ if (signal === 'SIGHUP' || signal === 'SIGINT' || signal === 'SIGTERM') {
564
+ terminateToolGroupsOnSignal(signal);
565
+ }
566
+ }
567
+ /**
568
+ * Add or remove the listeners that pass the end of this process on to the
569
+ * tools it runs: the termination signals, and `exit` (a `process.exit()` or
570
+ * an uncaught exception, e.g. a lock's `onCompromised` throwing from a timer).
571
+ */
572
+ function setTerminationCleanup(on) {
573
+ if (on === terminationCleanupInstalled)
574
+ return;
575
+ terminationCleanupInstalled = on;
576
+ for (const signal of TERMINATION_SIGNALS) {
577
+ if (on)
578
+ process.on(signal, onTerminationSignal);
579
+ else
580
+ process.off(signal, onTerminationSignal);
581
+ }
582
+ if (on)
583
+ process.on('exit', terminateToolGroupsOnExit);
584
+ else
585
+ process.off('exit', terminateToolGroupsOnExit);
586
+ }
587
+ /** Install the cleanup while a tool group runs; remove it once none does. */
588
+ function syncTerminationCleanup() {
589
+ setTerminationCleanup(activeToolGroups().length > 0);
590
+ }
591
+ /**
592
+ * SIGTERM every tool group this process started, then re-raise the signal to
593
+ * this process so it ends exactly as it would have without us (T12963).
594
+ *
595
+ * Tools run detached, in their own process group, so a signal that ends cleo
596
+ * (Ctrl-C reaches only the terminal's foreground group) never reaches them:
597
+ * the tool kept running, and the slot it held looked free once cleo was gone.
598
+ *
599
+ * Any listener at all suppresses a signal's default action, and
600
+ * `proper-lockfile` loads `signal-exit`, which re-raises only when its own
601
+ * listeners are the last ones left. So ours removes itself and re-raises:
602
+ * `signal-exit` then releases its locks and the process dies by the signal.
603
+ * Another listener of the signal sees it twice.
604
+ *
605
+ * @param signal - The signal received.
606
+ *
607
+ * @internal Exported for tests.
608
+ * @task T12963
609
+ */
610
+ export function terminateToolGroupsOnSignal(signal) {
611
+ terminateActiveToolGroups();
612
+ setTerminationCleanup(false);
613
+ process.kill(process.pid, signal);
614
+ }
615
+ /**
616
+ * The `exit` listener: SIGTERM every tool group still running. `exit`
617
+ * listeners must be synchronous, and `process.kill` is.
618
+ *
619
+ * @internal Exported for tests.
620
+ * @task T12963
621
+ */
622
+ export function terminateToolGroupsOnExit() {
623
+ terminateActiveToolGroups();
624
+ }
412
625
  function spawnCmd(cmd, args, cwd, spawnTimeoutMs, envOverlay) {
413
626
  return new Promise((resolve) => {
414
627
  const stdoutBuf = new TailBuffer(STREAM_TAIL_CAP_BYTES);
@@ -429,6 +642,10 @@ function spawnCmd(cmd, args, cwd, spawnTimeoutMs, envOverlay) {
429
642
  // preventing the `close` event from ever firing.
430
643
  detached: true,
431
644
  });
645
+ // T12963: while this group runs, every slot this process holds stays held
646
+ // even if cleo dies first, and a terminating signal is passed on to it.
647
+ const untrackGroup = trackToolGroup(child.pid);
648
+ syncTerminationCleanup();
432
649
  child.stdout?.on('data', (d) => {
433
650
  stdoutBuf.append(d);
434
651
  });
@@ -447,6 +664,8 @@ function spawnCmd(cmd, args, cwd, spawnTimeoutMs, envOverlay) {
447
664
  let spawnError = null;
448
665
  const finalise = (exitCode, signal) => {
449
666
  clearTimers();
667
+ untrackGroup();
668
+ syncTerminationCleanup();
450
669
  const stderr = stderrBuf.toString();
451
670
  resolve({
452
671
  exitCode,
@@ -510,6 +729,11 @@ export async function captureHead(projectRoot) {
510
729
  * why NOT `git status --porcelain`). Returns `null` for
511
730
  * non-git roots.
512
731
  *
732
+ * Since T12958 this is NOT part of the tool-cache key — {@link captureTreeHash}
733
+ * replaced it there, and (unlike this function) counts untracked, not-ignored
734
+ * files. It remains the dirty-tree identity for the gate-result cache, where
735
+ * the tracked-only rule below still applies.
736
+ *
513
737
  * Two repos with identical tracked content but different uncommitted edits
514
738
  * produce different fingerprints — so editing a tracked file before
515
739
  * re-verifying always invalidates the cache for tools sensitive to that file.
@@ -620,6 +844,141 @@ export async function captureDirtyFingerprint(projectRoot) {
620
844
  // that tradeoff exists to prevent.
621
845
  return hashGitStdout(['diff', 'HEAD', ...FINGERPRINT_PATHSPEC], projectRoot);
622
846
  }
847
+ /**
848
+ * Untracked paths {@link captureTreeHash} leaves out: CLEO's runtime state
849
+ * (`.cleo/`, written on every command) and the well-known untracked outputs
850
+ * of test and build tools that are not always gitignored. Neither a CLEO
851
+ * command nor the tool under test can therefore move the key it is stored
852
+ * under — the self-invalidation gh#1221 was about.
853
+ *
854
+ * Applies to UNTRACKED additions only. A tracked file under one of these
855
+ * paths (`.cleo/canon.yml`, a lint baseline, a tracked fixture `.log`) is
856
+ * source: its uncommitted edits always count.
857
+ */
858
+ function isUntrackedRuntimeOutput(path) {
859
+ if (path.startsWith('.cleo/'))
860
+ return true;
861
+ if (path.endsWith('.log') || path.endsWith('.tsbuildinfo'))
862
+ return true;
863
+ return path
864
+ .split('/')
865
+ .some((seg) => ['coverage', '.vitest', '.nyc_output', '.turbo', 'test-results'].includes(seg));
866
+ }
867
+ /**
868
+ * Untracked files larger than this are left out of {@link captureTreeHash}
869
+ * rather than hashed into the object database on every call. Such files are
870
+ * rarely source; a change to one does not move the key (force a run with
871
+ * `CLEO_EVIDENCE_FRESH=1`).
872
+ */
873
+ export const MAX_UNTRACKED_HASH_BYTES = 5 * 1024 * 1024;
874
+ /**
875
+ * Capture the git tree hash of the working tree's SOURCE content: HEAD's tree
876
+ * with every uncommitted change applied — staged or not, to tracked files and
877
+ * to untracked files that are not gitignored. Returns `null` for non-git
878
+ * roots or on any git failure (the entry is then unusable and nothing is
879
+ * cached).
880
+ *
881
+ * ## How (T12958)
882
+ *
883
+ * 1. Copy the checkout's own index to a private temporary file, preserving
884
+ * its atime/mtime. The copy keeps the index's stat cache, so git re-hashes
885
+ * only files whose stat changed. The preserved mtime matters: git's
886
+ * racy-clean check compares each entry's mtime with the INDEX FILE's
887
+ * mtime, and a fresh mtime on the copy would let a same-size edit made in
888
+ * the same timestamp tick as the last index write read as clean. The real
889
+ * index is never touched.
890
+ * 2. `GIT_INDEX_FILE=<copy> git add -u -- .` stages every modified or deleted
891
+ * TRACKED path — all of them, including tracked files under `.cleo/`.
892
+ * 3. `git ls-files -o --exclude-standard -z` lists untracked, not-ignored
893
+ * files; those that are not runtime output ({@link isUntrackedRuntimeOutput})
894
+ * and not over {@link MAX_UNTRACKED_HASH_BYTES} are added with
895
+ * `git update-index --add -z --stdin`.
896
+ * 4. `GIT_INDEX_FILE=<copy> git write-tree` prints the tree.
897
+ *
898
+ * A clean checkout yields exactly `HEAD^{tree}`. Two worktrees with the same
899
+ * source yield the same hash wherever they live, and an empty commit, an
900
+ * amend of only the message, or a rebase that reproduces the same content
901
+ * leave it unchanged.
902
+ *
903
+ * ## Untracked files (review of #1774, supersedes the gh#1221 tracked-only rule)
904
+ *
905
+ * An untracked, not-ignored file IS part of the hash. Tracked-only was safe
906
+ * while the key also carried the execution root; once the key is shared
907
+ * across worktrees it is a false pass: a worker's new, uncommitted module and
908
+ * failing test leave the tracked tree equal to main's, and main's cached pass
909
+ * would be served without the worker's tests ever running.
910
+ *
911
+ * Side effect: staging writes blob objects for dirty and new files into the
912
+ * repository's object database, and `write-tree` writes tree objects. Both
913
+ * are ordinary unreferenced loose objects: `git gc` prunes them after
914
+ * `gc.pruneExpire` (two weeks by default), so `git ls-tree <treeHash>` as an
915
+ * audit of an old entry is best-effort.
916
+ *
917
+ * @param root - Directory to fingerprint (the execution root).
918
+ * @returns 40/64 hex chars of the tree object id, or `null`.
919
+ *
920
+ * @task T12958
921
+ */
922
+ export async function captureTreeHash(root) {
923
+ // `--absolute-git-dir`, not `--git-path index`: the latter answers with an
924
+ // inherited `GIT_INDEX_FILE` (e.g. inside a git hook), which is not this
925
+ // checkout's index.
926
+ const gitDir = await spawnCmd('git', ['rev-parse', '--absolute-git-dir'], root);
927
+ if (gitDir.exitCode !== 0)
928
+ return null;
929
+ const realIndex = join(gitDir.stdout.trim(), 'index');
930
+ let tmpDir = null;
931
+ try {
932
+ tmpDir = mkdtempSync(join(tmpdir(), 'cleo-tree-'));
933
+ const indexFile = join(tmpDir, 'index');
934
+ const env = { GIT_INDEX_FILE: indexFile };
935
+ if (existsSync(realIndex)) {
936
+ copyFileSync(realIndex, indexFile);
937
+ const st = statSync(realIndex);
938
+ utimesSync(indexFile, st.atime, st.mtime);
939
+ }
940
+ else {
941
+ // No index yet: seed from HEAD. An unborn HEAD leaves the index empty,
942
+ // which is correct — nothing is tracked.
943
+ await spawnCmd('git', ['read-tree', 'HEAD'], root, undefined, env);
944
+ }
945
+ const tracked = await spawnCmd('git', ['add', '-u', '--', '.'], root, undefined, env);
946
+ if (tracked.exitCode !== 0)
947
+ return null;
948
+ const listed = execFileSync('git', ['ls-files', '-o', '--exclude-standard', '-z', '--', '.'], {
949
+ cwd: root,
950
+ encoding: 'utf-8',
951
+ maxBuffer: 256 * 1024 * 1024,
952
+ stdio: ['ignore', 'pipe', 'ignore'],
953
+ env: { ...process.env, ...env },
954
+ });
955
+ const untracked = listed.split('\0').filter((p) => {
956
+ if (p === '' || isUntrackedRuntimeOutput(p))
957
+ return false;
958
+ const st = statSync(join(root, p), { throwIfNoEntry: false });
959
+ return st?.isFile() === true && st.size <= MAX_UNTRACKED_HASH_BYTES;
960
+ });
961
+ if (untracked.length > 0) {
962
+ execFileSync('git', ['update-index', '--add', '-z', '--stdin'], {
963
+ cwd: root,
964
+ input: `${untracked.join('\0')}\0`,
965
+ stdio: ['pipe', 'ignore', 'ignore'],
966
+ env: { ...process.env, ...env },
967
+ });
968
+ }
969
+ const tree = await spawnCmd('git', ['write-tree'], root, undefined, env);
970
+ if (tree.exitCode !== 0)
971
+ return null;
972
+ return tree.stdout.trim() || null;
973
+ }
974
+ catch {
975
+ return null;
976
+ }
977
+ finally {
978
+ if (tmpDir !== null)
979
+ rmSync(tmpDir, { recursive: true, force: true });
980
+ }
981
+ }
623
982
  // ---------------------------------------------------------------------------
624
983
  // Cache directory + entry IO
625
984
  // ---------------------------------------------------------------------------
@@ -652,12 +1011,9 @@ export function readCacheEntry(projectRoot, key) {
652
1011
  return null;
653
1012
  try {
654
1013
  const parsed = JSON.parse(readFileSync(path, 'utf-8'));
655
- // Schema gate. Bumped to 2 by gh#1419; every entry written before
656
- // `executionRoot` was part of a run's identity is refused here, on this
657
- // one line. gh#1380 and gh#1404 each needed exactly this and each
658
- // reimplemented it as a bespoke null-check on their own field, while this
659
- // counter sat unused at 1 through both.
660
- if (parsed.schemaVersion !== 2 || parsed.key !== key)
1014
+ // Schema gate. Bumped to 2 by gh#1419 and to 3 by T12958; every entry
1015
+ // written under an older identity is refused here, on this one line.
1016
+ if (parsed.schemaVersion !== TOOL_CACHE_SCHEMA_VERSION || parsed.key !== key)
661
1017
  return null;
662
1018
  // A placeholder written by `runToolCached` to satisfy proper-lockfile's
663
1019
  // "file must exist" requirement. It carries no real result — treat as
@@ -670,18 +1026,10 @@ export function readCacheEntry(projectRoot, key) {
670
1026
  // shared verbatim with the persist guard in `runToolCached`.
671
1027
  if (!isEntryUsable(parsed))
672
1028
  return null;
673
- // gh#1419: refuse a hit whose originating tree is GONE.
674
- //
675
- // Keying on `executionRoot` stops one live worktree from being served
676
- // another live worktree's result. It does not address the worse case the
677
- // field survey actually found: 8 entries produced in worktrees that had
678
- // since been deleted, all `exitCode: 0`, all still servable. A stale
679
- // result is merely wrong; a result whose tree no longer exists is
680
- // UNFALSIFIABLE — there is nowhere left to go and check what ran.
681
- //
682
- // This is the one rule that is genuinely read-only. At write time the
683
- // directory exists by construction, so it cannot live in `isEntryUsable`
684
- // alongside the rules both paths share.
1029
+ // gh#1419: refuse a hit whose recorded tree is GONE. Kept under the
1030
+ // content key (review of #1774): the tree hash and environment
1031
+ // fingerprint identify what ran, but a result whose checkout no longer
1032
+ // exists cannot be re-examined for anything the fingerprints do not cover.
685
1033
  if (!existsSync(parsed.executionRoot))
686
1034
  return null;
687
1035
  return parsed;
@@ -690,6 +1038,49 @@ export function readCacheEntry(projectRoot, key) {
690
1038
  return null;
691
1039
  }
692
1040
  }
1041
+ const legacySwept = new Set();
1042
+ /**
1043
+ * Delete tool-cache entries written under an older schema, once per process
1044
+ * and store root.
1045
+ *
1046
+ * Their keys can never be computed again (the identity changed with the
1047
+ * schema), so nothing would ever read or overwrite them. Only files named
1048
+ * like a tool-cache key (32 hex chars) are considered; the `pr-`, `gate-`,
1049
+ * `reval-` and `failed-first-` files sharing the directory are left alone.
1050
+ *
1051
+ * @task T12958
1052
+ */
1053
+ function sweepLegacyEntriesOnce(projectRoot) {
1054
+ if (legacySwept.has(projectRoot))
1055
+ return;
1056
+ legacySwept.add(projectRoot);
1057
+ const dir = join(projectRoot, '.cleo', 'cache', 'evidence');
1058
+ let names;
1059
+ try {
1060
+ names = readdirSync(dir);
1061
+ }
1062
+ catch {
1063
+ return;
1064
+ }
1065
+ for (const name of names) {
1066
+ if (!/^[0-9a-f]{32}\.json$/.test(name))
1067
+ continue;
1068
+ // A sibling lock means a process may be working on this key right now.
1069
+ if (existsSync(join(dir, `${name}.lock`)))
1070
+ continue;
1071
+ try {
1072
+ const parsed = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
1073
+ if (parsed.pending === true)
1074
+ continue;
1075
+ if (parsed.schemaVersion !== TOOL_CACHE_SCHEMA_VERSION) {
1076
+ rmSync(join(dir, name), { force: true });
1077
+ }
1078
+ }
1079
+ catch {
1080
+ // unreadable or mid-write: leave it
1081
+ }
1082
+ }
1083
+ }
693
1084
  /**
694
1085
  * Atomically write a cache entry: writes to `.tmp` then renames so concurrent
695
1086
  * readers never observe a half-written file.
@@ -703,20 +1094,81 @@ export function writeCacheEntry(projectRoot, entry) {
703
1094
  writeFileSync(tmpPath, JSON.stringify(entry, null, 2), 'utf-8');
704
1095
  renameSync(tmpPath, finalPath);
705
1096
  }
706
- // ---------------------------------------------------------------------------
707
- // runToolCached — the main entry point used by validateTool
708
- // ---------------------------------------------------------------------------
1097
+ /**
1098
+ * Run the planned focused invocations in order, stopping at the first one
1099
+ * that decides anything (T12961). Each runs under the same memory bound and
1100
+ * heavy-tool env as the normal command, and inside the caller's semaphore
1101
+ * slot and per-key lock.
1102
+ *
1103
+ * Only a real, non-zero exit counts as `failed`. A spawn error, a signal kill,
1104
+ * any other resource kill ({@link resourceKillReason}: exit 137, a heap OOM)
1105
+ * or vitest reporting that the filters matched nothing is `inconclusive`, and
1106
+ * the caller falls back to the normal command — a focused run may shorten a
1107
+ * red result but must never invent one.
1108
+ */
1109
+ async function runFocused(command, plan, executionRoot, spawnTimeoutMs, toolEnv) {
1110
+ for (const run of plan) {
1111
+ const limited = withMemoryLimit(command.canonical, run.cmd, run.args, { executionRoot });
1112
+ const startedAt = Date.now();
1113
+ const result = await spawnCmd(limited.cmd, [...limited.args], run.cwd, spawnTimeoutMs, toolEnv);
1114
+ const durationMs = Date.now() - startedAt;
1115
+ if (result.timedOut)
1116
+ return { kind: 'timedOut', result, durationMs, cwd: run.cwd };
1117
+ const harnessFailure = confinementStartupFailure(result.stderr, limited.confined);
1118
+ if (harnessFailure !== null)
1119
+ return { kind: 'harness', result, durationMs, harnessFailure };
1120
+ if (result.exitCode === null || resourceKillReason(result) !== null) {
1121
+ return { kind: 'inconclusive' };
1122
+ }
1123
+ if (result.exitCode !== 0) {
1124
+ if (reportsNoTestFiles(`${result.stdout}\n${result.stderr}`))
1125
+ return { kind: 'inconclusive' };
1126
+ return { kind: 'failed', result, durationMs, cwd: run.cwd };
1127
+ }
1128
+ }
1129
+ return { kind: 'passed' };
1130
+ }
1131
+ /**
1132
+ * Tracked files of `root`, relative to it, for failing-file suffix lookup.
1133
+ * Empty on any git failure.
1134
+ */
1135
+ function listTrackedFiles(root) {
1136
+ try {
1137
+ return execFileSync('git', ['ls-files', '-z'], {
1138
+ cwd: root,
1139
+ encoding: 'utf-8',
1140
+ maxBuffer: 64 * 1024 * 1024,
1141
+ stdio: ['ignore', 'pipe', 'ignore'],
1142
+ })
1143
+ .split('\0')
1144
+ .filter(Boolean);
1145
+ }
1146
+ catch {
1147
+ return [];
1148
+ }
1149
+ }
709
1150
  /**
710
1151
  * Run a resolved tool command with caching + cross-process locking.
711
1152
  *
712
1153
  * Flow:
713
1154
  *
714
- * 1. Compute cache key (canonical+cmd+args+head+dirtyFingerprint).
1155
+ * 1. Compute cache key (canonical+cmd+args+treeHash+envFingerprint+
1156
+ * resourceEnv).
715
1157
  * 2. If a fresh entry exists → return it (no spawn).
716
- * 3. Acquire a `proper-lockfile` on the cache entry path.
1158
+ * 3. Acquire a `proper-lockfile` on the cache entry path. When another
1159
+ * caller holds it (same command, same content — possibly another
1160
+ * worktree), wait for its result and reuse it (T12958).
717
1161
  * 4. Re-check cache inside the lock (another process may have written it
718
1162
  * while we were waiting).
719
- * 5. Spawn the tool, capture stdout/stderr tails, write the entry, return.
1163
+ * 5. For `test`, when the previous run in this execution root failed on a
1164
+ * different tree, re-run only its failing files first; if they still
1165
+ * fail, return that (uncached, `scope: 'focused'`) failure without the
1166
+ * normal run (T12961).
1167
+ * 6. Spawn the tool, capture stdout/stderr tails. For a failing `test`,
1168
+ * re-run the FULL command once; a pass there is a pass marked `flaky`
1169
+ * (T12961). Write the entry, return. A run killed for resources is
1170
+ * returned with `resourceKill` set and is neither retried, recorded for
1171
+ * failed-first, nor written (T12989).
720
1172
  *
721
1173
  * Locks are auto-released on success or failure. Stale locks are reaped per
722
1174
  * the `lockStaleMs` option (default 10 min — long enough to cover a full
@@ -728,9 +1180,13 @@ export function writeCacheEntry(projectRoot, entry) {
728
1180
  * @returns Result envelope with `exitCode`, `stdoutTail`, `cacheHit`, etc.
729
1181
  *
730
1182
  * @task T1534
1183
+ * @task T12958
1184
+ * @task T12961
1185
+ * @task T12989
731
1186
  * @adr ADR-061
732
1187
  */
733
1188
  export async function runToolCached(command, projectRoot, opts = {}) {
1189
+ const callStartedAt = Date.now();
734
1190
  const tailBytes = opts.tailBytes ?? 512;
735
1191
  const lockStaleMs = opts.lockStaleMs ?? 600_000;
736
1192
  // T12105 / gh#1193: an explicit opts value (tests) wins; otherwise the
@@ -739,35 +1195,113 @@ export async function runToolCached(command, projectRoot, opts = {}) {
739
1195
  // gh#1220/#1226/#1230: the tool runs in — and is fingerprinted against —
740
1196
  // the caller's tree, which is NOT necessarily the store root. The cache
741
1197
  // ENTRY still lives under `projectRoot` so every worktree shares one cache;
742
- // that is sound because the key is content-addressed (HEAD + tracked
743
- // fingerprint), so two trees only collide when they hold the same code.
1198
+ // that is sound because the key is content-addressed (the tracked tree
1199
+ // hash, T12958), so two trees share an entry exactly when they hold the
1200
+ // same tracked code.
744
1201
  const executionRoot = opts.executionRoot ?? projectRoot;
745
- // gh#1221 escape hatch. The fingerprint covers TRACKED content only, so an
746
- // uncommitted NEW file does not invalidate the cache; this is the
747
- // documented way to force a measured run without committing first.
748
- // An explicit option always wins over the env var.
1202
+ const recordedRoot = normalizeExecutionRoot(executionRoot);
1203
+ // Escape hatch for anything the key cannot see (an ignored or excluded
1204
+ // path, environment state outside the fingerprint). An explicit option
1205
+ // always wins over the env var.
749
1206
  const bypassCache = opts.bypassCache ?? process.env['CLEO_EVIDENCE_FRESH'] === '1';
1207
+ const treeHash = await captureTreeHash(executionRoot);
1208
+ const envFingerprint = captureEnvFingerprint(executionRoot, command.canonical);
1209
+ // T12989: the overlay is computed ONCE and both spawned with and keyed on,
1210
+ // so the key describes the heap and worker limits the run actually got.
1211
+ const toolEnv = heavyToolEnv(command.canonical);
1212
+ const resourceEnv = captureResourceEnv(command.canonical, process.env, toolEnv);
750
1213
  const head = await captureHead(executionRoot);
751
- const dirtyFingerprint = await captureDirtyFingerprint(executionRoot);
752
- const key = computeCacheKey(command, head, dirtyFingerprint, executionRoot);
1214
+ const key = computeCacheKey(command, treeHash, envFingerprint, resourceEnv);
1215
+ const makeEntry = (run) => ({
1216
+ schemaVersion: TOOL_CACHE_SCHEMA_VERSION,
1217
+ key,
1218
+ canonical: command.canonical,
1219
+ displayName: command.displayName,
1220
+ cmd: command.cmd,
1221
+ args: command.args,
1222
+ source: command.source,
1223
+ treeHash,
1224
+ envFingerprint,
1225
+ resourceEnv,
1226
+ head,
1227
+ executionRoot: recordedRoot,
1228
+ ...run,
1229
+ capturedAt: new Date().toISOString(),
1230
+ });
1231
+ const hit = (entry) => ({
1232
+ exitCode: entry.exitCode,
1233
+ signal: entry.signal ?? null,
1234
+ stdoutTail: entry.stdoutTail,
1235
+ stderrTail: entry.stderrTail,
1236
+ durationMs: entry.durationMs,
1237
+ cacheHit: true,
1238
+ timedOut: false,
1239
+ lockBusy: false,
1240
+ harnessFailure: null,
1241
+ resourceKill: null,
1242
+ executionRoot,
1243
+ treeHash: entry.treeHash,
1244
+ ...(entry.failedFirst ? { failedFirst: entry.failedFirst } : {}),
1245
+ ...(entry.flaky ? { flaky: entry.flaky } : {}),
1246
+ entry,
1247
+ });
1248
+ // T12025: when the child exceeded its wall-clock deadline, do NOT persist a
1249
+ // cache entry — the run produced no real result. The lock is released via
1250
+ // withLock's finally so a subsequent retry can acquire it and attempt a
1251
+ // fresh spawn from the same pending entry.
1252
+ const timedOutResult = (result, durationMs, failedFirst) => ({
1253
+ exitCode: result.exitCode,
1254
+ signal: result.signal,
1255
+ stdoutTail: tailString(result.stdout, tailBytes),
1256
+ stderrTail: tailString(result.stderr, tailBytes),
1257
+ durationMs,
1258
+ cacheHit: false,
1259
+ timedOut: true,
1260
+ lockBusy: false,
1261
+ harnessFailure: null,
1262
+ resourceKill: null,
1263
+ executionRoot,
1264
+ treeHash,
1265
+ ...(failedFirst ? { failedFirst } : {}),
1266
+ entry: makeEntry({
1267
+ exitCode: null,
1268
+ signal: result.signal,
1269
+ stdoutTail: '',
1270
+ stderrTail: '',
1271
+ durationMs,
1272
+ }),
1273
+ });
1274
+ // gh#1397: a failure of CLEO's own confinement WRAPPER is not a verdict on
1275
+ // the project's tool. Like the `timedOut` branch, this run produced no
1276
+ // result to cache — caching one would serve a fabricated "your tests
1277
+ // failed" from a key that cannot rotate until the tree changes.
1278
+ const harnessResult = (result, durationMs, harnessFailure, failedFirst) => ({
1279
+ exitCode: result.exitCode,
1280
+ signal: result.signal,
1281
+ stdoutTail: tailString(result.stdout, tailBytes),
1282
+ stderrTail: tailString(result.stderr, tailBytes),
1283
+ durationMs,
1284
+ cacheHit: false,
1285
+ timedOut: false,
1286
+ lockBusy: false,
1287
+ executionRoot,
1288
+ treeHash,
1289
+ harnessFailure,
1290
+ resourceKill: null,
1291
+ ...(failedFirst ? { failedFirst } : {}),
1292
+ entry: makeEntry({
1293
+ exitCode: null,
1294
+ signal: result.signal,
1295
+ stdoutTail: '',
1296
+ stderrTail: tailString(result.stderr, tailBytes),
1297
+ durationMs,
1298
+ }),
1299
+ });
753
1300
  // Fast path — fresh cache hit
754
1301
  if (!bypassCache) {
755
1302
  const existing = readCacheEntry(projectRoot, key);
756
- if (existing) {
757
- return {
758
- exitCode: existing.exitCode,
759
- signal: existing.signal ?? null,
760
- stdoutTail: existing.stdoutTail,
761
- stderrTail: existing.stderrTail,
762
- durationMs: existing.durationMs,
763
- cacheHit: true,
764
- timedOut: false,
765
- lockBusy: false,
766
- harnessFailure: null,
767
- executionRoot,
768
- entry: existing,
769
- };
770
- }
1303
+ if (existing)
1304
+ return hit(existing);
771
1305
  }
772
1306
  // Slow path:
773
1307
  // 1. Acquire the global per-tool semaphore (bounds total concurrent
@@ -785,237 +1319,312 @@ export async function runToolCached(command, projectRoot, opts = {}) {
785
1319
  ensureCacheDir(projectRoot);
786
1320
  const cachePath = cacheEntryPath(projectRoot, key);
787
1321
  if (!existsSync(cachePath)) {
788
- writeFileSync(cachePath, JSON.stringify({ schemaVersion: 2, key, pending: true }), 'utf-8');
1322
+ writeFileSync(cachePath, JSON.stringify({ schemaVersion: TOOL_CACHE_SCHEMA_VERSION, key, pending: true }), 'utf-8');
789
1323
  }
790
- const releaseSemaphore = opts.skipGlobalSemaphore
791
- ? undefined
792
- : await acquireGlobalSlot(command.canonical, opts.semaphoreOptions);
793
- try {
794
- return await withLock(cachePath, async () => {
795
- // Inside the lock — re-check the cache. If another process beat us to
796
- // it, prefer its result.
797
- if (!bypassCache) {
798
- const fresh = readCacheEntry(projectRoot, key);
799
- if (fresh) {
800
- return {
801
- exitCode: fresh.exitCode,
802
- signal: fresh.signal ?? null,
803
- stdoutTail: fresh.stdoutTail,
804
- stderrTail: fresh.stderrTail,
805
- durationMs: fresh.durationMs,
806
- cacheHit: true,
807
- timedOut: false,
808
- lockBusy: false,
809
- harnessFailure: null,
810
- executionRoot,
811
- entry: fresh,
812
- };
1324
+ // The body run under the per-key lock: re-check, failed-first, spawn.
1325
+ const runLocked = async () => {
1326
+ // Inside the lock — re-check the cache. If another process beat us to
1327
+ // it, prefer its result.
1328
+ if (!bypassCache) {
1329
+ const fresh = readCacheEntry(projectRoot, key);
1330
+ if (fresh)
1331
+ return hit(fresh);
1332
+ }
1333
+ sweepLegacyEntriesOnce(projectRoot);
1334
+ const failedFirstEnabled = FAILED_FIRST_TOOLS.has(command.canonical);
1335
+ let trackedCache = null;
1336
+ const tracked = () => {
1337
+ trackedCache ??= listTrackedFiles(executionRoot);
1338
+ return trackedCache;
1339
+ };
1340
+ // T12961: failed-first. When the last run of this tool in this tree
1341
+ // failed on a DIFFERENT tree (i.e. something changed since), re-run only
1342
+ // its failing files before the normal command.
1343
+ //
1344
+ // Liveness rules (review of #1774), so a pointer can never pin a tree red:
1345
+ // - `bypassCache` (`CLEO_EVIDENCE_FRESH=1`) skips failed-first entirely;
1346
+ // - a pointer recorded on THIS tree is not re-focused: the normal
1347
+ // command runs and decides (a file that fails only in isolation is
1348
+ // thereby re-judged by the real suite);
1349
+ // - a focused run with no parseable FAIL line (a startup or config
1350
+ // crash) is inconclusive and also runs the normal command.
1351
+ // A focused red is returned but NEVER cached under the normal command's
1352
+ // key: the suite did not run, so it must not be served as the suite's
1353
+ // result. Its record lives in the pointer (now on this tree), so the next
1354
+ // run here goes straight to the normal command.
1355
+ let failedFirst;
1356
+ if (failedFirstEnabled && !bypassCache) {
1357
+ const pointer = readFailedFirstPointer(projectRoot, command.canonical, recordedRoot);
1358
+ const plan = pointer && pointer.treeHash !== treeHash
1359
+ ? planFocusedRuns(pointer.files, executionRoot)
1360
+ : null;
1361
+ if (pointer && plan) {
1362
+ const report = (outcome) => ({
1363
+ files: [...pointer.files],
1364
+ outcome,
1365
+ });
1366
+ const focused = await runFocused(command, plan, executionRoot, spawnTimeoutMs, toolEnv);
1367
+ switch (focused.kind) {
1368
+ case 'timedOut':
1369
+ return timedOutResult(focused.result, focused.durationMs, report('inconclusive'));
1370
+ case 'harness':
1371
+ return harnessResult(focused.result, focused.durationMs, focused.harnessFailure, report('inconclusive'));
1372
+ case 'failed': {
1373
+ const out = `${focused.result.stdout}\n${focused.result.stderr}`;
1374
+ const files = parseFailingTestFiles(out, executionRoot, tracked, focused.cwd);
1375
+ if (files.length === 0) {
1376
+ failedFirst = report('inconclusive');
1377
+ break;
1378
+ }
1379
+ writeFailedFirstPointer(projectRoot, command.canonical, recordedRoot, files, treeHash);
1380
+ const entry = makeEntry({
1381
+ exitCode: focused.result.exitCode,
1382
+ signal: focused.result.signal,
1383
+ stdoutTail: tailString(focused.result.stdout, tailBytes),
1384
+ stderrTail: tailString(focused.result.stderr, tailBytes),
1385
+ durationMs: focused.durationMs,
1386
+ failedTestFiles: files,
1387
+ failedFirst: report('failed'),
1388
+ scope: 'focused',
1389
+ ranFiles: [...pointer.files],
1390
+ });
1391
+ return {
1392
+ exitCode: entry.exitCode,
1393
+ signal: entry.signal ?? null,
1394
+ stdoutTail: entry.stdoutTail,
1395
+ stderrTail: entry.stderrTail,
1396
+ durationMs: entry.durationMs,
1397
+ cacheHit: false,
1398
+ timedOut: false,
1399
+ lockBusy: false,
1400
+ executionRoot,
1401
+ treeHash,
1402
+ harnessFailure: null,
1403
+ resourceKill: null,
1404
+ failedFirst: report('failed'),
1405
+ entry,
1406
+ };
1407
+ }
1408
+ default:
1409
+ failedFirst = report(focused.kind);
813
1410
  }
814
1411
  }
815
- // Spawn the tool ourselves.
816
- //
817
- // T12116: `test` and `build` run inside a transient systemd scope with
818
- // a hard `MemoryMax` and `MemorySwapMax=0`, so the kernel bounds the
819
- // ENTIRE process tree — a `pnpm -r` fan-out into fifteen packages is
820
- // still one cgroup, which is the multiplier the semaphore could not
821
- // see. Denying swap is the point: the failure this guards against was
822
- // a throttle-and-thrash host freeze, not an OOM kill. Degrades to an
823
- // unwrapped spawn off Linux or without a user systemd manager.
824
- const limited = withMemoryLimit(command.canonical, command.cmd, command.args, {
825
- // The scope name's rootHash8 comes off the SAME execution root the
826
- // cache key uses (T12112 / gh#1220), so the scope and the cache agree
827
- // on what "the tree under test" means.
828
- executionRoot,
829
- });
1412
+ }
1413
+ // Spawn the tool ourselves.
1414
+ //
1415
+ // T12116: `test` and `build` run inside a transient systemd scope with
1416
+ // a hard `MemoryMax` and `MemorySwapMax=0`, so the kernel bounds the
1417
+ // ENTIRE process tree — a `pnpm -r` fan-out into fifteen packages is
1418
+ // still one cgroup, which is the multiplier the semaphore could not
1419
+ // see. Denying swap is the point: the failure this guards against was
1420
+ // a throttle-and-thrash host freeze, not an OOM kill. Degrades to an
1421
+ // unwrapped spawn off Linux or without a user systemd manager.
1422
+ const limited = withMemoryLimit(command.canonical, command.cmd, command.args, {
1423
+ // The scope name's rootHash8 comes off the execution root the tool
1424
+ // runs in (T12112 / gh#1220).
1425
+ executionRoot,
1426
+ });
1427
+ const spawnNormal = async () => {
830
1428
  const startedAt = Date.now();
831
- const result = await spawnCmd(limited.cmd, [...limited.args], executionRoot, spawnTimeoutMs, heavyToolEnv(command.canonical));
832
- const durationMs = Date.now() - startedAt;
833
- // T12025: when the child exceeded its wall-clock deadline, do NOT
834
- // persist a cache entry — the run produced no real result. The lock
835
- // is released via withLock's finally so a subsequent retry can
836
- // acquire it and attempt a fresh spawn from the same pending entry.
837
- if (result.timedOut) {
838
- return {
839
- exitCode: result.exitCode,
840
- signal: result.signal,
841
- stdoutTail: tailString(result.stdout, tailBytes),
842
- stderrTail: tailString(result.stderr, tailBytes),
843
- durationMs,
844
- cacheHit: false,
845
- timedOut: true,
846
- lockBusy: false,
847
- harnessFailure: null,
848
- executionRoot,
849
- entry: {
850
- schemaVersion: 2,
851
- key,
852
- canonical: command.canonical,
853
- displayName: command.displayName,
854
- cmd: command.cmd,
855
- args: command.args,
856
- source: command.source,
857
- head,
858
- dirtyFingerprint,
859
- executionRoot,
860
- exitCode: null,
861
- signal: result.signal,
862
- stdoutTail: '',
863
- stderrTail: '',
864
- durationMs,
865
- capturedAt: new Date().toISOString(),
866
- },
867
- };
868
- }
869
- // gh#1397: CLEO wrapped this spawn itself (T12116), so a failure of
870
- // the WRAPPER is CLEO's own fault and not a verdict on the project's
871
- // tool. Checked before the cache entry is built because, like the
872
- // `timedOut` branch above, this run produced no result to cache — and
873
- // caching one would serve a fabricated "your tests failed" from a
874
- // key that cannot rotate until the tree changes.
875
- // `limited.confined` OR the project's own pinned wrapper: post-detection
876
- // (gh#1396) CLEO declines to wrap a command that is already
877
- // `systemd-run`, so `confined` is false for exactly the project whose
878
- // harness failures prompted gh#1397.
879
- const harnessFailure = confinementStartupFailure(result.stderr, limited.confined || isSystemdRunCommand(command.cmd));
880
- if (harnessFailure !== null) {
881
- return {
882
- exitCode: result.exitCode,
883
- signal: result.signal,
884
- stdoutTail: tailString(result.stdout, tailBytes),
885
- stderrTail: tailString(result.stderr, tailBytes),
886
- durationMs,
887
- cacheHit: false,
888
- timedOut: false,
889
- lockBusy: false,
890
- executionRoot,
891
- harnessFailure,
892
- entry: {
893
- schemaVersion: 2,
894
- key,
895
- canonical: command.canonical,
896
- displayName: command.displayName,
897
- cmd: command.cmd,
898
- args: command.args,
899
- source: command.source,
900
- head,
901
- dirtyFingerprint,
902
- executionRoot,
903
- exitCode: null,
904
- signal: result.signal,
905
- stdoutTail: '',
906
- stderrTail: tailString(result.stderr, tailBytes),
907
- durationMs,
908
- capturedAt: new Date().toISOString(),
909
- },
910
- };
911
- }
912
- const entry = {
913
- schemaVersion: 2,
914
- key,
915
- canonical: command.canonical,
916
- displayName: command.displayName,
917
- cmd: command.cmd,
918
- args: command.args,
919
- source: command.source,
920
- head,
921
- dirtyFingerprint,
922
- executionRoot,
923
- exitCode: result.exitCode,
924
- signal: result.signal,
925
- stdoutTail: tailString(result.stdout, tailBytes),
926
- stderrTail: tailString(result.stderr, tailBytes),
927
- durationMs,
928
- capturedAt: new Date().toISOString(),
929
- };
930
- // Persist only a usable entry. `isEntryUsable` is the SAME predicate
931
- // `readCacheEntry` applies, which is the point: these two guards used
932
- // to be independent transcriptions of one rule — `exitCode !== null
933
- // && head !== null`, written out twice — so a field added to one had
934
- // to be remembered into the other. gh#1380 and gh#1404 each edited
935
- // both, and nothing checked that they still agreed.
936
- //
937
- // What the shared predicate refuses, and why none of it is an
938
- // overcorrection:
939
- // - unknown outcome (`exitCode: null`) — a signal kill, or a
940
- // failure to start. The `timedOut` branch above already returns
941
- // without writing for this reason; an OOM kill that is not a CLEO
942
- // timeout (gh#1381) had no equivalent guard and fell through here.
943
- // - unrotatable key (`head: null`) — costs a non-git CLEO root all
944
- // caching. Stated rather than hidden: those projects are not
945
- // getting valid caching today, they are getting ONE answer
946
- // forever, and a slow correct answer beats a fast fabricated one.
947
- // - unattributable run (`executionRoot` absent) — cannot occur on
948
- // this path, and is required here anyway so that read and write
949
- // stay ONE rule rather than two that happen to match today.
1429
+ const result = await spawnCmd(limited.cmd, [...limited.args], executionRoot, spawnTimeoutMs, toolEnv);
1430
+ return { result, durationMs: Date.now() - startedAt };
1431
+ };
1432
+ // gh#1397: `limited.confined` OR the project's own pinned wrapper:
1433
+ // post-detection (gh#1396) CLEO declines to wrap a command that is
1434
+ // already `systemd-run`, so `confined` is false for exactly the project
1435
+ // whose harness failures prompted gh#1397.
1436
+ const harnessOf = (r) => confinementStartupFailure(r.stderr, limited.confined || isSystemdRunCommand(command.cmd));
1437
+ const first = await spawnNormal();
1438
+ if (first.result.timedOut) {
1439
+ return timedOutResult(first.result, first.durationMs, failedFirst);
1440
+ }
1441
+ const firstHarness = harnessOf(first.result);
1442
+ if (firstHarness !== null) {
1443
+ return harnessResult(first.result, first.durationMs, firstHarness, failedFirst);
1444
+ }
1445
+ let { result, durationMs } = first;
1446
+ // T12989: a run killed for resources — a signal, exit 128 + a kill signal,
1447
+ // or a heap OOM the runner caught and turned into exit 1 — is not a verdict
1448
+ // on the code. It is reported, but not flake-retried (a full rerun at the
1449
+ // same limits under the same pressure doubles the cost of the kill), not
1450
+ // recorded for failed-first (the unknown outcome leaves the pointer) and
1451
+ // not cached (`isEntryUsable` refuses `resourceKill`).
1452
+ const resourceKill = resourceKillReason(result);
1453
+ let failedTestFiles;
1454
+ let flaky;
1455
+ let flakyFailureTail;
1456
+ if (failedFirstEnabled && result.exitCode !== null && resourceKill === null) {
1457
+ let files = result.exitCode === 0
1458
+ ? []
1459
+ : parseFailingTestFiles(`${result.stdout}\n${result.stderr}`, executionRoot, tracked);
1460
+ // T12961 flake check (review of #1774): on a failure, re-run the FULL
1461
+ // recorded command once — never a narrower focused or per-package run,
1462
+ // which cannot see a `pnpm -r` bail, a coverage threshold, a non-vitest
1463
+ // step or a failure that only happens in the suite's context. A pass
1464
+ // on that identical rerun is a pass marked `flaky` with the first
1465
+ // run's failing files and failure tail; a second failure is red. A
1466
+ // rerun that times out, cannot start or is killed for resources decides
1467
+ // nothing, and the first failure stands.
950
1468
  //
951
- // Deliberately narrow in the other direction too: refusing, say, all
952
- // non-zero exits would trade a fabricated pass for a permanent cache
953
- // miss on healthy projects — the same overcorrection pointed the
954
- // other way.
955
- if (isEntryUsable(entry)) {
956
- writeCacheEntry(projectRoot, entry);
1469
+ // Only a NARROW failure is retried: the failing files must be named and
1470
+ // number at most MAX_FLAKE_RETRY_FILES. A broken build or a mass
1471
+ // failure is deterministic, and doubling its cost buys nothing.
1472
+ if (result.exitCode !== 0 && files.length > 0 && files.length <= MAX_FLAKE_RETRY_FILES) {
1473
+ const retry = await spawnNormal();
1474
+ if (!retry.result.timedOut &&
1475
+ harnessOf(retry.result) === null &&
1476
+ resourceKillReason(retry.result) === null) {
1477
+ if (retry.result.exitCode === 0) {
1478
+ flaky = files.length > 0 ? files : ['<unknown>'];
1479
+ flakyFailureTail = tailString(`${result.stdout}\n${result.stderr}`, tailBytes);
1480
+ files = [];
1481
+ result = retry.result;
1482
+ durationMs += retry.durationMs;
1483
+ }
1484
+ else if (retry.result.exitCode !== null) {
1485
+ const again = parseFailingTestFiles(`${retry.result.stdout}\n${retry.result.stderr}`, executionRoot, tracked);
1486
+ if (again.length > 0)
1487
+ files = again;
1488
+ result = retry.result;
1489
+ durationMs += retry.durationMs;
1490
+ }
1491
+ }
957
1492
  }
1493
+ // Remember which test files failed, so the next run on a changed tree
1494
+ // can try them first. A pass, or a failure whose files cannot be
1495
+ // named, clears the pointer; an unknown outcome leaves it.
1496
+ if (files.length > 0)
1497
+ failedTestFiles = files;
1498
+ writeFailedFirstPointer(projectRoot, command.canonical, recordedRoot, files, treeHash);
1499
+ }
1500
+ const entry = makeEntry({
1501
+ exitCode: result.exitCode,
1502
+ signal: result.signal,
1503
+ stdoutTail: tailString(result.stdout, tailBytes),
1504
+ stderrTail: tailString(result.stderr, tailBytes),
1505
+ durationMs,
1506
+ ...(resourceKill !== null ? { resourceKill } : {}),
1507
+ ...(failedTestFiles ? { failedTestFiles } : {}),
1508
+ ...(failedFirst ? { failedFirst } : {}),
1509
+ ...(flaky && result.exitCode === 0 ? { flaky, flakyFailureTail } : {}),
1510
+ });
1511
+ // Persist only a usable entry. `isEntryUsable` is the SAME predicate
1512
+ // `readCacheEntry` applies, which is the point: these two guards used
1513
+ // to be independent transcriptions of one rule — `exitCode !== null
1514
+ // && head !== null`, written out twice — so a field added to one had
1515
+ // to be remembered into the other. gh#1380 and gh#1404 each edited
1516
+ // both, and nothing checked that they still agreed.
1517
+ //
1518
+ // What the shared predicate refuses, and why none of it is an
1519
+ // overcorrection:
1520
+ // - unknown outcome (`exitCode: null`) — a signal kill, or a
1521
+ // failure to start. The `timedOut` branch above already returns
1522
+ // without writing for this reason; an OOM kill that is not a CLEO
1523
+ // timeout (gh#1381) had no equivalent guard and fell through here.
1524
+ // - resource kill (`resourceKill`, T12989) — a REAL exit code that is
1525
+ // not a verdict: exit 137 from a wrapper, or vitest's exit 1 after a
1526
+ // worker's heap OOM. Cached, it replayed the 3 GB OOM to a retry
1527
+ // given 6 GB until the entry was deleted by hand.
1528
+ // - unrotatable key (`treeHash: null`) — costs a non-git CLEO root
1529
+ // all caching. Stated rather than hidden: those projects are not
1530
+ // getting valid caching today, they are getting ONE answer
1531
+ // forever, and a slow correct answer beats a fast fabricated one.
1532
+ //
1533
+ // Deliberately narrow in the other direction too: refusing, say, all
1534
+ // non-zero exits would trade a fabricated pass for a permanent cache
1535
+ // miss on healthy projects — the same overcorrection pointed the
1536
+ // other way.
1537
+ if (isEntryUsable(entry)) {
1538
+ writeCacheEntry(projectRoot, entry);
1539
+ }
1540
+ return {
1541
+ exitCode: entry.exitCode,
1542
+ signal: entry.signal ?? null,
1543
+ stdoutTail: entry.stdoutTail,
1544
+ stderrTail: entry.stderrTail,
1545
+ durationMs: entry.durationMs,
1546
+ cacheHit: false,
1547
+ timedOut: false,
1548
+ lockBusy: false,
1549
+ executionRoot,
1550
+ treeHash,
1551
+ harnessFailure: null,
1552
+ resourceKill,
1553
+ ...(failedFirst ? { failedFirst } : {}),
1554
+ ...(entry.flaky ? { flaky: entry.flaky } : {}),
1555
+ entry,
1556
+ };
1557
+ };
1558
+ // Coalescing (T12958): with a content key, N agents in N worktrees at the
1559
+ // same tree compute the SAME key, so the per-key lock is what turns N
1560
+ // identical suites into one. A caller that finds the lock held WAITS for
1561
+ // the holder's result instead of failing fast (T12025's ~0.7 s give-up
1562
+ // made every second caller report E_EVIDENCE_TOOL_BUSY and retry, i.e.
1563
+ // run the suite anyway). The wait happens OUTSIDE the global semaphore so a
1564
+ // waiter does not occupy a slot a different key could use.
1565
+ //
1566
+ // When the holder releases without a usable entry (timeout, harness
1567
+ // failure, resource kill, crash) the waiter loops and runs the tool itself. The total wait
1568
+ // is bounded by `lockWaitMs`; past it the caller gets `lockBusy`.
1569
+ // The holder's worst case is a focused rerun, the normal command and its
1570
+ // one flake retry — three spawn deadlines — plus slack.
1571
+ const lockWaitMs = opts.lockWaitMs ?? 3 * spawnTimeoutMs + 60_000;
1572
+ const lockPollMs = opts.lockPollMs ?? 1_000;
1573
+ const waitDeadline = Date.now() + lockWaitMs;
1574
+ // A bypassing caller must not accept the entry it chose to bypass.
1575
+ const acceptable = (e) => !bypassCache || Date.parse(e.capturedAt) >= callStartedAt;
1576
+ for (;;) {
1577
+ const releaseSemaphore = opts.skipGlobalSemaphore
1578
+ ? undefined
1579
+ : await acquireGlobalSlot(command.canonical, opts.semaphoreOptions);
1580
+ try {
1581
+ return await withLock(cachePath, runLocked, { stale: lockStaleMs, retries: 3 });
1582
+ }
1583
+ catch (err) {
1584
+ // Only ELOCKED contention is waited out — permission errors and other
1585
+ // lock failures are re-thrown as real errors.
1586
+ const causeCode = err instanceof CleoError && err.code === ExitCode.LOCK_TIMEOUT
1587
+ ? err.cause?.code
1588
+ : undefined;
1589
+ if (causeCode !== 'ELOCKED')
1590
+ throw err;
1591
+ }
1592
+ finally {
1593
+ if (releaseSemaphore)
1594
+ await releaseSemaphore();
1595
+ }
1596
+ // Another caller is running this exact key. Wait for its result.
1597
+ while (Date.now() < waitDeadline) {
1598
+ const done = readCacheEntry(projectRoot, key);
1599
+ if (done && acceptable(done))
1600
+ return hit(done);
1601
+ if (!(await isLocked(cachePath, { stale: lockStaleMs })))
1602
+ break;
1603
+ await new Promise((r) => setTimeout(r, Math.min(lockPollMs, waitDeadline - Date.now())));
1604
+ }
1605
+ const done = readCacheEntry(projectRoot, key);
1606
+ if (done && acceptable(done))
1607
+ return hit(done);
1608
+ if (Date.now() >= waitDeadline) {
1609
+ // T12025: a typed, actionable result so callers surface
1610
+ // E_EVIDENCE_TOOL_BUSY instead of a generic error.
958
1611
  return {
959
- exitCode: entry.exitCode,
960
- signal: entry.signal ?? null,
961
- stdoutTail: entry.stdoutTail,
962
- stderrTail: entry.stderrTail,
963
- durationMs: entry.durationMs,
1612
+ exitCode: null,
1613
+ signal: null,
1614
+ stdoutTail: '',
1615
+ stderrTail: '',
1616
+ durationMs: 0,
964
1617
  cacheHit: false,
965
1618
  timedOut: false,
966
- lockBusy: false,
967
- executionRoot,
1619
+ lockBusy: true,
968
1620
  harnessFailure: null,
969
- entry,
1621
+ resourceKill: null,
1622
+ executionRoot,
1623
+ treeHash,
1624
+ entry: makeEntry({ exitCode: null, stdoutTail: '', stderrTail: '', durationMs: 0 }),
970
1625
  };
971
- }, { stale: lockStaleMs, retries: 3 });
972
- }
973
- catch (err) {
974
- // T12025 (lock contention): a held cache lock causes ~47 s of blind
975
- // proper-lockfile retries (50 × exponential backoff). Reduce to 3
976
- // retries (~0.7 s fail-fast) and return a typed actionable result
977
- // so callers surface E_EVIDENCE_TOOL_BUSY instead of a generic error.
978
- // Only surface lockBusy for actual ELOCKED contention — permission
979
- // errors and other lock failures are re-thrown as real errors.
980
- if (err instanceof CleoError && err.code === ExitCode.LOCK_TIMEOUT) {
981
- const causeCode = err.cause?.code;
982
- if (causeCode === 'ELOCKED') {
983
- return {
984
- exitCode: null,
985
- signal: null,
986
- stdoutTail: '',
987
- stderrTail: '',
988
- durationMs: 0,
989
- cacheHit: false,
990
- timedOut: false,
991
- lockBusy: true,
992
- harnessFailure: null,
993
- executionRoot,
994
- entry: {
995
- schemaVersion: 2,
996
- key,
997
- canonical: command.canonical,
998
- displayName: command.displayName,
999
- cmd: command.cmd,
1000
- args: command.args,
1001
- source: command.source,
1002
- head,
1003
- dirtyFingerprint,
1004
- executionRoot,
1005
- exitCode: null,
1006
- stdoutTail: '',
1007
- stderrTail: '',
1008
- durationMs: 0,
1009
- capturedAt: new Date().toISOString(),
1010
- },
1011
- };
1012
- }
1013
1626
  }
1014
- throw err;
1015
- }
1016
- finally {
1017
- if (releaseSemaphore)
1018
- await releaseSemaphore();
1627
+ // The holder released without a usable result: run it ourselves.
1019
1628
  }
1020
1629
  }
1021
1630
  function tailString(s, max) {