Class: Board

Inherits:
ApplicationRecord show all
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

Instance Method Summary collapse

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.expand_path(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

Returns:

  • (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_cardsObject

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

Returns:

  • (Boolean)


140
141
142
# File 'app/models/board.rb', line 140

def brief?
  brief_sha.present? && File.exist?(brief_path)
end

#brief_pathObject

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.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("repo-brief.md")
end

#brief_stale?Boolean

Returns:

  • (Boolean)


166
# File 'app/models/board.rb', line 166

def brief_stale? = (commits_behind_brief || 0) >= BRIEF_STALE_AT

#brief_staleness_colorObject

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

Returns:

  • (Boolean)


144
# File 'app/models/board.rb', line 144

def brief_working? = brief_status == "working"

#commits_behind_briefObject

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_shaObject

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.

Returns:

  • (Boolean)


64
65
66
# File 'app/models/board.rb', line 64

def permission_bypass?
  settings["permission_bypass"] != false
end

#repo_briefObject



136
137
138
# File 'app/models/board.rb', line 136

def repo_brief
  File.read(brief_path) if File.exist?(brief_path)
end

#tag_poolObject

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