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
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

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.



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



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

Returns:

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

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

Returns:

  • (Boolean)


148
149
150
# File 'app/models/board.rb', line 148

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/.



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

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)


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

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.



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

Returns:

  • (Boolean)


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

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.



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_shaObject

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

Returns:

  • (Boolean)


72
73
74
# File 'app/models/board.rb', line 72

def permission_bypass?
  permission_mode_default == "bypass"
end

#permission_mode_defaultObject



67
68
69
70
# File 'app/models/board.rb', line 67

def permission_mode_default
  settings["permission_mode"].presence_in(PERMISSION_MODES) ||
    (settings["permission_bypass"] == false ? "restricted" : "bypass")
end

#repo_briefObject



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

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.



123
124
125
# File 'app/models/board.rb', line 123

def tag_pool
  cards.pluck(:tags).flatten.compact.uniq.sort
end