beeverforge 0.1.0__py3-none-any.whl

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 (433) hide show
  1. beeverforge/__init__.py +7 -0
  2. beeverforge/actions/__init__.py +506 -0
  3. beeverforge/actions/base.py +490 -0
  4. beeverforge/actions/catalog.py +9549 -0
  5. beeverforge/actions/floor_intents.py +432 -0
  6. beeverforge/adapters/__init__.py +0 -0
  7. beeverforge/adapters/agent/__init__.py +71 -0
  8. beeverforge/adapters/agent/base.py +1101 -0
  9. beeverforge/adapters/agent/claude_code.py +1326 -0
  10. beeverforge/adapters/agent/codex.py +449 -0
  11. beeverforge/adapters/agent/kimi.py +442 -0
  12. beeverforge/adapters/agent/opencode.py +520 -0
  13. beeverforge/adapters/agent/registry.py +550 -0
  14. beeverforge/adapters/agent/roles.py +326 -0
  15. beeverforge/adapters/agent/routes.py +255 -0
  16. beeverforge/adapters/agent/session_store.py +221 -0
  17. beeverforge/adapters/agent/stream.py +367 -0
  18. beeverforge/adapters/agent/techlead.py +108 -0
  19. beeverforge/adapters/agent/token_pool.py +67 -0
  20. beeverforge/adapters/azure_devops.py +466 -0
  21. beeverforge/adapters/board/__init__.py +10 -0
  22. beeverforge/adapters/board/azure_devops.py +831 -0
  23. beeverforge/adapters/board/base.py +545 -0
  24. beeverforge/adapters/board/columns.py +261 -0
  25. beeverforge/adapters/board/factory.py +388 -0
  26. beeverforge/adapters/board/jira.py +487 -0
  27. beeverforge/adapters/board/local.py +360 -0
  28. beeverforge/adapters/board_db.py +423 -0
  29. beeverforge/adapters/board_setup/__init__.py +8 -0
  30. beeverforge/adapters/board_setup/base.py +60 -0
  31. beeverforge/adapters/board_setup/local.py +147 -0
  32. beeverforge/adapters/board_setup/registry.py +41 -0
  33. beeverforge/adapters/channel/__init__.py +5 -0
  34. beeverforge/adapters/channel/base.py +135 -0
  35. beeverforge/adapters/channel/panel.py +107 -0
  36. beeverforge/adapters/channel/registry.py +115 -0
  37. beeverforge/adapters/credential/__init__.py +8 -0
  38. beeverforge/adapters/credential/registry.py +342 -0
  39. beeverforge/adapters/credential/short_lived.py +69 -0
  40. beeverforge/adapters/credential_store/__init__.py +10 -0
  41. beeverforge/adapters/credential_store/base.py +39 -0
  42. beeverforge/adapters/credential_store/env.py +21 -0
  43. beeverforge/adapters/credential_store/file.py +329 -0
  44. beeverforge/adapters/credential_store/registry.py +413 -0
  45. beeverforge/adapters/embed/__init__.py +8 -0
  46. beeverforge/adapters/embed/base.py +39 -0
  47. beeverforge/adapters/embed/local.py +216 -0
  48. beeverforge/adapters/embed/registry.py +136 -0
  49. beeverforge/adapters/environment/__init__.py +30 -0
  50. beeverforge/adapters/environment/azure_pipelines.py +412 -0
  51. beeverforge/adapters/environment/base.py +35 -0
  52. beeverforge/adapters/environment/github_actions.py +108 -0
  53. beeverforge/adapters/environment/none.py +75 -0
  54. beeverforge/adapters/environment/registry.py +210 -0
  55. beeverforge/adapters/extract/__init__.py +6 -0
  56. beeverforge/adapters/extract/base.py +86 -0
  57. beeverforge/adapters/extract/mail.py +83 -0
  58. beeverforge/adapters/extract/markup.py +278 -0
  59. beeverforge/adapters/extract/model.py +94 -0
  60. beeverforge/adapters/extract/office.py +269 -0
  61. beeverforge/adapters/extract/pdf.py +332 -0
  62. beeverforge/adapters/extract/registry.py +218 -0
  63. beeverforge/adapters/extract/text.py +251 -0
  64. beeverforge/adapters/extract/vision.py +82 -0
  65. beeverforge/adapters/forge/__init__.py +30 -0
  66. beeverforge/adapters/forge/azure_devops.py +1961 -0
  67. beeverforge/adapters/forge/base.py +911 -0
  68. beeverforge/adapters/forge/github.py +1428 -0
  69. beeverforge/adapters/forge/local.py +894 -0
  70. beeverforge/adapters/forge/registry.py +252 -0
  71. beeverforge/adapters/github_app.py +173 -0
  72. beeverforge/adapters/github_cli.py +81 -0
  73. beeverforge/adapters/gitworktrees.py +183 -0
  74. beeverforge/adapters/notify/__init__.py +12 -0
  75. beeverforge/adapters/notify/base.py +45 -0
  76. beeverforge/adapters/notify/panel.py +43 -0
  77. beeverforge/adapters/notify/registry.py +301 -0
  78. beeverforge/adapters/preview/__init__.py +23 -0
  79. beeverforge/adapters/preview/base.py +221 -0
  80. beeverforge/adapters/preview/compose.py +1464 -0
  81. beeverforge/adapters/preview/none.py +60 -0
  82. beeverforge/adapters/preview/registry.py +125 -0
  83. beeverforge/adapters/reviewer/__init__.py +30 -0
  84. beeverforge/adapters/reviewer/base.py +75 -0
  85. beeverforge/adapters/reviewer/claude_code.py +195 -0
  86. beeverforge/adapters/reviewer/harness.py +169 -0
  87. beeverforge/adapters/sandbox/__init__.py +33 -0
  88. beeverforge/adapters/sandbox/base.py +470 -0
  89. beeverforge/adapters/sandbox/container.py +903 -0
  90. beeverforge/adapters/sandbox/pull.py +144 -0
  91. beeverforge/adapters/sandbox/registry.py +596 -0
  92. beeverforge/adapters/sandbox/timeouts.py +76 -0
  93. beeverforge/adapters/sandbox/worktree.py +1223 -0
  94. beeverforge/adapters/tracker/__init__.py +30 -0
  95. beeverforge/adapters/tracker/azure_devops.py +1058 -0
  96. beeverforge/adapters/tracker/base.py +876 -0
  97. beeverforge/adapters/tracker/github.py +732 -0
  98. beeverforge/adapters/tracker/github_board_setup.py +241 -0
  99. beeverforge/adapters/tracker/github_project.py +811 -0
  100. beeverforge/adapters/tracker/jira.py +1124 -0
  101. beeverforge/adapters/tracker/local.py +578 -0
  102. beeverforge/adapters/tracker/parse.py +573 -0
  103. beeverforge/adapters/tracker/registry.py +255 -0
  104. beeverforge/after_merge.py +123 -0
  105. beeverforge/agent_answer.py +199 -0
  106. beeverforge/api/__init__.py +4 -0
  107. beeverforge/api/app.py +4603 -0
  108. beeverforge/api/events.py +489 -0
  109. beeverforge/api/metrics_view.py +390 -0
  110. beeverforge/api/openapi.py +55 -0
  111. beeverforge/api/panel.html +6049 -0
  112. beeverforge/api/product_chat.py +709 -0
  113. beeverforge/api/project_route.py +89 -0
  114. beeverforge/api/read_models.py +774 -0
  115. beeverforge/approvals.py +341 -0
  116. beeverforge/box_prove.py +2150 -0
  117. beeverforge/certify/__init__.py +34 -0
  118. beeverforge/certify/controls.py +732 -0
  119. beeverforge/certify/pack.py +779 -0
  120. beeverforge/certify/pack.schema.json +226 -0
  121. beeverforge/certify/redact.py +336 -0
  122. beeverforge/certify/schema.py +144 -0
  123. beeverforge/certify/thresholds.yaml +61 -0
  124. beeverforge/certify/verify.py +469 -0
  125. beeverforge/cli.py +4642 -0
  126. beeverforge/cli_refusals.py +255 -0
  127. beeverforge/conformance/__init__.py +20 -0
  128. beeverforge/conformance/adapters.py +776 -0
  129. beeverforge/contracts/__init__.py +65 -0
  130. beeverforge/contracts/aliases.py +106 -0
  131. beeverforge/contracts/bot.py +42 -0
  132. beeverforge/contracts/checks.py +340 -0
  133. beeverforge/contracts/commands.py +183 -0
  134. beeverforge/contracts/decision.py +286 -0
  135. beeverforge/contracts/document.py +160 -0
  136. beeverforge/contracts/item_space.py +86 -0
  137. beeverforge/contracts/manifest.py +921 -0
  138. beeverforge/contracts/product.py +265 -0
  139. beeverforge/contracts/profile.py +214 -0
  140. beeverforge/contracts/project.py +597 -0
  141. beeverforge/contracts/refs.py +217 -0
  142. beeverforge/contracts/review.py +66 -0
  143. beeverforge/contracts/run.py +456 -0
  144. beeverforge/contracts/state.py +101 -0
  145. beeverforge/contracts/ticket.py +146 -0
  146. beeverforge/credentials.py +853 -0
  147. beeverforge/doctor.py +3504 -0
  148. beeverforge/doors.py +311 -0
  149. beeverforge/envfile.py +55 -0
  150. beeverforge/environ.py +278 -0
  151. beeverforge/factory.py +579 -0
  152. beeverforge/floor/__init__.py +31 -0
  153. beeverforge/floor/ladder.py +725 -0
  154. beeverforge/floor/reading.py +628 -0
  155. beeverforge/gate_seal.py +177 -0
  156. beeverforge/identity/__init__.py +14 -0
  157. beeverforge/identity/base.py +113 -0
  158. beeverforge/identity/local.py +324 -0
  159. beeverforge/identity/oidc.py +654 -0
  160. beeverforge/identity/people.py +444 -0
  161. beeverforge/identity/registry.py +97 -0
  162. beeverforge/knowledge/__init__.py +76 -0
  163. beeverforge/knowledge/bundle.py +220 -0
  164. beeverforge/knowledge/check.py +226 -0
  165. beeverforge/knowledge/contracts.py +407 -0
  166. beeverforge/knowledge/experiment.py +118 -0
  167. beeverforge/knowledge/flows.py +335 -0
  168. beeverforge/knowledge/gaps.py +154 -0
  169. beeverforge/knowledge/gate.py +352 -0
  170. beeverforge/knowledge/gather.py +156 -0
  171. beeverforge/knowledge/generator.py +822 -0
  172. beeverforge/knowledge/inventory.py +557 -0
  173. beeverforge/knowledge/okf.py +370 -0
  174. beeverforge/knowledge/pipeline.py +428 -0
  175. beeverforge/knowledge/render.py +146 -0
  176. beeverforge/knowledge/requests.py +173 -0
  177. beeverforge/knowledge/service.py +53 -0
  178. beeverforge/knowledge/staleness.py +112 -0
  179. beeverforge/knowledge/system/__init__.py +22 -0
  180. beeverforge/knowledge/system/adrs.py +91 -0
  181. beeverforge/knowledge/system/contracts.py +386 -0
  182. beeverforge/knowledge/system/deployments.py +564 -0
  183. beeverforge/knowledge/system/derive.py +887 -0
  184. beeverforge/knowledge/system/interfaces.py +321 -0
  185. beeverforge/knowledge/system/refresh.py +255 -0
  186. beeverforge/knowledge/system/render.py +300 -0
  187. beeverforge/knowledge/system/schemas.py +818 -0
  188. beeverforge/knowledge/system/text.py +201 -0
  189. beeverforge/knowledge/system/tree.py +210 -0
  190. beeverforge/language/__init__.py +15 -0
  191. beeverforge/language/assent.py +135 -0
  192. beeverforge/language/written.py +73 -0
  193. beeverforge/lifecycle/__init__.py +43 -0
  194. beeverforge/lifecycle/card.py +292 -0
  195. beeverforge/lifecycle/executor.py +255 -0
  196. beeverforge/lifecycle/handed_back.py +206 -0
  197. beeverforge/lifecycle/loops.py +404 -0
  198. beeverforge/lifecycle/observed.py +168 -0
  199. beeverforge/lifecycle/ports.py +433 -0
  200. beeverforge/lifecycle/reading.py +100 -0
  201. beeverforge/lifecycle/record.py +211 -0
  202. beeverforge/lifecycle/table.py +673 -0
  203. beeverforge/listeners.py +245 -0
  204. beeverforge/loader.py +173 -0
  205. beeverforge/memory/__init__.py +29 -0
  206. beeverforge/memory/ledger.py +237 -0
  207. beeverforge/memory/messages.py +500 -0
  208. beeverforge/memory/recall.py +414 -0
  209. beeverforge/memory/store.py +157 -0
  210. beeverforge/memory/transcript.py +697 -0
  211. beeverforge/namespace.py +278 -0
  212. beeverforge/observability/__init__.py +23 -0
  213. beeverforge/observability/autonomy.py +233 -0
  214. beeverforge/observability/events.py +195 -0
  215. beeverforge/observability/health.py +533 -0
  216. beeverforge/observability/job_record.py +141 -0
  217. beeverforge/observability/logs.py +368 -0
  218. beeverforge/observability/metrics.py +276 -0
  219. beeverforge/observability/query.py +565 -0
  220. beeverforge/observability/registry.py +260 -0
  221. beeverforge/observability/sqlite_metrics.py +333 -0
  222. beeverforge/observability/trajectory.py +243 -0
  223. beeverforge/onboarding/__init__.py +64 -0
  224. beeverforge/onboarding/concepts.py +450 -0
  225. beeverforge/onboarding/context.py +2428 -0
  226. beeverforge/onboarding/cover.py +97 -0
  227. beeverforge/onboarding/deployment.py +1040 -0
  228. beeverforge/onboarding/firstrun.py +1660 -0
  229. beeverforge/onboarding/history.py +363 -0
  230. beeverforge/onboarding/infer.py +2779 -0
  231. beeverforge/onboarding/live_ci.py +162 -0
  232. beeverforge/onboarding/onboard.py +1101 -0
  233. beeverforge/onboarding/preview_infer.py +1518 -0
  234. beeverforge/onboarding/preview_product.py +354 -0
  235. beeverforge/onboarding/preview_propose.py +1210 -0
  236. beeverforge/onboarding/propose_manifest.py +411 -0
  237. beeverforge/onboarding/questions.py +137 -0
  238. beeverforge/onboarding/readiness.py +1147 -0
  239. beeverforge/onboarding/renew.py +239 -0
  240. beeverforge/onboarding/spend.py +76 -0
  241. beeverforge/onboarding/starter.py +75 -0
  242. beeverforge/ops/__init__.py +6 -0
  243. beeverforge/ops/impediment.py +420 -0
  244. beeverforge/orchestrator/__init__.py +26 -0
  245. beeverforge/orchestrator/context.py +791 -0
  246. beeverforge/orchestrator/errors.py +27 -0
  247. beeverforge/orchestrator/explain.py +430 -0
  248. beeverforge/orchestrator/machine.py +4177 -0
  249. beeverforge/orchestrator/merge_policy.py +244 -0
  250. beeverforge/orchestrator/operator_guidelines.py +406 -0
  251. beeverforge/orchestrator/outcomes.py +39 -0
  252. beeverforge/orchestrator/promotion.py +369 -0
  253. beeverforge/orchestrator/read_only_pass.py +219 -0
  254. beeverforge/orchestrator/risk.py +191 -0
  255. beeverforge/orchestrator/validation.py +172 -0
  256. beeverforge/org_defaults/cards/defect-template.md +23 -0
  257. beeverforge/org_defaults/cards/defect-template.pt-BR.md +23 -0
  258. beeverforge/org_defaults/cards/rubric.yaml +140 -0
  259. beeverforge/org_defaults/cards/template.md +23 -0
  260. beeverforge/org_defaults/cards/template.pt-BR.md +23 -0
  261. beeverforge/org_defaults/engineering.md +92 -0
  262. beeverforge/org_defaults/floor.yaml +192 -0
  263. beeverforge/org_defaults/profiles/prototype.yaml +16 -0
  264. beeverforge/org_defaults/profiles/regulated.yaml +35 -0
  265. beeverforge/org_defaults/requirements/0000-template.md +39 -0
  266. beeverforge/org_defaults/roles/coordinator.md +48 -0
  267. beeverforge/org_defaults/roles/executor.md +100 -0
  268. beeverforge/org_defaults/roles/planner.md +117 -0
  269. beeverforge/org_defaults/roles/product.md +114 -0
  270. beeverforge/org_defaults/roles/recovery.md +35 -0
  271. beeverforge/org_defaults/roles/sizer.md +62 -0
  272. beeverforge/org_defaults/roles/techlead.md +61 -0
  273. beeverforge/org_defaults/tdd.md +10 -0
  274. beeverforge/own_work.py +41 -0
  275. beeverforge/paths.py +144 -0
  276. beeverforge/plugins.py +307 -0
  277. beeverforge/policy/__init__.py +10 -0
  278. beeverforge/policy/authz.py +105 -0
  279. beeverforge/policy/census.py +161 -0
  280. beeverforge/policy/conformance.py +300 -0
  281. beeverforge/policy/context_writes.py +123 -0
  282. beeverforge/policy/floor.py +42 -0
  283. beeverforge/policy/presets.py +174 -0
  284. beeverforge/policy/profiles.py +286 -0
  285. beeverforge/policy/protected.py +175 -0
  286. beeverforge/policy/test_work.py +70 -0
  287. beeverforge/preflight.py +772 -0
  288. beeverforge/presets/node.yaml +6 -0
  289. beeverforge/presets/python.yaml +9 -0
  290. beeverforge/presets/security-oss.yaml +26 -0
  291. beeverforge/presets/terraform.yaml +8 -0
  292. beeverforge/preview/__init__.py +600 -0
  293. beeverforge/preview/admit.py +599 -0
  294. beeverforge/preview/assemble.py +468 -0
  295. beeverforge/preview/demand.py +463 -0
  296. beeverforge/preview/live.py +185 -0
  297. beeverforge/preview/own.py +163 -0
  298. beeverforge/preview/plan.py +205 -0
  299. beeverforge/preview/product.py +219 -0
  300. beeverforge/preview/read.py +379 -0
  301. beeverforge/preview/reap.py +182 -0
  302. beeverforge/preview/siblings.py +140 -0
  303. beeverforge/preview/steps.py +475 -0
  304. beeverforge/preview/unit.py +56 -0
  305. beeverforge/product/__init__.py +56 -0
  306. beeverforge/product/accept.py +276 -0
  307. beeverforge/product/addressing.py +102 -0
  308. beeverforge/product/adjust.py +449 -0
  309. beeverforge/product/agenda.py +329 -0
  310. beeverforge/product/asked.py +270 -0
  311. beeverforge/product/attachments.py +433 -0
  312. beeverforge/product/authoring.py +2727 -0
  313. beeverforge/product/before_code.py +482 -0
  314. beeverforge/product/board.py +634 -0
  315. beeverforge/product/breaking.py +174 -0
  316. beeverforge/product/briefing.py +545 -0
  317. beeverforge/product/brownfield.py +240 -0
  318. beeverforge/product/cap.py +141 -0
  319. beeverforge/product/capabilities.py +438 -0
  320. beeverforge/product/cards.py +1187 -0
  321. beeverforge/product/case.py +545 -0
  322. beeverforge/product/chain.py +389 -0
  323. beeverforge/product/channel.py +215 -0
  324. beeverforge/product/clock.py +102 -0
  325. beeverforge/product/config.py +422 -0
  326. beeverforge/product/confirm.py +1394 -0
  327. beeverforge/product/conversation.py +133 -0
  328. beeverforge/product/corpus.py +624 -0
  329. beeverforge/product/distil.py +597 -0
  330. beeverforge/product/documents/__init__.py +19 -0
  331. beeverforge/product/documents/ingest.py +702 -0
  332. beeverforge/product/documents/reading.py +125 -0
  333. beeverforge/product/documents/record.py +263 -0
  334. beeverforge/product/documents/store.py +146 -0
  335. beeverforge/product/domain.py +283 -0
  336. beeverforge/product/door.py +686 -0
  337. beeverforge/product/engine.py +2686 -0
  338. beeverforge/product/evaluation/__init__.py +26 -0
  339. beeverforge/product/evaluation/__main__.py +80 -0
  340. beeverforge/product/evaluation/battery.py +252 -0
  341. beeverforge/product/evaluation/run.py +428 -0
  342. beeverforge/product/evaluation/score.py +326 -0
  343. beeverforge/product/events.py +968 -0
  344. beeverforge/product/facts.py +371 -0
  345. beeverforge/product/followup.py +761 -0
  346. beeverforge/product/forget.py +718 -0
  347. beeverforge/product/importing.py +1508 -0
  348. beeverforge/product/index/__init__.py +22 -0
  349. beeverforge/product/index/items.py +343 -0
  350. beeverforge/product/index/retrieval.py +577 -0
  351. beeverforge/product/index/search.py +538 -0
  352. beeverforge/product/index/store.py +298 -0
  353. beeverforge/product/index/sync.py +272 -0
  354. beeverforge/product/intents.py +180 -0
  355. beeverforge/product/key.py +43 -0
  356. beeverforge/product/loader.py +302 -0
  357. beeverforge/product/model.py +1591 -0
  358. beeverforge/product/module.py +6800 -0
  359. beeverforge/product/needs_action.py +209 -0
  360. beeverforge/product/onboard.py +402 -0
  361. beeverforge/product/page.py +177 -0
  362. beeverforge/product/progress.py +73 -0
  363. beeverforge/product/queue.py +343 -0
  364. beeverforge/product/reading.py +183 -0
  365. beeverforge/product/refresh.py +402 -0
  366. beeverforge/product/release.py +268 -0
  367. beeverforge/product/requester.py +55 -0
  368. beeverforge/product/role.py +2386 -0
  369. beeverforge/product/semaphore.py +497 -0
  370. beeverforge/product/sessions.py +171 -0
  371. beeverforge/product/sight.py +359 -0
  372. beeverforge/product/sources.py +351 -0
  373. beeverforge/product/speaker.py +166 -0
  374. beeverforge/product/staging.py +992 -0
  375. beeverforge/product/template.py +252 -0
  376. beeverforge/product/triage.py +338 -0
  377. beeverforge/product/unfiled.py +77 -0
  378. beeverforge/product/voice.py +5597 -0
  379. beeverforge/product/waiting.py +113 -0
  380. beeverforge/product/workspace.py +536 -0
  381. beeverforge/registry.py +891 -0
  382. beeverforge/review/__init__.py +11 -0
  383. beeverforge/review/evidence.py +45 -0
  384. beeverforge/review/threads.py +84 -0
  385. beeverforge/review/verdict.py +335 -0
  386. beeverforge/runtime/__init__.py +0 -0
  387. beeverforge/runtime/boxed_job.py +475 -0
  388. beeverforge/runtime/card_repo.py +163 -0
  389. beeverforge/runtime/host.py +398 -0
  390. beeverforge/runtime/repairable.py +232 -0
  391. beeverforge/runtime/repo_cache.py +911 -0
  392. beeverforge/runtime/temporal/__init__.py +26 -0
  393. beeverforge/runtime/temporal/activities.py +7015 -0
  394. beeverforge/runtime/temporal/connection.py +553 -0
  395. beeverforge/runtime/temporal/conversation.py +617 -0
  396. beeverforge/runtime/temporal/deadlines.py +86 -0
  397. beeverforge/runtime/temporal/interceptors.py +81 -0
  398. beeverforge/runtime/temporal/io.py +1042 -0
  399. beeverforge/runtime/temporal/poller.py +185 -0
  400. beeverforge/runtime/temporal/schedule.py +543 -0
  401. beeverforge/runtime/temporal/standing.py +141 -0
  402. beeverforge/runtime/temporal/starter.py +55 -0
  403. beeverforge/runtime/temporal/view.py +1708 -0
  404. beeverforge/runtime/temporal/vocabulary.py +95 -0
  405. beeverforge/runtime/temporal/worker.py +446 -0
  406. beeverforge/runtime/temporal/workflow.py +3550 -0
  407. beeverforge/runtime/toolbox.py +163 -0
  408. beeverforge/scheduler.py +75 -0
  409. beeverforge/semver.py +35 -0
  410. beeverforge/techlead/__init__.py +42 -0
  411. beeverforge/techlead/classify.py +514 -0
  412. beeverforge/techlead/conversation.py +1437 -0
  413. beeverforge/techlead/diagnosis.py +251 -0
  414. beeverforge/techlead/memory.py +292 -0
  415. beeverforge/techlead/pack.py +169 -0
  416. beeverforge/techlead/voice.py +892 -0
  417. beeverforge/techlead/watch.py +647 -0
  418. beeverforge/testing/__init__.py +1 -0
  419. beeverforge/testing/local_flow.py +287 -0
  420. beeverforge/util/__init__.py +0 -0
  421. beeverforge/util/bounded.py +77 -0
  422. beeverforge/util/causes.py +125 -0
  423. beeverforge/util/filelock.py +176 -0
  424. beeverforge/util/scratch.py +80 -0
  425. beeverforge/util/single_flight.py +107 -0
  426. beeverforge/util/yaml_io.py +252 -0
  427. beeverforge-0.1.0.dist-info/METADATA +187 -0
  428. beeverforge-0.1.0.dist-info/RECORD +433 -0
  429. beeverforge-0.1.0.dist-info/WHEEL +5 -0
  430. beeverforge-0.1.0.dist-info/entry_points.txt +2 -0
  431. beeverforge-0.1.0.dist-info/licenses/LICENSE +202 -0
  432. beeverforge-0.1.0.dist-info/licenses/NOTICE +22 -0
  433. beeverforge-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """beeverforge-platform — a framework to run software tickets autonomously.
2
+
3
+ Policies authorize actions; humans evaluate results. See
4
+ docs/adr/0001-foundational-conceptual-model.md for the conceptual model.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,506 @@
1
+ """The action layer — everything a human can ask the factory to DO, written once (C-23).
2
+
3
+ WHAT WAS ACTUALLY WRONG. The Slack bot never calls the HTTP API: zero httpx, zero requests, zero
4
+ aiohttp. The two front ends were written independently against the same domain, and by the time
5
+ this card was opened they had drifted in ways nobody chose:
6
+
7
+ resume the panel passes the DecisionRequest option key; Slack hard-codes ""
8
+ → a job parked with a real question can be answered from the panel and only
9
+ resumed blindly from Slack, in a channel where the question was posted
10
+ ack exists in Slack, does not exist in the panel at all
11
+ enable / scan exist in the panel, do not exist in Slack
12
+ approve_prod two implementations — a durable signal and an in-process PromotionRunner —
13
+ reachable from one front end each, under names that suggest they are the same
14
+ validation the panel checks the ref shape; Slack checks nothing
15
+
16
+ None of that is a bug anybody wrote. It is what happens when a capability has no home, and it is
17
+ the manifesto's empty SDK box: there was no layer for a third front end to be written against, so
18
+ a third front end would have made it three.
19
+
20
+ WHAT THIS LAYER IS. A table of actions. Each takes values and `by: Actor`, returns an `Outcome`,
21
+ and speaks no transport: no HTTP status, no mrkdwn, no Slack block, no exception escaping. The
22
+ front ends become mappings — a route or a verb picks a name and renders the result.
23
+
24
+ WHAT IT IS NOT. It is not authorization (C-26 / #55): `Actor.admin` is still decided by whoever
25
+ carried the request, and this layer only carries and records it. It is not the conversational
26
+ tech-lead (C-24 / #52): `ask` and `diagnose` are catalogued here and their brains still live in
27
+ the Slack package until that card moves them. Both are deliberate — putting either in this card
28
+ would mean designing identity or the agent's memory model inside a refactor, and that is how a
29
+ refactor becomes a rewrite.
30
+
31
+ REACHING IT. Two universal transports dispatch the whole catalog by name:
32
+
33
+ POST /api/act/{name} the panel and anything holding its token
34
+ beeverforge act <name> … a shell on the host
35
+
36
+ and the named front-end paths (`/api/temporal/act`, the Slack verbs, `/api/projects/{n}/enabled`)
37
+ are mappings onto the same rows. That is the property the guards in
38
+ `tests/test_the_action_layer.py` hold: **every action is reachable from at least two transports and
39
+ implemented by none of them.**
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import re
46
+
47
+ from beeverforge.actions.base import (
48
+ AROUND_EVERY_ROW,
49
+ BAD_PARAMETERS,
50
+ BAD_REF,
51
+ CODES,
52
+ CONFLICT,
53
+ DENIED,
54
+ ENGINE_UNREACHABLE,
55
+ FAILED,
56
+ FLOOR,
57
+ INVALID,
58
+ NO_CODE_YET,
59
+ NOT_ADMIN,
60
+ NOT_FOUND,
61
+ NOT_THE_INSTALLATIONS,
62
+ OK,
63
+ OUT_OF_SCOPE,
64
+ PRODUCT,
65
+ RAISED,
66
+ REASONS,
67
+ REPORTED_NOTHING,
68
+ SCOPES,
69
+ STEP_ALREADY,
70
+ STEP_DONE,
71
+ STEP_FAILED,
72
+ STEP_SKIPPED,
73
+ STEP_STATES,
74
+ SYSTEM,
75
+ UNAVAILABLE,
76
+ UNIMPLEMENTED,
77
+ UNKNOWN_ACTION,
78
+ ActionSpec,
79
+ Actor,
80
+ Outcome,
81
+ done,
82
+ failure_reference,
83
+ not_moved_yet,
84
+ refused,
85
+ step,
86
+ )
87
+
88
+ log = logging.getLogger("beeverforge.actions")
89
+
90
+ __all__ = [
91
+ "CATALOG", "CODES", "CONFLICT", "DENIED", "FAILED", "FLOOR", "INVALID", "NOT_FOUND", "OK",
92
+ "PRODUCT", "REASONS", "SCOPES", "STEP_ALREADY", "STEP_DONE", "STEP_FAILED", "STEP_SKIPPED",
93
+ "STEP_STATES", "SYSTEM", "UNAVAILABLE", "UNIMPLEMENTED", "ActionSpec", "Actor", "Outcome",
94
+ "done", "names", "not_moved_yet", "perform", "proposable", "reasons", "refused", "run_staged",
95
+ "spec", "step",
96
+ ]
97
+
98
+
99
+ async def run_staged(*, project: str, by: Actor, token: str = "") -> Outcome:
100
+ """Perform what the tech-lead staged for `project` — see `catalog.run_staged`.
101
+
102
+ Re-exported lazily for the same reason `_Catalog` is: importing the implementations costs
103
+ `temporalio`, and the panel is built to serve without the runtime extra."""
104
+ from beeverforge.actions.catalog import run_staged as _run
105
+
106
+ return await _run(project=project, by=by, token=token)
107
+
108
+
109
+ def _catalog() -> dict[str, ActionSpec]:
110
+ from beeverforge.actions.catalog import CATALOG as rows
111
+
112
+ return rows
113
+
114
+
115
+ class _Catalog:
116
+ """A lazy mapping over `catalog.py`, so importing `beeverforge.actions` costs nothing.
117
+
118
+ The catalog's implementations reach Temporal, the forge and the approver store. Importing them
119
+ eagerly would put `temporalio` on the import path of every front end — including the panel,
120
+ which is explicitly built to serve without the `runtime` extra installed. The bodies already
121
+ import lazily; this keeps the module itself lazy too, so the guard tests can walk the table on
122
+ a machine that has none of it.
123
+
124
+ THAT PROMISE IS HELD BY A GUARD SINCE #178, because for a while it was only written down: the
125
+ page's own words came from `view.py` and `workflow.py`, so `/` and `/api/floor` answered 500
126
+ on exactly the install this paragraph describes. It asks every GET route in an interpreter
127
+ where `temporalio` cannot be found:
128
+ `tests/test_the_panel_serves_without_the_engines_client.py`."""
129
+
130
+ def __getitem__(self, name: str) -> ActionSpec:
131
+ return _catalog()[name]
132
+
133
+ def __contains__(self, name: object) -> bool:
134
+ return name in _catalog()
135
+
136
+ def __iter__(self):
137
+ return iter(_catalog())
138
+
139
+ def __len__(self) -> int:
140
+ return len(_catalog())
141
+
142
+ def values(self):
143
+ return _catalog().values()
144
+
145
+ def items(self):
146
+ return _catalog().items()
147
+
148
+ def get(self, name: str, default=None):
149
+ return _catalog().get(name, default)
150
+
151
+
152
+ #: The one table. Iterating it is how a front end lists what it can offer, and how the guards
153
+ #: check that every row is reachable.
154
+ CATALOG = _Catalog()
155
+
156
+
157
+ def names() -> tuple[str, ...]:
158
+ """Every action, in catalog order — which is grouped by what an operator is doing, not
159
+ alphabetical, because this is what `beeverforge act --list` prints."""
160
+ return tuple(_catalog())
161
+
162
+
163
+ def spec(name: str) -> ActionSpec | None:
164
+ return _catalog().get((name or "").strip().lower())
165
+
166
+
167
+ def waits_for_code(found: ActionSpec) -> bool:
168
+ """Whether `perform` refuses this row for a project that started before its code (#992): it
169
+ `needs_code` and it names a `project` to look at — a row that names none acts on no project's
170
+ code and is never asked."""
171
+ return found.needs_code and "project" in found.parameters
172
+
173
+
174
+ def _waiting_for_its_code(project: object, key: str) -> str:
175
+ """The sentence a row that needs the code is refused with on a project that started before
176
+ it — `""` for any other project, and for a name the registry cannot answer about: the row
177
+ itself says "not registered" in its own words, and a registry that cannot be read is the row's
178
+ to report, never this check's to swallow into a refusal about code."""
179
+ from beeverforge.contracts.project import before_its_code, no_code_yet
180
+ from beeverforge.registry import ProjectRegistry
181
+
182
+ name = str(project or "").strip()
183
+ if not name:
184
+ return ""
185
+ try:
186
+ found = ProjectRegistry().get(name)
187
+ except (KeyError, ValueError):
188
+ return ""
189
+ if not before_its_code(found):
190
+ return ""
191
+ return no_code_yet(name, key)
192
+
193
+
194
+ def reasons(name: str) -> tuple[str, ...]:
195
+ """Every reason a refusal of `name` may carry in `data["reason"]` — what `perform` checks
196
+ around the row, what the row's own code paths refuse with, and what the rows it hands back
197
+ can say — in that order, each once. `()` for a name no row has.
198
+
199
+ DERIVED HERE, NOT WRITTEN ON EACH ROW, for `perform`'s part: it is `perform`'s code that says
200
+ them, so a copy of `not_admin` on every row would be one chance per row to forget the one
201
+ that turns `needs_admin` off."""
202
+ out: list[str] = []
203
+ todo = [(name or "").strip().lower()]
204
+ seen: set[str] = set()
205
+ while todo: # bounded: each row is visited once, and the catalogue is finite
206
+ key = todo.pop(0)
207
+ found = _catalog().get(key)
208
+ if found is None or key in seen:
209
+ continue
210
+ seen.add(key)
211
+ around = [r for r in AROUND_EVERY_ROW
212
+ if (r != BAD_REF or "issue" in found.parameters)
213
+ and (r != NOT_THE_INSTALLATIONS or found.installation)
214
+ and (r != NOT_ADMIN or found.needs_admin)
215
+ and (r != NO_CODE_YET or waits_for_code(found))]
216
+ out += [r for r in (*around, *found.refuses) if r not in out]
217
+ todo += list(found.relays)
218
+ return tuple(out)
219
+
220
+
221
+ #: What a proposal may CARRY. It was `{project, issue}` — a ticket and nothing else — because the
222
+ #: channel back was `[[SUGGEST verb #NN]]`, with nowhere to put a sentence. That dropped `adjust`,
223
+ #: and the consequence was the shape of the whole role: the tech-lead could propose that you throw
224
+ #: work away (`discard`) or rerun it blind (`resume`), and could not propose the one thing a senior
225
+ #: engineer actually says, which is WHAT TO CHANGE (#170).
226
+ #:
227
+ #: A SECRET IS STILL NOT ADDRESSABLE, and that is the line this set now draws. `_SECRET` below
228
+ #: names what may never travel through a proposal; a parameter outside it is prose a human reads on
229
+ #: the button before pressing it.
230
+ _ADDRESSABLE = frozenset({"project", "issue", "instruction"})
231
+
232
+
233
+ def proposable(by: Actor) -> tuple[str, ...]:
234
+ """The actions this actor may perform AND the tech-lead may propose, in catalog order (#121).
235
+
236
+ THREE FILTERS, ALL DERIVED, because the alternative is the sentence this replaces: the
237
+ tech-lead's guidance said *"never suggest prod/merge actions"* — a rule written when the
238
+ catalogue had no merge row. #120 added one, gated, reachable from the chat, and the guidance
239
+ went on forbidding it. A hand-written list of verbs in a prompt is a second copy of the
240
+ catalogue that nobody updates.
241
+
242
+ 1. **The asker's own credential**, by the SAME two checks `perform` applies and in the same
243
+ order — scope, then admin. A credential that cannot press the button must not be told to
244
+ ask for it; being handed an action and then refused is worse than not being offered it.
245
+ `perform`'s third, the installation's (#741), is not asked here because no row it guards
246
+ can pass (2): a proposal is never to add a project or revoke a person, and
247
+ `tests/test_the_installation_is_changed_through_its_rows.py` holds that.
248
+ 2. **What a human could have typed.** A suggestion is a proposal somebody approves with one
249
+ word, so it must be something they could have said themselves: the operator grammar
250
+ (`contracts/commands.py`, which is `resume`/`skip` and deliberately excludes prod) and the
251
+ floor matcher (`actions/floor_intents.py`, which is merge/discard/adjust). Rows like
252
+ `start`, `diagnose` and `product_release` are addressable by a ticket and are NOT things
253
+ the floor's own grammar accepts — which is exactly the line the old rule was reaching for.
254
+ 3. **What a proposal can carry** — see `_ADDRESSABLE`. This used to drop `adjust`, because
255
+ the channel was one verb and one ticket wide. It carries an instruction now (#170), and the
256
+ blast radius is closed by (2): `typeable` is `{adjust, discard, merge, resume, skip, stop}`,
257
+ so relaxing this admits EXACTLY `adjust`. `approve_prod` and `promote` stay out where they
258
+ already were — not in the floor grammar — rather than by this filter's accident.
259
+
260
+ `ack` is deliberately absent from (2). It is a person saying they have seen something, and
261
+ nobody can propose that on somebody else's behalf.
262
+ """
263
+ from beeverforge.actions.floor_intents import FLOOR_ROWS
264
+ from beeverforge.contracts.commands import ACTION_OF
265
+
266
+ typeable = set(ACTION_OF.values()) | set(FLOOR_ROWS.values())
267
+ out: list[str] = []
268
+ for name, found in _catalog().items():
269
+ if name not in typeable:
270
+ continue
271
+ if not by.may_enter(found.scope):
272
+ continue
273
+ if found.needs_admin and not by.admin:
274
+ continue
275
+ if set(found.required) - _ADDRESSABLE or "issue" not in found.required:
276
+ continue
277
+ out.append(name)
278
+ return tuple(out)
279
+
280
+
281
+ async def perform(name: str, *, by: Actor, **params: object) -> Outcome:
282
+ """Run one action. Never raises. Always returns an `Outcome`.
283
+
284
+ THE SIX THINGS THAT HAPPEN HERE RATHER THAN IN THREE FRONT ENDS:
285
+
286
+ 1. **The name is resolved**, and an unknown one is refused with the list of what exists —
287
+ the registry rule this codebase already holds everywhere else (`adapters/*/registry.py`).
288
+ 2. **Parameters are checked** against the spec, in both directions. A missing one is named; so
289
+ is an unexpected one, because a front end that renames a field and keeps working silently
290
+ is how `image=` came to be omitted at all four job-launch sites (see
291
+ `a-negative-guard-needs-a-positive-twin`).
292
+ 3. **A ticket ref is normalised once.** `#189` and `189` are the same ticket, and until now the
293
+ panel knew that and Slack did not.
294
+ 4. **`needs_admin` is enforced.** Slack had this gate, the panel had a token, the CLI had
295
+ nothing; now the three answers meet in `Actor.admin` and the decision is made in one place.
296
+ So is a row's `installation`: only an unscoped credential changes the deployment itself.
297
+ 5. **A row that needs the code waits for it** (`ActionSpec.needs_code`, #992): on a project
298
+ whose product started before its code it is refused by name, naming the first source.
299
+ 6. **Everything is logged with the actor**, whatever the result. This is the audit line — the
300
+ first time this platform can say who asked for a thing, which is the question an enterprise
301
+ security review opens with.
302
+
303
+ NEVER RAISING IS NOT POLITENESS. Both front ends already have a hard must-always-reply rule,
304
+ and both implemented it by wrapping every call in `except Exception`. Doing it here means an
305
+ action author cannot forget, and means the exception's real message survives — `first_message`
306
+ walks the chain, because an error crossing a Temporal activity boundary otherwise arrives as
307
+ the fixed string "Activity task failed" (#66).
308
+ """
309
+ key = (name or "").strip().lower()
310
+ found = _catalog().get(key)
311
+ if found is None:
312
+ return refused(
313
+ NOT_FOUND,
314
+ f"there is no action called {name!r} — this deployment does: " + ", ".join(names()),
315
+ reason=UNKNOWN_ACTION,
316
+ )
317
+
318
+ problem = _check_params(found, params)
319
+ if problem:
320
+ return refused(INVALID, problem, reason=BAD_PARAMETERS)
321
+
322
+ if "issue" in params:
323
+ ref, bad = _clean_ref(str(params["issue"]))
324
+ if bad:
325
+ return refused(INVALID, bad, reason=BAD_REF)
326
+ params["issue"] = ref
327
+
328
+ # SCOPE BEFORE ADMIN, because they answer different questions and the order is the point: a
329
+ # business analyst holding a product credential IS an admin of the product area — accepting a
330
+ # requirement is the most consequential act there — and must still be refused `merge` outright.
331
+ # Asking `admin` first would let that credential through on every row it happens to satisfy.
332
+ if not by.may_enter(found.scope):
333
+ log.warning("DENIED_SCOPE %s (%s) by %s (%s)", key, found.scope, by, _loggable(params))
334
+ # AN INSTALLATION ROW NAMES THE ADMINISTRATOR, NOT THE AREA (#741): a row that changes the
335
+ # installation is refused to a credential of ANY area, so sending a product credential to
336
+ # "somebody whose credential covers the floor" is the wrong remedy — a floor credential is
337
+ # refused it too. The reason stays `out_of_scope` (the area check is what refused), but the
338
+ # sentence points where the fix is.
339
+ remedy = ("ask somebody who administers this deployment" if found.installation
340
+ else f"ask somebody whose credential covers the {found.scope}")
341
+ return refused(
342
+ DENIED,
343
+ f"this credential is scoped to {', '.join(sorted(by.scopes or ())) or 'nothing'} and "
344
+ f"{key} belongs to the {found.scope}. Nothing was done — {remedy}.",
345
+ reason=OUT_OF_SCOPE,
346
+ )
347
+
348
+ # THE INSTALLATION IS THE ADMINISTRATOR'S (#741), asked after the area and before `admin`. A
349
+ # credential scoped to ANY area — a board token, a product token, an invitation to one of them
350
+ # — is somebody's operator or requirement-writer, and it is `admin` on its own surface; what it
351
+ # may never do is change what the deployment is: its projects, the people it names, whether it
352
+ # picks work up. Only an unscoped credential reaches these rows, which is the panel's own token
353
+ # and its per-person rows, an invitation to neither area, and a shell on the host.
354
+ if found.installation and by.scopes is not None:
355
+ log.warning("DENIED_INSTALLATION %s by %s (%s)", key, by, _loggable(params))
356
+ return refused(
357
+ DENIED,
358
+ f"{key} changes this installation, which is the administrator's; this credential is "
359
+ f"scoped to {', '.join(sorted(by.scopes)) or 'nothing'}. Nothing was done — ask "
360
+ f"somebody who administers this deployment.",
361
+ reason=NOT_THE_INSTALLATIONS,
362
+ )
363
+
364
+ if found.needs_admin and not by.admin:
365
+ log.warning("DENIED %s by %s (%s)", key, by, _loggable(params))
366
+ return refused(
367
+ DENIED,
368
+ f"{by} is not allowed to {key} — ask somebody listed as an admin for this project.",
369
+ reason=NOT_ADMIN,
370
+ )
371
+
372
+ # WHO FIRST, THEN WHAT THE PROJECT IS (#992): a row that needs the code is refused, by name and
373
+ # before it runs, on a project whose product started before its code — a refusal somebody can
374
+ # act on, where running it would fail in a clone, a board or a box with a sentence about that
375
+ if waits_for_code(found):
376
+ waiting = _waiting_for_its_code(params.get("project"), key)
377
+ if waiting:
378
+ log.info("NO_CODE_YET %s by %s (%s)", key, by, _loggable(params))
379
+ return refused(CONFLICT, waiting, reason=NO_CODE_YET)
380
+
381
+ from beeverforge.runtime.temporal.vocabulary import EngineUnreachable
382
+
383
+ try:
384
+ outcome = await found.run(by=by, **params)
385
+ except EngineUnreachable as exc:
386
+ # NOT A FAILURE OF THE ACTION, a state of the deployment (#674): the engine did not answer
387
+ # in time. FAILED is a 500 and a traceback; this is the panel's 503 and one line.
388
+ log.warning("action %s by %s: the engine did not answer (%s)", key, by, exc)
389
+ return refused(UNAVAILABLE, f"could not {key}: {exc}", reason=ENGINE_UNREACHABLE)
390
+ except Exception as exc: # noqa: BLE001 — an action must never take its caller down
391
+ # THE REFERENCE IS IN THE LINE AND IN THE ANSWER (#908), so the person who reads "could
392
+ # not …" can hand an operator the one traceback that is theirs; before, the answer named
393
+ # nothing the log could be searched for.
394
+ reference = failure_reference()
395
+ log.exception("action %s raised for %s reference=%s", key, by, reference)
396
+ return refused(FAILED, f"could not {key}: {_why(exc)}", reason=RAISED,
397
+ reference=reference)
398
+ if not isinstance(outcome, Outcome): # an action that returns None is a silent success (F5)
399
+ log.error("action %s returned %r, not an Outcome", key, type(outcome).__name__)
400
+ return refused(
401
+ FAILED,
402
+ f"'{key}' did not report what it did — treat this as a failure and check the logs "
403
+ f"before assuming it happened.",
404
+ reason=REPORTED_NOTHING,
405
+ )
406
+ if not outcome.ok and outcome.data.get("reason") not in reasons(key):
407
+ # THE ANSWER STANDS — it is the row's, and rewriting it here would hide the very word the
408
+ # caller needs to report. The guards in `tests/test_the_action_layer.py` read every
409
+ # row's refusals off its source; this is for the one they could not see.
410
+ log.error("action %s refused with an undeclared reason %r — it declares %s", key,
411
+ outcome.data.get("reason"), ", ".join(reasons(key)))
412
+ # A LABEL IS PRINTED AS ONE (`Actor.label`): what an anonymous caller says it is, beside the
413
+ # credential it used — never in `by=`, which is who that credential is.
414
+ label = str(getattr(by, "label", "") or "")
415
+ log.info("action=%s by=%s%s ok=%s code=%s params=%s", key, by,
416
+ f" label={label!r}" if label else "", outcome.ok, outcome.code or "-",
417
+ _loggable(params))
418
+ return outcome
419
+
420
+
421
+ #: Parameters whose VALUE must never reach a log line. `approve_prod` and `promote` take the
422
+ #: approver's password, and this module writes an audit line on every call including the refusals —
423
+ #: which is precisely the path a wrong password takes.
424
+ _SECRET = frozenset({"password", "token", "secret", "key"})
425
+
426
+
427
+ def _why(exc: Exception) -> str:
428
+ """What the refusal says an action died of — the install, when the install is what is missing.
429
+
430
+ THE ROWS THAT ARE THE ENGINE'S IMPORT ITS MODULES INSIDE THEIR BODIES, by design: that is what
431
+ keeps `temporalio` off every front end's import path (see `_Catalog`). So on an install made
432
+ without the `runtime` extra, `start`, `stop`, `approve` and the rest raise here, and the person
433
+ read `could not start: No module named 'temporalio'` — a cause with no remedy. The catch-all
434
+ above is the one guard all of them share, so it is the one place that names the install:
435
+ `host.CLIENT_MISSING`, the sentence every other surface gives for that condition (#178)."""
436
+ from beeverforge.runtime.host import CLIENT_MISSING, the_client_is_what_is_missing
437
+ from beeverforge.util.causes import first_message
438
+
439
+ # The chain, not only its head: a row that wraps its own failures still died of the import.
440
+ link: BaseException | None = exc
441
+ for _ in range(8): # bounded: a chain is short, and a hand-built cyclic one must not spin
442
+ if link is None:
443
+ break
444
+ if the_client_is_what_is_missing(link):
445
+ return CLIENT_MISSING
446
+ link = link.__cause__ or link.__context__
447
+ return first_message(exc)
448
+
449
+
450
+ def _loggable(params: dict[str, object]) -> dict[str, object]:
451
+ return {k: ("***" if k in _SECRET else v) for k, v in params.items()}
452
+
453
+
454
+ def _check_params(found: ActionSpec, params: dict[str, object]) -> str:
455
+ missing = [p for p in found.required if p not in params or params[p] in (None, "")]
456
+ if missing:
457
+ return (f"'{found.name}' needs {', '.join(missing)} — it takes "
458
+ + ", ".join(found.parameters) + ".")
459
+ unexpected = [p for p in params if p not in found.parameters]
460
+ if unexpected:
461
+ return (f"'{found.name}' does not take {', '.join(sorted(unexpected))} — it takes "
462
+ + (", ".join(found.parameters) or "no parameters") + ".")
463
+ return ""
464
+
465
+
466
+ #: What a tracker actually calls a ticket: `189` (GitHub, optionally `#`-prefixed), `CONT-412`
467
+ #: (Jira), `1234` (Azure DevOps). Letters, digits, one kind of separator, nothing else.
468
+ #:
469
+ #: Lifted verbatim from `api/app.py::_valid_issue`, which is the point: the panel held this and
470
+ #: Slack held nothing, so a ref typed in a channel went straight into a Temporal workflow id. The
471
+ #: bound matters for the same reason it did there — a ref becomes part of a workflow id and, via
472
+ #: `paths.journal_stem`, part of a filename.
473
+ _ISSUE_RE = re.compile(r"^[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*$")
474
+ #: …and the same ref carrying the repository it lives in (C-18): `owner/name#189`. One product can
475
+ #: own several repositories, so on a multi-repo board a bare number names no single ticket. Kept as
476
+ #: a SECOND, fully-anchored pattern rather than by loosening the first: `/` and `#` reach a
477
+ #: workflow id and a filename, and the way to allow exactly two segments and one hash is to spell
478
+ #: that out, not to add characters to a class and hope.
479
+ _QUALIFIED_RE = re.compile(
480
+ r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*#[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*$")
481
+ _ISSUE_MAX = 64
482
+ #: The qualified form needs room for `owner/name#` on top of the ref itself. A SEPARATE bound, so
483
+ #: widening it here cannot quietly widen what a bare ref may be — the bound is what keeps a ref
484
+ #: from becoming an unreasonable workflow id or filename, and each shape deserves its own.
485
+ _QUALIFIED_MAX = 160
486
+
487
+
488
+ def _clean_ref(issue: str) -> tuple[str, str]:
489
+ """`(ref, problem)` — the provider's own ref without the decoration a human types.
490
+
491
+ `#189` and `189` must not be two tickets. The panel already knew that in `_valid_issue`; Slack
492
+ passed whatever was typed straight into a Temporal workflow id, so `skip #250` and `skip 250`
493
+ addressed different workflows and only one of them existed.
494
+
495
+ REJECTING IS STILL WORTH DOING even though `paths.py` neutralises a hostile ref on its own
496
+ (C-06a). This is where there is somebody to tell: a sentence naming the problem beats a job
497
+ launched against a ref no provider will ever recognise, which surfaces minutes later as an
498
+ unexplained 404."""
499
+ ref = (issue or "").strip().lstrip("#").strip()
500
+ bare = bool(ref) and len(ref) <= _ISSUE_MAX and bool(_ISSUE_RE.match(ref))
501
+ qualified = bool(ref) and len(ref) <= _QUALIFIED_MAX and bool(_QUALIFIED_RE.match(ref))
502
+ if not (bare or qualified):
503
+ return "", ("that is not a ticket reference — letters, digits, '-' or '_', optionally "
504
+ "with the repository it lives in "
505
+ f"(e.g. 189, #189, CONT-412, owner/name#189), got {issue!r}")
506
+ return ref, ""