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
- PERMISSION_MODES =
Board default for worker autonomy (§ permissions), three honest modes:
bypass — agents act without asking (full shell) ask — shell commands pause for your 👍 in the card chat restricted — file tools only; agents cannot execute anything(Back-compat: the earlier boolean permission_bypass=false maps to restricted.)
%w[bypass ask restricted].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
- #permission_mode_default ⇒ Object
- #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.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'app/models/board.rb', line 87 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.
107 108 109 110 111 112 113 114 |
# File 'app/models/board.rb', line 107 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
116 117 118 119 120 |
# File 'app/models/board.rb', line 116 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
76 77 78 |
# File 'app/models/board.rb', line 76 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.
190 191 192 193 |
# File 'app/models/board.rb', line 190 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
148 149 150 |
# File 'app/models/board.rb', line 148 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/.
140 141 142 |
# File 'app/models/board.rb', line 140 def brief_path Pathname(File.(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("repo-brief.md") end |
#brief_stale? ⇒ Boolean
174 |
# File 'app/models/board.rb', line 174 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.
179 180 181 182 183 184 185 186 |
# File 'app/models/board.rb', line 179 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
152 |
# File 'app/models/board.rb', line 152 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.
163 164 165 166 167 168 169 170 171 172 |
# File 'app/models/board.rb', line 163 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.
155 156 157 158 159 |
# File 'app/models/board.rb', line 155 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
72 73 74 |
# File 'app/models/board.rb', line 72 def == "bypass" end |
#permission_mode_default ⇒ Object
67 68 69 70 |
# File 'app/models/board.rb', line 67 def settings["permission_mode"].presence_in(PERMISSION_MODES) || (settings["permission_bypass"] == false ? "restricted" : "bypass") end |
#repo_brief ⇒ Object
144 145 146 |
# File 'app/models/board.rb', line 144 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.
123 124 125 |
# File 'app/models/board.rb', line 123 def tag_pool cards.pluck(:tags).flatten.compact.uniq.sort end |