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,278 @@
1
+ """The directory this platform claims inside a CLIENT's repository, and the branches it mints there.
2
+
3
+ The product is BeeverForge and the folder it writes into a client's repository is `.beeverforge/`
4
+ (the product owner, 2026-08-07: everything lives under `.beeverforge`). The folder used to carry an
5
+ acronym, and for a while this module was a MIGRATION: readers answered to both names, writers
6
+ emitted only the new one. That code left on 2026-08-25, with the decision that the public
7
+ repository has no old installation to migrate — the one deployment that ran under the former name
8
+ renamed its own files.
9
+
10
+ WHAT REMAINS OF THE OLD NAME IS A REFUSAL, NOT A READ. A repository that still carries the retired
11
+ directory and nothing under the new one is refused BY NAME, with the sentence saying what to rename.
12
+ The alternative — a reader that simply does not look there — would report "no manifest" to a
13
+ repository that plainly has one, and the person reading that would go looking for a typo in a file
14
+ the platform never opened. That is the absence-read-as-compliance class, and a rename is exactly
15
+ where it breeds.
16
+
17
+ AN EXPLICIT PATH IS NEVER OVERRIDDEN. `Project.manifest_path` exists so a client can put the file
18
+ where their conventions say; the refusal below applies only to OUR default location, because the
19
+ retired name has a twin only there.
20
+
21
+ …AND THE ONE EXCEPTION IS A PULL REQUEST, which is written here because it is the exception to the
22
+ paragraph above (GitHub issue #259). READING honours a `manifest_path` outside the repository,
23
+ through the very same `root / relative` join — an absolute value wins it, which is exactly what
24
+ makes setup, the gates, `box prove`, `doctor` and `conformance` work against a manifest the client
25
+ keeps elsewhere.
26
+ PROPOSING one cannot: a pull request carries only files that live in the repository it is opened
27
+ on. So `env apply --pr` and `onboard` refuse such a row BY NAME, before they clone and before they
28
+ write — they used to take the same join, write the manifest over the real file outside the
29
+ throwaway clone, and only then be refused by `git add` with git's words about a path the platform
30
+ had composed. `propose_manifest.leaves_the_repository` is that one rule, asked once.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import os
37
+ from pathlib import Path
38
+
39
+ log = logging.getLogger("beeverforge.namespace")
40
+
41
+ #: The directory this platform claims inside a client's repository.
42
+ DIR = ".beeverforge"
43
+
44
+ #: The prefix of every branch this platform mints in a client's repository — the job branches
45
+ #: (`beeverforge/<ticket>`), the onboarding proposal, the environment rehearsal, the box proof.
46
+ #: The most visible name the product has: it is born in EVERY pull request on the client's own
47
+ #: repository, which is why it carries the product's name and not an acronym the client never
48
+ #: chose (#106 item 5).
49
+ BRANCH_PREFIX = "beeverforge"
50
+
51
+
52
+ def job_branch(ticket_id: str, change: int = 0) -> str:
53
+ """The branch a job for `ticket_id` works on — the one name every job is known by.
54
+
55
+ Recalculated from the ticket id on every entry, so a repair or a resume finds the branch the
56
+ open pull request tracks without anybody storing it.
57
+
58
+ `change` IS WHICH CHANGE OF THE CARD THIS IS (#448 slice 4): 0 the first, and one more each
59
+ time its requester said "not yet" at the last gate — after the first change had MERGED. Every
60
+ change had this one name, and the box pushes with `--force` (`publish_branch`), so a second
61
+ change would have been pushed over the branch of the merged one. The first change keeps the
62
+ name it always had; a later one is `<prefix>/<id>-<n>`, n counting changes from 2 — a sibling
63
+ name, never `<prefix>/<id>/…`, which git refuses beside an existing `<prefix>/<id>`. It is
64
+ recalculated like the rest, from the change number the job carries (`JobParams.change`)."""
65
+ bare = str(ticket_id).lstrip('#')
66
+ return f"{BRANCH_PREFIX}/{bare}-{change + 1}" if change > 0 else f"{BRANCH_PREFIX}/{bare}"
67
+
68
+
69
+ #: The two files that live in it, as the defaults every caller starts from.
70
+ MANIFEST = f"{DIR}/project.yaml"
71
+ PRODUCT_MANIFEST = f"{DIR}/product.yaml"
72
+
73
+ #: The directory's RETIRED name. Named here, once, for one purpose: to refuse a repository still
74
+ #: on it with a sentence, instead of reporting "no manifest" to somebody looking at one. Nothing
75
+ #: reads under it; the guard in `tests/test_the_namespace_is_the_products_name.py` holds this to
76
+ #: be the only module that spells it.
77
+ RETIRED_DIR = ".sdlc"
78
+
79
+
80
+ class RetiredNamespace(FileNotFoundError):
81
+ """A repository still on the retired directory name, and nothing under the current one.
82
+
83
+ A `FileNotFoundError` on purpose: to every caller that already handles a missing manifest, this
84
+ IS a missing manifest — the only difference is that the sentence says what to rename, which is
85
+ the difference between a client fixing it in a minute and hunting a typo in a file the platform
86
+ never opened."""
87
+
88
+
89
+ def _retired_twin(relative: str) -> str:
90
+ """The same path under the retired directory name, or `""` when it is not one of ours."""
91
+ # `removeprefix("./")`, NOT `lstrip("./")`. `lstrip` takes a SET of characters and eats every
92
+ # leading `.` or `/` there is — `.beeverforge/project.yaml` became `beeverforge/…`, matched
93
+ # nothing, and every project fell through to "no manifest" (34 tests red, the day the rename
94
+ # landed). Kept as a comment because the two names still differ by a leading dot.
95
+ rel = str(relative).replace("\\", "/").removeprefix("./")
96
+ if rel == DIR or rel.startswith(f"{DIR}/"):
97
+ return RETIRED_DIR + rel[len(DIR):]
98
+ return ""
99
+
100
+
101
+ def resolve(root: Path, relative: str, *, project: str = "") -> Path:
102
+ """The path to read — and a refusal, by name, when only the retired twin is there.
103
+
104
+ Returns `root / relative` whether or not it exists: "missing" is then reported in the name we
105
+ use, and a client reading the error is told what to create. The one thing this never does is
106
+ answer with the retired path or pretend it did not see it.
107
+ """
108
+ new = root / relative
109
+ if new.exists():
110
+ return new
111
+ old_rel = _retired_twin(relative)
112
+ if old_rel and (root / old_rel).exists():
113
+ raise RetiredNamespace(
114
+ f"project {project or root.name!r} has no manifest at {new} — it has one at "
115
+ f"`{old_rel}`, which is this platform's former name and is not read. Rename the "
116
+ f"directory `{RETIRED_DIR}/` to `{DIR}/` in that repository; nothing under "
117
+ f"`{RETIRED_DIR}/` is read.")
118
+ return new
119
+
120
+
121
+ def operator_path(filename: str) -> Path:
122
+ """The operator's file under `~/.beeverforge/` — the registry, the approver store, the resume
123
+ handles.
124
+
125
+ Called from the composition points that resolve these paths (`ProjectRegistry.__init__`,
126
+ `approvals.store_path`), never at import — the house has paid for import-time side effects
127
+ before. An explicit override (env var, argument) bypasses this entirely: an explicit path is
128
+ never second-guessed."""
129
+ return Path.home() / DIR / filename
130
+
131
+
132
+ #: Where a process leaves the build it runs from, for the OTHER halves of the deployment to read
133
+ #: (#135). One file per ROLE — `build-worker.json`, `build-panel.json`.
134
+ #:
135
+ #: HALF A STACK CAN BE STALE AND NOTHING SAYS SO. `build_stamp()` answers "which code am I", and
136
+ #: every surface that prints it prints its OWN — so the worker's doctor line said one build while
137
+ #: the panel served a page from a build twenty-eight hours older, and the operator read the old
138
+ #: page reporting the old state with no way to tell (pilot, 2026-08-17: I had told him to rebuild
139
+ #: only the worker, and the fix he was looking for lived in the panel).
140
+ #:
141
+ #: PER ROLE, AND SYMMETRIC, because the stale half is whichever one you did not rebuild — naming a
142
+ #: file after the role that happened to be fresh in the first episode would bake this deployment's
143
+ #: accident into the product. Every process that serves a surface announces; every surface can name
144
+ #: the ones that disagree with it, including roles that do not exist yet.
145
+ BUILD_FILE = "build-{role}.json"
146
+
147
+
148
+ def announce_build(role: str, *, where: str = "") -> str:
149
+ """Record THIS process's build under `role`, in the shared state directory. Returns the stamp.
150
+
151
+ Best-effort: a deployment whose state directory is read-only still boots — it simply cannot say
152
+ what it is running, which is where this started, so the failure is logged rather than raised."""
153
+ import json
154
+
155
+ stamp, built = build_stamp()
156
+ if not stamp:
157
+ # NOT A BUILT IMAGE — a checkout, where the code on disk IS the code running and there are
158
+ # no separate halves to disagree. Announcing here would mean creating
159
+ # `/var/lib/beeverforge` on somebody's laptop (or logging a permission warning on every
160
+ # boot when that fails) to record that we are nothing in particular.
161
+ return ""
162
+ root = _state_dir(where)
163
+ try:
164
+ root.mkdir(parents=True, exist_ok=True)
165
+ (root / BUILD_FILE.format(role=role)).write_text(
166
+ json.dumps({"stamp": stamp, "built_at": built}), encoding="utf-8")
167
+ except OSError as exc:
168
+ log.warning("could not record this %s's build for the rest of the deployment to read (%s) "
169
+ "— a stale half beside a fresh one will stay invisible", role, exc)
170
+ return ""
171
+ return stamp
172
+
173
+
174
+ def announced_builds(*, where: str = "") -> dict[str, tuple[str, str]]:
175
+ """`{role: (stamp, built_at)}` for every half that has announced. Empty when none has.
176
+
177
+ EMPTY IS NOT A DISAGREEMENT. A deployment whose other half predates this, or whose halves do
178
+ not share a state directory, has told us nothing — and nothing is what a caller must be able to
179
+ distinguish from "told us, and it differs"."""
180
+ import json
181
+
182
+ found: dict[str, tuple[str, str]] = {}
183
+ root = _state_dir(where)
184
+ try:
185
+ files = sorted(root.glob(BUILD_FILE.format(role="*")))
186
+ except OSError:
187
+ return found
188
+ prefix, suffix = BUILD_FILE.split("{role}")
189
+ for f in files:
190
+ try:
191
+ data = json.loads(f.read_text(encoding="utf-8"))
192
+ except (OSError, ValueError):
193
+ continue # a half-written file is one we have not been told about yet
194
+ role = f.name[len(prefix):-len(suffix)] if suffix else f.name[len(prefix):]
195
+ found[role] = (str(data.get("stamp") or ""), str(data.get("built_at") or ""))
196
+ return found
197
+
198
+
199
+ def _state_dir(where: str = "") -> Path:
200
+ """The directory the halves of this deployment share. Compose mounts it into both."""
201
+ return Path(where or os.environ.get("BEEVERFORGE_STATE_DIR") or "/var/lib/beeverforge")
202
+
203
+
204
+ def build_disagreement(role: str, *, where: str = "") -> dict[str, tuple[str, str]]:
205
+ """The halves whose build differs from THIS process's, `{role: (stamp, built_at)}`.
206
+
207
+ Empty means either "everybody agrees" or "there is nothing to compare" — callers that need to
208
+ tell those apart (the panel's banner does; it must not cry wolf) check `build_stamp()` and
209
+ `announced_builds()` themselves."""
210
+ mine, _ = build_stamp()
211
+ if not mine:
212
+ return {}
213
+ return {r: v for r, v in announced_builds(where=where).items()
214
+ if r != role and v[0] and v[0] != mine}
215
+
216
+
217
+ def build_stamp() -> tuple[str, str]:
218
+ """`(code-hash, built-at)` for the image this process runs from — `("", "")` outside one.
219
+
220
+ THE QUESTION NOBODY COULD ANSWER (2026-08-14). The compose worker BAKES the package, so
221
+ `git pull && docker compose up -d` restarts the previous build and every command inside
222
+ keeps answering from it — silently, indistinguishably. The pilot operator ran the same
223
+ diagnostic three times against fixes that were on his disk and not in his worker, and
224
+ neither he nor I could tell from the output; I had told him the rebuild was optional, which
225
+ was wrong. A running deployment must be able to say WHICH code it is.
226
+
227
+ Read from a file the image writes at build time (`docker/worker.Dockerfile`). Absent means
228
+ "not a built image" — a laptop checkout, where the code on disk IS the code running — and
229
+ that is reported as such rather than invented."""
230
+ import json
231
+ from pathlib import Path
232
+
233
+ try:
234
+ data = json.loads(Path("/etc/beeverforge/build.json").read_text(encoding="utf-8"))
235
+ except (OSError, ValueError):
236
+ return "", ""
237
+ return str(data.get("code") or ""), str(data.get("built_at") or "")
238
+
239
+
240
+ # ── whether the halves of this deployment agree, as a REPORT ─────────────────────────────────
241
+ #
242
+ # MOVED OUT OF `api/app.py` (#144). Which code each half runs is a fact about the deployment,
243
+ # not about the web layer — the floor's ladder, the CLI and any channel need the same answer,
244
+ # and a second copy is how two surfaces come to disagree about what is even running.
245
+ def build_agreement(role: str = "panel") -> dict:
246
+ """Which code each half of this deployment runs, and whether they agree (#135).
247
+
248
+ A COMPOSE STACK IS TWO IMAGES, and both BAKE the package. `docker compose up -d --build worker`
249
+ leaves the panel serving whatever it was built from — so on 2026-08-17 the pilot rebuilt,
250
+ pressed
251
+ F5, and read a page from an image twenty-eight hours older, reporting the older world. `docker
252
+ ps`
253
+ said `Up 28 hours` beside `Up 2 minutes`, and nothing on the screen he was actually looking at
254
+ could say it. He spent the round believing a fix had not worked.
255
+
256
+ Each process could only ever print its OWN stamp, which is why this needs the state volume both
257
+ of them mount: the worker WRITES its build there at boot, and the panel reads it back here.
258
+
259
+ THREE ANSWERS, and the third is not a failure:
260
+
261
+ agree = True both halves report the same build
262
+ agree = False they differ — PROVEN, and the panel says so in the loudest place it has
263
+ agree = None it cannot be established, and no claim is made. Either this is not a built
264
+ image at all (a checkout: one tree, no halves to disagree) or the worker
265
+ has not announced — an older worker, or a deployment whose halves do not
266
+ share a state directory. Silence beats a false alarm on a working stack,
267
+ and beats a false all-clear on a split one.
268
+ """
269
+ mine, mine_at = build_stamp()
270
+ others = {r: v for r, v in announced_builds().items() if r != role and v[0]}
271
+ if not mine or not others:
272
+ agree: bool | None = None
273
+ else:
274
+ agree = all(stamp == mine for stamp, _ in others.values())
275
+ return {
276
+ "role": role, "stamp": mine, "built_at": mine_at, "agree": agree,
277
+ "others": {r: {"stamp": s, "built_at": b} for r, (s, b) in sorted(others.items())},
278
+ }
@@ -0,0 +1,23 @@
1
+ from beeverforge.observability.events import (
2
+ EventKind,
3
+ EventSink,
4
+ FileEventSink,
5
+ InMemoryEventSink,
6
+ JobEvent,
7
+ NullEventSink,
8
+ StdoutEventSink,
9
+ TeeEventSink,
10
+ now_iso,
11
+ )
12
+
13
+ __all__ = [
14
+ "EventKind",
15
+ "EventSink",
16
+ "FileEventSink",
17
+ "InMemoryEventSink",
18
+ "JobEvent",
19
+ "NullEventSink",
20
+ "StdoutEventSink",
21
+ "TeeEventSink",
22
+ "now_iso",
23
+ ]
@@ -0,0 +1,233 @@
1
+ """Autonomy, as a number read off the card's record (#85, hole 4).
2
+
3
+ THE FACTS EXISTED AND NOTHING READ THEM. Every pass a job makes is an `agent_run` row tagged with
4
+ its role (`orchestrator/machine.py::_count`), and since ADR-0055 every card's life is a record of
5
+ transitions (`lifecycle/record.py`). Whether a card reached its merge on the factory's first try —
6
+ or took three repairs, a park and a person's adjustment — was in those rows for every card, and
7
+ "is the factory getting better?" was answered by opening twenty job pages. This reads them once.
8
+
9
+ A CARD IS MEASURED ONLY WHEN THE RECORD HOLDS ITS START. A measured card's record holds `promoted`
10
+ before its first `merged`: the moment the factory was handed the card, and the moment it landed.
11
+ Cards only carry `promoted` since #414 (2026-10-04), so every card merged before that — and any
12
+ card whose promotion the record does not hold — is counted on its own, by name, as BEFORE THE
13
+ RECORD, and never enters a rate. A yield over cards whose start nobody recorded is a yield over
14
+ whichever of them happened to be recorded.
15
+
16
+ THE DEFINITIONS, one decision each:
17
+
18
+ first-pass a measured merged card with NO repair-role pass and no `parked`, `resumed` or
19
+ `adjusted` between its promotion and its merge. `question_asked` and
20
+ `accepted` are gates the design puts in the road, not somebody stepping in.
21
+ yield first-pass cards over measured merged cards — `None` when there are none,
22
+ never 0: a factory that merged nothing did not fail every card
23
+ rework rate code-writing passes (the executor and the five repair roles) per measured
24
+ merged card; 1 is one pass each. The planner and the reviewer write no code
25
+ and are not rework
26
+ repair depth how many measured merged cards took 0, 1, 2 and 3+ repair passes
27
+ park reasons every `parked` row, read into the tech-lead's own taxonomy
28
+ (`techlead/classify.py`) from the note it recorded — said as "classified from
29
+ the recorded note", because the park did not declare its class (a park that
30
+ records one is a follow-up). A note nobody can read is `unknown`, never
31
+ `transient`: guessing "it passes on its own" is how a broken thing looks fine
32
+
33
+ A PASS BELONGS TO ITS CARD, NOT TO A WINDOW. The `agent_run` rows are written when the job ends,
34
+ which on the attended driver is AFTER the merge it handed back went through the door
35
+ (`cli._run_and_record`), so bounding them by the merge's time would drop every pass of a card the
36
+ job merged itself. Every pass recorded for the card counts; the record's sequence, not a clock,
37
+ bounds the interventions.
38
+
39
+ THE RECORD WINS OVER THE JOB'S ROW. A job row is written once, at the job's end, and says how far
40
+ THAT job got — `pr_open`, while the merge a person made an hour later is in the record. A job row
41
+ past the merge is read only to name a card BEFORE THE RECORD that the record never saw merge.
42
+
43
+ READ-ONLY AND PURE. Rows in, a dict out: it reads no store, writes nothing, calls no door and
44
+ starts nothing that spends. The CLI hands it `scan_all_or_raise`'s rows and the cost dashboard
45
+ the rows it already scanned, so `beeverforge autonomy --json` and `/api/metrics`'s `autonomy`
46
+ block are one function's answer and cannot disagree.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ from collections import Counter
52
+ from collections.abc import Iterable
53
+ from datetime import UTC, datetime
54
+
55
+ #: The pass that writes the code the first time (`machine.py::_count(…, "executor")`).
56
+ EXECUTOR = "executor"
57
+
58
+ #: The passes that write code AGAIN because what was written did not hold — each tagged where it
59
+ #: runs: a validation that failed (`repair`), a suppression the gate refused, a review's findings,
60
+ #: a red CI, and a box that died mid-pass (`recovery`). Any one of them on a card means the card
61
+ #: did not land on the factory's first try.
62
+ REPAIR_ROLES = frozenset({"repair", "suppression_repair", "review_repair", "ci_repair",
63
+ "recovery"})
64
+
65
+ #: What counts as rework: every pass that writes code. The planner plans and the reviewer reads;
66
+ #: counting them would make a project that turned review on look like it repairs more.
67
+ WRITES_CODE = frozenset({EXECUTOR, *REPAIR_ROLES})
68
+
69
+ #: Somebody stepping in between the promotion and the merge: a park, its resumption, and the
70
+ #: release manager's adjustment. `question_asked` and `accepted` are not here — the design asks
71
+ #: them, and a card that waited at a gate it was meant to wait at did not need rescuing.
72
+ INTERVENTIONS = frozenset({"parked", "resumed", "adjusted"})
73
+
74
+ PROMOTED, MERGED, PARKED = "promoted", "merged", "parked"
75
+
76
+ #: The repair-depth buckets, in the order the table reads.
77
+ DEPTHS = ("0", "1", "2", "3+")
78
+
79
+
80
+ def _said(key: str, language: str | None, **params: object) -> str:
81
+ from beeverforge.techlead import voice
82
+
83
+ return voice.say(voice.AUTONOMY, key, language, **params)
84
+
85
+
86
+ def _moment(value: object) -> datetime | None:
87
+ """A row's time, as a moment — `None` for one that does not parse. A naive time is UTC: every
88
+ writer here stamps an offset, and a test or a hand-written row that did not means UTC."""
89
+ if isinstance(value, datetime):
90
+ found = value
91
+ else:
92
+ try:
93
+ found = datetime.fromisoformat(str(value or "").strip())
94
+ except ValueError:
95
+ return None
96
+ return found if found.tzinfo else found.replace(tzinfo=UTC)
97
+
98
+
99
+ def _within(ts: object, start: datetime | None) -> bool:
100
+ """Whether a row's time is in the window. With no window, every row is; with one, a row whose
101
+ time does not parse is NOT — a card cannot be claimed for a week nobody can place it in."""
102
+ if start is None:
103
+ return True
104
+ when = _moment(ts)
105
+ return when is not None and when >= start
106
+
107
+
108
+ def _project_of(row: dict) -> str:
109
+ return str(row.get("pk") or row.get("project") or "")
110
+
111
+
112
+ def _decimal(value: float, language: str | None) -> str:
113
+ """A rate as a person in that language writes one: `1.67`, `1,67`."""
114
+ text = f"{value:.2f}".rstrip("0").rstrip(".")
115
+ return text.replace(".", ",") if (language or "").lower().startswith("pt") else text
116
+
117
+
118
+ def _labels(language: str | None) -> dict:
119
+ keys = ("title", "yield", "rework", "measured", "before", "depth", "repairs", "cards",
120
+ "parks", "cause", "count")
121
+ return {k: _said(f"label.{k}", language) for k in keys}
122
+
123
+
124
+ def autonomy(records: Iterable[dict], project: str, *, since: object = None,
125
+ language: str | None = None) -> dict:
126
+ """The autonomy block for `project`, from `records` — the store's rows as a scan returns them,
127
+ every project and every kind (it keeps its own).
128
+
129
+ `since` (a moment, or an ISO time) narrows it to the cards whose first merge — and the parks —
130
+ happened from then on; `None` is the whole record. `language` is the project's: the numbers
131
+ are numbers, and every sentence and label beside them is in it."""
132
+ from beeverforge.contracts.refs import canonical_ref, ref_sort_key
133
+ from beeverforge.contracts.state import PAST_THE_MERGE
134
+ from beeverforge.lifecycle import record
135
+ from beeverforge.techlead.classify import CLASSES, classify
136
+
137
+ start = _moment(since) if since is not None else None
138
+ mine = [r for r in records if _project_of(r) == project]
139
+ histories = record.histories([r for r in mine if r.get("kind") == record.KIND])
140
+
141
+ # EVERY PASS ON ITS CARD, joined by the ref's one spelling: `#12` and `12` are one card, and
142
+ # `DAR-12` is a card too — never reduced to a number that would drop it or meet another's
143
+ roles: dict[str, list[str]] = {}
144
+ job_merged: dict[str, str] = {}
145
+ for row in mine:
146
+ ticket = canonical_ref(row.get("ticket"))
147
+ if row.get("kind") == "agent_run":
148
+ roles.setdefault(ticket, []).append(str(row.get("role") or ""))
149
+ elif row.get("kind") == "job" and str(row.get("state") or "") in PAST_THE_MERGE:
150
+ ts = str(row.get("ts") or "")
151
+ job_merged[ticket] = min(job_merged.get(ticket, ts), ts)
152
+
153
+ measured: list[tuple[int, int, bool]] = [] # (repair passes, code passes, first-pass)
154
+ before: list[str] = []
155
+ reasons: Counter[str] = Counter()
156
+ for card, history in histories.items():
157
+ for row in history.rows:
158
+ if row.event == PARKED and _within(row.ts, start):
159
+ facts = row.facts or {}
160
+ text = str(facts.get("note") or facts.get("reason") or row.why or "")
161
+ state = str(facts.get("job_state") or "")
162
+ reasons[classify(text, state=state).cause] += 1
163
+ merged = next((r for r in history.rows if r.event == MERGED), None)
164
+ if merged is None or not _within(merged.ts, start):
165
+ continue
166
+ promoted = next((r.seq for r in history.rows
167
+ if r.event == PROMOTED and r.seq < merged.seq), None)
168
+ if promoted is None:
169
+ before.append(card)
170
+ continue
171
+ stepped_in = any(r.event in INTERVENTIONS for r in history.rows
172
+ if promoted < r.seq < merged.seq)
173
+ passes = roles.get(canonical_ref(card), [])
174
+ repairs = sum(1 for role in passes if role in REPAIR_ROLES)
175
+ code = sum(1 for role in passes if role in WRITES_CODE)
176
+ measured.append((repairs, code, not repairs and not stepped_in))
177
+ for card, ts in job_merged.items():
178
+ held = histories.get(card)
179
+ if held is not None and any(r.event == MERGED for r in held.rows):
180
+ continue # the record saw it merge, and the record has spoken above
181
+ if _within(ts, start):
182
+ before.append(card)
183
+
184
+ n = len(measured)
185
+ clean = sum(1 for _, _, first in measured if first)
186
+ code_passes = sum(code for _, code, _ in measured)
187
+ depth = Counter(DEPTHS[min(repairs, 3)] for repairs, _, _ in measured)
188
+ window = (_said("window", language, day=start.date().isoformat()) if start is not None
189
+ else "")
190
+ rate = round(code_passes / n, 4) if n else None
191
+ parks = sum(reasons.values())
192
+ block = {
193
+ "project": project,
194
+ "readable": True,
195
+ "since": start.isoformat() if start is not None else None,
196
+ "measured": n,
197
+ "first_pass": clean,
198
+ "yield": round(clean / n, 4) if n else None,
199
+ "code_passes": code_passes,
200
+ "rework_rate": rate,
201
+ "repair_depth": {d: depth.get(d, 0) for d in DEPTHS},
202
+ "before_the_record": sorted(set(before), key=ref_sort_key),
203
+ "parks": parks,
204
+ "park_reasons": dict(sorted(reasons.items(), key=lambda kv: (-kv[1], kv[0]))),
205
+ }
206
+ block["said"] = {
207
+ "title": _said("title", language, project=project),
208
+ "headline": (_said("yield", language, clean=clean, merged=n, window=window,
209
+ pct=round(clean / n * 100)) if n
210
+ else _said("none", language, window=window)),
211
+ "rework": (_said("rework", language, passes=code_passes, rate=_decimal(rate, language))
212
+ if rate is not None else ""),
213
+ "before_the_record": (_said("before", language, count=len(block["before_the_record"]))
214
+ if block["before_the_record"] else ""),
215
+ "scope": _said("scope", language),
216
+ "parks": (_said("parks", language, count=parks, window=window) if parks
217
+ else _said("parks.none", language, window=window)),
218
+ "labels": _labels(language),
219
+ # EVERY CLASS THE CLASSIFIER HAS, each a word in the phrasebook — read from it, never a
220
+ # copy, so the table never shows a class it cannot name (review of #545)
221
+ "causes": {c: _said(f"cause.{c}", language) for c in CLASSES},
222
+ }
223
+ return block
224
+
225
+
226
+ def unread(project: str, cause: object, *, language: str | None = None) -> dict:
227
+ """The block when the store would not answer: no number at all, and the sentence that says
228
+ why and what to check. NEVER the empty record's block — "no card measured yet" from a store
229
+ nobody could read is the factory claiming a fact it did not look at (#126)."""
230
+ return {"project": project, "readable": False,
231
+ "said": {"title": _said("title", language, project=project),
232
+ "headline": _said("unread", language, cause=str(cause).rstrip(".")),
233
+ "labels": _labels(language)}}