hecks 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/postgres_era/lineage/chain_sql.rb +2 -1
  3. data/lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb +4 -2
  4. data/lib/hecks/adapters/driven/postgres_era/lineage/split_sql.rb +16 -8
  5. data/lib/hecks/adapters/driven/postgres_era/lineage/transform_sql.rb +7 -3
  6. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb +2 -1
  7. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/mint_request.rb +2 -1
  8. data/lib/hecks/adapters/driven/postgres_era/lineage_manager/refusals.rb +2 -1
  9. data/lib/hecks/adapters/driving/cli_runner/bang_rule.rb +3 -0
  10. data/lib/hecks/adapters/driving/cli_runner/completion_words.rb +2 -0
  11. data/lib/hecks/adapters/driving/cli_runner/read_context.rb +6 -3
  12. data/lib/hecks/adapters/driving/cli_runner/renamed_names.rb +2 -1
  13. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +2 -2
  14. data/lib/hecks/adapters/driving/cli_runner/resume_words.rb +1 -0
  15. data/lib/hecks/adapters/driving/cli_runner/run_reference.rb +6 -3
  16. data/lib/hecks/adapters/driving/cli_runner/search_words.rb +2 -0
  17. data/lib/hecks/adapters/driving/cli_runner/tour_words.rb +1 -0
  18. data/lib/hecks/adapters/driving/cli_runner/verb_names.rb +2 -1
  19. data/lib/hecks/adapters/driving/cli_runner/verbosity.rb +2 -0
  20. data/lib/hecks/adapters/driving/cli_runner/word_help.rb +40 -0
  21. data/lib/hecks/adapters/driving/cli_runner.rb +2 -0
  22. data/lib/hecks/adapters/driving/mcp/progress_sink.rb +4 -2
  23. data/lib/hecks/adapters/driving/mcp.rb +2 -1
  24. data/lib/hecks/adapters/driving/mcp_scope/argument_policy.rb +2 -1
  25. data/lib/hecks/adapters/driving/ruby/aggregate_module/hooks.rb +28 -14
  26. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +4 -2
  27. data/lib/hecks/assembly.rb +18 -4
  28. data/lib/hecks/behaviors/expectations/adapter_selection.rb +82 -0
  29. data/lib/hecks/behaviors/expectations/boot.rb +12 -5
  30. data/lib/hecks/bluebook/acknowledgements.rb +1 -1
  31. data/lib/hecks/bluebook/command.rb +3 -2
  32. data/lib/hecks/bluebook/declaration.rb +2 -1
  33. data/lib/hecks/bluebook/dsl/adapter_builder.rb +9 -1
  34. data/lib/hecks/bluebook/dsl/bluebook_builder/metadata.rb +6 -4
  35. data/lib/hecks/bluebook/dsl/bluebook_builder/modules.rb +6 -3
  36. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/correlation_keys.rb +2 -1
  37. data/lib/hecks/bluebook/dsl/command_builder/folded_sets.rb +4 -2
  38. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +2 -1
  39. data/lib/hecks/bluebook/hecksagon.rb +6 -3
  40. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +9 -2
  41. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  42. data/lib/hecks/bluebook/model_check/aggregate_checks.rb +2 -1
  43. data/lib/hecks/bluebook/model_check/classification_checks.rb +51 -0
  44. data/lib/hecks/bluebook/model_check/context_checks.rb +2 -0
  45. data/lib/hecks/bluebook/model_check/redundant_relationships.rb +2 -1
  46. data/lib/hecks/ci_speed/adapters/shard_partitioner.adapter +7 -0
  47. data/lib/hecks/ci_speed/adapters/shard_partitioner.rb +66 -0
  48. data/lib/hecks/ci_speed/adapters/timings_source.adapter +7 -0
  49. data/lib/hecks/ci_speed/adapters/timings_source.rb +68 -0
  50. data/lib/hecks/ci_speed/ci_speed.bluebook +111 -0
  51. data/lib/hecks/ci_speed/ci_speed.ports.hecksagon +22 -0
  52. data/lib/hecks/ci_workflows/ci_workflows.bluebook +50 -6
  53. data/lib/hecks/coder/coder.bluebook +3 -1
  54. data/lib/hecks/command_names/index.rb +1 -1
  55. data/lib/hecks/command_names/resolution.rb +15 -5
  56. data/lib/hecks/composer/toolbox.rb +2 -1
  57. data/lib/hecks/corpus.rb +4 -0
  58. data/lib/hecks/doc/reference/readme.rb +1 -1
  59. data/lib/hecks/framework_bluebooks.rb +3 -2
  60. data/lib/hecks/fuzzing/differential.rb +1 -1
  61. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  62. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +1 -1
  63. data/lib/hecks/fuzzing/persistence_parity.rb +1 -1
  64. data/lib/hecks/fuzzing/target_capabilities.rb +1 -1
  65. data/lib/hecks/hecks/adapters/ci_runs.adapter +7 -0
  66. data/lib/hecks/hecks/adapters/ci_runs.rb +80 -0
  67. data/lib/hecks/hecks/adapters/claude_agent_list.rb +43 -0
  68. data/lib/hecks/hecks/adapters/claude_agents.rb +50 -19
  69. data/lib/hecks/hecks/adapters/claude_jobs.rb +36 -0
  70. data/lib/hecks/hecks/adapters/codebase/corpus_queries.rb +2 -1
  71. data/lib/hecks/hecks/adapters/codebase/minitest_runs.rb +4 -2
  72. data/lib/hecks/hecks/adapters/codebase/run_options.rb +2 -1
  73. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +3 -19
  74. data/lib/hecks/hecks/adapters/dispatching/brief_text.rb +5 -3
  75. data/lib/hecks/hecks/adapters/dispatching/launcher.rb +2 -1
  76. data/lib/hecks/hecks/adapters/dispatching/plan.rb +8 -4
  77. data/lib/hecks/hecks/adapters/dispatching/settings.rb +2 -1
  78. data/lib/hecks/hecks/adapters/dispatching/settler.rb +4 -2
  79. data/lib/hecks/hecks/adapters/dispatching.rb +6 -3
  80. data/lib/hecks/hecks/adapters/doctor_reading.rb +4 -2
  81. data/lib/hecks/hecks/adapters/git_landing/carried.rb +42 -0
  82. data/lib/hecks/hecks/adapters/git_landing/finisher.rb +2 -1
  83. data/lib/hecks/hecks/adapters/git_landing/flagged.rb +32 -0
  84. data/lib/hecks/hecks/adapters/git_landing/tidy.rb +27 -14
  85. data/lib/hecks/hecks/adapters/git_landing.rb +30 -13
  86. data/lib/hecks/hecks/adapters/git_leftovers.adapter +7 -0
  87. data/lib/hecks/hecks/adapters/git_leftovers.rb +49 -0
  88. data/lib/hecks/hecks/adapters/git_main_pull.rb +2 -2
  89. data/lib/hecks/hecks/adapters/git_train.rb +9 -5
  90. data/lib/hecks/hecks/adapters/git_worktree_add.rb +8 -4
  91. data/lib/hecks/hecks/adapters/github_pull_requests.rb +2 -1
  92. data/lib/hecks/hecks/adapters/in_process_operations.rb +2 -1
  93. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +1 -1
  94. data/lib/hecks/hecks/adapters/journal_store/readings/composition.rb +2 -1
  95. data/lib/hecks/hecks/adapters/journal_store/translation_write/shape_edge.rb +4 -2
  96. data/lib/hecks/hecks/adapters/journal_store/translation_write.rb +4 -2
  97. data/lib/hecks/hecks/adapters/landed_branches.rb +38 -0
  98. data/lib/hecks/hecks/adapters/main_boarding.adapter +7 -0
  99. data/lib/hecks/hecks/adapters/main_boarding.rb +85 -0
  100. data/lib/hecks/hecks/adapters/main_integration/checks.rb +63 -0
  101. data/lib/hecks/hecks/adapters/main_integration/command_runner.rb +32 -0
  102. data/lib/hecks/hecks/adapters/main_integration/range_guard.rb +49 -0
  103. data/lib/hecks/hecks/adapters/main_integration/repository.rb +83 -0
  104. data/lib/hecks/hecks/adapters/main_integration/runs.rb +51 -0
  105. data/lib/hecks/hecks/adapters/main_integration/train.rb +74 -0
  106. data/lib/hecks/hecks/adapters/main_integration.adapter +7 -0
  107. data/lib/hecks/hecks/adapters/main_integration.rb +77 -0
  108. data/lib/hecks/hecks/adapters/main_verification.adapter +7 -0
  109. data/lib/hecks/hecks/adapters/main_verification.rb +89 -0
  110. data/lib/hecks/hecks/adapters/push_recorder.adapter +7 -0
  111. data/lib/hecks/hecks/adapters/push_recorder.rb +78 -0
  112. data/lib/hecks/hecks/adapters/red_flag_guard.rb +62 -0
  113. data/lib/hecks/hecks/adapters/red_flag_marker.rb +101 -0
  114. data/lib/hecks/hecks/adapters/red_flag_report.rb +59 -0
  115. data/lib/hecks/hecks/adapters/red_flag_survey.rb +65 -0
  116. data/lib/hecks/hecks/adapters/red_flagging.adapter +7 -0
  117. data/lib/hecks/hecks/adapters/red_flagging.rb +98 -0
  118. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +6 -1
  119. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +6 -3
  120. data/lib/hecks/hecks/adapters/settings_reading/event_tap_row.rb +33 -0
  121. data/lib/hecks/hecks/adapters/settings_reading.rb +3 -1
  122. data/lib/hecks/hecks/codebase.behaviors +0 -6
  123. data/lib/hecks/hecks/custodian.behaviors +22 -1
  124. data/lib/hecks/hecks/delegate/delegate.bluebook +128 -15
  125. data/lib/hecks/hecks/deliver/deliver.bluebook +450 -2
  126. data/lib/hecks/hecks/environments/memory.world +5 -0
  127. data/lib/hecks/hecks/hecks.contextmap +8 -0
  128. data/lib/hecks/hecks/hecks.hecksagon +106 -6
  129. data/lib/hecks/hecks/hecks.world +9 -1
  130. data/lib/hecks/hecks/migrate/migrate.bluebook +6 -1
  131. data/lib/hecks/hecks/redflag/redflag.bluebook +249 -0
  132. data/lib/hecks/hecks/test/test.bluebook +0 -19
  133. data/lib/hecks/language/adapter.bluebook +13 -0
  134. data/lib/hecks/language/bluebook/vocabulary.bluebook +8 -9
  135. data/lib/hecks/language/sentence/sentence.bluebook +2 -0
  136. data/lib/hecks/pipeline/pipeline.bluebook +251 -0
  137. data/lib/hecks/pipeline/pipeline.hecksagon +17 -0
  138. data/lib/hecks/pipeline_board/adapters/cache.rb +43 -0
  139. data/lib/hecks/pipeline_board/adapters/pipeline_fill.rb +56 -0
  140. data/lib/hecks/pipeline_board/adapters/pipeline_git.rb +82 -0
  141. data/lib/hecks/pipeline_board/adapters/pipeline_github/runs.rb +66 -0
  142. data/lib/hecks/pipeline_board/adapters/pipeline_github.adapter +7 -0
  143. data/lib/hecks/pipeline_board/adapters/pipeline_github.rb +70 -0
  144. data/lib/hecks/pipeline_board/adapters/pipeline_jobs.adapter +7 -0
  145. data/lib/hecks/pipeline_board/adapters/pipeline_jobs.rb +87 -0
  146. data/lib/hecks/pipeline_board/adapters/pipeline_journal/chain.rb +40 -0
  147. data/lib/hecks/pipeline_board/adapters/pipeline_journal/tap.rb +75 -0
  148. data/lib/hecks/pipeline_board/adapters/pipeline_journal.adapter +7 -0
  149. data/lib/hecks/pipeline_board/adapters/pipeline_journal.rb +83 -0
  150. data/lib/hecks/pipeline_board/adapters/pipeline_stages/ci_tiles.rb +85 -0
  151. data/lib/hecks/pipeline_board/adapters/pipeline_stages/fanout.rb +73 -0
  152. data/lib/hecks/pipeline_board/adapters/pipeline_stages/gather.rb +57 -0
  153. data/lib/hecks/pipeline_board/adapters/pipeline_stages/metrics.rb +36 -0
  154. data/lib/hecks/pipeline_board/adapters/pipeline_stages/strip.rb +102 -0
  155. data/lib/hecks/pipeline_board/adapters/pipeline_stages/tile_kit.rb +57 -0
  156. data/lib/hecks/pipeline_board/adapters/pipeline_stages/work_tiles.rb +59 -0
  157. data/lib/hecks/pipeline_board/adapters/pipeline_stages.adapter +7 -0
  158. data/lib/hecks/pipeline_board/adapters/pipeline_stages.rb +84 -0
  159. data/lib/hecks/pipeline_board/adapters/pipeline_tickets.adapter +7 -0
  160. data/lib/hecks/pipeline_board/adapters/pipeline_tickets.rb +77 -0
  161. data/lib/hecks/pipeline_board/adapters/pipeline_web/page.html +292 -0
  162. data/lib/hecks/pipeline_board/adapters/pipeline_web/sequence.html +190 -0
  163. data/lib/hecks/pipeline_board/adapters/pipeline_web/server.rb +105 -0
  164. data/lib/hecks/pipeline_board/adapters/pipeline_web/snapshot.rb +57 -0
  165. data/lib/hecks/pipeline_board/adapters/pipeline_web/stream.rb +59 -0
  166. data/lib/hecks/pipeline_board/adapters/pipeline_web/wiring.html +536 -0
  167. data/lib/hecks/pipeline_board/adapters/pipeline_web.adapter +7 -0
  168. data/lib/hecks/pipeline_board/adapters/pipeline_web.rb +80 -0
  169. data/lib/hecks/pipeline_board/adapters/pipeline_wiring/details.rb +85 -0
  170. data/lib/hecks/pipeline_board/adapters/pipeline_wiring/graph.rb +120 -0
  171. data/lib/hecks/pipeline_board/adapters/pipeline_wiring.adapter +7 -0
  172. data/lib/hecks/pipeline_board/adapters/pipeline_wiring.rb +36 -0
  173. data/lib/hecks/pipeline_board/adapters/probe.rb +48 -0
  174. data/lib/hecks/pipeline_board/pipeline_board.bluebook +144 -0
  175. data/lib/hecks/pipeline_board/pipeline_board.hecksagon +12 -0
  176. data/lib/hecks/pipeline_board/pipeline_board.ports.hecksagon +47 -0
  177. data/lib/hecks/ports/persistence/binding_policy.rb +2 -1
  178. data/lib/hecks/ports/persistence/plugins/era/translation/compose.rb +4 -3
  179. data/lib/hecks/ports/persistence/repository_factory.rb +2 -1
  180. data/lib/hecks/projections/ci_pipeline/table.rb +2 -1
  181. data/lib/hecks/projections/deploy/lambda/stack/recipes.rb +2 -2
  182. data/lib/hecks/projections/undo.rb +2 -1
  183. data/lib/hecks/projector/cli_projector/command_help.rb +5 -4
  184. data/lib/hecks/projector/cli_projector/renames.rb +4 -2
  185. data/lib/hecks/projector/cli_projector/usage.rb +4 -3
  186. data/lib/hecks/projector/exporter/adapters.rb +3 -2
  187. data/lib/hecks/projector/narrate_projector/use_cases.rb +74 -0
  188. data/lib/hecks/projector/narrate_projector.rb +10 -3
  189. data/lib/hecks/quality_control/adapters/main_maintenance.rb +9 -5
  190. data/lib/hecks/quality_control/adapters/qa_auto_mint.rb +21 -2
  191. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +1 -1
  192. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +2 -1
  193. data/lib/hecks/quality_control/cli/qa_fix_main_bug/publishing.rb +8 -4
  194. data/lib/hecks/quality_control/cli/qa_fix_main_bug.rb +10 -8
  195. data/lib/hecks/quality_control/cli/qa_generated_domains/promotion.rb +2 -2
  196. data/lib/hecks/quality_control/cli/qa_main_gate/gate_run.rb +6 -3
  197. data/lib/hecks/quality_control/cli/qa_main_gate/logging.rb +8 -4
  198. data/lib/hecks/quality_control/cli/qa_main_gate/shell.rb +4 -2
  199. data/lib/hecks/quality_control/cli/qa_main_gate/sweeping.rb +8 -4
  200. data/lib/hecks/quality_control/cli/qa_main_gate/worktree.rb +3 -2
  201. data/lib/hecks/quality_control/cli/qa_main_gate.rb +10 -5
  202. data/lib/hecks/quality_control/cli/qa_sweep/all_mode/children.rb +1 -1
  203. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +1 -1
  204. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  205. data/lib/hecks/quality_control/cli/qa_tick.rb +6 -3
  206. data/lib/hecks/quality_control/quality_control.bluebook +11 -11
  207. data/lib/hecks/quality_control/quality_control.ports.hecksagon +6 -3
  208. data/lib/hecks/query_ir/touchpoints.rb +2 -1
  209. data/lib/hecks/refused.rb +2 -1
  210. data/lib/hecks/release/approval.rb +4 -2
  211. data/lib/hecks/runtime/event_log.rb +27 -0
  212. data/lib/hecks/runtime/event_tap.rb +106 -0
  213. data/lib/hecks/runtime/loader/boot_steps.rb +1 -1
  214. data/lib/hecks/runtime/loader.rb +35 -19
  215. data/lib/hecks/runtime/registry/durability_warnings.rb +12 -5
  216. data/lib/hecks/runtime/registry/runtime_state.rb +4 -1
  217. data/lib/hecks/runtime/registry/saga_anchor.rb +54 -0
  218. data/lib/hecks/runtime/registry/saga_durability_rule.rb +66 -0
  219. data/lib/hecks/runtime/registry/saga_persistence.rb +58 -12
  220. data/lib/hecks/runtime/registry/verification.rb +7 -3
  221. data/lib/hecks/runtime/saga_interpreter/lifecycle.rb +2 -1
  222. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  223. data/lib/hecks/rust_format.rb +2 -1
  224. data/lib/hecks/shape_ledger/edge_hygiene.rb +10 -5
  225. data/lib/hecks/shape_ledger.rb +1 -1
  226. data/lib/hecks/storehouse/audit.rb +2 -1
  227. data/lib/hecks/storehouse/identity.rb +6 -3
  228. data/lib/hecks/storehouse.rb +2 -1
  229. data/lib/hecks/tickets/bluebook/tickets.bluebook +5 -4
  230. data/lib/hecks/tools/ci_workflows.rb +2 -1
  231. data/lib/hecks/tools/document.rb +12 -1
  232. data/lib/hecks/tools/downstream_model_check.rb +4 -2
  233. data/lib/hecks/tools/fuzz_sweep/shards.rb +9 -30
  234. data/lib/hecks/tools/model_check/examination.rb +25 -2
  235. data/lib/hecks/tools/move_disables.rb +4 -2
  236. data/lib/hecks/tools/regeneration_run/scratch.rb +2 -1
  237. data/lib/hecks/tools/rspec_shard_files.rb +12 -15
  238. data/lib/hecks/tools/sequence_script.rb +1 -1
  239. data/lib/hecks/tools/shard_planner.rb +65 -0
  240. data/lib/hecks/tools/stage_progress.rb +2 -1
  241. data/lib/hecks/tools/stress_concurrency_specs.rb +2 -1
  242. data/lib/hecks/version.rb +1 -1
  243. data/lib/hecks/vocabulary.rb +8 -9
  244. data/lib/hecks.rb +5 -2
  245. data/prebuilt/{13c04bdc5970295b310f0a980d397ba722232c8da99cc1a0cdb383249921e871.marshal → 4b81646f769ae5b1e7457f9b11c48d8bc1093e96a1dd6fb61864065121ca6f01.marshal} +0 -0
  246. data/prebuilt/{e3857b77f47d9ecc1787b2e1f5136364f3cb3444ef4aea5479f3b98167affea2.marshal → 681890632e3f4a99646623dcae44af36b1bad66c515f5abf67a2460372b9c6fd.marshal} +0 -0
  247. data/rust/host/HECKS_RELEASE +1 -1
  248. data/rust/parser/src/emit.rs +5 -1
  249. data/rust/parser/src/ir.rs +2 -0
  250. data/rust/parser/src/keywords.rs +12 -0
  251. data/rust/parser/src/main.rs +1 -0
  252. data/rust/parser/src/parse/adapter.rs +1 -0
  253. data/rust/parser/src/phrases.rs +16 -0
  254. data/rust/parser/src/sentence/examples.rs +93 -0
  255. data/rust/parser/src/sentence/mod.rs +1 -0
  256. metadata +89 -5
  257. data/lib/hecks/tools/refresh_rspec_runtime_baseline.rb +0 -166
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "cache"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ # The `Jobs` port's adapter: answers `Sessions` with the dispatched sessions of the last day,
10
+ # from each job's own `state.json` and `timeline.jsonl` and from `claude agents --json`. It
11
+ # reads the job's state and never the ledger, and writes nothing.
12
+ class PipelineJobs
13
+ WINDOW_SECONDS = 24 * 3600
14
+ FRESH_SECONDS = 600
15
+
16
+ # @param aggregate [Object, nil] unused
17
+ # @param settings [Hash] `jobs_dir:` the directory of jobs, `agents:` a callable answering
18
+ # the parsed `claude agents --json`, `runs:` a callable answering session id => `{row:}`,
19
+ # `now:` a callable answering the time
20
+ # @param root [String, nil] unused
21
+ def initialize(aggregate: nil, settings: {}, root: nil)
22
+ @dir = settings[:jobs_dir] || File.join(Dir.home, ".claude", "jobs")
23
+ @agents = settings[:agents] || -> { PipelineBoard::Probe.json("claude", "agents", "--json") }
24
+ @runs = settings[:runs] || -> { {} }
25
+ @now = settings[:now] || -> { Time.now }
26
+ @cache = PipelineBoard::Cache.new(ttl: settings.fetch(:ttl, 15))
27
+ end
28
+
29
+ # @return [Hash{Symbol => String}] `text:` the sessions as JSON, busy first, then newest first
30
+ def sessions = { text: JSON.generate(rows) }
31
+
32
+ # @return [Array<Hash>] the sessions, each with `id`, `name`, `status`, `state`, `detail`,
33
+ # `started`, `ended` and the `row` it was dispatched for
34
+ def rows
35
+ agents = listed
36
+ found = Dir.glob(File.join(@dir, "*", "state.json")).filter_map { |path| session(path, agents) }
37
+ found.sort_by { |row| [row[:status] == "busy" ? 0 : 1, row[:started].to_s] }.reverse
38
+ end
39
+
40
+ private
41
+
42
+ def listed
43
+ @cache.fetch(:agents) { Array(@agents.call).to_h { |agent| [agent["id"].to_s, agent] } }
44
+ rescue PipelineBoard::Unavailable
45
+ {}
46
+ end
47
+
48
+ def session(path, agents)
49
+ id = File.basename(File.dirname(path))
50
+ age = @now.call - File.mtime(path)
51
+ agent = agents[id]
52
+ return if age > WINDOW_SECONDS && !agent
53
+
54
+ build(id, JSON.parse(File.read(path)), agent, age, File.join(File.dirname(path), "timeline.jsonl"))
55
+ rescue JSON::ParserError, SystemCallError
56
+ nil
57
+ end
58
+
59
+ def build(id, state, agent, age, timeline)
60
+ edges = edges_of(timeline)
61
+ status = status_of(state, agent, age)
62
+ { id: id, name: name_of(id, agent), status: status, state: state["state"].to_s,
63
+ detail: state["detail"].to_s.slice(0, 140), started: stamp(agent, edges),
64
+ ended: status == "busy" ? nil : edges.last, row: @runs.call.dig(id, :row).to_s }
65
+ end
66
+
67
+ def name_of(id, agent) = (agent && agent["name"]) || id
68
+
69
+ def status_of(state, agent, age)
70
+ return "busy" if (agent && agent["status"] == "busy") || (state["state"] == "working" && age < FRESH_SECONDS)
71
+
72
+ %w[failed error].include?(state["state"]) ? "failed" : "done"
73
+ end
74
+
75
+ def stamp(agent, edges)
76
+ agent ? Time.at(agent["startedAt"].to_i / 1000).utc.iso8601 : edges.first
77
+ end
78
+
79
+ def edges_of(timeline)
80
+ lines = File.readlines(timeline)
81
+ [lines.first, lines.last].map { |line| JSON.parse(line)["at"] }
82
+ rescue SystemCallError, JSON::ParserError
83
+ []
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ module PipelineBoard
8
+ # Turns an event-trace report into board rows: the event, each policy that reacted with the
9
+ # outcome of its delivery and the command it reached, and the events that followed on the same
10
+ # record (the ones the command emitted, read from the journal's own order).
11
+ module Chain
12
+ FOLLOWING = 3
13
+
14
+ private
15
+
16
+ def chained(report)
17
+ events = JSON.parse(JSON.generate(report)).fetch("events", [])
18
+ events.map.with_index { |entry, index| row(entry, events.drop(index + 1)) }
19
+ end
20
+
21
+ def row(entry, later)
22
+ event = entry["event"]
23
+ { key: "journal:#{File.basename(entry["database"].to_s)}:#{event["id"]}", source: "journal",
24
+ at: event["occurred_at"], aggregate: event["aggregate"], id: event["aggregate_id"],
25
+ name: event["name"], reactions: entry["subscribers"].to_a.map { |sub| reaction(sub, event, later) } }
26
+ end
27
+
28
+ def reaction(sub, event, later)
29
+ { policy: sub["consumer"].to_s.sub("policy:", ""), command: sub["reaches"].to_s,
30
+ outcome: sub["outcome"].to_s, emitted: following(event, later) }
31
+ end
32
+
33
+ def following(event, later)
34
+ same = later.map { |entry| entry["event"] }.select { |other| other["aggregate_id"] == event["aggregate_id"] }
35
+ same.first(FOLLOWING).map { |other| other["name"] }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ module PipelineBoard
8
+ # Reads the runtime's opt-in event log (`Hecks::Runtime::EventTap`): one JSON line per event
9
+ # and one per policy delivery, which this reader attaches to the event it answered. A line
10
+ # that carries its own `policy` is an event with its reaction. It opens the file for reading
11
+ # only; a missing file holds no events.
12
+ class Tap
13
+ TAIL_LINES = 600
14
+ FOLLOWING = 3
15
+
16
+ # @param path [String] the log's path
17
+ def initialize(path)
18
+ @path = path
19
+ end
20
+
21
+ # @return [Array<Hash>] the newest events as board rows, oldest first, each with the
22
+ # reactions the log holds for it
23
+ def rows
24
+ return [] unless File.file?(@path)
25
+
26
+ events = parse(File.readlines(@path).last(TAIL_LINES))
27
+ link(events)
28
+ rescue SystemCallError
29
+ []
30
+ end
31
+
32
+ private
33
+
34
+ def parse(lines) = lines.filter_map { |line| decode(line) }
35
+
36
+ def decode(line)
37
+ JSON.parse(line)
38
+ rescue JSON::ParserError
39
+ nil
40
+ end
41
+
42
+ def link(lines)
43
+ rows = []
44
+ lines.each { |line| line["kind"] == "reaction" ? attach(rows, line) : rows << event_row(line, rows.size) }
45
+ rows.each_with_index { |row, index| follow(row, rows.drop(index + 1)) }
46
+ end
47
+
48
+ def event_row(data, index)
49
+ { key: "tap:#{data["at"]}:#{data["aggregate"]}:#{data["id"]}:#{data["event"]}:#{index}", source: "tap",
50
+ at: data["at"], aggregate: data["aggregate"], id: data["id"], name: data["event"], reactions: inline(data) }
51
+ end
52
+
53
+ def inline(data) = data["policy"] ? [reaction(data)] : []
54
+
55
+ def reaction(data)
56
+ { policy: data["policy"], command: data["command"].to_s, outcome: data["outcome"].to_s,
57
+ emitted: Array(data["emitted"]) }
58
+ end
59
+
60
+ def attach(rows, line)
61
+ row = rows.reverse.find { |held| held[:id].to_s == line["id"].to_s && held[:name] == bare(line["event"]) }
62
+ row[:reactions] << reaction(line) if row
63
+ end
64
+
65
+ def bare(name) = name.to_s.split(".").last
66
+
67
+ def follow(row, later)
68
+ names = later.select { |other| other[:id] == row[:id] && other[:name] != row[:name] }.first(FOLLOWING)
69
+ row[:reactions].each { |reaction| reaction[:emitted] = names.map { |other| other[:name] } if reaction[:emitted].empty? }
70
+ row
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,7 @@
1
+ # Binds the `Journal` port to `PipelineJournal`, which answers `Events` from the journal stores, read-only.
2
+ # Required by name, not relative, so a copy of this directory shares the one implementation loaded.
3
+ require "hecks/pipeline_board/adapters/pipeline_journal"
4
+
5
+ Hecks.adapter "PipelineJournal" do
6
+ port "Journal"
7
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "cache"
5
+ require_relative "pipeline_journal/tap"
6
+ require_relative "pipeline_journal/chain"
7
+
8
+ module Hecks
9
+ module Adapters
10
+ # The `Journal` port's adapter: answers `Events` with the newest events the tooling recorded
11
+ # and, for each, the policies that reacted, what became of the delivery and the command it
12
+ # reached. The persisted stores are read by the same reader `era.event_trace` uses, opened
13
+ # read-only; an opt-in local event log (`events.jsonl`) is tailed for the domains that persist
14
+ # nothing. The board writes to neither.
15
+ class PipelineJournal
16
+ include PipelineBoard::Chain
17
+
18
+ DEFAULT_DOMAIN = "lib/hecks/hecks"
19
+
20
+ # Where the opt-in event log is written when a world turns it on.
21
+ TAP_PATH = File.join(Dir.home, ".claude", "hecks-dev", "events.jsonl")
22
+
23
+ # @param aggregate [Object, nil] unused
24
+ # @param settings [Hash] `trace:` stands in for the reader (a callable taking the row
25
+ # count and answering its report), `tap:` the event log's path, `ttl:` how long a read
26
+ # is kept
27
+ # @param root [String, nil] the checkout whose domain directory is read
28
+ def initialize(aggregate: nil, settings: {}, root: nil)
29
+ @root = root || Dir.pwd
30
+ @trace = settings[:trace] || method(:trace)
31
+ @tap = PipelineBoard::Tap.new(settings.fetch(:tap) { TAP_PATH })
32
+ @cache = PipelineBoard::Cache.new(ttl: settings.fetch(:ttl, 2))
33
+ end
34
+
35
+ # @param limit [Hash, Integer, nil] how many events (40 when none is named)
36
+ # @return [Hash{Symbol => String}] `text:` the events as JSON, newest first
37
+ def events(limit: nil)
38
+ { text: JSON.generate(recent(limit.is_a?(Hash) ? limit[:value] : limit)) }
39
+ end
40
+
41
+ # @param limit [Integer, nil] how many events
42
+ # @return [Array<Hash>] the events, newest first, each with its `key`, `reactions` and
43
+ # `source`
44
+ # @raise [PipelineBoard::Unavailable] when no store can be read and the log holds nothing
45
+ def recent(limit = nil)
46
+ count = limit.to_i.positive? ? limit.to_i : 40
47
+ found = journal(count) + @tap.rows
48
+ raise PipelineBoard::Unavailable, "no store was readable and no event log exists" if found.empty?
49
+
50
+ found.sort_by { |row| row[:at].to_s }.reverse.first(count)
51
+ end
52
+
53
+ private
54
+
55
+ def journal(count)
56
+ @cache.fetch([:trace, count]) { chained(@trace.call(count)) }
57
+ rescue PipelineBoard::Unavailable
58
+ []
59
+ end
60
+
61
+ def trace(count)
62
+ require "hecks"
63
+ require "hecks/hecks/adapters/journal_store"
64
+ reading = Adapters::JournalStore::Readings::EventTrace
65
+ selection = reading::Selection.new(nil, nil, count, nil)
66
+ reading::Report.new(registry, selection).to_h
67
+ rescue StandardError => e
68
+ raise PipelineBoard::Unavailable, "journal: #{e.message[0, 80]}"
69
+ end
70
+
71
+ def registry = @registry ||= Hecks.describe(File.join(owner_root, DEFAULT_DOMAIN)).registry
72
+
73
+ # The checkout whose local overlays name the stores: a linked worktree has none, so its main
74
+ # checkout is read instead.
75
+ def owner_root
76
+ common = PipelineBoard::Probe.run("git", "-C", @root, "rev-parse", "--git-common-dir").strip
77
+ File.basename(common) == ".git" ? File.dirname(File.expand_path(common, @root)) : @root
78
+ rescue PipelineBoard::Unavailable
79
+ @root
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tile_kit"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ module PipelineBoard
8
+ # The tiles that read CI: success rate, wall time against the goal, failed jobs on the head,
9
+ # how long main has been red, and how long a commit takes to get a green CI.
10
+ module CiTiles
11
+ include TileKit
12
+
13
+ GOAL_MINUTES = 3
14
+
15
+ # @param runs [Array<Hash>] CI runs, oldest first
16
+ def success(runs)
17
+ spec = { id: "success", label: "CI success", unit: "%", detail: "last #{runs.size} main runs" }
18
+ built = windowed(spec, runs) { |list| success_rate(list) }
19
+ built.merge(state: band(built[:value], 80, 50))
20
+ end
21
+
22
+ def wall(runs)
23
+ spec = { id: "wall", label: "Median CI wall", unit: "min", lower: true, detail: "goal #{GOAL_MINUTES} min" }
24
+ built = windowed(spec, runs.select { |run| run[:status] == "completed" }) do |list|
25
+ median(list.map { |run| run[:wall_minutes] })
26
+ end
27
+ built.merge(state: over(built[:value], GOAL_MINUTES))
28
+ end
29
+
30
+ def failed_head(runs)
31
+ head, before = runs.last(2).reverse
32
+ return unlisted(head) if head[:failed_jobs].nil?
33
+
34
+ change = before && trend(before[:failed_jobs], head[:failed_jobs], "jobs", lower_is_better: true)
35
+ state = head[:failed_jobs].zero? ? "ok" : "failed"
36
+ tile("failed_head", "Failed jobs on head", head[:failed_jobs], "jobs",
37
+ detail: "run #{head[:sha]}", trend: change, state: state)
38
+ end
39
+
40
+ def unlisted(head)
41
+ unknown_tile("failed_head", "Failed jobs on head", { unknown: "GitHub would not list the jobs of #{head[:sha]}" })
42
+ end
43
+
44
+ def red_for(runs, now)
45
+ return tile("red", "Main red for", 0, "h", detail: "main is green") if runs.last[:conclusion] == "success"
46
+
47
+ green = runs.reverse.find { |run| run[:conclusion] == "success" }
48
+ hours = green && ((now - finished(green)) / 3600.0).round(1)
49
+ note = green ? "since #{green[:sha]} went green" : "no green run in the window"
50
+ tile("red", "Main red for", hours, "h", state: "failed", detail: note)
51
+ end
52
+
53
+ def lead(data, runs)
54
+ times = data[:commit_times].to_h
55
+ spec = { id: "lead", label: "Commit to green CI", unit: "min", lower: true,
56
+ detail: "landed to pushed is not recorded" }
57
+ windowed(spec, runs.select { |run| times[run[:sha]] }) do |list|
58
+ median(list.map { |run| (finished(run) - Time.parse(times[run[:sha]])) / 60.0 })
59
+ end
60
+ end
61
+
62
+ # @param spec [Hash] `id`, `label`, `unit`, `detail` and `lower` (a fall is an improvement)
63
+ def windowed(spec, list)
64
+ early, late = halves(list)
65
+ change = trend(yield(early), yield(late), spec[:unit], lower_is_better: spec[:lower] || false)
66
+ tile(spec[:id], spec[:label], yield(list), spec[:unit], detail: spec[:detail], trend: change)
67
+ end
68
+
69
+ def band(value, good, fair)
70
+ return "unknown" if value.nil?
71
+ return "ok" if value >= good
72
+
73
+ value >= fair ? "attention" : "failed"
74
+ end
75
+
76
+ def over(value, goal)
77
+ return "unknown" if value.nil?
78
+ return "ok" if value <= goal
79
+
80
+ value <= goal * 2 ? "attention" : "failed"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Adapters
5
+ module PipelineBoard
6
+ # How the work fans out across sessions: which row each session runs, who claimed each row,
7
+ # and the flags that say the two disagree. It reads rows and answers rows; it decides nothing.
8
+ module Fanout
9
+ module_function
10
+
11
+ # @param sessions [Array<Hash>] the board's sessions
12
+ # @param work [Array<Hash>] the open Work items
13
+ # @param landed [Array<String>] the branches the Landing records say were merged
14
+ # @param fill [Hash, nil] the Dispatch.Fill preview
15
+ # @return [Array<Hash>] one row per open Work row: its claim, its sessions and its flags
16
+ def mapping(sessions:, work:, landed:, fill:)
17
+ busy = sessions.select { |s| s[:status] == "busy" }
18
+ world = { busy: busy, landed: landed, fill: fill, areas: work.to_h { |w| [w[:slug], w[:area].to_a] } }
19
+ work.map { |row| mapped(row, world) }
20
+ end
21
+
22
+ # @return [Array<Hash>] every flag of every row, each with its `row`, `kind` and `detail`
23
+ def flags_of(mapped) = mapped.flat_map { |m| m[:flags].map { |flag| flag.merge(row: m[:slug]) } }
24
+
25
+ def mapped(row, world)
26
+ on = world[:busy].select { |s| s[:row] == row[:slug] }
27
+ { slug: row[:slug], status: row[:status], claimed_by: row[:claimed_by], claimed_at: row[:claimed_at],
28
+ sessions: on.map { |s| s[:id] }, flags: flags(row, on, world) }
29
+ end
30
+
31
+ def flags(row, on, world)
32
+ found = []
33
+ found << unclaimed(row, on) if on.any? && row[:claimed_by].to_s.empty?
34
+ found << twice(on) if on.size > 1
35
+ found.concat(overlapping(row, on, world))
36
+ found.concat(stale(row, world))
37
+ end
38
+
39
+ def unclaimed(row, on)
40
+ { kind: "unclaimed", detail: "#{on.first[:id]} runs on #{row[:slug]}, which has no claimed_by" }
41
+ end
42
+
43
+ def twice(on)
44
+ { kind: "duplicate", detail: "#{on.size} sessions run on one work item: #{on.map { |s| s[:id] }.join(", ")}" }
45
+ end
46
+
47
+ def overlapping(row, on, world)
48
+ return [] if on.empty?
49
+
50
+ clash = world[:busy].select { |s| s[:row] != row[:slug] && clashes?(s, row, world) }
51
+ clash.map { |s| { kind: "overlap", detail: "#{s[:id]} (#{s[:row]}) works an area that overlaps #{row[:slug]}" } }
52
+ end
53
+
54
+ def clashes?(session, row, world) = touches?(world[:areas][session[:row]], row[:area].to_a)
55
+
56
+ def touches?(mine, theirs)
57
+ mine.to_a.any? { |a| theirs.any? { |b| a.start_with?(b) || b.start_with?(a) } }
58
+ end
59
+
60
+ def stale(row, world)
61
+ return [] unless world[:landed].include?(row[:slug]) && %w[planned started].include?(row[:status])
62
+
63
+ [{ kind: "landed", detail: "#{row[:slug]} already landed but is still #{row[:status]}#{restart(row, world)}" }]
64
+ end
65
+
66
+ def restart(row, world)
67
+ again = world[:fill].to_h.fetch(:would_start, []).any? { |pick| pick[:slug] == row[:slug] }
68
+ again ? "; Dispatch.Fill would start another session on it" : ""
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ module PipelineBoard
8
+ # Asks each reader its question and keeps the answer, or `{unknown: <reason>}` when the reader
9
+ # could not answer, so one failed read never takes the others down. The reads run side by
10
+ # side, so a page takes as long as its slowest read.
11
+ module Gather
12
+ # Each section: the reader, its method, whether it answers a query document, and arguments.
13
+ READS = {
14
+ ci_runs: [:github, :ci_runs, :query], slowest_jobs: [:github, :slowest_jobs, :query],
15
+ release: %i[github latest_release plain], local: %i[git local_main plain],
16
+ train: %i[git train plain], stable: %i[git stable plain],
17
+ open_work: [:tickets, :open_work, :query], counts: %i[tickets counts plain],
18
+ landed: %i[tickets landed_branches plain], sessions: [:jobs, :sessions, :query],
19
+ red_flag: %i[git red_flag plain], fill: %i[fill preview plain], events: [:journal, :recent, :plain, 40],
20
+ history: [:journal, :recent, :plain, 300]
21
+ }.freeze
22
+
23
+ private
24
+
25
+ def guard
26
+ yield
27
+ rescue PipelineBoard::Unavailable => e
28
+ { unknown: e.message }
29
+ rescue StandardError => e
30
+ { unknown: "#{e.class}: #{e.message.to_s.slice(0, 100)}" }
31
+ end
32
+
33
+ def queried
34
+ guard { JSON.parse(yield[:text], symbolize_names: true) }
35
+ end
36
+
37
+ def read(spec)
38
+ reader, method, kind, *args = spec
39
+ ask = -> { instance_variable_get("@#{reader}").public_send(method, *args) }
40
+ kind == :query ? queried(&ask) : guard(&ask)
41
+ end
42
+
43
+ def gathered
44
+ threads = READS.transform_values { |spec| Thread.new { read(spec) } }
45
+ threads.transform_values(&:value)
46
+ end
47
+
48
+ def dated(data)
49
+ runs = data[:ci_runs]
50
+ return {} unless runs.is_a?(Array)
51
+
52
+ { commit_times: guard { @git.commit_times(runs.map { |run| run[:sha] }) } }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "ci_tiles"
5
+ require_relative "work_tiles"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ module PipelineBoard
10
+ # The key-metric tiles: each a number, its unit, a state word and a trend against the earlier
11
+ # half of the same window. A section that could not be read gives a tile that says `unknown`.
12
+ module Metrics
13
+ extend CiTiles
14
+ extend WorkTiles
15
+
16
+ # @param data [Hash] the board's sections, each an answer or `{unknown: reason}`
17
+ # @param now [Time] the time the tiles are read at
18
+ # @return [Array<Hash>] the tiles, in the order the page shows them
19
+ def self.tiles(data, now)
20
+ runs = data[:ci_runs]
21
+ return [unknown_tile("ci", "CI", runs)] + work(data, now) unless runs.is_a?(Array) && !runs.empty?
22
+
23
+ ci(data, runs, now) + work(data, now)
24
+ end
25
+
26
+ def self.ci(data, runs, now)
27
+ [success(runs), wall(runs), failed_head(runs), red_for(runs, now), lead(data, runs)]
28
+ end
29
+
30
+ def self.work(data, now)
31
+ [ahead(data[:local], now), working(data), throughput(data[:history], now), release(data[:release], now)]
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "hecks/hecks/adapters/red_flag_report"
5
+
6
+ module Hecks
7
+ module Adapters
8
+ module PipelineBoard
9
+ # The pipeline strip: one row per stage, left to right, each with a state word, a count and a
10
+ # one-line detail. A stage whose section could not be read says `unknown: <reason>` and the
11
+ # others still answer.
12
+ module Strip
13
+ module_function
14
+
15
+ # @param data [Hash] the board's sections
16
+ # @param now [Time] the time the stages are read at
17
+ # @return [Array<Hash>] `name`, `state`, `count` and `detail` for each stage
18
+ def build(data, now)
19
+ [*flag(data[:red_flag], now), work(data[:counts]), sessions(data[:sessions]), local(data[:local]),
20
+ train(data[:train], now), ci(data[:ci_runs]), promote(data[:stable]), release(data[:release])]
21
+ end
22
+
23
+ # The red flag row, first in the strip while a flag stands: its commit, the minutes main has
24
+ # been red, and who has checked in. Nothing while main is not flagged or the read failed.
25
+ def flag(standing, now)
26
+ return [] unless standing.is_a?(Hash) && standing.key?("id")
27
+
28
+ report = Hecks::Adapters::RedFlagReport.new(standing, now: now)
29
+ [row("Red flag", "failed", standing["id"], report.text.tr("\n", "; "))]
30
+ end
31
+
32
+ def row(name, state, count, detail) = { name: name, state: state, count: count.to_s, detail: detail }
33
+
34
+ def unknown(name, section)
35
+ reason = section.is_a?(Hash) ? section[:unknown] : "no data"
36
+ row(name, "unknown", "-", "unknown: #{reason}")
37
+ end
38
+
39
+ def bad?(section) = section.nil? || (section.is_a?(Hash) && section.key?(:unknown))
40
+
41
+ def work(counts)
42
+ return unknown("Work items", counts) if bad?(counts)
43
+
44
+ planned, started, done = %w[planned started done].map { |status| counts.fetch(status, 0) }
45
+ row("Work items", started.positive? ? "working" : "ok", "#{planned}/#{started}/#{done}", "planned / started / done")
46
+ end
47
+
48
+ def sessions(list)
49
+ return unknown("Sessions", list) unless list.is_a?(Array)
50
+
51
+ busy = list.count { |s| s[:status] == "busy" }
52
+ row("Sessions", busy.positive? ? "working" : "ok", busy, "#{busy} busy of #{list.size} in the last day")
53
+ end
54
+
55
+ def local(main)
56
+ return unknown("Local main", main) if bad?(main)
57
+
58
+ tree = main[:dirty].zero? ? "clean" : "#{main[:dirty]} changed files"
59
+ note = "#{main[:ahead]} ahead, #{main[:behind]} behind origin/main; #{tree}"
60
+ row("Local main", main[:ahead].zero? ? "ok" : "attention", main[:ahead], note)
61
+ end
62
+
63
+ def train(info, now)
64
+ return unknown("Train", info) if bad?(info)
65
+
66
+ age = info[:at] ? ((now - Time.parse(info[:at])) / 3600.0).round(1) : "?"
67
+ note = "last push #{age} h ago#{"; not on origin/main" unless info[:pushed]}"
68
+ row("Train", info[:pushed] ? "ok" : "attention", info[:sha], note)
69
+ end
70
+
71
+ def ci(runs)
72
+ return unknown("CI", runs) unless runs.is_a?(Array) && runs.any?
73
+
74
+ last = runs.last
75
+ verdict = last[:conclusion].empty? ? last[:status] : last[:conclusion]
76
+ failed = last[:failed_jobs] || "?"
77
+ note = "#{last[:sha]} #{verdict}, #{last[:wall_minutes]} min, #{failed} failed jobs"
78
+ row("CI", ci_state(last), failed, note)
79
+ end
80
+
81
+ def ci_state(run)
82
+ return "working" unless run[:status] == "completed"
83
+
84
+ run[:conclusion] == "success" ? "ok" : "failed"
85
+ end
86
+
87
+ def promote(stable)
88
+ return unknown("Promote", stable) if bad?(stable)
89
+
90
+ note = "stable #{stable[:sha]}; #{stable[:behind]} commits behind main"
91
+ row("Promote", stable[:behind].zero? ? "ok" : "attention", stable[:behind], note)
92
+ end
93
+
94
+ def release(info)
95
+ return unknown("Release", info) if bad?(info)
96
+
97
+ row("Release", info[:published] ? "ok" : "attention", info[:tag], info[:published] ? "published" : "not published")
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end