Class: Board
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Board
- Defined in:
- app/models/board.rb
Constant Summary collapse
- DEFAULT_COLUMNS =
Captured from the author's live board (2026-07-04, second capture) — the battle-tested layout. accepts_from is stored as NAMES here and resolved to column ids by install_default_columns! (ids don't exist until creation).
[ { name: "Tasks", archetype: "inbox", policy: { "plan_approval" => false, "arrivals" => "top", "accepts_from_names" => ["Planning", "Review", "QA", "Done"] } }, { name: "Planning", archetype: "planning", policy: { "ai" => true, "model" => "claude-haiku-4-5-20251001", "plan_approval" => false, "on_entry" => [{ "action" => "assistant_greeting" }], "on_entry_text" => "The planning assistant reads the card and opens the discussion.", "footer" => [{ "label" => "Model:", "compute" => "model" }], "accepts_from_names" => ["Tasks", "In Progress", "Review", "QA"] } }, { name: "In Progress", archetype: "execution", policy: { "ai" => true, "model" => "claude-opus-4-8", "effort" => "high", "concurrency_limit" => 3, "plan_approval" => true, "budget_per_run_cents" => 200, "timeout_minutes" => 90, "max_turns" => 80, "tools" => %w[read edit run_commands git_commit_push], "on_entry" => [{ "action" => "start_agent_run" }], "accepts_from_names" => ["Planning", "Review", "QA"], "footer" => [{ "label" => "Model:", "compute" => "model" }], "instructions" => "Follow repo conventions. Write tests when the repo has a suite." } }, { name: "Review", archetype: "review", policy: { "ai" => true, "plan_approval" => false, "accepts_from_names" => ["In Progress", "QA"], "on_entry" => [{ "action" => "mark_pr_ready" }], "on_entry_text" => "Take the PR out of draft — mark it ready for review on GitHub." } }, { name: "QA", archetype: "review", policy: { "ai" => true, "plan_approval" => false, "accepts_from_names" => ["Review"], "on_entry" => [{ "action" => "mark_pr_ready" }] } }, { name: "Done", archetype: "terminal", policy: { "ai" => false, "plan_approval" => false, "arrivals" => "top", "accepts_from_names" => ["Tasks", "Planning", "Review", "QA"], "footer" => [{ "label" => "Total cost:", "compute" => "sum_cost" }], "on_entry" => [{ "action" => "merge_pr" }] } } ].freeze
- BRIEF_STALE_AT =
--- Repo brief (card #12) --------------------------------------------- A one-time deep dive that maps the repo, stored as flat markdown in .cardinal/ (never the host repo) and injected into worker prompts to spare each run the exploration tax. Metadata (which SHA/model/when) lives on the board so staleness can be judged against the current HEAD.
Storage is a file + metadata, not one text column, so a structure provider (the Graphify child card) can slot a richer representation in underneath later without a migration.
10
Class Method Summary collapse
-
.bootstrap!(repo_path) ⇒ Object
First-run setup for a portable instance (cardinal.md §16): build the board from the repo Cardinal was launched inside.
-
.detect_default_branch(repo_path) ⇒ Object
The REMOTE's default branch, not whatever happened to be checked out when
cardinal upfirst ran — launching from a feature branch must not make Done merge toward that feature branch forever. - .sanitize_remote_url(url) ⇒ Object
Instance Method Summary collapse
- #archive_accepts?(column) ⇒ Boolean
-
#attention_cards ⇒ Object
Cards currently waiting on the human, ordered by urgency — feeds the attention inbox in the board header.
- #brief? ⇒ Boolean
-
#brief_path ⇒ Object
Honor CARDINAL_DATA_DIR: in gem mode Rails.root is the installed gem (read-only); the instance's data lives in the target repo's .cardinal/.
- #brief_stale? ⇒ Boolean
-
#brief_staleness_color ⇒ Object
Grey → red interpolation over 0..BRIEF_STALE_AT commits behind, emitted as a validated hex into the button's inline style (mirrors Column#safe_color).
- #brief_working? ⇒ Boolean
-
#commits_behind_brief ⇒ Object
How many commits landed since the brief was generated.
-
#head_sha ⇒ Object
HEAD of the board's repo right now — the yardstick staleness measures against.
-
#install_default_columns! ⇒ Object
Create the default columns, then resolve accepts_from_names -> ids.
-
#permission_bypass? ⇒ Boolean
Board default for worker autonomy (§ permissions): true (default) lets agents act without asking — full shell inside their workspace.
- #repo_brief ⇒ Object
-
#tag_pool ⇒ Object
Every tag in use on this board — the pool the tag picker offers.
Class Method Details
.bootstrap!(repo_path) ⇒ Object
First-run setup for a portable instance (cardinal.md §16): build the board from the repo Cardinal was launched inside.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'app/models/board.rb', line 79 def self.bootstrap!(repo_path) repo_path = File.(repo_path) # Raw configured URL (get-url applies insteadOf rewrites, which can embed # credential-helper tokens); strip any userinfo defensively either way. origin, origin_ok = Open3.capture2e("git", "-C", repo_path, "config", "--get", "remote.origin.url") board = create!( name: File.basename(repo_path), repo_url: origin_ok.success? ? sanitize_remote_url(origin.strip) : nil, default_branch: detect_default_branch(repo_path), local_path: repo_path ) board.install_default_columns! board end |
.detect_default_branch(repo_path) ⇒ Object
The REMOTE's default branch, not whatever happened to be checked out when
cardinal up first ran — launching from a feature branch must not make
Done merge toward that feature branch forever. Fallback chain: origin's
HEAD → current branch → "main". Editable later in board settings.
99 100 101 102 103 104 105 106 |
# File 'app/models/board.rb', line 99 def self.detect_default_branch(repo_path) head, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "refs/remotes/origin/HEAD") return head.strip.delete_prefix("refs/remotes/origin/") if ok.success? && head.strip.present? # symbolic-ref, not rev-parse: works even on an unborn branch (fresh init). branch, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "--short", "HEAD") ok.success? && branch.strip.present? ? branch.strip : "main" end |
.sanitize_remote_url(url) ⇒ Object
108 109 110 111 112 |
# File 'app/models/board.rb', line 108 def self.sanitize_remote_url(url) # Drop any userinfo (tokens from credential-helper rewrites). Regex, not # URI#userinfo= — Ruby 3.4's RFC3986 parser silently ignores that setter. url.sub(%r{\A(\w+://)[^@/]+@}, '\1') end |
Instance Method Details
#archive_accepts?(column) ⇒ Boolean
68 69 70 |
# File 'app/models/board.rb', line 68 def archive_accepts?(column) Array(archive_accepts_from).map(&:to_s).include?(column.id.to_s) end |
#attention_cards ⇒ Object
Cards currently waiting on the human, ordered by urgency — feeds the attention inbox in the board header.
182 183 184 185 |
# File 'app/models/board.rb', line 182 def attention_cards cards.where(status: %w[needs_input failed work_complete]) .order(Arel.sql("CASE status WHEN 'needs_input' THEN 0 WHEN 'failed' THEN 1 ELSE 2 END"), updated_at: :asc) end |
#brief? ⇒ Boolean
140 141 142 |
# File 'app/models/board.rb', line 140 def brief? brief_sha.present? && File.exist?(brief_path) end |
#brief_path ⇒ Object
Honor CARDINAL_DATA_DIR: in gem mode Rails.root is the installed gem (read-only); the instance's data lives in the target repo's .cardinal/.
132 133 134 |
# File 'app/models/board.rb', line 132 def brief_path Pathname(File.(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("repo-brief.md") end |
#brief_stale? ⇒ Boolean
166 |
# File 'app/models/board.rb', line 166 def brief_stale? = (commits_behind_brief || 0) >= BRIEF_STALE_AT |
#brief_staleness_color ⇒ Object
Grey → red interpolation over 0..BRIEF_STALE_AT commits behind, emitted as a validated hex into the button's inline style (mirrors Column#safe_color). Deep red once the brief is stale enough to over-anchor on.
171 172 173 174 175 176 177 178 |
# File 'app/models/board.rb', line 171 def brief_staleness_color behind = commits_behind_brief || 0 grey = [0x8a, 0x8a, 0x8a] red = [0xd4, 0x33, 0x33] t = [behind.to_f / BRIEF_STALE_AT, 1.0].min rgb = grey.zip(red).map { |g, r| (g + (r - g) * t).round } format("#%02x%02x%02x", *rgb) end |
#brief_working? ⇒ Boolean
144 |
# File 'app/models/board.rb', line 144 def brief_working? = brief_status == "working" |
#commits_behind_brief ⇒ Object
How many commits landed since the brief was generated. nil when there's no brief (nothing to be stale against) or the SHA is unknown to the repo.
155 156 157 158 159 160 161 162 163 164 |
# File 'app/models/board.rb', line 155 def commits_behind_brief return @commits_behind_brief if defined?(@commits_behind_brief) @commits_behind_brief = if brief_sha.blank? || local_path.blank? nil else out, ok = Open3.capture2e("git", "-C", local_path, "rev-list", "--count", "#{brief_sha}..HEAD") ok.success? ? out.strip.to_i : nil end end |
#head_sha ⇒ Object
HEAD of the board's repo right now — the yardstick staleness measures against.
147 148 149 150 151 |
# File 'app/models/board.rb', line 147 def head_sha return nil if local_path.blank? out, ok = Open3.capture2e("git", "-C", local_path, "rev-parse", "HEAD") ok.success? ? out.strip : nil end |
#install_default_columns! ⇒ Object
Create the default columns, then resolve accepts_from_names -> ids.
41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'app/models/board.rb', line 41 def install_default_columns! DEFAULT_COLUMNS.each_with_index do |attrs, index| columns.create!(name: attrs[:name], archetype: attrs[:archetype], position: index, policy: attrs[:policy].except("accepts_from_names")) end DEFAULT_COLUMNS.each do |attrs| names = attrs[:policy]["accepts_from_names"] or next col = columns.find_by!(name: attrs[:name]) ids = columns.where(name: names).pluck(:id).map(&:to_s) col.update!(policy: col.policy.merge("accepts_from" => ids)) end end |
#permission_bypass? ⇒ Boolean
Board default for worker autonomy (§ permissions): true (default) lets agents act without asking — full shell inside their workspace. false restricts workers to file tools (read/search/edit/write; Cardinal commits for them) unless a card explicitly overrides back to full autonomy.
64 65 66 |
# File 'app/models/board.rb', line 64 def settings["permission_bypass"] != false end |
#repo_brief ⇒ Object
136 137 138 |
# File 'app/models/board.rb', line 136 def repo_brief File.read(brief_path) if File.exist?(brief_path) end |
#tag_pool ⇒ Object
Every tag in use on this board — the pool the tag picker offers.
115 116 117 |
# File 'app/models/board.rb', line 115 def tag_pool cards.pluck(:tags).flatten.compact.uniq.sort end |