@toolu/opencode 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. package/generated/GENERATED-NOTES.md +16 -0
  2. package/generated/agents/toolu--architect.md +52 -0
  3. package/generated/agents/toolu--deep-explore.md +65 -0
  4. package/generated/agents/toolu--implementer.md +50 -0
  5. package/generated/agents/toolu--quick-task.md +54 -0
  6. package/generated/agents/toolu--research-agent.md +101 -0
  7. package/generated/commands/toolu--commit.md +8 -0
  8. package/generated/commands/toolu--review-and-commit.md +8 -0
  9. package/generated/opencode.toolu.json +116 -0
  10. package/generated/skills/toolu--brainstorm/SKILL.md +57 -0
  11. package/generated/skills/toolu--brainstorm/references/design-questions.md +36 -0
  12. package/generated/skills/toolu--commit--skill/SKILL.md +11 -0
  13. package/generated/skills/toolu--debug/SKILL.md +54 -0
  14. package/generated/skills/toolu--deep-research/SKILL.md +51 -0
  15. package/generated/skills/toolu--execution/SKILL.md +77 -0
  16. package/generated/skills/toolu--orchestrator/SKILL.md +145 -0
  17. package/generated/skills/toolu--orchestrator/references/delegation-cost.md +104 -0
  18. package/generated/skills/toolu--orchestrator/references/model-routing.md +112 -0
  19. package/generated/skills/toolu--plan/SKILL.md +56 -0
  20. package/generated/skills/toolu--plan/references/ledger.md +18 -0
  21. package/generated/skills/toolu--plan-review/SKILL.md +48 -0
  22. package/generated/skills/toolu--review-and-commit--skill/SKILL.md +10 -0
  23. package/generated/skills/toolu--setup/SKILL.md +24 -0
  24. package/generated/skills/toolu--setup/scripts/setup.sh +206 -0
  25. package/generated/skills/toolu--spec/SKILL.md +67 -0
  26. package/generated/skills/toolu--spec-review/SKILL.md +50 -0
  27. package/generated/skills/toolu--test/SKILL.md +49 -0
  28. package/package.json +51 -0
  29. package/plugins/agent-browser/.claude-plugin/plugin.json +18 -0
  30. package/plugins/agent-browser/.codex-plugin/plugin.json +7 -0
  31. package/plugins/agent-browser/README.md +18 -0
  32. package/plugins/agent-browser/hooks/hooks.json +15 -0
  33. package/plugins/agent-browser/hooks/session-start.sh +42 -0
  34. package/plugins/agent-browser/skills/agent-browser/SKILL.md +77 -0
  35. package/plugins/agent-browser/skills/agent-browser/scripts/agent-browser.sh +80 -0
  36. package/plugins/ast-grep/.claude-plugin/plugin.json +18 -0
  37. package/plugins/ast-grep/.codex-plugin/plugin.json +7 -0
  38. package/plugins/ast-grep/README.md +21 -0
  39. package/plugins/ast-grep/hooks/hooks.json +15 -0
  40. package/plugins/ast-grep/hooks/post-tools.d/byte-savings.sh +67 -0
  41. package/plugins/ast-grep/hooks/pre-tools.d/search-nudge.sh +170 -0
  42. package/plugins/ast-grep/hooks/register.sh +68 -0
  43. package/plugins/ast-grep/scripts/byte-savings-report.sh +28 -0
  44. package/plugins/ast-grep/skills/ast-grep/SKILL.md +127 -0
  45. package/plugins/ast-grep/skills/ast-grep/references/ast-grep-advanced.md +44 -0
  46. package/plugins/ast-grep/skills/ast-grep/scripts/ast-grep.sh +103 -0
  47. package/plugins/context7/.claude-plugin/plugin.json +18 -0
  48. package/plugins/context7/.codex-plugin/plugin.json +7 -0
  49. package/plugins/context7/README.md +19 -0
  50. package/plugins/context7/hooks/hooks.json +15 -0
  51. package/plugins/context7/hooks/session-start.sh +42 -0
  52. package/plugins/context7/skills/context7/SKILL.md +64 -0
  53. package/plugins/context7/skills/context7/scripts/search.sh +151 -0
  54. package/plugins/exa-search/.claude-plugin/plugin.json +18 -0
  55. package/plugins/exa-search/.codex-plugin/plugin.json +7 -0
  56. package/plugins/exa-search/README.md +19 -0
  57. package/plugins/exa-search/hooks/hooks.json +15 -0
  58. package/plugins/exa-search/hooks/session-start.sh +34 -0
  59. package/plugins/exa-search/skills/exa-search/SKILL.md +91 -0
  60. package/plugins/exa-search/skills/exa-search/scripts/search.sh +221 -0
  61. package/plugins/jev/.claude-plugin/plugin.json +19 -0
  62. package/plugins/jev/.codex-plugin/plugin.json +7 -0
  63. package/plugins/jev/README.md +61 -0
  64. package/plugins/jev/hooks/hooks.json +25 -0
  65. package/plugins/jev/hooks/lib/common.sh +49 -0
  66. package/plugins/jev/hooks/session-start.sh +45 -0
  67. package/plugins/jev/hooks/user-prompt-submit.sh +41 -0
  68. package/plugins/jev/skills/jev/SKILL.md +81 -0
  69. package/plugins/jev/skills/jev/evals/README.md +98 -0
  70. package/plugins/jev/skills/jev/references/problem-solving.md +198 -0
  71. package/plugins/jev/skills/jev/scripts/jev.sh +405 -0
  72. package/plugins/jira/.claude-plugin/plugin.json +18 -0
  73. package/plugins/jira/.codex-plugin/plugin.json +7 -0
  74. package/plugins/jira/README.md +42 -0
  75. package/plugins/jira/hooks/hooks.json +15 -0
  76. package/plugins/jira/hooks/session-start.sh +34 -0
  77. package/plugins/jira/skills/jira/SKILL.md +123 -0
  78. package/plugins/jira/skills/jira/scripts/jira.sh +70 -0
  79. package/plugins/jira/skills/jira/scripts/lib/adf.sh +17 -0
  80. package/plugins/jira/skills/jira/scripts/lib/attachment.sh +94 -0
  81. package/plugins/jira/skills/jira/scripts/lib/board.sh +58 -0
  82. package/plugins/jira/skills/jira/scripts/lib/http.sh +184 -0
  83. package/plugins/jira/skills/jira/scripts/lib/issue.sh +159 -0
  84. package/plugins/jira/skills/jira/scripts/lib/paginate.sh +45 -0
  85. package/plugins/jira/skills/jira/scripts/lib/plan-parse.sh +70 -0
  86. package/plugins/jira/skills/jira/scripts/lib/plan-run.sh +115 -0
  87. package/plugins/jira/skills/jira/scripts/lib/plan-store.sh +168 -0
  88. package/plugins/jira/skills/jira/scripts/lib/plan.sh +108 -0
  89. package/plugins/jira/skills/jira/scripts/lib/project.sh +42 -0
  90. package/plugins/jira/skills/jira/scripts/lib/raw.sh +15 -0
  91. package/plugins/jira/skills/jira/scripts/lib/search.sh +41 -0
  92. package/plugins/jira/skills/jira/scripts/lib/sprint.sh +88 -0
  93. package/plugins/jira/skills/jira/scripts/lib/user.sh +45 -0
  94. package/plugins/jira/skills/jira/scripts/lib/worklog.sh +56 -0
  95. package/plugins/pr-babysit/.claude-plugin/plugin.json +25 -0
  96. package/plugins/pr-babysit/.codex-plugin/plugin.json +7 -0
  97. package/plugins/pr-babysit/README.md +38 -0
  98. package/plugins/pr-babysit/commands/babysit.md +13 -0
  99. package/plugins/pr-babysit/hooks/check-toolu.sh +20 -0
  100. package/plugins/pr-babysit/hooks/hooks.json +15 -0
  101. package/plugins/pr-babysit/scripts/babysit-tick.sh +133 -0
  102. package/plugins/pr-babysit/scripts/collect-pr.sh +224 -0
  103. package/plugins/pr-babysit/scripts/lib/common.sh +146 -0
  104. package/plugins/pr-babysit/scripts/lib/gh.sh +150 -0
  105. package/plugins/pr-babysit/scripts/lib/lock.sh +84 -0
  106. package/plugins/pr-babysit/scripts/lib/normalize.sh +98 -0
  107. package/plugins/pr-babysit/scripts/lib/state.sh +37 -0
  108. package/plugins/pr-babysit/scripts/parse-verdict.sh +171 -0
  109. package/plugins/pr-babysit/scripts/record.sh +69 -0
  110. package/plugins/pr-babysit/scripts/reduce-state.sh +245 -0
  111. package/plugins/pr-babysit/scripts/reply-thread.sh +90 -0
  112. package/plugins/pr-babysit/scripts/resolve-thread.sh +71 -0
  113. package/plugins/pr-babysit/skills/babysit/SKILL.md +31 -0
  114. package/plugins/pr-babysit/skills/babysit/references/helper.md +209 -0
  115. package/plugins/pr-babysit/workflows/babysit.md +722 -0
  116. package/plugins/python-quality/.claude-plugin/plugin.json +24 -0
  117. package/plugins/python-quality/.codex-plugin/plugin.json +6 -0
  118. package/plugins/python-quality/README.md +23 -0
  119. package/plugins/python-quality/hooks/check-toolu.sh +20 -0
  120. package/plugins/python-quality/hooks/concerns/00-preamble.sh +85 -0
  121. package/plugins/python-quality/hooks/concerns/10-size-file.sh +6 -0
  122. package/plugins/python-quality/hooks/concerns/20-tests.sh +37 -0
  123. package/plugins/python-quality/hooks/concerns/30-suppression.sh +33 -0
  124. package/plugins/python-quality/hooks/concerns/50-size-fn.sh +49 -0
  125. package/plugins/python-quality/hooks/concerns/70-no-mocks.sh +76 -0
  126. package/plugins/python-quality/hooks/concerns/90-docs.sh +52 -0
  127. package/plugins/python-quality/hooks/concerns/99-finalize.sh +36 -0
  128. package/plugins/python-quality/hooks/hooks.json +19 -0
  129. package/plugins/python-quality/hooks/register.sh +65 -0
  130. package/plugins/rust-quality/.claude-plugin/plugin.json +24 -0
  131. package/plugins/rust-quality/.codex-plugin/plugin.json +6 -0
  132. package/plugins/rust-quality/README.md +24 -0
  133. package/plugins/rust-quality/hooks/check-toolu.sh +20 -0
  134. package/plugins/rust-quality/hooks/concerns/00-preamble.sh +93 -0
  135. package/plugins/rust-quality/hooks/concerns/10-size-file.sh +10 -0
  136. package/plugins/rust-quality/hooks/concerns/20-tests.sh +84 -0
  137. package/plugins/rust-quality/hooks/concerns/30-suppression.sh +26 -0
  138. package/plugins/rust-quality/hooks/concerns/40-unsafe.sh +39 -0
  139. package/plugins/rust-quality/hooks/concerns/50-size-fn.sh +38 -0
  140. package/plugins/rust-quality/hooks/concerns/55-size-impl.sh +28 -0
  141. package/plugins/rust-quality/hooks/concerns/60-error-handling.sh +149 -0
  142. package/plugins/rust-quality/hooks/concerns/70-no-mocks.sh +139 -0
  143. package/plugins/rust-quality/hooks/concerns/90-docs.sh +32 -0
  144. package/plugins/rust-quality/hooks/concerns/99-finalize.sh +36 -0
  145. package/plugins/rust-quality/hooks/hooks.json +19 -0
  146. package/plugins/rust-quality/hooks/register.sh +65 -0
  147. package/plugins/statusline/.claude-plugin/plugin.json +19 -0
  148. package/plugins/statusline/.codex-plugin/plugin.json +7 -0
  149. package/plugins/statusline/README.md +108 -0
  150. package/plugins/statusline/commands/setup.md +30 -0
  151. package/plugins/statusline/hooks/hooks.json +15 -0
  152. package/plugins/statusline/hooks/session-start.sh +45 -0
  153. package/plugins/statusline/scripts/collect-status.sh +147 -0
  154. package/plugins/statusline/scripts/setup.sh +93 -0
  155. package/plugins/statusline/scripts/status.sh +55 -0
  156. package/plugins/statusline/skills/status/SKILL.md +13 -0
  157. package/plugins/statusline/statusline.sh +177 -0
  158. package/plugins/toolu/.claude-plugin/plugin.json +19 -0
  159. package/plugins/toolu/.codex-plugin/plugin.json +7 -0
  160. package/plugins/toolu/README.md +35 -0
  161. package/plugins/toolu/agents/.gitkeep +0 -0
  162. package/plugins/toolu/agents/architect.md +52 -0
  163. package/plugins/toolu/agents/deep-explore.md +65 -0
  164. package/plugins/toolu/agents/implementer.md +50 -0
  165. package/plugins/toolu/agents/quick-task.md +54 -0
  166. package/plugins/toolu/agents/research-agent.md +107 -0
  167. package/plugins/toolu/assets/agents/architect.toml +9 -0
  168. package/plugins/toolu/assets/agents/deep-explore.toml +9 -0
  169. package/plugins/toolu/assets/agents/implementer.toml +9 -0
  170. package/plugins/toolu/assets/agents/quick-task.toml +9 -0
  171. package/plugins/toolu/assets/agents/research-agent.toml +9 -0
  172. package/plugins/toolu/commands/.gitkeep +0 -0
  173. package/plugins/toolu/commands/commit.md +5 -0
  174. package/plugins/toolu/commands/review-and-commit.md +5 -0
  175. package/plugins/toolu/hooks/docs/gates.md +157 -0
  176. package/plugins/toolu/hooks/docs/model-routing.md +14 -0
  177. package/plugins/toolu/hooks/docs/post-compaction.md +3 -0
  178. package/plugins/toolu/hooks/docs/push-review.md +123 -0
  179. package/plugins/toolu/hooks/docs/session-start-python.md +3 -0
  180. package/plugins/toolu/hooks/docs/session-start-rust.md +4 -0
  181. package/plugins/toolu/hooks/docs/session-start-ts.md +3 -0
  182. package/plugins/toolu/hooks/docs/session-start.md +16 -0
  183. package/plugins/toolu/hooks/hooks.json +76 -0
  184. package/plugins/toolu/hooks/lib/config.sh +355 -0
  185. package/plugins/toolu/hooks/lib/detect.sh +929 -0
  186. package/plugins/toolu/hooks/lib/diff-sha.sh +32 -0
  187. package/plugins/toolu/hooks/lib/dispatch.sh +365 -0
  188. package/plugins/toolu/hooks/lib/docs-sync-config.sh +81 -0
  189. package/plugins/toolu/hooks/lib/edit-records.sh +157 -0
  190. package/plugins/toolu/hooks/lib/gate-file.sh +201 -0
  191. package/plugins/toolu/hooks/lib/gate-mode.sh +244 -0
  192. package/plugins/toolu/hooks/lib/host.sh +205 -0
  193. package/plugins/toolu/hooks/lib/permissions.sh +125 -0
  194. package/plugins/toolu/hooks/lib/plan-ledger-parse.sh +219 -0
  195. package/plugins/toolu/hooks/lib/plan-ledger-preflight.sh +137 -0
  196. package/plugins/toolu/hooks/lib/plan-ledger.sh +749 -0
  197. package/plugins/toolu/hooks/lib/push-waiver.sh +116 -0
  198. package/plugins/toolu/hooks/lib/quality-config.sh +184 -0
  199. package/plugins/toolu/hooks/lib/registry.sh +72 -0
  200. package/plugins/toolu/hooks/lib/state-sweeper.sh +194 -0
  201. package/plugins/toolu/hooks/lib/telemetry.sh +94 -0
  202. package/plugins/toolu/hooks/lib/verdict.sh +477 -0
  203. package/plugins/toolu/hooks/post-tools/mod.sh +42 -0
  204. package/plugins/toolu/hooks/post-tools/modules/gate-status.sh +140 -0
  205. package/plugins/toolu/hooks/post-tools/modules/push-waiver.sh +97 -0
  206. package/plugins/toolu/hooks/pre-compact.sh +18 -0
  207. package/plugins/toolu/hooks/pre-tools/agent-tier.sh +111 -0
  208. package/plugins/toolu/hooks/pre-tools/mod.sh +37 -0
  209. package/plugins/toolu/hooks/pre-tools/modules/bash-commands.sh +187 -0
  210. package/plugins/toolu/hooks/pre-tools/modules/code-edit-rules.sh +78 -0
  211. package/plugins/toolu/hooks/pre-tools/modules/commit-gate.sh +70 -0
  212. package/plugins/toolu/hooks/pre-tools/modules/docs-sync.sh +137 -0
  213. package/plugins/toolu/hooks/pre-tools/modules/mcp-blocker.sh +151 -0
  214. package/plugins/toolu/hooks/pre-tools/modules/plan-ledger.sh +201 -0
  215. package/plugins/toolu/hooks/pre-tools/modules/protected-files.sh +177 -0
  216. package/plugins/toolu/hooks/pre-tools/modules/push-review.sh +273 -0
  217. package/plugins/toolu/hooks/pre-tools/modules/quality-gate.sh +87 -0
  218. package/plugins/toolu/hooks/session-start.sh +379 -0
  219. package/plugins/toolu/hooks/user-prompt-submit.sh +197 -0
  220. package/plugins/toolu/scripts/context-budget.sh +111 -0
  221. package/plugins/toolu/scripts/debug-log.sh +98 -0
  222. package/plugins/toolu/scripts/debug-stack.sh +108 -0
  223. package/plugins/toolu/scripts/debug-testfail.sh +97 -0
  224. package/plugins/toolu/settings/.gitkeep +0 -0
  225. package/plugins/toolu/settings/README.md +127 -0
  226. package/plugins/toolu/settings/bash-allowlist.txt +12 -0
  227. package/plugins/toolu/settings/bash-denylist.txt +25 -0
  228. package/plugins/toolu/settings/code-edit-rules.json +67 -0
  229. package/plugins/toolu/settings/commit-prefixes.txt +12 -0
  230. package/plugins/toolu/settings/mcp-blocklist.txt +8 -0
  231. package/plugins/toolu/settings/permissions.fragment.json +36 -0
  232. package/plugins/toolu/settings/protected-files.txt +30 -0
  233. package/plugins/toolu/settings/rust-unsafe-exemptions.txt +5 -0
  234. package/plugins/toolu/settings/toolu.config.example.json +130 -0
  235. package/plugins/toolu/skills/.gitkeep +0 -0
  236. package/plugins/toolu/skills/brainstorm/SKILL.md +57 -0
  237. package/plugins/toolu/skills/brainstorm/references/design-questions.md +36 -0
  238. package/plugins/toolu/skills/commit/SKILL.md +11 -0
  239. package/plugins/toolu/skills/debug/SKILL.md +54 -0
  240. package/plugins/toolu/skills/debug/evals/README.md +17 -0
  241. package/plugins/toolu/skills/debug/evals/cases.json +24 -0
  242. package/plugins/toolu/skills/debug/evals/results.json +34 -0
  243. package/plugins/toolu/skills/deep-research/SKILL.md +51 -0
  244. package/plugins/toolu/skills/execution/SKILL.md +77 -0
  245. package/plugins/toolu/skills/orchestrator/SKILL.md +145 -0
  246. package/plugins/toolu/skills/orchestrator/references/delegation-cost.md +104 -0
  247. package/plugins/toolu/skills/orchestrator/references/model-routing.md +112 -0
  248. package/plugins/toolu/skills/plan/SKILL.md +56 -0
  249. package/plugins/toolu/skills/plan/references/ledger.md +18 -0
  250. package/plugins/toolu/skills/plan-review/SKILL.md +48 -0
  251. package/plugins/toolu/skills/review-and-commit/SKILL.md +10 -0
  252. package/plugins/toolu/skills/setup/SKILL.md +24 -0
  253. package/plugins/toolu/skills/setup/scripts/setup.sh +206 -0
  254. package/plugins/toolu/skills/spec/SKILL.md +67 -0
  255. package/plugins/toolu/skills/spec-review/SKILL.md +50 -0
  256. package/plugins/toolu/skills/test/SKILL.md +49 -0
  257. package/plugins/toolu/workflows/commit.md +21 -0
  258. package/plugins/toolu/workflows/host-mapping.md +22 -0
  259. package/plugins/toolu/workflows/review-and-commit.md +54 -0
  260. package/plugins/toolu/workflows/semantic-judgments.md +35 -0
  261. package/plugins/toolu-review/.claude-plugin/plugin.json +18 -0
  262. package/plugins/toolu-review/.codex-plugin/plugin.json +7 -0
  263. package/plugins/toolu-review/README.md +17 -0
  264. package/plugins/toolu-review/hooks/hooks.json +15 -0
  265. package/plugins/toolu-review/hooks/session-start.sh +34 -0
  266. package/plugins/toolu-review/skills/review/SKILL.md +90 -0
  267. package/plugins/toolu-review/skills/review/scripts/write-state.sh +189 -0
  268. package/plugins/ts-quality/.claude-plugin/plugin.json +24 -0
  269. package/plugins/ts-quality/.codex-plugin/plugin.json +6 -0
  270. package/plugins/ts-quality/README.md +24 -0
  271. package/plugins/ts-quality/hooks/check-toolu.sh +20 -0
  272. package/plugins/ts-quality/hooks/concerns/00-preamble.sh +123 -0
  273. package/plugins/ts-quality/hooks/concerns/10-imports.sh +33 -0
  274. package/plugins/ts-quality/hooks/concerns/15-type-as.sh +9 -0
  275. package/plugins/ts-quality/hooks/concerns/20-tests.sh +26 -0
  276. package/plugins/ts-quality/hooks/concerns/25-size-file.sh +40 -0
  277. package/plugins/ts-quality/hooks/concerns/30-size-fn.sh +53 -0
  278. package/plugins/ts-quality/hooks/concerns/35-react-hooks.sh +7 -0
  279. package/plugins/ts-quality/hooks/concerns/40-factory.sh +5 -0
  280. package/plugins/ts-quality/hooks/concerns/45-typeguard.sh +6 -0
  281. package/plugins/ts-quality/hooks/concerns/50-type-dup.sh +16 -0
  282. package/plugins/ts-quality/hooks/concerns/55-naming.sh +11 -0
  283. package/plugins/ts-quality/hooks/concerns/60-console.sh +6 -0
  284. package/plugins/ts-quality/hooks/concerns/65-suppression.sh +12 -0
  285. package/plugins/ts-quality/hooks/concerns/70-ui-confirm.sh +8 -0
  286. package/plugins/ts-quality/hooks/concerns/72-ui-radix.sh +12 -0
  287. package/plugins/ts-quality/hooks/concerns/74-react-props.sh +6 -0
  288. package/plugins/ts-quality/hooks/concerns/76-toast.sh +10 -0
  289. package/plugins/ts-quality/hooks/concerns/78-error-ast.sh +242 -0
  290. package/plugins/ts-quality/hooks/concerns/80-throw-literal.sh +23 -0
  291. package/plugins/ts-quality/hooks/concerns/85-no-mocks.sh +109 -0
  292. package/plugins/ts-quality/hooks/concerns/90-duplication.sh +30 -0
  293. package/plugins/ts-quality/hooks/concerns/92-docs.sh +36 -0
  294. package/plugins/ts-quality/hooks/concerns/94-handler.sh +15 -0
  295. package/plugins/ts-quality/hooks/concerns/99-finalize.sh +46 -0
  296. package/plugins/ts-quality/hooks/hooks.json +19 -0
  297. package/plugins/ts-quality/hooks/register.sh +65 -0
  298. package/src/adapter/__tests__/evaluate.test.ts +82 -0
  299. package/src/adapter/__tests__/permission-map.test.ts +91 -0
  300. package/src/adapter/evaluate.ts +54 -0
  301. package/src/adapter/permission-map.ts +177 -0
  302. package/src/bootstrap/__tests__/bootstrap.test.ts +148 -0
  303. package/src/bootstrap/entrypoint.ts +18 -0
  304. package/src/bootstrap/index.ts +5 -0
  305. package/src/bootstrap/readiness.ts +59 -0
  306. package/src/bootstrap/result.ts +21 -0
  307. package/src/bootstrap/runtime.ts +99 -0
  308. package/src/bootstrap/test-helpers.ts +25 -0
  309. package/src/host/__tests__/host.test.ts +37 -0
  310. package/src/host/detect.ts +46 -0
  311. package/src/host/index.ts +10 -0
  312. package/src/host/roots.ts +59 -0
  313. package/src/host/types.ts +14 -0
  314. package/src/inventory/__tests__/inventory.test.ts +69 -0
  315. package/src/inventory/index.ts +9 -0
  316. package/src/inventory/manifest.ts +46 -0
  317. package/src/inventory/scan.ts +66 -0
  318. package/src/inventory/selection.ts +92 -0
  319. package/src/inventory/types.ts +22 -0
  320. package/src/lifecycle/__tests__/lifecycle.test.ts +7 -0
  321. package/src/lifecycle/index.ts +2 -0
  322. package/src/lifecycle/table.ts +42 -0
  323. package/src/plugin/__tests__/plugin-stub.test.ts +6 -0
  324. package/src/plugin/plugin-stub.ts +7 -0
  325. package/src/plugin/toolu.ts +142 -0
  326. package/src/preflight/__tests__/preflight.test.ts +20 -0
  327. package/src/preflight/check.ts +75 -0
  328. package/src/preflight/index.ts +2 -0
  329. package/src/select/__tests__/select.test.ts +52 -0
  330. package/src/select/index.ts +2 -0
  331. package/src/select/resolve.ts +87 -0
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env bash
2
+ # collect-pr.sh — the read side of the pr-babysit helper.
3
+ #
4
+ # Fetches everything one babysit tick needs from GitHub and writes ONE
5
+ # versioned snapshot. No decisions are made here (see reduce-state.sh); the
6
+ # only judgment is "which comment is the CI reviewer's verdict", delegated to
7
+ # parse-verdict.sh.
8
+ #
9
+ # Usage: collect-pr.sh --repo <owner/repo> --pr <n> --out <path>
10
+ # [--page-size N] [--timeout SECONDS]
11
+ #
12
+ # Behavior (spec: docs/toolu/specs/2026-09-19-pr-babysit-helper-design.md):
13
+ # - four independent reads run concurrently as background jobs (pr view,
14
+ # review threads, issue comments, reviews), each through lib/gh.sh's
15
+ # timeout + bounded retry; any failure aborts the whole collection with a
16
+ # structured error and NO snapshot;
17
+ # - reviewThreads paginate via `gh api graphql --paginate --slurp`; a
18
+ # thread whose comments overflow one page is completed with a cursor loop
19
+ # on node(id:); REST comments/reviews paginate via --paginate --slurp;
20
+ # - the PR head is read before and after the fan-out; if it moved the whole
21
+ # collection runs again once, then fails with head_moved;
22
+ # - the bot comment is the LAST CI-reviewer issue comment that
23
+ # parse-verdict.sh recognises as a review, else the last CI-reviewer
24
+ # comment (verdict state unknown), else null (state absent).
25
+ # Exit: 0 snapshot written · 2 usage · 3 structured error (stdout JSON).
26
+ set -euo pipefail
27
+
28
+ PB_SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
29
+ # shellcheck source=lib/common.sh
30
+ . "$PB_SCRIPT_DIR/lib/common.sh"
31
+ # shellcheck source=lib/gh.sh
32
+ . "$PB_SCRIPT_DIR/lib/gh.sh"
33
+ # shellcheck source=lib/normalize.sh
34
+ . "$PB_SCRIPT_DIR/lib/normalize.sh"
35
+
36
+ repo=""; number=""; out=""; page_size=100
37
+ while [ $# -gt 0 ]; do
38
+ case "$1" in
39
+ --repo) repo="${2:-}"; shift 2 ;;
40
+ --pr) number="${2:-}"; shift 2 ;;
41
+ --out) out="${2:-}"; shift 2 ;;
42
+ --page-size) page_size="${2:-}"; shift 2 ;;
43
+ --timeout) PB_GH_TIMEOUT="${2:-}"; shift 2 ;;
44
+ *) pb_fail usage "collect-pr.sh: unknown argument: $1" ;;
45
+ esac
46
+ done
47
+ [[ "$repo" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || pb_fail usage "collect-pr.sh: --repo <owner/repo> required"
48
+ [[ "$number" =~ ^[0-9]+$ ]] || pb_fail usage "collect-pr.sh: --pr <n> required"
49
+ [ -n "$out" ] || pb_fail usage "collect-pr.sh: --out <path> required"
50
+ [[ "$page_size" =~ ^[0-9]+$ ]] && [ "$page_size" -ge 1 ] || pb_fail usage "collect-pr.sh: --page-size must be a positive integer"
51
+ [[ "$PB_GH_TIMEOUT" =~ ^[0-9]+$ ]] && [ "$PB_GH_TIMEOUT" -ge 1 ] || pb_fail usage "collect-pr.sh: --timeout must be a positive integer"
52
+
53
+ pb_require jq gh
54
+ pb_init
55
+ pb_mktmpdir
56
+ owner="${repo%%/*}"; name="${repo#*/}"
57
+ PARSER="$(pb_plugin_root)/scripts/parse-verdict.sh"
58
+ # _collect_once returns this when the PR head moved between its two reads.
59
+ PB_HEAD_MOVED_RC=10
60
+ [ -f "$PARSER" ] || pb_fail usage "collect-pr.sh: parse-verdict.sh not found at $PARSER"
61
+
62
+ # _read_head -> current headRefOid on stdout, or a structured error document
63
+ # on stdout with a non-zero status. It runs inside `$(...)`, so it must NOT
64
+ # exit: an `exit` there only leaves the subshell and the caller would carry
65
+ # on with the error text as a "head". The caller re-emits via _die_with_doc.
66
+ _read_head() {
67
+ pb_gh "$PB_TMPDIR/head.json" pr view "$number" --repo "$repo" --json headRefOid || {
68
+ pb_error api_error "gh head failed after ${PB_GH_ATTEMPTED:-0} attempt(s): ${PB_GH_LAST_ERR:-rc ${PB_GH_LAST_RC:-?}}" \
69
+ "$(jq -nc --argjson attempts "${PB_GH_ATTEMPTED:-0}" --arg class "${PB_GH_CLASS:-}" --arg lastMessage "${PB_GH_LAST_ERR:-}" \
70
+ '{source:"head", attempts:$attempts, class:$class, lastMessage:$lastMessage}')"
71
+ return 1
72
+ }
73
+ jq -er '.headRefOid' "$PB_TMPDIR/head.json" 2>/dev/null \
74
+ || { pb_error invalid_json "collect-pr.sh: head read returned no headRefOid" '{"source":"head"}'; return 1; }
75
+ }
76
+
77
+ # _die_with_doc DOC — re-emit a captured error document on the real stdout
78
+ # and exit with the code it maps to (used after a failed head read).
79
+ _die_with_doc() {
80
+ printf '%s\n' "$1"
81
+ exit "$(pb_exit_code "$(jq -r '.errors[0].code // "api_error"' <<<"$1" 2>/dev/null || echo api_error)")"
82
+ }
83
+
84
+ # _job NAME GH_ARGS... — one background read. Writes NAME.json and NAME.meta
85
+ # (the pb_gh outcome) so the parent can report exactly which read failed.
86
+ _job() {
87
+ local jname="$1"; shift
88
+ local rc=0
89
+ pb_gh "$PB_TMPDIR/$jname.json" "$@" || rc=$?
90
+ if [ "$rc" -eq 0 ] && ! pb_gh_json_ok "$PB_TMPDIR/$jname.json"; then
91
+ rc=1; PB_GH_CLASS=invalid_json
92
+ PB_GH_LAST_ERR="response is not valid JSON or carries GraphQL errors[]"
93
+ fi
94
+ jq -nc --argjson rc "$rc" --argjson attempts "${PB_GH_ATTEMPTED:-0}" \
95
+ --arg class "${PB_GH_CLASS:-}" --arg lastMessage "${PB_GH_LAST_ERR:-}" \
96
+ '{rc:$rc, attempts:$attempts, class:$class, lastMessage:$lastMessage}' >"$PB_TMPDIR/$jname.meta"
97
+ }
98
+
99
+ # _fan_out — run the four reads concurrently, then fail on the first bad meta.
100
+ _fan_out() {
101
+ local j pids=""
102
+ _job pr pr view "$number" --repo "$repo" --json "$(pb_pr_view_fields)" & pids="$pids $!"
103
+ # Strings go through -f (raw); -F would coerce a numeric-looking owner or
104
+ # repo name into a JSON number and break the GraphQL variable types.
105
+ _job threads api graphql --paginate --slurp \
106
+ -f owner="$owner" -f repo="$name" -F number="$number" -F pageSize="$page_size" \
107
+ -f query="$(pb_gql_threads)" & pids="$pids $!"
108
+ _job comments api --paginate --slurp "repos/$repo/issues/$number/comments?per_page=$page_size" & pids="$pids $!"
109
+ _job reviews api --paginate --slurp "repos/$repo/pulls/$number/reviews?per_page=$page_size" & pids="$pids $!"
110
+ for j in $pids; do wait "$j" || true; done
111
+ for j in pr threads comments reviews; do
112
+ [ -f "$PB_TMPDIR/$j.meta" ] || pb_fail api_error "collect-pr.sh: read '$j' produced no outcome" "{\"source\":\"$j\"}"
113
+ if [ "$(jq -r .rc "$PB_TMPDIR/$j.meta")" != 0 ]; then
114
+ local code=api_error
115
+ [ "$(jq -r .class "$PB_TMPDIR/$j.meta")" = invalid_json ] && code=invalid_json
116
+ pb_fail "$code" "collect-pr.sh: read '$j' failed: $(jq -r .lastMessage "$PB_TMPDIR/$j.meta")" \
117
+ "$(jq -c --arg source "$j" '{source:$source, attempts, class, lastMessage}' "$PB_TMPDIR/$j.meta")"
118
+ fi
119
+ done
120
+ }
121
+
122
+ # _complete_thread_comments — for every thread whose comment page overflowed,
123
+ # follow the cursor on node(id:) and splice the remaining comments in.
124
+ _complete_thread_comments() {
125
+ local ids tid extra=0
126
+ ids=$(jq -r '.[] | select(.commentsHasNextPage == true) | .id' "$PB_TMPDIR/threads.norm.json")
127
+ [ -n "$ids" ] || { echo 0 >"$PB_TMPDIR/threadComments.pages"; return 0; }
128
+ for tid in $ids; do
129
+ local cursor
130
+ cursor=$(jq -r --arg id "$tid" '.[] | select(.id == $id) | .commentsEndCursor' "$PB_TMPDIR/threads.norm.json")
131
+ # The first page is already in hand: resume from its endCursor.
132
+ pb_gh "$PB_TMPDIR/tc.json" api graphql --paginate --slurp \
133
+ -f id="$tid" -F pageSize="$page_size" -f endCursor="$cursor" \
134
+ -f query="$(pb_gql_thread_comments)" || pb_gh_fail threadComments
135
+ pb_gh_json_ok "$PB_TMPDIR/tc.json" || pb_fail invalid_json "collect-pr.sh: thread comment page for $tid is not valid JSON" '{"source":"threadComments"}'
136
+ extra=$((extra + $(jq 'length' "$PB_TMPDIR/tc.json")))
137
+ jq "$(pb_jq_thread_comments_from_pages)" "$PB_TMPDIR/tc.json" >"$PB_TMPDIR/tc.norm.json"
138
+ jq --arg id "$tid" --slurpfile more "$PB_TMPDIR/tc.norm.json" \
139
+ 'map(if .id == $id then .comments += $more[0] | .commentsHasNextPage = false | .commentsEndCursor = null else . end)' \
140
+ "$PB_TMPDIR/threads.norm.json" >"$PB_TMPDIR/threads.norm.next.json"
141
+ mv "$PB_TMPDIR/threads.norm.next.json" "$PB_TMPDIR/threads.norm.json"
142
+ done
143
+ echo "$extra" >"$PB_TMPDIR/threadComments.pages"
144
+ }
145
+
146
+ # _select_bot_comment — write bot.json: {comment, verdict}.
147
+ _select_bot_comment() {
148
+ local ids cid body chosen=""
149
+ ids=$(jq -r "$(pb_jq_defs) [.[] | select(.author | is_ci_reviewer)] | reverse | .[].id" "$PB_TMPDIR/comments.norm.json")
150
+ for cid in $ids; do
151
+ body=$(jq -r --argjson id "$cid" '.[] | select(.id == $id) | .body' "$PB_TMPDIR/comments.norm.json")
152
+ printf '%s' "$body" | bash "$PARSER" >"$PB_TMPDIR/verdict.json"
153
+ if jq -e '.is_review_comment == true' "$PB_TMPDIR/verdict.json" >/dev/null; then chosen="$cid"; break; fi
154
+ done
155
+ if [ -z "$chosen" ]; then
156
+ # No recognised review: fall back to the last CI-reviewer comment (state
157
+ # unknown, degraded) or none at all (state absent).
158
+ chosen=$(printf '%s\n' "$ids" | head -n 1)
159
+ if [ -n "$chosen" ]; then
160
+ body=$(jq -r --argjson id "$chosen" '.[] | select(.id == $id) | .body' "$PB_TMPDIR/comments.norm.json")
161
+ printf '%s' "$body" | bash "$PARSER" >"$PB_TMPDIR/verdict.json"
162
+ else
163
+ printf '' | bash "$PARSER" | jq '.state = "absent"' >"$PB_TMPDIR/verdict.json"
164
+ fi
165
+ fi
166
+ if [ -n "$chosen" ]; then
167
+ jq -c --argjson id "$chosen" --slurpfile v "$PB_TMPDIR/verdict.json" \
168
+ '{comment: (.[] | select(.id == $id) | {id, url, createdAt, updatedAt, author}), verdict: $v[0]}' \
169
+ "$PB_TMPDIR/comments.norm.json" >"$PB_TMPDIR/bot.json"
170
+ else
171
+ jq -nc --slurpfile v "$PB_TMPDIR/verdict.json" '{comment: null, verdict: $v[0]}' >"$PB_TMPDIR/bot.json"
172
+ fi
173
+ }
174
+
175
+ _collect_once() {
176
+ local head_before head_after
177
+ head_before=$(_read_head) || _die_with_doc "$head_before"
178
+ _fan_out
179
+ jq "$(pb_jq_threads_from_pages)" "$PB_TMPDIR/threads.json" >"$PB_TMPDIR/threads.norm.json"
180
+ _complete_thread_comments
181
+ jq "$(pb_jq_rest_items) | map($(pb_jq_issue_comment))" "$PB_TMPDIR/comments.json" >"$PB_TMPDIR/comments.norm.json"
182
+ jq "$(pb_jq_rest_items) | map($(pb_jq_review))" "$PB_TMPDIR/reviews.json" >"$PB_TMPDIR/reviews.norm.json"
183
+ _select_bot_comment
184
+ head_after=$(_read_head) || _die_with_doc "$head_after"
185
+ if [ "$head_before" != "$head_after" ]; then
186
+ : >"$PB_TMPDIR/head.moved"
187
+ return "$PB_HEAD_MOVED_RC"
188
+ fi
189
+ printf '%s' "$head_after" >"$PB_TMPDIR/head.sha"
190
+ }
191
+
192
+ # A moved head (rc 10) earns exactly one more collection; anything else has
193
+ # already exited through pb_fail inside _collect_once.
194
+ moved=0
195
+ if ! pb_retry_on_rc "$PB_HEAD_MOVED_RC" 2 _collect_once; then
196
+ pb_fail head_moved "collect-pr.sh: PR head moved twice during collection" '{"source":"head"}'
197
+ fi
198
+ [ ! -f "$PB_TMPDIR/head.moved" ] || moved=1
199
+
200
+ _build_snapshot() {
201
+ jq -n \
202
+ --arg collectedAt "$(pb_now)" --arg repo "$repo" --argjson number "$number" \
203
+ --arg head "$(cat "$PB_TMPDIR/head.sha")" --argjson recollected "$moved" \
204
+ --argjson pageSize "$page_size" \
205
+ --slurpfile pr "$PB_TMPDIR/pr.json" \
206
+ --slurpfile threads "$PB_TMPDIR/threads.norm.json" \
207
+ --slurpfile comments "$PB_TMPDIR/comments.norm.json" \
208
+ --slurpfile reviews "$PB_TMPDIR/reviews.norm.json" \
209
+ --slurpfile bot "$PB_TMPDIR/bot.json" \
210
+ --argjson pThreads "$(jq 'length' "$PB_TMPDIR/threads.json")" \
211
+ --argjson pThreadComments "$(cat "$PB_TMPDIR/threadComments.pages")" \
212
+ --argjson pComments "$(jq 'length' "$PB_TMPDIR/comments.json")" \
213
+ --argjson pReviews "$(jq 'length' "$PB_TMPDIR/reviews.json")" \
214
+ '{version: 1, collectedAt: $collectedAt, repo: $repo, number: $number,
215
+ head: {sha: $head, verifiedAfterFanout: true, recollected: ($recollected == 1)},
216
+ pageSize: $pageSize,
217
+ pr: ($pr[0] | {number, title, url, author: (.author.login // null), state, baseRefName, headRefName,
218
+ headRefOid, mergeable, reviewDecision, statusCheckRollup: (.statusCheckRollup // [])}),
219
+ threads: ($threads[0] | map(del(.commentsHasNextPage, .commentsEndCursor))),
220
+ comments: $comments[0], reviews: $reviews[0], bot: $bot[0],
221
+ pages: {threads: $pThreads, threadComments: $pThreadComments, comments: $pComments, reviews: $pReviews}}'
222
+ }
223
+ pb_atomic_write_json "$out" _build_snapshot || pb_fail invalid_json "collect-pr.sh: could not assemble the snapshot" '{"source":"snapshot"}'
224
+ printf '%s\n' "$out"
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # common.sh — shared plumbing for the pr-babysit helper scripts.
3
+ #
4
+ # Sourced, never run. Provides: plugin-root resolution from this file's own
5
+ # location (no environment variable names the root), dependency checks, a
6
+ # structured-error emitter with the closed exit-code map, an atomic JSON
7
+ # writer, and the single EXIT handler every entrypoint installs via pb_init.
8
+ #
9
+ # Portability contract (see docs/toolu/specs/2026-09-19-pr-babysit-helper-design.md):
10
+ # bash 3.2 — no mapfile/readarray, no declare -A, no wait -n, no ${var,,}.
11
+
12
+ # Closed error-code → exit-code map. Adding a code is a schema change.
13
+ # usage 2 · gh_unavailable/jq_required/api_error/invalid_json/head_moved/
14
+ # state_malformed/slot_mismatch 3 · duplicate_reply 4 · resolve_unconfirmed 5
15
+ # locked 75 (EX_TEMPFAIL)
16
+ pb_exit_code() {
17
+ case "$1" in
18
+ usage) echo 2 ;;
19
+ duplicate_reply) echo 4 ;;
20
+ resolve_unconfirmed) echo 5 ;;
21
+ locked) echo 75 ;;
22
+ gh_unavailable|jq_required|api_error|invalid_json|head_moved|state_malformed|slot_mismatch) echo 3 ;;
23
+ *) echo 3 ;;
24
+ esac
25
+ }
26
+
27
+ # pb_plugin_root -> absolute path of plugins/pr-babysit (this file lives in
28
+ # scripts/lib/). Resolved from BASH_SOURCE so an installed copy under any
29
+ # host cache path works without configuration.
30
+ pb_plugin_root() {
31
+ local here
32
+ here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) || return 1
33
+ (cd "$here/../.." && pwd -P)
34
+ }
35
+
36
+ # pb_require CMD... -> exit through pb_fail when a dependency is missing.
37
+ pb_require() {
38
+ local cmd
39
+ for cmd in "$@"; do
40
+ command -v "$cmd" >/dev/null 2>&1 && continue
41
+ case "$cmd" in
42
+ jq) pb_fail jq_required "jq is required" ;;
43
+ gh) pb_fail gh_unavailable "gh is required" ;;
44
+ *) pb_fail gh_unavailable "$cmd is required" ;;
45
+ esac
46
+ done
47
+ }
48
+
49
+ # pb_error CODE MESSAGE [EXTRA_JSON] -> one structured error document on stdout.
50
+ # EXTRA_JSON (an object) is merged into the error entry.
51
+ pb_error() {
52
+ local code="$1" message="$2" extra="${3:-}"
53
+ [ -n "$extra" ] || extra='{}'
54
+ jq -nc --arg code "$code" --arg message "$message" --argjson extra "$extra" \
55
+ '{version:1, errors:[({code:$code, message:$message} + $extra)]}'
56
+ }
57
+
58
+ # pb_fail CODE MESSAGE [EXTRA_JSON] -> emit the error and exit with its code.
59
+ pb_fail() {
60
+ pb_error "$@"
61
+ exit "$(pb_exit_code "$1")"
62
+ }
63
+
64
+ # pb_now -> ISO-8601 UTC timestamp.
65
+ pb_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
66
+
67
+ # pb_json_valid FILE -> 0 when FILE parses as JSON.
68
+ pb_json_valid() { jq -e . "$1" >/dev/null 2>&1; }
69
+
70
+ # pb_atomic_write_json TARGET CMD [ARGS...]
71
+ # Run CMD with stdout captured into a unique temp file beside TARGET, then
72
+ # rename it over TARGET only when CMD exited 0 AND the output parses as JSON.
73
+ # Any failure removes the temp file and leaves TARGET byte-identical, so a
74
+ # reader never sees a partial document and a crashed writer leaves no residue.
75
+ # The in-flight temp file is published so pb_on_exit can remove it when a
76
+ # signal lands mid-write: PB_ATOMIC_PREFIX is set BEFORE mktemp runs (a trap
77
+ # can fire between mktemp returning and the assignment below, so the exact
78
+ # name may never be recorded), and it carries $$ so the sweep only ever
79
+ # touches this process's own temps.
80
+ PB_ATOMIC_TMP=""
81
+ PB_ATOMIC_PREFIX=""
82
+ pb_atomic_write_json() {
83
+ local target="$1"; shift
84
+ local dir tmp
85
+ dir=$(dirname "$target")
86
+ mkdir -p "$dir" || return 1
87
+ PB_ATOMIC_PREFIX="$dir/.$(basename "$target").tmp.$$."
88
+ if ! tmp=$(mktemp "${PB_ATOMIC_PREFIX}XXXXXX"); then
89
+ PB_ATOMIC_PREFIX=""; return 1
90
+ fi
91
+ PB_ATOMIC_TMP="$tmp"
92
+ if ! "$@" >"$tmp"; then
93
+ rm -f "$tmp"; PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""; return 1
94
+ fi
95
+ if ! pb_json_valid "$tmp"; then
96
+ rm -f "$tmp"; PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""; return 1
97
+ fi
98
+ if ! mv -f "$tmp" "$target"; then
99
+ rm -f "$tmp"; PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""; return 1
100
+ fi
101
+ PB_ATOMIC_TMP=""; PB_ATOMIC_PREFIX=""
102
+ }
103
+
104
+ # pb_retry_on_rc RETRY_RC ATTEMPTS CMD [ARGS...]
105
+ # Run CMD; while it returns exactly RETRY_RC and attempts remain, run it
106
+ # again. Returns CMD's last exit code. Used for "collect again once when the
107
+ # PR head moved under us": any other failure propagates on the first try.
108
+ pb_retry_on_rc() {
109
+ local retry_rc="$1" attempts="$2"; shift 2
110
+ local n=0 rc=0
111
+ while :; do
112
+ n=$((n + 1))
113
+ rc=0
114
+ "$@" || rc=$?
115
+ [ "$rc" -eq "$retry_rc" ] || return "$rc"
116
+ [ "$n" -lt "$attempts" ] || return "$rc"
117
+ echo "pr-babysit: attempt $n of $attempts returned $rc; retrying $1" >&2
118
+ done
119
+ }
120
+
121
+ # pb_mktmpdir -> create the per-run scratch dir (removed by pb_on_exit).
122
+ pb_mktmpdir() {
123
+ PB_TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/pr-babysit.XXXXXX") || return 1
124
+ export PB_TMPDIR
125
+ }
126
+
127
+ # pb_on_exit — the one EXIT handler: release the slot lock (if held) and
128
+ # remove the scratch dir. Installed by pb_init; scripts never add a second
129
+ # EXIT trap. Runs on signals too (TERM/INT re-raise through EXIT).
130
+ pb_on_exit() {
131
+ local rc=$?
132
+ # A second signal must not re-enter `exit` and abort the cleanup midway.
133
+ trap '' TERM INT
134
+ if declare -F pb_lock_release >/dev/null 2>&1; then pb_lock_release; fi
135
+ [ -n "${PB_ATOMIC_TMP:-}" ] && rm -f "$PB_ATOMIC_TMP"
136
+ [ -n "${PB_ATOMIC_PREFIX:-}" ] && rm -f "${PB_ATOMIC_PREFIX}"*
137
+ [ -n "${PB_TMPDIR:-}" ] && [ -d "${PB_TMPDIR:-}" ] && rm -rf "$PB_TMPDIR"
138
+ return "$rc"
139
+ }
140
+
141
+ # pb_init — install the EXIT handler and forward TERM/INT into it.
142
+ pb_init() {
143
+ trap pb_on_exit EXIT
144
+ trap 'exit 143' TERM
145
+ trap 'exit 130' INT
146
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bash
2
+ # gh.sh — bounded `gh` transport for the pr-babysit helper.
3
+ #
4
+ # Sourced after common.sh. Every GitHub call goes through pb_gh so timeouts,
5
+ # retries, and error classification live in one place:
6
+ #
7
+ # pb_run_with_timeout SECONDS OUT ERR CMD... run CMD, kill it after SECONDS
8
+ # pb_gh_classify RC ERR_FILE OUT_FILE -> transient | permanent | ok
9
+ # pb_gh OUT_FILE ARGS... gh ARGS with retry; stdout → OUT_FILE
10
+ # pb_gh_json_ok FILE -> 0 when FILE is JSON without errors[]
11
+ #
12
+ # GNU `timeout` is absent on macOS and `gh` has no timeout flag, so the
13
+ # watchdog is a background job plus a sleeping killer subshell — bash 3.2 has
14
+ # no `wait -n`, so the job's exit code is read from a status file it writes.
15
+ #
16
+ # Retry policy (spec §Architecture): 3 attempts with waits of 2 s and 4 s
17
+ # between them (the 8 s step of PB_GH_BACKOFF only applies when
18
+ # PB_GH_ATTEMPTS is raised), for timeouts, 5xx, connection errors, HTTP 429,
19
+ # and 403 whose body mentions the rate limit. Any other 4xx is permanent and
20
+ # never retried. Observed real
21
+ # shapes this classifies (2026-09-19, gh 2.101.0):
22
+ # refused host → rc 1, stderr `dial tcp 127.0.0.1:1: connect: connection refused`
23
+ # 404 → rc 1, stdout `{"message":"Not Found",…,"status":"404"}`, stderr `gh: Not Found (HTTP 404)`
24
+ # GraphQL miss → rc 1, stdout `{"data":{…null…},"errors":[{"type":"NOT_FOUND",…}]}`
25
+
26
+ PB_GH_TIMEOUT="${PB_GH_TIMEOUT:-60}"
27
+ PB_GH_ATTEMPTS="${PB_GH_ATTEMPTS:-3}"
28
+ PB_GH_BACKOFF="${PB_GH_BACKOFF:-2 4 8}"
29
+ # Exit code the watchdog reports for a killed command (mirrors GNU timeout).
30
+ PB_GH_RC_TIMEOUT=124
31
+
32
+ # pb_run_with_timeout SECONDS OUT ERR CMD... -> CMD's exit code, or 124 when
33
+ # the watchdog killed it. stdout/stderr go to the OUT/ERR files.
34
+ pb_run_with_timeout() {
35
+ local secs="$1" out="$2" err="$3"; shift 3
36
+ local rcfile wdfile pid watchdog sleeper rc
37
+ rcfile=$(mktemp "${PB_TMPDIR:-${TMPDIR:-/tmp}}/pb-rc.XXXXXX") || return 1
38
+ wdfile="$rcfile.wd"
39
+ : >"$rcfile"; : >"$wdfile"
40
+ # `set +e` inside: the caller's errexit would otherwise end the subshell
41
+ # before the exit code is recorded, and an empty rc file reads as a timeout.
42
+ (
43
+ set +e
44
+ "$@" >"$out" 2>"$err"
45
+ echo $? >"$rcfile"
46
+ ) &
47
+ pid=$!
48
+ # The watchdog detaches from the caller's stdio and publishes its sleeper's
49
+ # pid: killing only the subshell would orphan `sleep`, which then holds any
50
+ # pipe the caller (bats, a `$(...)`) is waiting on until the timeout elapses.
51
+ (
52
+ sleep "$secs" &
53
+ echo $! >"$wdfile"
54
+ wait $!
55
+ kill "$pid" 2>/dev/null
56
+ ) >/dev/null 2>&1 &
57
+ watchdog=$!
58
+ # Every wait/kill below may legitimately fail (job already gone, watchdog
59
+ # killed → wait returns 143); callers run under `set -e`, so none of them
60
+ # may be a bare statement.
61
+ wait "$pid" 2>/dev/null || true
62
+ sleeper=$(cat "$wdfile" 2>/dev/null || true)
63
+ [ -z "$sleeper" ] || kill "$sleeper" 2>/dev/null || true
64
+ kill "$watchdog" 2>/dev/null || true
65
+ wait "$watchdog" 2>/dev/null || true
66
+ rc=$(cat "$rcfile" 2>/dev/null || true)
67
+ rm -f "$rcfile" "$wdfile"
68
+ if [ -z "$rc" ]; then
69
+ echo "pr-babysit: timed out after ${secs}s: $*" >>"$err"
70
+ return "$PB_GH_RC_TIMEOUT"
71
+ fi
72
+ return "$rc"
73
+ }
74
+
75
+ # pb_gh_classify RC ERR_FILE OUT_FILE -> prints transient | permanent | ok.
76
+ pb_gh_classify() {
77
+ local rc="$1" err="$2" out="$3" text status
78
+ [ "$rc" -eq 0 ] && { echo ok; return 0; }
79
+ [ "$rc" -eq "$PB_GH_RC_TIMEOUT" ] && { echo transient; return 0; }
80
+ # `set -e` callers: every pipeline that may match nothing ends in `|| true`.
81
+ text=$( { cat "$err" 2>/dev/null; cat "$out" 2>/dev/null; } || true)
82
+ # HTTP status: gh prints "(HTTP NNN)" on stderr; REST bodies carry "status".
83
+ status=$(printf '%s' "$text" | grep -oE '\(HTTP [0-9]{3}\)' | head -1 | tr -dc '0-9' || true)
84
+ [ -n "$status" ] || status=$(jq -r '.status? // empty' "$out" 2>/dev/null | head -1 || true)
85
+ case "$status" in
86
+ 5??|429) echo transient; return 0 ;;
87
+ 403)
88
+ if printf '%s' "$text" | grep -qi 'rate limit'; then echo transient; else echo permanent; fi
89
+ return 0 ;;
90
+ 4??) echo permanent; return 0 ;;
91
+ esac
92
+ if printf '%s' "$text" | grep -qiE 'connection refused|connection reset|no such host|i/o timeout|TLS handshake|unexpected EOF|EOF$|network is unreachable|temporary failure|timed out'; then
93
+ echo transient; return 0
94
+ fi
95
+ # GraphQL errors[] without an HTTP status (NOT_FOUND etc.) are permanent.
96
+ if jq -e '.errors? | type == "array" and length > 0' "$out" >/dev/null 2>&1; then
97
+ echo permanent; return 0
98
+ fi
99
+ echo permanent
100
+ }
101
+
102
+ # pb_gh OUT_FILE ARGS... -> run `gh ARGS` with timeout + bounded retry.
103
+ # 0 on success (OUT_FILE holds stdout). Non-zero after the policy is
104
+ # exhausted or on a permanent error; sets PB_GH_ATTEMPTED, PB_GH_LAST_RC,
105
+ # PB_GH_LAST_ERR (first stderr line), PB_GH_CLASS for the caller's error.
106
+ pb_gh() {
107
+ local out="$1"; shift
108
+ local err attempt rc class delay i
109
+ err="$out.err"
110
+ PB_GH_ATTEMPTED=0; PB_GH_LAST_RC=0; PB_GH_LAST_ERR=""; PB_GH_CLASS=ok
111
+ attempt=0
112
+ while [ "$attempt" -lt "$PB_GH_ATTEMPTS" ]; do
113
+ attempt=$((attempt + 1))
114
+ PB_GH_ATTEMPTED=$attempt
115
+ rc=0
116
+ pb_run_with_timeout "$PB_GH_TIMEOUT" "$out" "$err" gh "$@" || rc=$?
117
+ class=$(pb_gh_classify "$rc" "$err" "$out")
118
+ PB_GH_LAST_RC=$rc; PB_GH_CLASS=$class
119
+ PB_GH_LAST_ERR=$(head -n 1 "$err" 2>/dev/null || true)
120
+ case "$class" in
121
+ ok) rm -f "$err"; return 0 ;;
122
+ permanent) return 1 ;;
123
+ esac
124
+ [ "$attempt" -lt "$PB_GH_ATTEMPTS" ] || break
125
+ i=0; delay=2
126
+ for delay in $PB_GH_BACKOFF; do
127
+ i=$((i + 1))
128
+ [ "$i" -eq "$attempt" ] && break
129
+ done
130
+ echo "pr-babysit: gh $1 attempt $attempt failed ($class: ${PB_GH_LAST_ERR:-rc $rc}); retrying in ${delay}s" >&2
131
+ sleep "$delay"
132
+ done
133
+ return 1
134
+ }
135
+
136
+ # pb_gh_json_ok FILE -> 0 when FILE parses and carries no GraphQL errors[].
137
+ pb_gh_json_ok() {
138
+ jq -e 'type as $t | if $t == "object" then ((.errors? // []) | length) == 0 else true end' "$1" >/dev/null 2>&1
139
+ }
140
+
141
+ # pb_gh_fail SOURCE — emit api_error/invalid_json from the last pb_gh state
142
+ # and exit 3. SOURCE names the read that failed (pr, threads, comments, …).
143
+ pb_gh_fail() {
144
+ local source="$1" code=api_error
145
+ [ "${PB_GH_CLASS:-}" = invalid_json ] && code=invalid_json
146
+ pb_fail "$code" "gh ${source} failed after ${PB_GH_ATTEMPTED:-0} attempt(s): ${PB_GH_LAST_ERR:-rc ${PB_GH_LAST_RC:-?}}" \
147
+ "$(jq -nc --arg source "$source" --argjson attempts "${PB_GH_ATTEMPTED:-0}" \
148
+ --arg class "${PB_GH_CLASS:-}" --arg lastMessage "${PB_GH_LAST_ERR:-}" \
149
+ '{source:$source, attempts:$attempts, class:$class, lastMessage:$lastMessage}')"
150
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # lock.sh — one-writer lock per slot state file.
3
+ #
4
+ # Sourced after common.sh. `mkdir` is atomic on every POSIX filesystem, so the
5
+ # lock is a directory beside the state file: <state-file>.lock/ holding `pid`
6
+ # and `since` (epoch seconds). Atomic rename alone does not stop two
7
+ # controllers from losing each other's updates — this does. A lock whose pid is
8
+ # dead or older than PB_LOCK_STALE_SECONDS is stale and reclaimed with a stderr
9
+ # note. Release is wired through pb_on_exit (common.sh) so a crash or signal
10
+ # never leaves a live lock behind.
11
+
12
+ PB_LOCK_STALE_SECONDS="${PB_LOCK_STALE_SECONDS:-600}"
13
+ PB_LOCK_DIR=""
14
+ # The lock dir this process is trying to take. A signal can land between
15
+ # `mkdir` succeeding and PB_LOCK_DIR being set; pb_lock_release uses this to
16
+ # recognise (and remove) such a half-written lock of its own on exit.
17
+ PB_LOCK_WANT=""
18
+ PB_LOCK_HOLDER_PID=""
19
+ PB_LOCK_HOLDER_SINCE=""
20
+
21
+ # pb_lock_path STATE_FILE -> the lock directory path.
22
+ pb_lock_path() { printf '%s.lock' "$1"; }
23
+
24
+ # _pb_lock_stale LOCKDIR -> 0 when the holder is dead or too old.
25
+ _pb_lock_stale() {
26
+ local lockdir="$1" pid since now
27
+ pid=$(cat "$lockdir/pid" 2>/dev/null || echo "")
28
+ since=$(cat "$lockdir/since" 2>/dev/null || echo "")
29
+ now=$(date +%s)
30
+ # A lock dir with no pid/since is a half-written lock from a crashed taker.
31
+ [ -n "$pid" ] && [ -n "$since" ] || return 0
32
+ case "$pid$since" in *[!0-9]*) return 0 ;; esac
33
+ if ! kill -0 "$pid" 2>/dev/null; then return 0; fi
34
+ [ $((now - since)) -gt "$PB_LOCK_STALE_SECONDS" ] && return 0
35
+ return 1
36
+ }
37
+
38
+ # pb_lock_acquire STATE_FILE -> 0 and PB_LOCK_DIR set; 75 when a live holder
39
+ # owns it (PB_LOCK_HOLDER_PID/SINCE describe it). Reclaims a stale lock once.
40
+ pb_lock_acquire() {
41
+ local state_file="$1" lockdir attempt
42
+ lockdir=$(pb_lock_path "$state_file")
43
+ mkdir -p "$(dirname "$state_file")" || return 1
44
+ PB_LOCK_WANT="$lockdir"
45
+ for attempt in 1 2; do
46
+ if mkdir "$lockdir" 2>/dev/null; then
47
+ PB_LOCK_DIR="$lockdir"
48
+ printf '%s\n' "$$" >"$lockdir/pid"
49
+ date +%s >"$lockdir/since"
50
+ return 0
51
+ fi
52
+ PB_LOCK_HOLDER_PID=$(cat "$lockdir/pid" 2>/dev/null || echo "")
53
+ PB_LOCK_HOLDER_SINCE=$(cat "$lockdir/since" 2>/dev/null || echo "")
54
+ if [ "$attempt" -eq 1 ] && _pb_lock_stale "$lockdir"; then
55
+ echo "pr-babysit: reclaiming stale lock $lockdir (pid ${PB_LOCK_HOLDER_PID:-?}, since ${PB_LOCK_HOLDER_SINCE:-?})" >&2
56
+ rm -rf "$lockdir"
57
+ continue
58
+ fi
59
+ return 75
60
+ done
61
+ return 75
62
+ }
63
+
64
+ # pb_lock_release — remove the lock only if this process took it.
65
+ pb_lock_release() {
66
+ if [ -n "$PB_LOCK_DIR" ]; then
67
+ if [ "$(cat "$PB_LOCK_DIR/pid" 2>/dev/null)" = "$$" ]; then
68
+ rm -rf "$PB_LOCK_DIR"
69
+ fi
70
+ elif [ -n "$PB_LOCK_WANT" ] && [ -d "$PB_LOCK_WANT" ] && [ ! -e "$PB_LOCK_WANT/pid" ]; then
71
+ # mkdir succeeded but a signal arrived before the pid was written: the
72
+ # dir is ours and empty — take it back rather than leave a stale lock.
73
+ rm -rf "$PB_LOCK_WANT"
74
+ fi
75
+ PB_LOCK_DIR=""; PB_LOCK_WANT=""
76
+ return 0
77
+ }
78
+
79
+ # pb_lock_fail STATE_FILE — emit the structured `locked` error and exit 75.
80
+ pb_lock_fail() {
81
+ pb_fail locked "slot is held by another controller" \
82
+ "$(jq -nc --arg pid "$PB_LOCK_HOLDER_PID" --arg since "$PB_LOCK_HOLDER_SINCE" \
83
+ '{pid:($pid|tonumber? // null), since:($since|tonumber? // null)}')"
84
+ }