cardinal-ai 0.2.8 β†’ 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 644f883c694c74ad5189ef319f46beaf3e679a7aa47ae43be9bb316560ba46cf
4
- data.tar.gz: a54a728cece60d820acbaef2ad4abdcb8080d4fd888f122700f1266f85b9713f
3
+ metadata.gz: 8ad9a65f2b86ce70952431f7f4c7689ddf6791213f7667d4bf87c2230381bce6
4
+ data.tar.gz: adc0e789a102a8382516b9b1eecd06fc911476187ce92ed4030e7e020c7c8ccb
5
5
  SHA512:
6
- metadata.gz: c4d36b5b5fc1936cdea3946cc53977708d15c2035fd1e6e54cf9f5d06679d8c1e40fa056e8e955ffa3f9447bc7c8213c990ebcb581ecf862d44fc57b8b0d01d8
7
- data.tar.gz: 840bb89331ac1d362b81a6194d5887f1729702274e217fd921af5493bffd05f32a0047607b2a4ca0b72046ba4263cb85e93e2f770c3adb499fc703c298ef93b7
6
+ metadata.gz: 73e01d661c1341d873927ce0504de1f7f7e946f6fa7611bd96d4bdcb39e391fc55cb4f012fc66c67f7cf60e271a56bd11c7ca58a170cb9ff2bb0baebb708be5a
7
+ data.tar.gz: 1dee5696b49d701fa0f9ef4ee4f9d4b1e4a69c1b3a5d77b3d4f8cfe5379133024fcc853e9b91f89f0078183d0055e2b7b58c78669108b37f89e8614916ec7050
@@ -87,6 +87,27 @@ a { color: var(--blue); text-decoration: none; }
87
87
  font-size: 13px;
88
88
  }
89
89
 
90
+ /* Board search & filter (card #51): global box center-top, per-column πŸ”. */
91
+ .topbar-center { flex: 1; display: flex; justify-content: center; padding: 0 14px; }
92
+ .global-search {
93
+ width: min(340px, 38vw); background: var(--surface-2); border: 1px solid var(--border);
94
+ border-radius: 6px; color: var(--text); padding: 5px 10px;
95
+ }
96
+ .global-search::placeholder, .col-search::placeholder { color: var(--text-dim); }
97
+ .col-tools { display: flex; align-items: center; gap: 6px; }
98
+ .col-search-toggle {
99
+ background: none; border: 0; padding: 0 2px; cursor: pointer;
100
+ font-size: 12px; opacity: .55; line-height: 1;
101
+ }
102
+ .col-search-toggle:hover { opacity: 1; }
103
+ .col-search {
104
+ display: none; margin: 0 0 8px; width: 100%;
105
+ background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px;
106
+ color: var(--text); padding: 4px 8px; font-size: 12px;
107
+ }
108
+ .col-search.open { display: block; }
109
+ .filter-hidden { display: none !important; }
110
+
90
111
  .pull-form { display: inline; }
91
112
  #repo-pull-status { font-size: 0.85rem; }
92
113
  #repo-pull-status .pull-ok { color: var(--green); }
@@ -56,6 +56,7 @@ class CardsController < ApplicationController
56
56
  attrs.delete(:pr_url) if @card.pr_url.present?
57
57
  @card.update!(attrs)
58
58
  log_changelog!
59
+ log_config_change!
59
60
 
60
61
  respond_to do |format|
61
62
  # Explicitly patch the board face in this tab too β€” Turbo suppresses a
@@ -146,8 +147,12 @@ class CardsController < ApplicationController
146
147
  end
147
148
 
148
149
  def card_params
149
- attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact)
150
+ attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact, :model, :effort)
150
151
  attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
152
+ # Blank model/effort from the "Use column default" option mean "no override" β€”
153
+ # store nil so effective_* falls back to the column (card #33).
154
+ attrs[:model] = attrs[:model].presence if attrs.key?(:model)
155
+ attrs[:effort] = attrs[:effort].presence if attrs.key?(:effort)
151
156
  attrs.to_h.symbolize_keys
152
157
  end
153
158
 
@@ -166,4 +171,16 @@ class CardsController < ApplicationController
166
171
  text: "Details edited: #{changed.join(", ")}")
167
172
  end
168
173
  end
