Class: Card

Inherits:
ApplicationRecord show all
Includes:
ModelLabeling
Defined in:
app/models/card.rb

Constant Summary collapse

STATUSES =
%w[
  draft discussing queued working needs_input blocked failed
  work_complete in_review changes_requested approved done archived
].freeze
{
  "inbox"     => %w[draft archived],
  "planning"  => %w[draft discussing archived],
  "execution" => %w[queued working needs_input blocked failed work_complete archived],
  "review"    => %w[in_review changes_requested approved archived],
  "terminal"  => %w[done blocked archived] # blocked: merge gate refused (CI red/pending)
}.freeze
STATUS_GLYPHS =

Card-face status glyphs. Keyed on status, except ready_for_approval? (a derived plan-park state, not a status) which pre-empts the ❓ below.

{
  "working" => "", "needs_input" => "", "failed" => "",
  "work_complete" => "", "done" => "", "queued" => "",
  "discussing" => "💬", "in_review" => "👁", "approved" => "👍",
  "changes_requested" => "🔁"
}.freeze
ARCHIVE_RESTORE =

Where an unarchived card lands: back in its column, in that column's most inert legal status — nothing fires, no agent starts.

{ "inbox" => "draft", "planning" => "discussing",
"execution" => "work_complete", "review" => "in_review",
"terminal" => "done" }.freeze
PERMISSION_MODES =

Per-card model/effort override (card #33). Nil fields mean "use the column default", so existing cards are unaffected. Read fresh at every segment spawn (start, restart, resume) by the runner and the planning assistant, so a change takes effect on the next segment — never on an already-running one. Permission override (nil = board default): "bypass" forces full autonomy for this card, "ask" forces the restricted file-tools mode.

["bypass", "ask"].freeze

Instance Method Summary collapse

Methods included from ModelLabeling

#model_label_of, #model_short_of

Instance Method Details

#assistant_costObject



131
# File 'app/models/card.rb', line 131

def assistant_cost = ai_calls.where(kind: "assistant").sum(:cost)

#awaiting_assistant?Boolean

Is the planning assistant expected to post next? True right after entering a planning column (kickoff inspection pending) or after a user message.

Returns:

  • (Boolean)


135
136
137
138
139
140
# File 'app/models/card.rb', line 135

def awaiting_assistant?
  return false unless column.planning? && column.ai?
  last = events.where(kind: %w[user_message assistant_message error column_move]).order(:id).last
  return false if last&.payload&.[]("note") # a note-only message expects no reply
  last.present? && %w[user_message column_move].include?(last.kind)
end

#compact_working?Boolean

A technical "compact" journal is being (re)generated in the background (§card #34) — the AI-readable context a resuming agent reads to skip re-exploration.

Returns:

  • (Boolean)


68
# File 'app/models/card.rb', line 68

def compact_working? = compact_status == "working"

#config_overridden?Boolean

Does this card override either half of the column's "how much brain" pair? Drives the de-magic marker on the card face: the board must never hide that a card will spend on a model other than its column's default.

Returns:

  • (Boolean)


113
# File 'app/models/card.rb', line 113

def config_overridden? = model.present? || effort.present?

#default_branch_nameObject



160
161
162
# File 'app/models/card.rb', line 160

def default_branch_name
  "cardinal/#{number}-#{title.parameterize[0, 40]}"
end

#effective_effortObject



108
# File 'app/models/card.rb', line 108

def effective_effort = effort.presence || column.effort

#effective_modelObject



107
# File 'app/models/card.rb', line 107

def effective_model  = model.presence || column.model

#effective_model_labelObject

"Opus - High*" — the effective model label for card faces and footers, with a trailing * when overridden so the override is visible without opening the card. Nil when no model resolves (AI off / unset), matching Column#model_label.



118
119
120
121
122
# File 'app/models/card.rb', line 118

def effective_model_label
  label = model_label_of(effective_model, effective_effort)
  return if label.blank?
  config_overridden? ? "#{label}*" : label
end

#effective_permission_bypass?Boolean

Returns:

  • (Boolean)


99
100
101
102
103
104
105
# File 'app/models/card.rb', line 99

def effective_permission_bypass?
  case permission_mode
  when "bypass" then true
  when "ask"    then false
  else board.permission_bypass?
  end
end

#latest_progressObject

Latest one-line progress event, shown live on the card face (§6).



87
88
89
# File 'app/models/card.rb', line 87

def latest_progress
  events.where(kind: "progress").last&.payload&.[]("text")
end

#log!(kind, actor: "system", run: nil, **payload) ⇒ Object



164
165
166
# File 'app/models/card.rb', line 164

def log!(kind, actor: "system", run: nil, **payload)
  events.create!(kind:, actor:, run:, payload:)
end

#needs_attention?Boolean

Returns:

  • (Boolean)


61
# File 'app/models/card.rb', line 61

def needs_attention? = %w[needs_input blocked failed work_complete].include?(status)

#planning_ready?Boolean

Planning chip state (card #25): the assistant ends discussion by posting a "Ready for execution" brief — the SAME marker Agent::Runner promotes into worker briefings. When that's the assistant's latest word, the card face flips from "replied" to "ready".

Returns:

  • (Boolean)


146
147
148
149
# File 'app/models/card.rb', line 146

def planning_ready?
  discussing? &&
    events.where(kind: "assistant_message").order(:id).last&.text.to_s.match?(/ready for execution/i)
end

#ready_for_approval?Boolean

The agent has proposed a plan and is parked waiting on the user's approve click — distinct from a genuine question. Same underlying needs_input status; the plan-phase park is the signal (mirrors the work panel, §detail). The status guard means the run query only fires for already-parked cards.

Returns:

  • (Boolean)


76
77
78
# File 'app/models/card.rb', line 76

def ready_for_approval?
  needs_input? && runs.needs_input.order(:id).last&.phase == "plan"
end

#running?Boolean

Returns:

  • (Boolean)


70
# File 'app/models/card.rb', line 70

def running? = %w[queued working needs_input].include?(status)

#status_glyphObject

Card-face glyph: a bell when a plan is awaiting approval, else the plain per-status mapping.



82
83
84
# File 'app/models/card.rb', line 82

def status_glyph
  ready_for_approval? ? "🔔" : STATUS_GLYPHS[status]
end

#summary_working?Boolean

A customer-friendly summary is being (re)generated in the background (§card #35).

Returns:

  • (Boolean)


64
# File 'app/models/card.rb', line 64

def summary_working? = summary_status == "working"

#thinking?Boolean

Some AI is expected to write to this card imminently.

Returns:

  • (Boolean)


152
153
154
# File 'app/models/card.rb', line 152

def thinking?
  awaiting_assistant? || working?
end

#to_paramObject

URLs speak card numbers, matching every other surface (header #N, branches, PR titles) — not database ids, which drift after deletions.



158
# File 'app/models/card.rb', line 158

def to_param = number.to_s

#total_costObject

Running tally across every run on the card — the closed-card cost footer (card #20). Sums stopped/restarted segments so the total reflects real spend. Honest money: worker runs PLUS every one-shot call made on this card's behalf (planning assistant, ai_task, summary/compact) — see AiCall.



128
# File 'app/models/card.rb', line 128

def total_cost = runs.sum(:cost) + ai_calls.sum(:cost)

#total_output_tokensObject



129
# File 'app/models/card.rb', line 129

def total_output_tokens = runs.sum(:output_tokens) + ai_calls.sum(:output_tokens)