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 +4 -4
- data/app/assets/stylesheets/cardinal.css +21 -0
- data/app/controllers/cards_controller.rb +18 -1
- data/app/helpers/application_helper.rb +10 -0
- data/app/javascript/controllers/filter_controller.js +103 -0
- data/app/jobs/assistant_reply_job.rb +3 -1
- data/app/jobs/merge_pr_job.rb +26 -1
- data/app/jobs/start_run_job.rb +3 -1
- data/app/models/card.rb +23 -0
- data/app/models/column.rb +4 -8
- data/app/models/concerns/model_labeling.rb +20 -0
- data/app/models/event.rb +1 -1
- data/app/services/agent/runner.rb +5 -2
- data/app/views/boards/show.html.erb +7 -0
- data/app/views/cards/_card.html.erb +3 -2
- data/app/views/cards/_detail.html.erb +33 -1
- data/app/views/columns/_column.html.erb +9 -2
- data/db/migrate/20260704231436_add_model_and_effort_to_cards.rb +6 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ad9a65f2b86ce70952431f7f4c7689ddf6791213f7667d4bf87c2230381bce6
|
|
4
|
+
data.tar.gz: adc0e789a102a8382516b9b1eecd06fc911476187ce92ed4030e7e020c7c8ccb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
data/app/jobs/merge_pr_job.rb
CHANGED
|
@@ -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"])
|
|
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?
|
data/app/jobs/start_run_job.rb
CHANGED
|
@@ -21,7 +21,9 @@ class StartRunJob < ApplicationJob
|
|
|
21
21
|
return
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
session
|
|
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
|
-
|
|
129
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
12
|
-
|
|
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"
|
data/lib/cardinal/version.rb
CHANGED
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.
|
|
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
|