174
+
175
+ # A model/effort override is not a routine detail edit β€” it changes what the
176
+ # card will spend on. Log each change explicitly with its old→new value (the
177
+ # "config_change" kind, card #33) instead of coalescing it into the changelog
178
+ # above, so the timeline records exactly what the money will run on and when.
179
+ def log_config_change!
180
+ (@card.previous_changes.keys & %w[model effort]).each do |field|
181
+ old, new = @card.previous_changes[field].map { |v| v.presence || "column default" }
182
+ @card.log!("config_change", actor: "user", field: field, old: old, new: new,
183
+ text: "#{field.capitalize} changed: #{old} β†’ #{new}")
184
+ end
185
+ end
169
186
  end
@@ -13,6 +13,16 @@ module ApplicationHelper
13
13
  options
14
14
  end
15
15
 
16
+ # Model options for a card's per-card override (card #33). Same list as the
17
+ # column gear, but the blank option reads "Use column default (<model>)" so the
18
+ # fallback names the concrete model the card runs on when left unset.
19
+ def card_model_options(card)
20
+ options = model_options(card.model)
21
+ default = card.column.model_short.presence
22
+ options[0] = ["Use column default#{" (#{default})" if default}", ""]
23
+ options
24
+ end
25
+
16
26
  # Timeline text is Markdown (agents write it constantly). escape_html turns
17
27
  # any raw HTML in the text into visible text instead of live DOM β€” an agent
18
28
  # pasting `<div class=...>` inside a code fence must never restyle the page.
@@ -0,0 +1,103 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Board search & filter (card #51): one global query (topbar center) plus
4
+ // optional per-column queries (πŸ” in each column header). A card must match
5
+ // both the global query and its own column's query; non-matches hide.
6
+ //
7
+ // Filter state lives HERE, not in the DOM β€” Turbo morphs and column stream
8
+ // replaces rebuild board markup at will, so a MutationObserver restores input
9
+ // values and re-applies hiding after every re-render.
10
+ export default class extends Controller {
11
+ static targets = ["global"]
12
+
13
+ connect() {
14
+ this.state = { global: "", cols: {} }
15
+ this.slash = (e) => {
16
+ if (e.key === "/" && !e.target.closest("input, textarea, [contenteditable]")) {
17
+ e.preventDefault()
18
+ this.globalTarget.focus()
19
+ }
20
+ }
21
+ document.addEventListener("keydown", this.slash)
22
+ // childList only: our own class/value writes are attribute mutations and
23
+ // must not re-trigger (no observe loop).
24
+ this.observer = new MutationObserver(() => this.schedule())
25
+ this.observer.observe(this.element, { childList: true, subtree: true })
26
+ }
27
+
28
+ disconnect() {
29
+ document.removeEventListener("keydown", this.slash)
30
+ this.observer?.disconnect()
31
+ clearTimeout(this.timer)
32
+ }
33
+
34
+ schedule() {
35
+ clearTimeout(this.timer)
36
+ this.timer = setTimeout(() => this.restore(), 60)
37
+ }
38
+
39
+ global(event) {
40
+ this.state.global = event.target.value
41
+ this.apply()
42
+ }
43
+
44
+ column(event) {
45
+ this.state.cols[event.target.dataset.colId] = event.target.value
46
+ this.apply()
47
+ }
48
+
49
+ toggle(event) {
50
+ const col = event.currentTarget.closest(".column")
51
+ const input = col.querySelector(".col-search")
52
+ if (input.classList.toggle("open")) {
53
+ input.focus()
54
+ } else {
55
+ input.value = ""
56
+ this.state.cols[col.dataset.colId] = ""
57
+ this.apply()
58
+ }
59
+ }
60
+
61
+ // Esc inside a search box clears just that box.
62
+ clear(event) {
63
+ if (event.key !== "Escape") return
64
+ event.target.value = ""
65
+ if (this.hasGlobalTarget && event.target === this.globalTarget) {
66
+ this.state.global = ""
67
+ } else {
68
+ this.state.cols[event.target.dataset.colId] = ""
69
+ }
70
+ event.target.blur()
71
+ this.apply()
72
+ }
73
+
74
+ // After a morph or column replace rebuilt markup: put state back into any
75
+ // re-rendered inputs (never fighting one the user is typing in), re-open
76
+ // column boxes that hold a query, then re-hide.
77
+ restore() {
78
+ if (this.hasGlobalTarget && document.activeElement !== this.globalTarget) {
79
+ this.globalTarget.value = this.state.global
80
+ }
81
+ this.element.querySelectorAll(".col-search").forEach(input => {
82
+ const q = this.state.cols[input.dataset.colId] || ""
83
+ if (document.activeElement !== input) input.value = q
84
+ if (q !== "") input.classList.add("open")
85
+ })
86
+ this.apply()
87
+ }
88
+
89
+ apply() {
90
+ const g = this.norm(this.state.global)
91
+ this.element.querySelectorAll(".card[data-search]").forEach(card => {
92
+ const colId = card.closest(".column")?.dataset.colId
93
+ const c = this.norm(this.state.cols[colId])
94
+ const hay = card.dataset.search
95
+ const hit = (!g || hay.includes(g)) && (!c || hay.includes(c))
96
+ card.classList.toggle("filter-hidden", !hit)
97
+ })
98
+ }
99
+
100
+ norm(value) {
101
+ return (value || "").trim().toLowerCase()
102
+ }
103
+ }
@@ -45,7 +45,9 @@ class AssistantReplyJob < ApplicationJob
45
45
  # assistant's last reply, not just the latest message.
46
46
  def converse(card, kickoff:)
47
47
  repo = card.board.local_path.presence
48
- common = { model: card.column.model.presence || FALLBACK_MODEL,
48
+ # Effective model honors a per-card override (card #33) β€” the same
49
+ # effective_model the worker uses, so one card resolves one model everywhere.
50
+ common = { model: card.effective_model.presence || FALLBACK_MODEL,
49
51
  tools: repo ? "Read,Glob,Grep" : nil,
50
52
  cwd: repo, max_turns: MAX_TURNS, with_session: true }
51
53
 
@@ -7,11 +7,17 @@ class MergePrJob < ApplicationJob
7
7
  card = Card.find(card_id)
8
8
  return if card.pr_url.blank? || card.pr_state == "merged"
9
9
  return unless checks_green?(card)
10
+ return unless mergeable?(card)
10
11
 
11
12
  # Best-effort undraft β€” a QA column may already have done it, and gh
12
13
  # errors on an already-ready PR; the merge step is the real gate.
13
14
  Open3.capture2e("gh", "pr", "ready", card.pr_url)
14
- run_step(card, ["gh", "pr", "merge", card.pr_url, "--squash", "--delete-branch"]) or return
15
+ unless run_step(card, ["gh", "pr", "merge", card.pr_url, "--squash", "--delete-branch"])
16
+ # The floor (card #55): a card whose merge failed must never sit in
17
+ # Done claiming done β€” block it so the attention dropdown surfaces it.
18
+ card.update!(status: "blocked")
19
+ return
20
+ end
15
21
 
16
22
  card.update!(pr_state: "merged")
17
23
  card.log!("status_change", text: "PR squash-merged and branch deleted β€” shipped πŸŽ‰")
@@ -40,6 +46,25 @@ class MergePrJob < ApplicationJob
40
46
  false
41
47
  end
42
48
 
49
+ # A sibling card's merge can conflict this one after its CI ran (card #55) β€”
50
+ # mergeability is a separate axis from checks. Ask before attempting so the
51
+ # conflict parks with a clean reason instead of a raw gh error. UNKNOWN
52
+ # (GitHub still computing) and gh hiccups fall through to the merge attempt,
53
+ # which remains the authority.
54
+ def mergeable?(card)
55
+ out, status = Open3.capture2e("gh", "pr", "view", card.pr_url, "--json", "mergeable")
56
+ return true unless status.success?
57
+ return true unless JSON.parse(out)["mergeable"] == "CONFLICTING"
58
+
59
+ card.log!("error", text: "Merge conflict with #{card.board.default_branch} β€” not merged. " \
60
+ "Another card's merge likely landed first. Drag this card back to " \
61
+ "In Progress for a conflict-resolution run, then bring it back here.")
62
+ card.update!(status: "blocked")
63
+ false
64
+ rescue JSON::ParserError
65
+ true
66
+ end
67
+
43
68
  def run_step(card, cmd)
44
69
  out, status = Open3.capture2e(*cmd)
45
70
  return true if status.success?
@@ -21,7 +21,9 @@ class StartRunJob < ApplicationJob
21
21
  return
22
22
  end
23
23
 
24
- session = card.agent_sessions.create!(status: "provisioning", model: column.model)
24
+ # Record the model the session actually runs on β€” the card's effective
25
+ # (possibly overridden) model, not the raw column default (card #33).
26
+ session = card.agent_sessions.create!(status: "provisioning", model: card.effective_model)
25
27
  run = session.runs.create!(status: "queued", briefing: { "card" => card.title, "column" => column.name })
26
28
  Agent::Runner.start(run)
27
29
  end
data/app/models/card.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  class Card < ApplicationRecord
2
+ include ModelLabeling
3
+
2
4
  STATUSES = %w[
3
5
  draft discussing queued working needs_input blocked failed
4
6
  work_complete in_review changes_requested approved done archived
@@ -76,6 +78,27 @@ class Card < ApplicationRecord
76
78
  events.where(kind: "progress").last&.payload&.[]("text")
77
79
  end
78
80
 
81
+ # Per-card model/effort override (card #33). Nil fields mean "use the column
82
+ # default", so existing cards are unaffected. Read fresh at every segment
83
+ # spawn (start, restart, resume) by the runner and the planning assistant, so
84
+ # a change takes effect on the next segment β€” never on an already-running one.
85
+ def effective_model = model.presence || column.model
86
+ def effective_effort = effort.presence || column.effort
87
+
88
+ # Does this card override either half of the column's "how much brain" pair?
89
+ # Drives the de-magic marker on the card face: the board must never hide that
90
+ # a card will spend on a model other than its column's default.
91
+ def config_overridden? = model.present? || effort.present?
92
+
93
+ # "Opus - High*" β€” the effective model label for card faces and footers, with
94
+ # a trailing * when overridden so the override is visible without opening the
95
+ # card. Nil when no model resolves (AI off / unset), matching Column#model_label.
96
+ def effective_model_label
97
+ label = model_label_of(effective_model, effective_effort)
98
+ return if label.blank?
99
+ config_overridden? ? "#{label}*" : label
100
+ end
101
+
79
102
  # Running tally across every run on the card β€” the closed-card cost footer
80
103
  # (card #20). Sums stopped/restarted segments so the total reflects real spend.
81
104
  def total_cost = runs.sum(:cost)
data/app/models/column.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  class Column < ApplicationRecord
2
+ include ModelLabeling
3
+
2
4
  ARCHETYPES = %w[inbox planning execution review terminal].freeze
3
5
 
4
6
  belongs_to :board
@@ -105,17 +107,11 @@ class Column < ApplicationRecord
105
107
  end
106
108
 
107
109
  # "claude-sonnet-4-6" β†’ "sonnet", for compact chips on card faces.
108
- def model_short
109
- model.to_s[/claude-([a-z]+)/, 1] || model
110
- end
110
+ def model_short = model_short_of(model)
111
111
 
112
112
  # "Opus - High" β€” human label for cost footers (card #20). Effort is optional,
113
113
  # so a model with no configured effort renders just "Opus".
114
- def model_label
115
- return if model.blank?
116
- label = model_short.to_s.capitalize
117
- effort.present? ? "#{label} - #{effort.to_s.capitalize}" : label
118
- end
114
+ def model_label = model_label_of(model, effort)
119
115
 
120
116
  validates :name, presence: true
121
117
  validates :position, presence: true
@@ -0,0 +1,20 @@
1
+ # Shared model-name formatting for the two places a model+effort pair is
2
+ # resolved and shown: a Column's own policy and a Card's effective (possibly
3
+ # overridden) config. Kept in one place so a card face and its column can never
4
+ # disagree about how a model reads β€” the board must not misreport what spends.
5
+ module ModelLabeling
6
+ extend ActiveSupport::Concern
7
+
8
+ # "claude-sonnet-4-6" β†’ "sonnet"; passes through a non-claude name unchanged.
9
+ def model_short_of(model_name)
10
+ model_name.to_s[/claude-([a-z]+)/, 1] || model_name.presence
11
+ end
12
+
13
+ # "Opus - High" β€” human label from a model + optional effort. Effort is
14
+ # optional, so a model with no effort renders just "Opus". Blank model β†’ nil.
15
+ def model_label_of(model_name, effort_name)
16
+ return if model_name.blank?
17
+ label = model_short_of(model_name).to_s.capitalize
18
+ effort_name.present? ? "#{label} - #{effort_name.to_s.capitalize}" : label
19
+ end
20
+ end
data/app/models/event.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  class Event < ApplicationRecord
2
2
  KINDS = %w[
3
3
  user_message agent_message assistant_message
4
- status_change column_move move_rejected plan_proposed plan_approved
4
+ status_change config_change column_move move_rejected plan_proposed plan_approved
5
5
  question answer progress
6
6
  tool_call tool_result artifact_created
7
7
  run_started run_finished final_report error
@@ -125,8 +125,11 @@ module Agent
125
125
  # list itself, not a request in the prompt.
126
126
  cmd += ["--tools", "Read,Glob,Grep,Edit,Write"] unless column.shell_access?
127
127
  end
128
- cmd += ["--model", column.model] if column.model.present?
129
- cmd += ["--effort", column.effort] if column.effort.present?
128
+ # Effective (possibly card-overridden) config, read fresh here at spawn
129
+ # time β€” so a mid-run override takes effect on the next segment (start,
130
+ # restart, resume), never on one already streaming (card #33).
131
+ cmd += ["--model", card.effective_model] if card.effective_model.present?
132
+ cmd += ["--effort", card.effective_effort] if card.effective_effort.present?
130
133
  cmd += ["--resume", run.external_session_id] if resuming && run.external_session_id.present?
131
134
 
132
135
  result = {}
@@ -1,5 +1,6 @@
1
1
  <%= turbo_stream_from @board %>
2
2
 
3
+ <div data-controller="filter">
3
4
  <header class="topbar">
4
5
  <div class="topbar-left">
5
6
  <h1>Cardinal AI <span class="sep">β–Έ</span> <span id="board-name"><%= @board.name %></span></h1>
@@ -37,6 +38,11 @@
37
38
  <% end %>
38
39
  <span id="repo-pull-status"></span>
39
40
  </div>
41
+ <div class="topbar-center">
42
+ <input type="search" id="global-search" class="global-search" placeholder="Search cards… /"
43
+ data-filter-target="global"
44
+ data-action="input->filter#global keydown->filter#clear">
45
+ </div>
40
46
  <div class="topbar-right">
41
47
  <button type="button" class="theme-toggle"
42
48
  data-controller="theme" data-action="theme#toggle">β˜€ Light</button>
@@ -108,6 +114,7 @@
108
114
  <%= render "columns/column", column: column %>
109
115
  <% end %>
110
116
  </main>
117
+ </div>
111
118
 
112
119
  <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
113
120
  <%= render "cards/detail" if @card %>
@@ -1,4 +1,5 @@
1
- <article class="card status-<%= card.status %>" id="<%= dom_id(card) %>" data-card-id="<%= card.number %>">
1
+ <article class="card status-<%= card.status %>" id="<%= dom_id(card) %>" data-card-id="<%= card.number %>"
2
+ data-search="<%= [card.title, "##{card.number}", Array(card.tags).join(" "), card.description.to_s.truncate(400)].join(" ").downcase %>">
2
3
  <%= link_to card_path(card), class: "card-link", data: { turbo_frame: "modal", turbo_action: "advance" } do %>
3
4
  <div class="card-title">
4
5
  <%= card.title %>
@@ -38,7 +39,7 @@
38
39
  <% else %>
39
40
  <span class="footer-pr"></span>
40
41
  <% end %>
41
- <span class="footer-center"><%= "πŸ€– #{card.column.model_label}" if has_cost && card.column.model_label %></span>
42
+ <span class="footer-center"><%= "πŸ€– #{card.effective_model_label}" if has_cost && card.effective_model_label %></span>
42
43
  <span class="footer-cost"><%= "$#{card.total_cost.round(2)}" if has_cost %></span>
43
44
  </div>
44
45
  <% end %>
@@ -140,6 +140,38 @@
140
140
  <% end %>
141
141
  <% end %>
142
142
 
143
+ <%# Per-card model/effort override (card #33). Its own autosave form so a
144
+ change saves and re-renders the card face (override marker) without
145
+ replacing this modal mid-interaction. The summary names the effective
146
+ state even when collapsed β€” the board must not hide what will spend. %>
147
+ <div class="model-override" data-controller="autosave">
148
+ <%= form_with model: @card, data: { autosave_target: "form", action: "change->autosave#save" } do |f| %>
149
+ <%= hidden_field_tag :autosave, "1" %>
150
+ <details class="advanced-rules">
151
+ <summary>
152
+ 🧠 Model:
153
+ <% if @card.config_overridden? %>
154
+ <strong>override</strong> (<%= @card.effective_model_label&.delete_suffix("*") || "none" %>)
155
+ <% else %>
156
+ column default (<%= @card.column.model_label || "none" %>)
157
+ <% end %>
158
+ <span class="autosave-status" data-autosave-target="status"></span>
159
+ </summary>
160
+ <div class="field-row">
161
+ <div>
162
+ <label>Model <%= info_tip("Overrides the column's model for THIS card only β€” worker runs and this card's planning replies both. Leave on \"Use column default\" to follow the column.") %></label>
163
+ <%= f.select :model, card_model_options(@card), selected: @card.model.to_s %>
164
+ </div>
165
+ <div>
166
+ <label>Effort <%= info_tip("How hard the model thinks per step, for this card only. Higher = smarter but slower and pricier. Leave on default to follow the column.") %></label>
167
+ <%= f.select :effort, [["Use column default", ""], "low", "medium", "high", "max"], selected: @card.effort %>
168
+ </div>
169
+ </div>
170
+ <p class="hint">Overrides this card's model and effort. Only model + effort β€” budget, timeout, and concurrency are column policy. Takes effect on the next run segment (a run already going finishes on the model it started with).</p>
171
+ </details>
172
+ <% end %>
173
+ </div>
174
+
143
175
  <h3>Work</h3>
144
176
  <% runs = @card.runs.order(:id) %>
145
177
  <% parked = runs.select(&:needs_input?).last %>
@@ -187,7 +219,7 @@
187
219
 
188
220
  <% if latest && (latest.cost.positive? || latest.output_tokens.positive?) %>
189
221
  <div class="work-footer">
190
- <span class="footer-left"><%= "πŸ€– #{@card.column.model_label}" if @card.column.model_label %></span>
222
+ <span class="footer-left"><%= "πŸ€– #{@card.effective_model_label}" if @card.effective_model_label %></span>
191
223
  <span class="footer-cost">$<%= latest.cost.round(2) %> Β· <%= latest.output_tokens %> out</span>
192
224
  </div>
193
225
  <% end %>
@@ -8,9 +8,16 @@
8
8
  data: { turbo_frame: "modal" } %>
9
9
  <% end %>
10
10
  </span>
11
- <%= link_to "βš™", edit_column_path(column), class: "gear", title: "Column settings",
12
- data: { turbo_frame: "modal" } %>
11
+ <span class="col-tools">
12
+ <button type="button" class="col-search-toggle" title="Filter this column"
13
+ data-action="filter#toggle">πŸ”</button>
14
+ <%= link_to "βš™", edit_column_path(column), class: "gear", title: "Column settings",
15
+ data: { turbo_frame: "modal" } %>
16
+ </span>
13
17
  </header>
18
+ <input type="search" class="col-search" placeholder="Filter <%= column.name %>…"
19
+ data-col-id="<%= column.id %>"
20
+ data-action="input->filter#column keydown->filter#clear">
14
21
  <p class="drop-hint">β†’ <%= column.drag_hint %></p>
15
22
  <div class="cards<%= " cards-clickable" if column.inbox? %>"
16
23
  data-controller="board-column"
@@ -0,0 +1,6 @@
1
+ class AddModelAndEffortToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :model, :string
4
+ add_column :cards, :effort, :string
5
+ end
6
+ end
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.8"
2
+ VERSION = "0.2.9"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardinal-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.2.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -176,6 +176,7 @@ files:
176
176
  - app/javascript/controllers/clipboard_controller.js
177
177
  - app/javascript/controllers/composer_controller.js
178
178
  - app/javascript/controllers/dismiss_controller.js
179
+ - app/javascript/controllers/filter_controller.js
179
180
  - app/javascript/controllers/index.js
180
181
  - app/javascript/controllers/modal_controller.js
181
182
  - app/javascript/controllers/reveal_controller.js
@@ -200,6 +201,7 @@ files:
200
201
  - app/models/board.rb
201
202
  - app/models/card.rb
202
203
  - app/models/column.rb
204
+ - app/models/concerns/model_labeling.rb
203
205
  - app/models/event.rb
204
206
  - app/models/run.rb
205
207
  - app/services/agent/runner.rb
@@ -264,6 +266,7 @@ files:
264
266
  - db/migrate/20260704120000_add_repo_brief_to_boards.rb
265
267
  - db/migrate/20260704130000_add_summary_to_cards.rb
266
268
  - db/migrate/20260704140000_add_compact_to_cards.rb
269
+ - db/migrate/20260704231436_add_model_and_effort_to_cards.rb
267
270
  - db/queue_schema.rb
268
271
  - db/seeds.rb
269
272
  - docker/agent/Dockerfile