@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,929 @@
1
+ #!/usr/bin/env bash
2
+ # Shared detection helpers for project-agnostic hooks.
3
+ # Source via: . "${BASH_SOURCE%/*}/../lib/detect.sh"
4
+
5
+ _TOOLU_DETECT_LIB_DIR="$(cd "${BASH_SOURCE%/*}" && pwd)"
6
+ # shellcheck source=host.sh
7
+ . "$_TOOLU_DETECT_LIB_DIR/host.sh"
8
+
9
+ # Print the absolute project root (git toplevel) or "" if not in a git repo.
10
+ detect_project_root() {
11
+ git rev-parse --show-toplevel 2>/dev/null || true
12
+ }
13
+
14
+ # Print the project name (basename of the git toplevel) or "" if not in a git repo.
15
+ # Returns 0 even outside a git repo: a bare `[ -n "$root" ] && basename` would
16
+ # exit 1 when root is empty, which under `set -e` aborts callers before their
17
+ # own "unknown"-style fallback can run.
18
+ detect_project_name() {
19
+ local root
20
+ root=$(detect_project_root)
21
+ if [ -n "$root" ]; then basename "$root"; fi
22
+ }
23
+
24
+ # Print the package manager: bun | pnpm | npm | yarn | "" (none detected).
25
+ detect_node_pm() {
26
+ local root
27
+ root=$(detect_project_root)
28
+ [ -z "$root" ] && return 0
29
+ [ -f "$root/bun.lock" ] && echo bun && return
30
+ [ -f "$root/bun.lockb" ] && echo bun && return
31
+ [ -f "$root/pnpm-lock.yaml" ] && echo pnpm && return
32
+ [ -f "$root/yarn.lock" ] && echo yarn && return
33
+ [ -f "$root/package-lock.json" ] && echo npm && return
34
+ }
35
+
36
+ # Echo "rust" if a Cargo.toml exists at the project root.
37
+ detect_rust() {
38
+ local root
39
+ root=$(detect_project_root)
40
+ [ -z "$root" ] && return 0
41
+ [ -f "$root/Cargo.toml" ] && echo rust
42
+ }
43
+
44
+ # Echo "python" if any of pyproject.toml, setup.py, setup.cfg, or
45
+ # requirements.txt exists at the project root.
46
+ detect_python() {
47
+ local root
48
+ root=$(detect_project_root)
49
+ [ -z "$root" ] && return 0
50
+ { [ -f "$root/pyproject.toml" ] || [ -f "$root/setup.py" ] \
51
+ || [ -f "$root/setup.cfg" ] || [ -f "$root/requirements.txt" ]; } && echo python
52
+ }
53
+
54
+ # Echo "ts" if a tsconfig*.json exists anywhere in the project.
55
+ detect_ts() {
56
+ local root
57
+ root=$(detect_project_root)
58
+ [ -z "$root" ] && return 0
59
+ git -C "$root" ls-files '**/tsconfig*.json' 'tsconfig*.json' 2>/dev/null \
60
+ | grep -q . && echo ts
61
+ }
62
+
63
+ # Echo the project's TS linter: biome | oxc | eslint | "" (presence-only, by
64
+ # config-file at the git root; precedence biome > oxc > eslint). Used to point
65
+ # the agent at the real tool and to suppress our own overlapping nits — we
66
+ # never invoke the tool.
67
+ detect_ts_linter() {
68
+ local root
69
+ root=$(detect_project_root)
70
+ [ -z "$root" ] && return 0
71
+ { [ -f "$root/biome.json" ] || [ -f "$root/biome.jsonc" ]; } && echo biome && return
72
+ [ -f "$root/.oxlintrc.json" ] && echo oxc && return
73
+ { compgen -G "$root/.eslintrc*" >/dev/null 2>&1 \
74
+ || compgen -G "$root/eslint.config.*" >/dev/null 2>&1; } && echo eslint && return
75
+ }
76
+
77
+ # Echo the project's Python linter: ruff | "" (presence-only, by config-file
78
+ # or a `[tool.ruff` section in pyproject.toml at the project root). Used to
79
+ # point the agent at the real tool and to suppress our own overlapping nits —
80
+ # we never invoke the tool.
81
+ detect_python_linter() {
82
+ local root
83
+ root=$(detect_project_root)
84
+ [ -z "$root" ] && return 0
85
+ { [ -f "$root/ruff.toml" ] || [ -f "$root/.ruff.toml" ]; } && echo ruff && return
86
+ [ -f "$root/pyproject.toml" ] && grep -q '^\[tool\.ruff' "$root/pyproject.toml" 2>/dev/null && echo ruff
87
+ }
88
+
89
+ # Echo "clippy" if a clippy config exists at the git root.
90
+ detect_clippy() {
91
+ local root
92
+ root=$(detect_project_root)
93
+ [ -z "$root" ] && return 0
94
+ { [ -f "$root/clippy.toml" ] || [ -f "$root/.clippy.toml" ]; } && echo clippy
95
+ }
96
+
97
+ # Echo the plugin spec ("name@marketplace") if installed at any scope.
98
+ # Reads ~/.claude/plugins/installed_plugins.json (Claude Code's authoritative
99
+ # install registry).
100
+ #
101
+ # Exit codes:
102
+ # 0 + spec on stdout — installed.
103
+ # 0 + empty stdout — registry parsed; spec not present.
104
+ # 2 + empty stdout — INDETERMINATE: registry missing, jq missing, or
105
+ # malformed JSON. Callers should suppress install
106
+ # warnings rather than spam users on a box where the
107
+ # registry was moved or jq was uninstalled.
108
+ #
109
+ # Usage: detect_plugin_installed "ts-quality@toolu"
110
+ detect_plugin_installed() {
111
+ local spec="$1"
112
+ [ -z "$spec" ] && return 0
113
+ if [ "$(toolu_host)" = codex ]; then
114
+ toolu_codex_plugin_installed "$spec"
115
+ return $?
116
+ fi
117
+ # Same config-root resolution as registry.sh, so the install gate and the
118
+ # registry modules it gates follow CLAUDE_CONFIG_DIR together.
119
+ local registry="${CLAUDE_PLUGINS_REGISTRY:-${TOOLU_CONFIG_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}}/plugins/installed_plugins.json}"
120
+ [ -f "$registry" ] || return 2
121
+ command -v jq >/dev/null 2>&1 || return 2
122
+ # Guard against malformed registry (top-level `plugins` missing or wrong
123
+ # type) — treat as indeterminate, not "not installed".
124
+ if ! jq -e '.plugins | type == "object"' "$registry" >/dev/null 2>&1; then
125
+ return 2
126
+ fi
127
+ # `has($s)` is tolerant of value type — array, object, number, or null at
128
+ # the spec key all parse cleanly. The prior `.plugins[$s] | length > 0`
129
+ # filter errored on non-array values and silently false-positived a WARN
130
+ # if Claude Code ever wrote a non-array there.
131
+ if jq -e --arg s "$spec" '.plugins | has($s)' "$registry" >/dev/null 2>&1; then
132
+ echo "$spec"
133
+ return 0
134
+ fi
135
+ return 0
136
+ }
137
+
138
+ # Echo "ast-grep" if either `sg` or `ast-grep` is on PATH.
139
+ detect_ast_grep() {
140
+ if command -v sg >/dev/null 2>&1 || command -v ast-grep >/dev/null 2>&1; then
141
+ echo ast-grep
142
+ fi
143
+ }
144
+
145
+ # Return the base branch from origin/HEAD, or "main" if remote is missing.
146
+ # Takes an optional repo root as $1 — callers gating a push must pass the root
147
+ # the push targets, since a worktree and its main checkout can sit on different
148
+ # branches with different origin/HEAD resolution.
149
+ detect_base_branch() {
150
+ local root ref
151
+ root="${1:-$(detect_project_root)}"
152
+ [ -z "$root" ] && { echo main; return; }
153
+ ref=$(git -C "$root" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null)
154
+ if [ -n "$ref" ]; then
155
+ echo "${ref#refs/remotes/origin/}"
156
+ else
157
+ echo main
158
+ fi
159
+ }
160
+
161
+ # Print the data dir for settings/ lookups. Override with $TOOLU_SETTINGS_DIR.
162
+ toolu_settings_dir() {
163
+ if [ -n "${TOOLU_SETTINGS_DIR:-}" ]; then
164
+ echo "$TOOLU_SETTINGS_DIR"
165
+ elif [ -d "${HOME}/.claude/settings" ]; then
166
+ echo "${HOME}/.claude/settings"
167
+ else
168
+ local self
169
+ self=$(cd "${BASH_SOURCE%/*}/.." && pwd)
170
+ echo "$self/../settings"
171
+ fi
172
+ }
173
+
174
+ # Strip heredoc bodies from a shell command read on stdin.
175
+ #
176
+ # Handles `<<TAG`, `<<-TAG` (tab-stripped form), quoted/unquoted tags, and
177
+ # tolerates trailing redirections/pipes on the heredoc-start line
178
+ # (e.g. `<<EOF > /tmp/x`, `<<EOF | tee`). Any [A-Za-z_][A-Za-z0-9_]* identifier
179
+ # is accepted as a tag — the previous sed recipe hardcoded `EOF` and silently
180
+ # missed `<<-EOF`, `<<END`, or `<<EOF > file`.
181
+ #
182
+ # Why this matters: bash-commands.sh, quality-gate.sh, push-review.sh, and
183
+ # search-nudge.sh all run their deny/match patterns over the command. Without
184
+ # stripping the heredoc body, prose like a commit message containing the
185
+ # substring `cargo test` would false-positive a deny rule.
186
+ strip_heredocs() {
187
+ awk '
188
+ BEGIN { in_heredoc = 0; tag = ""; tag_tab = "" }
189
+ {
190
+ if (in_heredoc) {
191
+ line = $0
192
+ if (line == tag || line == tag_tab) { in_heredoc = 0; tag = ""; tag_tab = "" }
193
+ next
194
+ }
195
+ if (match($0, /<<-?[ \t]*"?'\''?[A-Za-z_][A-Za-z0-9_]*"?'\''?/)) {
196
+ m = substr($0, RSTART, RLENGTH)
197
+ # Strip leading `<<`, optional `-`, quotes, and surrounding whitespace.
198
+ gsub(/^<<-?[ \t]*"?'\''?/, "", m)
199
+ gsub(/"?'\''?$/, "", m)
200
+ tag = m
201
+ tag_tab = "\t" m
202
+ in_heredoc = 1
203
+ print
204
+ next
205
+ }
206
+ print
207
+ }
208
+ '
209
+ }
210
+
211
+ # Echo the bodies of UNQUOTED heredocs read on stdin, one line each.
212
+ #
213
+ # The mirror of strip_heredocs, for the one case stripping gets wrong: bash
214
+ # expands `$(...)` inside an UNQUOTED heredoc (`<<EOF`), so a command
215
+ # substitution in that body really runs. A QUOTED tag (`<<'EOF'`, `<<"EOF"`)
216
+ # expands nothing, so its body is literal text and is NOT emitted here.
217
+ #
218
+ # Used by bash_write_targets to catch a write hidden in a heredoc body.
219
+ _toolu_unquoted_heredoc_bodies() {
220
+ awk '
221
+ BEGIN { in_heredoc = 0; tag = ""; tag_tab = ""; quoted = 0 }
222
+ {
223
+ if (in_heredoc) {
224
+ if ($0 == tag || $0 == tag_tab) { in_heredoc = 0; tag = ""; tag_tab = ""; quoted = 0 }
225
+ else if (!quoted) { print }
226
+ next
227
+ }
228
+ if (match($0, /<<-?[ \t]*"?'\''?[A-Za-z_][A-Za-z0-9_]*"?'\''?/)) {
229
+ m = substr($0, RSTART, RLENGTH)
230
+ # A quote anywhere in the tag token means the body never expands.
231
+ quoted = (m ~ /["'\'']/) ? 1 : 0
232
+ gsub(/^<<-?[ \t]*"?'\''?/, "", m)
233
+ gsub(/"?'\''?$/, "", m)
234
+ tag = m
235
+ tag_tab = "\t" m
236
+ in_heredoc = 1
237
+ next
238
+ }
239
+ }
240
+ '
241
+ }
242
+
243
+ # Echo a filesystem-safe slug for a branch name: '/'→'_', strip to
244
+ # [a-zA-Z0-9_-], empty → "_default". Used to key per-branch transient state
245
+ # files (push-review, plan-ledger). Takes the branch name as $1.
246
+ branch_slug() {
247
+ local branch="$1"
248
+ local slug
249
+ slug=$(echo "$branch" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')
250
+ [[ -z "$slug" ]] && slug="_default"
251
+ echo "$slug"
252
+ }
253
+
254
+ # Find the end of a `$(...)` body that starts at index $2 of string $1.
255
+ #
256
+ # Quotes inside the body hide parens: in `$(echo ")")` the first `)` is data,
257
+ # not the terminator. A depth counter that ignores quoting stops there, returns
258
+ # a truncated body, and the rest of the command line is parsed as if it were
259
+ # inside the substitution — which is how a real `git push` after one used to go
260
+ # unseen.
261
+ #
262
+ # Sets $_TOOLU_SUBST_BODY (the body, parens excluded) and $_TOOLU_SUBST_END
263
+ # (the index just past the closing paren).
264
+ _toolu_subst_body() {
265
+ local s="$1" i="$2" start="$2"
266
+ local n=${#s} depth=1 q="" c
267
+ while [ "$i" -lt "$n" ] && [ "$depth" -gt 0 ]; do
268
+ c="${s:$i:1}"
269
+ if [ -n "$q" ]; then
270
+ if [ "$c" = '\' ] && [ "$q" = '"' ]; then
271
+ i=$((i + 2))
272
+ continue
273
+ fi
274
+ [ "$c" = "$q" ] && q=""
275
+ i=$((i + 1))
276
+ continue
277
+ fi
278
+ case "$c" in
279
+ "'"|'"') q="$c" ;;
280
+ '\') i=$((i + 1)) ;;
281
+ "(") depth=$((depth + 1)) ;;
282
+ ")") depth=$((depth - 1)) ;;
283
+ esac
284
+ i=$((i + 1))
285
+ done
286
+ _TOOLU_SUBST_BODY="${s:$start:$((i - start - 1))}"
287
+ _TOOLU_SUBST_END="$i"
288
+ }
289
+
290
+ # Split a command string into simple statements, honouring shell quoting.
291
+ #
292
+ # Statements keep their original text (quotes included) — the tokenizer below
293
+ # removes those. Only UNQUOTED `;`, `&&`, `||`, `|`, `&` and newlines separate
294
+ # statements, so an operator inside a quoted argument does not split it.
295
+ #
296
+ # Command-substitution bodies — `$(...)` and backticks — are lifted out and
297
+ # emitted as statements in their own right, because `foo $(git push)` really
298
+ # does push. Inside single quotes nothing is substituted, so those are left as
299
+ # literal text.
300
+ #
301
+ # Results land in the global array $_TOOLU_STMTS (bash 3.2 has no nameref).
302
+ _toolu_split_statements() {
303
+ local s="$1"
304
+ local i=0 n=${#s} c nxt q="" cur="" body depth start
305
+ _TOOLU_STMTS=()
306
+
307
+ _toolu_flush_stmt() {
308
+ case "$cur" in
309
+ *[![:space:]]*) _TOOLU_STMTS+=("$cur") ;;
310
+ esac
311
+ cur=""
312
+ }
313
+
314
+ while [ "$i" -lt "$n" ]; do
315
+ c="${s:$i:1}"
316
+
317
+ # Single quotes: literal to the closing quote. No operators, no expansion.
318
+ if [ "$q" = "'" ]; then
319
+ cur+="$c"
320
+ [ "$c" = "'" ] && q=""
321
+ i=$((i + 1))
322
+ continue
323
+ fi
324
+
325
+ # Double quotes: operators are literal, but substitutions still run.
326
+ if [ "$q" = '"' ]; then
327
+ if [ "$c" = '\' ]; then
328
+ cur+="${s:$i:2}"
329
+ i=$((i + 2))
330
+ continue
331
+ fi
332
+ if [ "$c" = '"' ]; then
333
+ cur+="$c"
334
+ q=""
335
+ i=$((i + 1))
336
+ continue
337
+ fi
338
+ if [ "$c" = '$' ] && [ "${s:$((i + 1)):1}" = "(" ]; then
339
+ _toolu_subst_body "$s" "$((i + 2))"
340
+ [ -n "$_TOOLU_SUBST_BODY" ] && _TOOLU_SUBSTS+=("$_TOOLU_SUBST_BODY")
341
+ # A space, not nothing: the text on either side of a removed
342
+ # substitution was never one word, and joining it could manufacture a
343
+ # command that was not written (`gi$(x)t push`).
344
+ cur+=" "
345
+ i="$_TOOLU_SUBST_END"
346
+ continue
347
+ fi
348
+ cur+="$c"
349
+ i=$((i + 1))
350
+ continue
351
+ fi
352
+
353
+ # Unquoted.
354
+ case "$c" in
355
+ "'"|'"')
356
+ q="$c"
357
+ cur+="$c"
358
+ ;;
359
+ '\')
360
+ cur+="${s:$i:2}"
361
+ i=$((i + 1))
362
+ ;;
363
+ '`')
364
+ start=$((i + 1))
365
+ i=$start
366
+ while [ "$i" -lt "$n" ] && [ "${s:$i:1}" != '`' ]; do
367
+ i=$((i + 1))
368
+ done
369
+ body="${s:$start:$((i - start))}"
370
+ [ -n "$body" ] && _TOOLU_SUBSTS+=("$body")
371
+ cur+=" "
372
+ ;;
373
+ '$')
374
+ if [ "${s:$((i + 1)):1}" = "(" ]; then
375
+ _toolu_subst_body "$s" "$((i + 2))"
376
+ [ -n "$_TOOLU_SUBST_BODY" ] && _TOOLU_SUBSTS+=("$_TOOLU_SUBST_BODY")
377
+ cur+=" "
378
+ i="$_TOOLU_SUBST_END"
379
+ continue
380
+ fi
381
+ cur+="$c"
382
+ ;;
383
+ '('|')')
384
+ # A command can follow an unquoted paren: a case-arm pattern
385
+ # (`a) git push;;`), a subshell (`(cd x && git push)`), a function body.
386
+ # Without treating these as boundaries the arm's command was glued to
387
+ # the pattern, so the statement's first token was `a)` and the push was
388
+ # never seen. A paren opening a substitution is consumed above and never
389
+ # reaches here.
390
+ _toolu_flush_stmt
391
+ ;;
392
+ ';'|'&'|'|'|$'\n')
393
+ nxt="${s:$((i + 1)):1}"
394
+ if { [ "$c" = '&' ] && [ "$nxt" = '&' ]; } || { [ "$c" = '|' ] && [ "$nxt" = '|' ]; }; then
395
+ i=$((i + 1))
396
+ fi
397
+ _toolu_flush_stmt
398
+ ;;
399
+ *)
400
+ cur+="$c"
401
+ ;;
402
+ esac
403
+ i=$((i + 1))
404
+ done
405
+ _toolu_flush_stmt
406
+ unset -f _toolu_flush_stmt
407
+ }
408
+
409
+ # Tokenize ONE statement into argv, dropping quote characters and keeping a
410
+ # quoted run as a single token. This is what makes prose safe: in
411
+ # `echo "no git push rules"` the quoted text is one argument token, so the
412
+ # command is `echo`, not `git`.
413
+ #
414
+ # Results land in the global array $_TOOLU_TOKENS.
415
+ _toolu_statement_tokens() {
416
+ local s="$1"
417
+ local i=0 n=${#s} c q="" tok="" started=0
418
+ _TOOLU_TOKENS=()
419
+ while [ "$i" -lt "$n" ]; do
420
+ c="${s:$i:1}"
421
+ if [ -n "$q" ]; then
422
+ if [ "$c" = "$q" ]; then
423
+ q=""
424
+ elif [ "$c" = '\' ] && [ "$q" = '"' ]; then
425
+ tok+="${s:$((i + 1)):1}"
426
+ i=$((i + 1))
427
+ else
428
+ tok+="$c"
429
+ fi
430
+ i=$((i + 1))
431
+ continue
432
+ fi
433
+ case "$c" in
434
+ "'"|'"')
435
+ q="$c"
436
+ started=1
437
+ ;;
438
+ '\')
439
+ tok+="${s:$((i + 1)):1}"
440
+ i=$((i + 1))
441
+ started=1
442
+ ;;
443
+ ' '|$'\t'|$'\n')
444
+ if [ "$started" = 1 ]; then
445
+ _TOOLU_TOKENS+=("$tok")
446
+ tok=""
447
+ started=0
448
+ fi
449
+ ;;
450
+ *)
451
+ tok+="$c"
452
+ started=1
453
+ ;;
454
+ esac
455
+ i=$((i + 1))
456
+ done
457
+ [ "$started" = 1 ] && _TOOLU_TOKENS+=("$tok")
458
+ return 0
459
+ }
460
+
461
+ # Print the git subcommand a statement invokes, or nothing.
462
+ #
463
+ # Git's global options sit between `git` and the subcommand, and two of them
464
+ # (-C, -c) take a separate value — consuming those is what keeps
465
+ # `git -c push.default=simple push` a push and `git commit -m "push"` a commit.
466
+ _toolu_git_subcommand() {
467
+ _toolu_statement_tokens "$1"
468
+ [ "${#_TOOLU_TOKENS[@]}" -gt 1 ] || return 1
469
+
470
+ # A command is not always the statement's first token. Grouping and keyword
471
+ # tokens can precede it (`deploy() { git push; }`, `if ! git push`), and so
472
+ # can environment assignments (`GIT_DIR=x git push`). Skipping them is what
473
+ # keeps those a push; `echo git push` is untouched, because `echo` is a
474
+ # command, not a prefix.
475
+ local start=0
476
+ while [ "$start" -lt "${#_TOOLU_TOKENS[@]}" ]; do
477
+ case "${_TOOLU_TOKENS[$start]}" in
478
+ "{"|"}"|"!"|if|while|until|then|else|elif|do|time|exec|command|builtin|env|nohup|sudo)
479
+ start=$((start + 1))
480
+ ;;
481
+ [A-Za-z_]*=*)
482
+ start=$((start + 1))
483
+ ;;
484
+ *)
485
+ break
486
+ ;;
487
+ esac
488
+ done
489
+
490
+ [ "$((start + 1))" -lt "${#_TOOLU_TOKENS[@]}" ] || return 1
491
+ [ "${_TOOLU_TOKENS[$start]}" = "git" ] || return 1
492
+
493
+ local i=$((start + 1)) t
494
+ while [ "$i" -lt "${#_TOOLU_TOKENS[@]}" ]; do
495
+ t="${_TOOLU_TOKENS[$i]}"
496
+ case "$t" in
497
+ -C|-c)
498
+ # These take a separate value. With nothing after them the command line
499
+ # is malformed (`git -C` alone is a git usage error), so there is no
500
+ # subcommand to report rather than one to guess at.
501
+ [ "$((i + 2))" -lt "${#_TOOLU_TOKENS[@]}" ] || return 1
502
+ i=$((i + 2))
503
+ ;;
504
+ --*=*|--*|-?)
505
+ i=$((i + 1))
506
+ ;;
507
+ *)
508
+ printf '%s' "$t"
509
+ return 0
510
+ ;;
511
+ esac
512
+ done
513
+ return 1
514
+ }
515
+
516
+ # Return 0 iff COMMAND ($1) invokes `git <SUBCOMMAND>` ($2) anywhere.
517
+ #
518
+ # Structural, not textual. The old regex matched the raw string, so any prose
519
+ # containing the two words in sequence — `echo "no git push rules"`, a commit
520
+ # message, a doc line — fired the push gates. Resolving the actual command of
521
+ # each statement is the difference between "these words appear" and "this runs".
522
+ toolu_runs_git_subcommand() {
523
+ local cmd sub="$2" stmt found=1
524
+ cmd=$(printf '%s\n' "$1" | strip_heredocs)
525
+
526
+ _TOOLU_SUBSTS=()
527
+ _toolu_split_statements "$cmd"
528
+ local -a stmts=("${_TOOLU_STMTS[@]}")
529
+ local -a substs=("${_TOOLU_SUBSTS[@]}")
530
+
531
+ if [ "${#stmts[@]}" -gt 0 ]; then
532
+ for stmt in "${stmts[@]}"; do
533
+ if [ "$(_toolu_git_subcommand "$stmt")" = "$sub" ]; then
534
+ found=0
535
+ break
536
+ fi
537
+ done
538
+ fi
539
+
540
+ # A substitution body is its own command line; scan each recursively so a
541
+ # push hidden in `$(...)` is not missed.
542
+ if [ "$found" -ne 0 ]; then
543
+ if [ "${#substs[@]}" -gt 0 ]; then
544
+ for stmt in "${substs[@]}"; do
545
+ if toolu_runs_git_subcommand "$stmt" "$sub"; then
546
+ found=0
547
+ break
548
+ fi
549
+ done
550
+ fi
551
+ fi
552
+ return "$found"
553
+ }
554
+
555
+ # Return 0 iff COMMAND ($1) runs `git push`.
556
+ #
557
+ # Structural, via toolu_runs_git_subcommand: the command is parsed into
558
+ # statements and git's real subcommand is resolved, so the two words appearing
559
+ # in prose (`echo "no git push rules"`, a commit message, a doc line) is not a
560
+ # push. Heredoc bodies are stripped first, and `$(...)`/backtick bodies are
561
+ # scanned as commands of their own.
562
+ is_git_push() {
563
+ toolu_runs_git_subcommand "$1" push
564
+ }
565
+
566
+ # Return 0 iff COMMAND ($1) runs `git commit`.
567
+ #
568
+ # Same structural detection as is_git_push — `git commit-tree` is a different
569
+ # subcommand and does not match, and `echo "remember to git commit"` is prose.
570
+ is_git_commit() {
571
+ toolu_runs_git_subcommand "$1" commit
572
+ }
573
+
574
+ # Echo candidate file-write targets a Bash COMMAND ($1) may write to, one per
575
+ # line -- best-effort, not a full shell grammar. Extends protected-file
576
+ # coverage (protected-files.sh) to the Bash tool: Edit/Write/MultiEdit carry a
577
+ # path-shaped tool_input the hook can check directly, but `Bash` does not, so
578
+ # `sed -i` or a redirect against the same path had no structured field to
579
+ # inspect and sailed through (github.com/Falconiere/toolu/issues/176).
580
+ #
581
+ # Covers the realistic bypasses, not every one a shell can express:
582
+ # - output redirection: >, >>, and fd-qualified/duplicated forms
583
+ # (1>, 2>>, &>, &>>) -- the token right after the operator. `2>&1` (fd
584
+ # duplication, not a file) is excluded.
585
+ # - `tee [-a] FILE...` -- every non-flag token after `tee`
586
+ # - `sed -i[SUFFIX] ...` / `perl -i[SUFFIX] ...` -- every non-flag token once
587
+ # `-i` is seen (over-includes the script argument; harmless, since
588
+ # protected patterns are path-shaped and a sed/perl script is not)
589
+ # - `cp ... DEST` / `mv ... DEST` / `install ... DEST` -- the last non-flag
590
+ # token
591
+ # - `dd of=FILE`
592
+ # - `python3 -c '...open("FILE", "w"...)...'` (and `python -c`) -- the first
593
+ # string literal passed to `open()` in a write/append mode; the exact
594
+ # bypass reported in the issue above
595
+ #
596
+ # Heredoc bodies are stripped first via strip_heredocs, and the command is
597
+ # split into statements/tokens via _toolu_split_statements /
598
+ # _toolu_statement_tokens -- the same quote- and heredoc-safe parsing
599
+ # toolu_runs_git_subcommand uses -- so a path mentioned in prose or a commit
600
+ # message is not mistaken for a write target.
601
+ bash_write_targets() {
602
+ local cmd stmt
603
+ cmd=$(printf '%s\n' "$1" | strip_heredocs)
604
+
605
+ _TOOLU_SUBSTS=()
606
+ _toolu_split_statements "$cmd"
607
+ local -a stmts=("${_TOOLU_STMTS[@]}")
608
+ local -a substs=("${_TOOLU_SUBSTS[@]}")
609
+
610
+ for stmt in "${stmts[@]}"; do
611
+ _toolu_statement_tokens "$stmt"
612
+ local -a tokens=("${_TOOLU_TOKENS[@]}")
613
+ local n=${#tokens[@]} i first bin
614
+ [ "$n" -eq 0 ] && continue
615
+
616
+ # Resolve the command token past env-var assignments / sudo / env / command.
617
+ first=0
618
+ while [ "$first" -lt "$n" ]; do
619
+ case "${tokens[$first]}" in
620
+ sudo|env|command|nohup) first=$((first + 1)) ;;
621
+ [A-Za-z_]*=*) first=$((first + 1)) ;;
622
+ *) break ;;
623
+ esac
624
+ done
625
+ bin="${tokens[$first]:-}"
626
+
627
+ for ((i = 0; i < n; i++)); do
628
+ local tok="${tokens[$i]}" next
629
+
630
+ # Redirection operator as its own token: >, >>, N>, N>>, &>, &>>.
631
+ if [[ "$tok" =~ ^([0-9]*|&)\>{1,2}$ ]]; then
632
+ next="${tokens[$((i + 1))]:-}"
633
+ [[ "$next" =~ ^\&[0-9]+$ ]] && continue
634
+ [ -n "$next" ] && printf '%s\n' "$next"
635
+ continue
636
+ fi
637
+
638
+ [ "$i" -eq "$first" ] || continue
639
+ local j
640
+ case "$bin" in
641
+ tee)
642
+ for ((j = i + 1; j < n; j++)); do
643
+ [[ "${tokens[$j]}" == -* ]] && continue
644
+ printf '%s\n' "${tokens[$j]}"
645
+ done
646
+ ;;
647
+ sed|perl)
648
+ local seen_i=0
649
+ for ((j = i + 1; j < n; j++)); do
650
+ if [ "$seen_i" -eq 0 ]; then
651
+ [[ "${tokens[$j]}" == -i* ]] && seen_i=1
652
+ continue
653
+ fi
654
+ [[ "${tokens[$j]}" == -* ]] && continue
655
+ printf '%s\n' "${tokens[$j]}"
656
+ done
657
+ ;;
658
+ cp|mv|install)
659
+ local last=""
660
+ for ((j = i + 1; j < n; j++)); do
661
+ [[ "${tokens[$j]}" == -* ]] && continue
662
+ last="${tokens[$j]}"
663
+ done
664
+ [ -n "$last" ] && printf '%s\n' "$last"
665
+ ;;
666
+ dd)
667
+ for ((j = i + 1; j < n; j++)); do
668
+ [[ "${tokens[$j]}" == of=* ]] && printf '%s\n' "${tokens[$j]#of=}"
669
+ done
670
+ ;;
671
+ python|python3)
672
+ for ((j = i + 1; j < n; j++)); do
673
+ if [ "${tokens[$j]}" = "-c" ]; then
674
+ local script="${tokens[$((j + 1))]:-}"
675
+ # EVERY open() in the script, not just the first: a second write
676
+ # hidden behind a benign one is a bypass, not a footnote. The
677
+ # matched text is removed with a QUOTED expansion so a glob
678
+ # metacharacter in the script can't corrupt the walk.
679
+ #
680
+ # Modes: w/a/x always write; r is read-only UNLESS it carries a
681
+ # '+' (r+, rb+), which opens for writing too. Bare r/rb stay out.
682
+ while [[ "$script" =~ open\([\'\"]([^\'\"]+)[\'\"][[:space:]]*,[[:space:]]*[\'\"]([wax][^\'\"]*|r[^\'\"]*\+[^\'\"]*)[\'\"] ]]; do
683
+ printf '%s\n' "${BASH_REMATCH[1]}"
684
+ script="${script#*"${BASH_REMATCH[0]}"}"
685
+ done
686
+ break
687
+ fi
688
+ done
689
+ ;;
690
+ esac
691
+ done
692
+ done
693
+
694
+ # A path hidden in a command substitution is still a write once that
695
+ # substitution runs -- recurse the same way toolu_runs_git_subcommand does.
696
+ for stmt in "${substs[@]}"; do
697
+ bash_write_targets "$stmt"
698
+ done
699
+
700
+ # strip_heredocs dropped every heredoc body above, which is right for prose
701
+ # -- but bash EXPANDS `$(...)` inside an UNQUOTED heredoc, so
702
+ # `cat <<EOF` / `$(echo x > .env)` / `EOF` really does write .env. Scan the
703
+ # substitutions in those bodies (and only those: a quoted tag, <<'EOF',
704
+ # expands nothing, so its body stays literal text).
705
+ local hd_bodies
706
+ hd_bodies=$(printf '%s\n' "$1" | _toolu_unquoted_heredoc_bodies)
707
+ if [ -n "$hd_bodies" ]; then
708
+ _TOOLU_SUBSTS=()
709
+ _toolu_split_statements "$hd_bodies"
710
+ local -a hd_substs=("${_TOOLU_SUBSTS[@]}")
711
+ for stmt in "${hd_substs[@]}"; do
712
+ bash_write_targets "$stmt"
713
+ done
714
+ fi
715
+ }
716
+
717
+ # Echo the absolute root of the repo a `git push` command ($1) targets.
718
+ #
719
+ # The hook's own cwd is not authoritative: `git -C <path> push` targets another
720
+ # checkout entirely. Resolving from the command keeps a worktree push gated
721
+ # against the worktree's own state, instead of reading the main checkout's state
722
+ # file (wrong branch, wrong diff) or — when the main checkout happens to sit on
723
+ # the base branch — skipping the gate altogether.
724
+ #
725
+ # Falls back to the cwd's toplevel, then the host-native project root, then cwd.
726
+ push_target_root() {
727
+ local command="$1" dir root
728
+ local -a cd_args=()
729
+
730
+ # Split on statement separators first: in `git -C a status && git -C b push`
731
+ # only the `push` segment's own -C counts. Within that segment, collect EVERY
732
+ # -C preceding `push` and replay them all — git applies them cumulatively
733
+ # (each relative to the last), so honouring only the first or last would
734
+ # resolve a different directory than the push itself uses.
735
+ while IFS= read -r dir; do
736
+ [ -n "$dir" ] || continue
737
+ dir=${dir%\"}
738
+ dir=${dir#\"}
739
+ cd_args+=(-C "$dir")
740
+ done < <(printf '%s\n' "$command" | strip_heredocs \
741
+ | awk '{ gsub(/&&|\|\||;|\|/, "\n"); print }' \
742
+ | awk '{
743
+ for (i = 1; i <= NF; i++)
744
+ if ($i == "push") {
745
+ for (j = 1; j < i; j++) if ($j == "-C") print $(j + 1)
746
+ exit
747
+ }
748
+ }')
749
+
750
+ if [ ${#cd_args[@]} -gt 0 ]; then
751
+ root=$(git "${cd_args[@]}" rev-parse --show-toplevel 2>/dev/null)
752
+ fi
753
+ [ -n "$root" ] || root=$(git rev-parse --show-toplevel 2>/dev/null)
754
+ [ -n "$root" ] || root=$(toolu_project_root)
755
+ [ -n "$root" ] || root=$(pwd)
756
+ echo "$root"
757
+ }
758
+
759
+ # push_target_branch COMMAND REPO_ROOT -> the branch the push should be judged
760
+ # as, or empty when it cannot be named.
761
+ #
762
+ # Attached HEAD: the checked-out branch, exactly as before — the review state
763
+ # file is keyed by it and a refspec must not silently re-key it.
764
+ # Detached HEAD (pr-babysit's `git worktree add --detach` + `push origin
765
+ # HEAD:<branch>` contract): the refspec DESTINATION of the push segment —
766
+ # `HEAD:x`, `+HEAD:refs/heads/x`, `src:x`, bare `x` all name `x`; a delete
767
+ # (`:x`), bare `HEAD`, or a wildcard yields empty, and the gate's detached
768
+ # deny stands.
769
+ push_target_branch() {
770
+ local command="$1" root="$2" branch refspec dst
771
+ branch=$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
772
+ if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then
773
+ echo "$branch"
774
+ return 0
775
+ fi
776
+ # First positional after the remote in the `push` segment (same statement
777
+ # split as push_target_root). Option values for the few value-taking push
778
+ # options are skipped so `-o ci.skip origin HEAD:x` still finds the refspec.
779
+ refspec=$(printf '%s\n' "$command" | strip_heredocs \
780
+ | awk '{ gsub(/&&|\|\||;|\|/, "\n"); print }' \
781
+ | awk '{
782
+ for (i = 1; i <= NF; i++)
783
+ if ($i == "push") {
784
+ n = 0
785
+ for (j = i + 1; j <= NF; j++) {
786
+ if ($j == "--") continue
787
+ if ($j ~ /^-/) {
788
+ if ($j == "-o" || $j == "--push-option" || $j == "--receive-pack" || $j == "--exec" || $j == "--repo") j++
789
+ continue
790
+ }
791
+ n++
792
+ if (n == 2) { print $j; exit }
793
+ }
794
+ exit
795
+ }
796
+ }')
797
+ refspec=${refspec#+}
798
+ [ -n "$refspec" ] || return 0
799
+ case "$refspec" in
800
+ :*) return 0 ;; # delete refspec: no destination to review
801
+ HEAD) return 0 ;; # bare HEAD on a detached checkout names nothing
802
+ *:*) dst=${refspec#*:} ;;
803
+ *) dst=$refspec ;;
804
+ esac
805
+ dst=${dst#refs/heads/}
806
+ case "$dst" in *'*'*|'') return 0 ;; esac
807
+ echo "$dst"
808
+ }
809
+
810
+ # Read non-comment non-blank lines from a settings file. Returns 0 with no
811
+ # output if the file is missing.
812
+ read_list() {
813
+ [ -f "$1" ] || return 0
814
+ grep -vE '^\s*(#|$)' "$1"
815
+ }
816
+
817
+ # count_code_lines FILE -> lines of real code (blank lines and comments
818
+ # excluded). Handles // line comments and /* ... */ blocks (incl. multi-line and
819
+ # inline), for both TS and Rust (/// and //! reduce to // and are dropped).
820
+ # Heuristic: does not track // or /* inside string literals — consistent with
821
+ # the other comment-stripping passes in the quality modules. Known edge: a
822
+ # string like `let s = "/* x";` flips block mode on and under-counts following
823
+ # code until a `*/` appears. Rare in practice; full literal-aware parsing isn't
824
+ # worth it here. Lives in detect.sh (not quality-config.sh) so the lang modules
825
+ # get one honest definition with no fallback — they already hard-require detect.sh.
826
+ count_code_lines() {
827
+ awk '
828
+ BEGIN { inblock=0; n=0 }
829
+ {
830
+ line=$0
831
+ if (inblock) {
832
+ idx=index(line,"*/")
833
+ if (idx>0) { line=substr(line, idx+2); inblock=0 } else next
834
+ }
835
+ while ((s=index(line,"/*"))>0) {
836
+ rest=substr(line, s+2); e=index(rest,"*/")
837
+ if (e>0) { line=substr(line,1,s-1) substr(rest, e+2) }
838
+ else { line=substr(line,1,s-1); inblock=1; break }
839
+ }
840
+ c=index(line,"//"); if (c>0) line=substr(line,1,c-1)
841
+ gsub(/^[ \t]+|[ \t]+$/, "", line)
842
+ if (length(line)>0) n++
843
+ }
844
+ # If we ended still inside a /* block, an unterminated comment OR (more
845
+ # likely) a string literal containing /* flipped block mode on and swallowed
846
+ # the rest of the file. Undercounting there would let an oversized file slip
847
+ # the size gate, so fall back to the raw line count — fail toward flagging.
848
+ # Intentional: the raw NR over-counts (it includes the blanks/comments we
849
+ # normally exclude). That is the fail-toward-flagging choice — better to
850
+ # over-count and flag than under-count and let an oversized file pass.
851
+ END { if (inblock) print NR; else print n }
852
+ ' "$1" 2>/dev/null
853
+ }
854
+
855
+ # count_python_code_lines FILE -> lines of real code (blank lines and
856
+ # full-line `#` comments excluded). Trailing `#` comments on a code line, and
857
+ # docstring lines (they are plain string-literal lines, not `#`-prefixed),
858
+ # COUNT as code — deliberate fail-toward-flagging: distinguishing a real
859
+ # comment from a `#` inside a string, or tracking docstring state, isn't worth
860
+ # it here, and over-counting risks a false positive, not a missed oversized file.
861
+ count_python_code_lines() {
862
+ awk '
863
+ {
864
+ line=$0
865
+ gsub(/^[ \t]+|[ \t]+$/, "", line)
866
+ if (length(line) == 0) next
867
+ if (substr(line, 1, 1) == "#") next
868
+ n++
869
+ }
870
+ END { print n+0 }
871
+ ' "$1" 2>/dev/null
872
+ }
873
+
874
+ # Return 0 if the file has more `/*` openers than `*/` closers — i.e. an
875
+ # unterminated block comment, or (more often) a string literal containing `/*`
876
+ # that confuses count_code_lines into the raw-line-count fallback. Lets the lang
877
+ # modules tell the user the size figure is approximate, not exact.
878
+ has_unterminated_block() {
879
+ [ -f "$1" ] || return 1
880
+ local open close
881
+ open=$(grep -o '/\*' "$1" 2>/dev/null | wc -l | tr -d ' ')
882
+ close=$(grep -o '\*/' "$1" 2>/dev/null | wc -l | tr -d ' ')
883
+ [ "${open:-0}" -gt "${close:-0}" ]
884
+ }
885
+
886
+ # Echo a repo-relative path for the given (potentially absolute) file_path.
887
+ # Falls back to the input unchanged if it cannot determine the project root
888
+ # or if the path is not under that root.
889
+ #
890
+ # Claude's Edit/Write tools send absolute paths; settings globs are written
891
+ # repo-relative. Without this helper, [[ /abs/path == hooks/lib/** ]] is
892
+ # always false, silently no-op'ing trusted-script protection.
893
+ to_relative_path() {
894
+ local p="${1:-}"
895
+ [ -z "$p" ] && { echo ""; return 0; }
896
+ local root
897
+ root=$(detect_project_root)
898
+ if [ -n "$root" ]; then
899
+ case "$p" in
900
+ "$root"/*) echo "${p#"$root"/}" ;;
901
+ *) echo "$p" ;;
902
+ esac
903
+ else
904
+ echo "$p"
905
+ fi
906
+ }
907
+
908
+ # toolu_plugin_active SPEC
909
+ # Boolean wrapper over detect_plugin_installed, used to gate "registry" hook
910
+ # modules on whether the contributing plugin is still installed.
911
+ #
912
+ # SPEC is the full "name@marketplace" spec (same arg detect_plugin_installed
913
+ # takes — it matches by exact spec via `.plugins | has($spec)`).
914
+ #
915
+ # Returns:
916
+ # 0 = plugin installed, OR indeterminate (no manifest / jq missing /
917
+ # malformed) -> fail open, so enforcement isn't silently lost on
918
+ # environments without the install registry.
919
+ # 1 = plugin definitively absent (registry parsed, spec not present).
920
+ toolu_plugin_active() {
921
+ local rc out
922
+ out=$(detect_plugin_installed "$1")
923
+ rc=$?
924
+ # Indeterminate (exit 2): fail open.
925
+ [ "$rc" -eq 2 ] && return 0
926
+ # Installed: detect_plugin_installed echoes the spec.
927
+ [ -n "$out" ] && return 0
928
+ return 1
929
+ }