cardinal-ai 0.2.7 β 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 +24 -3
- data/app/controllers/boards_controller.rb +13 -3
- data/app/controllers/cards_controller.rb +49 -6
- data/app/controllers/runs_controller.rb +11 -1
- data/app/helpers/application_helper.rb +10 -0
- data/app/javascript/controllers/dismiss_controller.js +23 -0
- data/app/javascript/controllers/filter_controller.js +103 -0
- data/app/jobs/assistant_reply_job.rb +3 -1
- data/app/jobs/compact_job.rb +97 -0
- data/app/jobs/merge_pr_job.rb +26 -1
- data/app/jobs/start_run_job.rb +3 -1
- data/app/models/card.rb +50 -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 +26 -4
- data/app/views/boards/show.html.erb +7 -0
- data/app/views/cards/_card.html.erb +10 -17
- data/app/views/cards/_compact_panel.html.erb +28 -0
- data/app/views/cards/_detail.html.erb +40 -4
- data/app/views/cards/_dismiss_flash.html.erb +5 -0
- data/app/views/columns/_column.html.erb +9 -2
- data/config/routes.rb +1 -0
- data/db/migrate/20260704140000_add_compact_to_cards.rb +7 -0
- data/db/migrate/20260704231436_add_model_and_effort_to_cards.rb +6 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +9 -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); }
|
|
@@ -216,9 +237,9 @@ body.dragging .drop-hint { display: block; }
|
|
|
216
237
|
}
|
|
217
238
|
.card-footer:hover .footer-pr { color: var(--blue); }
|
|
218
239
|
.footer-left { min-width: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
219
|
-
.footer-
|
|
220
|
-
.footer-cost { white-space: nowrap; }
|
|
221
|
-
.footer-pr { color: var(--text-dim); }
|
|
240
|
+
.footer-center { min-width: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; }
|
|
241
|
+
.footer-cost { white-space: nowrap; flex-shrink: 0; }
|
|
242
|
+
.footer-pr { color: var(--text-dim); flex-shrink: 0; }
|
|
222
243
|
|
|
223
244
|
/* Open-card cost tally: sits at the foot of the work panel, live-updating. */
|
|
224
245
|
.work-footer {
|
|
@@ -60,10 +60,19 @@ class BoardsController < ApplicationController
|
|
|
60
60
|
def pull
|
|
61
61
|
board = Board.first!
|
|
62
62
|
message, ok = pull_repo(board)
|
|
63
|
-
|
|
63
|
+
streams = [turbo_stream.update(
|
|
64
64
|
"repo-pull-status",
|
|
65
65
|
helpers.tag.span(message, class: ok ? "pull-ok" : "pull-err")
|
|
66
|
-
)
|
|
66
|
+
)]
|
|
67
|
+
if @pulled_commits
|
|
68
|
+
# New code often means new JS (Stimulus controllers, importmap entries)
|
|
69
|
+
# that an already-open tab will never fetch β Turbo morphs keep the page
|
|
70
|
+
# alive on stale assets forever. A pull is a deliberate "give me the new
|
|
71
|
+
# version", so finish the job with a full reload.
|
|
72
|
+
streams << turbo_stream.append("repo-pull-status",
|
|
73
|
+
helpers.tag.script("setTimeout(() => window.location.reload(), 1200)".html_safe))
|
|
74
|
+
end
|
|
75
|
+
render turbo_stream: streams
|
|
67
76
|
end
|
|
68
77
|
|
|
69
78
|
private
|
|
@@ -87,7 +96,8 @@ class BoardsController < ApplicationController
|
|
|
87
96
|
count, = Open3.capture2e("git", "-C", repo, "rev-list", "--count", "#{before.strip}..#{after.strip}")
|
|
88
97
|
migrated = run_pending_migrations
|
|
89
98
|
note = migrated.positive? ? " Β· ran #{helpers.pluralize(migrated, "migration")}" : ""
|
|
90
|
-
|
|
99
|
+
@pulled_commits = true
|
|
100
|
+
["Pulled #{helpers.pluralize(count.strip.to_i, "new commit")}#{note} Β· reloadingβ¦", true]
|
|
91
101
|
end
|
|
92
102
|
end
|
|
93
103
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
class CardsController < ApplicationController
|
|
2
|
-
before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :destroy]
|
|
2
|
+
before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy]
|
|
3
3
|
|
|
4
4
|
def new
|
|
5
5
|
@board = Board.first!
|
|
@@ -30,11 +30,11 @@ class CardsController < ApplicationController
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def show
|
|
33
|
-
@zoom = params[:zoom].presence_in(%w[conversation activity debug summary]) || "conversation"
|
|
33
|
+
@zoom = params[:zoom].presence_in(%w[conversation activity debug summary compact]) || "conversation"
|
|
34
34
|
@events = case @zoom
|
|
35
35
|
when "conversation" then @card.events.conversation
|
|
36
36
|
when "activity" then @card.events.activity
|
|
37
|
-
when "summary" then Event.none #
|
|
37
|
+
when "summary", "compact" then Event.none # these tabs show a card panel, not events
|
|
38
38
|
else @card.events
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -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
|
|
@@ -81,7 +82,20 @@ class CardsController < ApplicationController
|
|
|
81
82
|
@card.update!(status: "approved")
|
|
82
83
|
@card.log!("status_change", actor: "user", text: "Work approved β drag to Done to ship")
|
|
83
84
|
end
|
|
84
|
-
|
|
85
|
+
|
|
86
|
+
respond_to do |format|
|
|
87
|
+
# Flash the verdict in place, then let the dismiss controller minimize the
|
|
88
|
+
# modal. Patch the board face too β Turbo suppresses this tab's own refresh
|
|
89
|
+
# broadcast, so without this the card keeps its stale status once we close.
|
|
90
|
+
format.turbo_stream do
|
|
91
|
+
render turbo_stream: [
|
|
92
|
+
turbo_stream.replace(helpers.dom_id(@card), partial: "cards/card", locals: { card: @card }),
|
|
93
|
+
turbo_stream.replace("review-callout", partial: "cards/dismiss_flash",
|
|
94
|
+
locals: { message: "Approved β closingβ¦" })
|
|
95
|
+
]
|
|
96
|
+
end
|
|
97
|
+
format.html { redirect_to card_path(@card) }
|
|
98
|
+
end
|
|
85
99
|
end
|
|
86
100
|
|
|
87
101
|
# Generate a customer-friendly summary on demand (card #35). Non-blocking,
|
|
@@ -97,6 +111,19 @@ class CardsController < ApplicationController
|
|
|
97
111
|
partial: "cards/summary_panel", locals: { card: @card })
|
|
98
112
|
end
|
|
99
113
|
|
|
114
|
+
# Generate a technical "compact" journal on demand (card #34). The AI-readable
|
|
115
|
+
# mirror of #summarize: flip the card into its "working" state, morph the Compact
|
|
116
|
+
# panel so the button reflects it, and let CompactJob do the one-shot synthesis
|
|
117
|
+
# in the background. Skipped when one is already running.
|
|
118
|
+
def compact
|
|
119
|
+
unless @card.compact_working?
|
|
120
|
+
@card.update!(compact_status: "working")
|
|
121
|
+
CompactJob.perform_later(@card)
|
|
122
|
+
end
|
|
123
|
+
render turbo_stream: turbo_stream.replace("card_compact",
|
|
124
|
+
partial: "cards/compact_panel", locals: { card: @card })
|
|
125
|
+
end
|
|
126
|
+
|
|
100
127
|
def move
|
|
101
128
|
from_column = @card.column
|
|
102
129
|
to_column = @card.board.columns.find(params[:column_id])
|
|
@@ -120,15 +147,19 @@ class CardsController < ApplicationController
|
|
|
120
147
|
end
|
|
121
148
|
|
|
122
149
|
def card_params
|
|
123
|
-
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary)
|
|
150
|
+
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact, :model, :effort)
|
|
124
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)
|
|
125
156
|
attrs.to_h.symbolize_keys
|
|
126
157
|
end
|
|
127
158
|
|
|
128
159
|
# Changelog in the activity timeline (the mechanism already exists). A burst
|
|
129
160
|
# of autosaves coalesces into one entry instead of one per pause-in-typing.
|
|
130
161
|
def log_changelog!
|
|
131
|
-
changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary]
|
|
162
|
+
changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary compact]
|
|
132
163
|
return if changed.empty?
|
|
133
164
|
|
|
134
165
|
last = @card.events.order(:id).last
|
|
@@ -140,4 +171,16 @@ class CardsController < ApplicationController
|
|
|
140
171
|
text: "Details edited: #{changed.join(", ")}")
|
|
141
172
|
end
|
|
142
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
|
|
143
186
|
end
|
|
@@ -21,7 +21,17 @@ class RunsController < ApplicationController
|
|
|
21
21
|
card.log!("plan_approved", actor: "user", run: run, text: "Plan approved")
|
|
22
22
|
ResumeRunJob.perform_later(run.id, "", approve: true)
|
|
23
23
|
end
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
respond_to do |format|
|
|
26
|
+
# Flash the approval in place, then the dismiss controller minimizes the
|
|
27
|
+
# modal. The run advances asynchronously (ResumeRunJob), so there's no
|
|
28
|
+
# synchronous card state to morph here β just confirm and close.
|
|
29
|
+
format.turbo_stream do
|
|
30
|
+
render turbo_stream: turbo_stream.replace("plan-callout", partial: "cards/dismiss_flash",
|
|
31
|
+
locals: { message: "Plan approved β runningβ¦" })
|
|
32
|
+
end
|
|
33
|
+
format.html { redirect_to card_path(card) }
|
|
34
|
+
end
|
|
25
35
|
end
|
|
26
36
|
|
|
27
37
|
# Restart a run that parked or failed on its turn budget / timeout. Mirrors
|
|
@@ -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,23 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Briefly show a confirmation, then minimize the surrounding card modal.
|
|
4
|
+
// Rendered into an approval callout so the user sees the verdict land before
|
|
5
|
+
// the card snaps back to the columns view. On connect we start a timer; when
|
|
6
|
+
// it fires we hand off to the enclosing modal controller's close().
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static values = { delay: { type: Number, default: 1100 } }
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.timer = setTimeout(() => this.dismiss(), this.delayValue)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
clearTimeout(this.timer)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dismiss() {
|
|
19
|
+
const el = this.element.closest("[data-controller~='modal']")
|
|
20
|
+
if (!el) return
|
|
21
|
+
this.application.getControllerForElementAndIdentifier(el, "modal")?.close()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# On-demand, AI-readable "compact" of a card (card #34): the technical mirror of
|
|
2
|
+
# SummaryJob. Where Summary compresses a card into a couple of non-technical lines
|
|
3
|
+
# for a customer chat, Compact distills everything that happened β the brief,
|
|
4
|
+
# timeline, runs, final reports, and code commits β into a dense technical journal
|
|
5
|
+
# a *resuming agent* can read instead of re-exploring the repo and re-deriving
|
|
6
|
+
# context. That's the whole point: spend a few tokens now to save many later.
|
|
7
|
+
# Generation is user-triggered only; the result persists on the card and stays
|
|
8
|
+
# fully editable. A prior compact (possibly hand-edited) rides along as context so
|
|
9
|
+
# a regeneration refines rather than discards what the user kept.
|
|
10
|
+
class CompactJob < ApplicationJob
|
|
11
|
+
queue_as :default
|
|
12
|
+
|
|
13
|
+
FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
|
|
14
|
+
|
|
15
|
+
SYSTEM = <<~SYS.freeze
|
|
16
|
+
You write a dense, technical engineering journal for an AI agent that will
|
|
17
|
+
resume work on this card later. Your reader is a machine, not a customer:
|
|
18
|
+
optimize for signal, not readability. Capture, in compact form, everything a
|
|
19
|
+
resuming engineer needs so they do NOT have to re-explore the repo or re-derive
|
|
20
|
+
context β what was built and how, files and components touched, key APIs and
|
|
21
|
+
libraries used, decisions made and the reasons behind them, issues found,
|
|
22
|
+
blockers hit, and anything left unfinished or deliberately deferred. Prefer
|
|
23
|
+
terse structured notes (short headings, bullets, file paths, symbol names) over
|
|
24
|
+
prose. Omit pleasantries and customer-facing framing. Write only the journal.
|
|
25
|
+
SYS
|
|
26
|
+
|
|
27
|
+
def perform(card)
|
|
28
|
+
return clear_working(card) unless ClaudeCli.available?
|
|
29
|
+
|
|
30
|
+
model = card.board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
|
|
31
|
+
compact = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1)
|
|
32
|
+
|
|
33
|
+
card.update!(compact: compact.to_s.strip, compact_generated_at: Time.current, compact_status: nil)
|
|
34
|
+
card.broadcast_replace_to card, target: "card_compact",
|
|
35
|
+
partial: "cards/compact_panel", locals: { card: card }
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# A failed generation must not leave the button stuck on "Generatingβ¦".
|
|
38
|
+
clear_working(card)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def clear_working(card)
|
|
44
|
+
card.update!(compact_status: nil)
|
|
45
|
+
card.broadcast_replace_to card, target: "card_compact",
|
|
46
|
+
partial: "cards/compact_panel", locals: { card: card }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_prompt(card)
|
|
50
|
+
parts = ["Card ##{card.number}: #{card.title}"]
|
|
51
|
+
parts << "Tags: #{card.tags.join(", ")}" if card.tags.any?
|
|
52
|
+
parts << "\nBrief / description:\n#{card.description}" if card.description.present?
|
|
53
|
+
|
|
54
|
+
timeline = card.events.activity.filter_map { |e| event_line(e) }
|
|
55
|
+
parts << "\nWhat happened (technical timeline):\n#{timeline.join("\n")}" if timeline.any?
|
|
56
|
+
|
|
57
|
+
reports = card.events.where(kind: "final_report").filter_map(&:text).map(&:strip).reject(&:blank?)
|
|
58
|
+
parts << "\nFinal reports from runs:\n#{reports.join("\n\n---\n\n")}" if reports.any?
|
|
59
|
+
|
|
60
|
+
runs = card.runs.order(:id).map { |r| "- Run ##{r.id}: #{r.status}#{" (#{r.phase})" if r.phase.present?}" }
|
|
61
|
+
parts << "\nRuns:\n#{runs.join("\n")}" if runs.any?
|
|
62
|
+
|
|
63
|
+
commits = commit_lines(card)
|
|
64
|
+
parts << "\nCode changes (commit messages):\n#{commits.join("\n")}" if commits.any?
|
|
65
|
+
|
|
66
|
+
if card.compact.present?
|
|
67
|
+
parts << "\nThe existing technical compact is below. It may have been edited by hand, " \
|
|
68
|
+
"so preserve details the user kept and fold in any new work rather than " \
|
|
69
|
+
"starting from scratch:\n#{card.compact}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
parts.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def event_line(event)
|
|
76
|
+
text = event.text.to_s.strip
|
|
77
|
+
return nil if text.blank?
|
|
78
|
+
"- #{event.actor}: #{text.truncate(600)}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Commit messages for the card's branch, read from the per-card workspace
|
|
82
|
+
# checkout when it still exists. The checkout isn't guaranteed to be present
|
|
83
|
+
# (it's left in place after a run but may be pruned), so this is best-effort β
|
|
84
|
+
# the timeline already narrates the work when commits are unavailable.
|
|
85
|
+
def commit_lines(card)
|
|
86
|
+
return [] if card.branch_name.blank?
|
|
87
|
+
path = Agent::Workspace::Local.new(card).path
|
|
88
|
+
return [] unless File.directory?(path.join(".git"))
|
|
89
|
+
|
|
90
|
+
base = "origin/#{card.board.default_branch}"
|
|
91
|
+
out, ok = Open3.capture2e("git", "-C", path.to_s, "log", "--oneline", "--no-decorate", "#{base}..HEAD")
|
|
92
|
+
return [] unless ok.success?
|
|
93
|
+
out.lines.map(&:strip).reject(&:blank?).map { |l| "- #{l}" }
|
|
94
|
+
rescue StandardError
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
end
|
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
|
|
@@ -23,6 +25,15 @@ class Card < ApplicationRecord
|
|
|
23
25
|
has_many :agent_sessions, dependent: :destroy
|
|
24
26
|
has_many :runs, through: :agent_sessions
|
|
25
27
|
|
|
28
|
+
# Card-face status glyphs. Keyed on status, except `ready_for_approval?`
|
|
29
|
+
# (a derived plan-park state, not a status) which pre-empts the β below.
|
|
30
|
+
STATUS_GLYPHS = {
|
|
31
|
+
"working" => "β‘", "needs_input" => "β", "failed" => "β",
|
|
32
|
+
"work_complete" => "β
", "done" => "β", "queued" => "β³",
|
|
33
|
+
"discussing" => "π¬", "in_review" => "π", "approved" => "π",
|
|
34
|
+
"changes_requested" => "π"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
26
37
|
enum :status, STATUSES.index_by(&:itself)
|
|
27
38
|
|
|
28
39
|
validates :title, presence: true
|
|
@@ -42,13 +53,52 @@ class Card < ApplicationRecord
|
|
|
42
53
|
# A customer-friendly summary is being (re)generated in the background (Β§card #35).
|
|
43
54
|
def summary_working? = summary_status == "working"
|
|
44
55
|
|
|
56
|
+
# A technical "compact" journal is being (re)generated in the background (Β§card
|
|
57
|
+
# #34) β the AI-readable context a resuming agent reads to skip re-exploration.
|
|
58
|
+
def compact_working? = compact_status == "working"
|
|
59
|
+
|
|
45
60
|
def running? = %w[queued working needs_input].include?(status)
|
|
46
61
|
|
|
62
|
+
# The agent has proposed a plan and is parked waiting on the user's approve
|
|
63
|
+
# click β distinct from a genuine question. Same underlying `needs_input`
|
|
64
|
+
# status; the plan-phase park is the signal (mirrors the work panel, Β§detail).
|
|
65
|
+
# The status guard means the run query only fires for already-parked cards.
|
|
66
|
+
def ready_for_approval?
|
|
67
|
+
needs_input? && runs.needs_input.order(:id).last&.phase == "plan"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Card-face glyph: a bell when a plan is awaiting approval, else the plain
|
|
71
|
+
# per-status mapping.
|
|
72
|
+
def status_glyph
|
|
73
|
+
ready_for_approval? ? "π" : STATUS_GLYPHS[status]
|
|
74
|
+
end
|
|
75
|
+
|
|
47
76
|
# Latest one-line progress event, shown live on the card face (Β§6).
|
|
48
77
|
def latest_progress
|
|
49
78
|
events.where(kind: "progress").last&.payload&.[]("text")
|
|
50
79
|
end
|
|
51
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
|
+
|
|
52
102
|
# Running tally across every run on the card β the closed-card cost footer
|
|
53
103
|
# (card #20). Sums stopped/restarted segments so the total reflects real spend.
|
|
54
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 = {}
|
|
@@ -356,7 +359,7 @@ module Agent
|
|
|
356
359
|
## Card conversation so far
|
|
357
360
|
#{conversation_excerpt.presence || "(none)"}
|
|
358
361
|
|
|
359
|
-
#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
362
|
+
#{compact_section}#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
360
363
|
#{execute_rules}
|
|
361
364
|
PROMPT
|
|
362
365
|
end
|
|
@@ -377,7 +380,7 @@ module Agent
|
|
|
377
380
|
## Card conversation so far
|
|
378
381
|
#{conversation_excerpt.presence || "(none)"}
|
|
379
382
|
|
|
380
|
-
#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
383
|
+
#{compact_section}#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
381
384
|
Explore the repository as needed, then present a short numbered plan-of-attack
|
|
382
385
|
(files you'll touch, approach, how you'll verify) and stop. The user will approve
|
|
383
386
|
or redirect before any changes are made.
|
|
@@ -398,6 +401,25 @@ module Agent
|
|
|
398
401
|
"## Repo brief\n#{brief.strip}\n\n"
|
|
399
402
|
end
|
|
400
403
|
|
|
404
|
+
# The user-generated technical "compact" (card #34), if one exists β a dense
|
|
405
|
+
# journal of prior work on this card written for a resuming agent. Injected so
|
|
406
|
+
# the agent picks up where the last session left off instead of re-deriving
|
|
407
|
+
# context. Explicitly framed as technical resume notes, distinct from the
|
|
408
|
+
# human conversation above and from the customer-facing summary. Empty string
|
|
409
|
+
# (not nil) when absent, so the heredoc stays clean.
|
|
410
|
+
def compact_section
|
|
411
|
+
compact = card.compact.presence or return ""
|
|
412
|
+
<<~SECTION
|
|
413
|
+
## Technical resume context (compact from a prior work session)
|
|
414
|
+
The notes below were generated (and possibly hand-edited) to capture the
|
|
415
|
+
technical state of prior work on this card β what was built, decided, and
|
|
416
|
+
blocked. Treat them as an authoritative shortcut to avoid re-exploring the
|
|
417
|
+
repo; verify against the actual code before relying on any specific detail.
|
|
418
|
+
#{compact.strip}
|
|
419
|
+
|
|
420
|
+
SECTION
|
|
421
|
+
end
|
|
422
|
+
|
|
401
423
|
# The planning assistant's distilled "Ready for execution" brief, if the
|
|
402
424
|
# conversation produced one β the most load-bearing artifact of planning.
|
|
403
425
|
def planning_brief
|
|
@@ -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,11 +1,9 @@
|
|
|
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 %>
|
|
5
|
-
<span class="status-glyph"><%=
|
|
6
|
-
"work_complete" => "β
", "done" => "β", "queued" => "β³",
|
|
7
|
-
"discussing" => "π¬", "in_review" => "π", "approved" => "π",
|
|
8
|
-
"changes_requested" => "π" }[card.status] %></span>
|
|
6
|
+
<span class="status-glyph"><%= card.status_glyph %></span>
|
|
9
7
|
</div>
|
|
10
8
|
<% if card.queued? %>
|
|
11
9
|
<% ahead = card.column.cards.where(status: "queued").where("position < ?", card.position).count %>
|
|
@@ -23,9 +21,6 @@
|
|
|
23
21
|
<% end %>
|
|
24
22
|
<div class="card-meta">
|
|
25
23
|
<% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
|
|
26
|
-
<% if card.running? && card.column.ai? && card.column.model %>
|
|
27
|
-
<span class="chip agent-chip">π€ <%= card.column.model_short %><%= " Β· #{card.column.effort}" if card.column.effort %></span>
|
|
28
|
-
<% end %>
|
|
29
24
|
<% if card.awaiting_assistant? %>
|
|
30
25
|
<span class="chip agent-chip thinking-chip">πͺΆ <span class="typing-dots mini"><span></span><span></span><span></span></span></span>
|
|
31
26
|
<% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
|
|
@@ -39,15 +34,13 @@
|
|
|
39
34
|
<% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
|
|
40
35
|
<% if has_cost || card.pr_url.present? %>
|
|
41
36
|
<div class="card-footer">
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<% end %>
|
|
50
|
-
</span>
|
|
37
|
+
<% if card.pr_url.present? %>
|
|
38
|
+
<a class="footer-pr" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> β</a>
|
|
39
|
+
<% else %>
|
|
40
|
+
<span class="footer-pr"></span>
|
|
41
|
+
<% end %>
|
|
42
|
+
<span class="footer-center"><%= "π€ #{card.effective_model_label}" if has_cost && card.effective_model_label %></span>
|
|
43
|
+
<span class="footer-cost"><%= "$#{card.total_cost.round(2)}" if has_cost %></span>
|
|
51
44
|
</div>
|
|
52
45
|
<% end %>
|
|
53
46
|
</article>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div id="card_compact" class="summary-panel compact-panel" data-controller="autosave">
|
|
2
|
+
<div class="summary-head">
|
|
3
|
+
<h3>Technical compact <span class="autosave-status" data-autosave-target="status"></span></h3>
|
|
4
|
+
<% if card.compact_working? %>
|
|
5
|
+
<button type="button" class="deep-dive working summary-generate" disabled>
|
|
6
|
+
<span class="pulse-dot"></span> Generatingβ¦
|
|
7
|
+
</button>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= button_to compact_card_path(card), class: "deep-dive summary-generate",
|
|
10
|
+
title: "Distill everything this card did into a dense technical journal a resuming agent can read" do %>
|
|
11
|
+
β¨ <%= card.compact.present? ? "Regenerate" : "Generate compact" %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<p class="hint summary-blurb">A dense, technical journal of what was built, decided, and blocked β the AI reads this to resume work without re-exploring the repo. Fully editable; your edits are respected when you regenerate.</p>
|
|
17
|
+
|
|
18
|
+
<%= form_with model: card, class: "summary-form",
|
|
19
|
+
data: { autosave_target: "form", action: "input->autosave#save change->autosave#save" } do |f| %>
|
|
20
|
+
<%= hidden_field_tag :autosave, "1" %>
|
|
21
|
+
<%= f.text_area :compact, rows: 10, placeholder: "No compact yet β click Generate compact, or write your own technical notes.",
|
|
22
|
+
disabled: card.compact_working? %>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% if card.compact_generated_at.present? %>
|
|
26
|
+
<p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.compact_generated_at) %> ago</p>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<div class="detail-panes">
|
|
32
32
|
<section class="timeline" data-controller="scroll">
|
|
33
33
|
<nav class="zoom-tabs">
|
|
34
|
-
<% %w[conversation activity debug summary].each do |zoom| %>
|
|
34
|
+
<% %w[conversation activity debug summary compact].each do |zoom| %>
|
|
35
35
|
<%= link_to zoom.capitalize, card_path(@card, zoom: zoom),
|
|
36
36
|
class: ("active" if @zoom == zoom) %>
|
|
37
37
|
<% end %>
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
<div class="timeline-scroll">
|
|
42
42
|
<%= render "cards/summary_panel", card: @card %>
|
|
43
43
|
</div>
|
|
44
|
+
<% elsif @zoom == "compact" %>
|
|
45
|
+
<div class="timeline-scroll">
|
|
46
|
+
<%= render "cards/compact_panel", card: @card %>
|
|
47
|
+
</div>
|
|
44
48
|
<% else %>
|
|
45
49
|
<div class="timeline-scroll" data-scroll-target="scroller">
|
|
46
50
|
<% if @card.description.present? %>
|
|
@@ -123,7 +127,7 @@
|
|
|
123
127
|
<%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
|
|
124
128
|
<% end %>
|
|
125
129
|
<% if @card.in_review? %>
|
|
126
|
-
<div class="panel-callout callout-plan">
|
|
130
|
+
<div class="panel-callout callout-plan" id="review-callout">
|
|
127
131
|
<p><strong>Your verdict.</strong> Check the final report in the timeline.
|
|
128
132
|
Approve, or just say what's wrong in the conversation β that marks it changes-requested,
|
|
129
133
|
and dragging the card back to a work column carries your feedback into the next run.</p>
|
|
@@ -136,12 +140,44 @@
|
|
|
136
140
|
<% end %>
|
|
137
141
|
<% end %>
|
|
138
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
|
+
|
|
139
175
|
<h3>Work</h3>
|
|
140
176
|
<% runs = @card.runs.order(:id) %>
|
|
141
177
|
<% parked = runs.select(&:needs_input?).last %>
|
|
142
178
|
<% latest = runs.last %>
|
|
143
179
|
<% if parked&.phase == "plan" %>
|
|
144
|
-
<div class="panel-callout callout-plan">
|
|
180
|
+
<div class="panel-callout callout-plan" id="plan-callout">
|
|
145
181
|
<p><strong>Plan proposed.</strong> Approve to let the agent execute, or reply in the timeline to redirect.</p>
|
|
146
182
|
<%= button_to "π Approve plan", approve_run_path(parked), class: "approve-btn", form_class: "align-right" %>
|
|
147
183
|
</div>
|
|
@@ -183,7 +219,7 @@
|
|
|
183
219
|
|
|
184
220
|
<% if latest && (latest.cost.positive? || latest.output_tokens.positive?) %>
|
|
185
221
|
<div class="work-footer">
|
|
186
|
-
<span class="footer-left"><%= @card.
|
|
222
|
+
<span class="footer-left"><%= "π€ #{@card.effective_model_label}" if @card.effective_model_label %></span>
|
|
187
223
|
<span class="footer-cost">$<%= latest.cost.round(2) %> Β· <%= latest.output_tokens %> out</span>
|
|
188
224
|
</div>
|
|
189
225
|
<% end %>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%# Momentary confirmation shown in place of an approval callout, then it
|
|
2
|
+
auto-closes the card modal (see dismiss_controller). %>
|
|
3
|
+
<div class="panel-callout callout-plan" data-controller="dismiss" data-dismiss-delay-value="1100">
|
|
4
|
+
<p><strong><%= message %></strong></p>
|
|
5
|
+
</div>
|
|
@@ -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/config/routes.rb
CHANGED
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
|
|
@@ -175,6 +175,8 @@ files:
|
|
|
175
175
|
- app/javascript/controllers/board_column_controller.js
|
|
176
176
|
- app/javascript/controllers/clipboard_controller.js
|
|
177
177
|
- app/javascript/controllers/composer_controller.js
|
|
178
|
+
- app/javascript/controllers/dismiss_controller.js
|
|
179
|
+
- app/javascript/controllers/filter_controller.js
|
|
178
180
|
- app/javascript/controllers/index.js
|
|
179
181
|
- app/javascript/controllers/modal_controller.js
|
|
180
182
|
- app/javascript/controllers/reveal_controller.js
|
|
@@ -185,6 +187,7 @@ files:
|
|
|
185
187
|
- app/jobs/ai_task_job.rb
|
|
186
188
|
- app/jobs/application_job.rb
|
|
187
189
|
- app/jobs/assistant_reply_job.rb
|
|
190
|
+
- app/jobs/compact_job.rb
|
|
188
191
|
- app/jobs/deep_dive_job.rb
|
|
189
192
|
- app/jobs/mark_pr_ready_job.rb
|
|
190
193
|
- app/jobs/merge_pr_job.rb
|
|
@@ -198,6 +201,7 @@ files:
|
|
|
198
201
|
- app/models/board.rb
|
|
199
202
|
- app/models/card.rb
|
|
200
203
|
- app/models/column.rb
|
|
204
|
+
- app/models/concerns/model_labeling.rb
|
|
201
205
|
- app/models/event.rb
|
|
202
206
|
- app/models/run.rb
|
|
203
207
|
- app/services/agent/runner.rb
|
|
@@ -211,7 +215,9 @@ files:
|
|
|
211
215
|
- app/views/boards/edit.html.erb
|
|
212
216
|
- app/views/boards/show.html.erb
|
|
213
217
|
- app/views/cards/_card.html.erb
|
|
218
|
+
- app/views/cards/_compact_panel.html.erb
|
|
214
219
|
- app/views/cards/_detail.html.erb
|
|
220
|
+
- app/views/cards/_dismiss_flash.html.erb
|
|
215
221
|
- app/views/cards/_summary_panel.html.erb
|
|
216
222
|
- app/views/cards/_tag_picker.html.erb
|
|
217
223
|
- app/views/cards/new.html.erb
|
|
@@ -259,6 +265,8 @@ files:
|
|
|
259
265
|
- db/migrate/20260704000002_add_assistant_session_to_cards.rb
|
|
260
266
|
- db/migrate/20260704120000_add_repo_brief_to_boards.rb
|
|
261
267
|
- db/migrate/20260704130000_add_summary_to_cards.rb
|
|
268
|
+
- db/migrate/20260704140000_add_compact_to_cards.rb
|
|
269
|
+
- db/migrate/20260704231436_add_model_and_effort_to_cards.rb
|
|
262
270
|
- db/queue_schema.rb
|
|
263
271
|
- db/seeds.rb
|
|
264
272
|
- docker/agent/Dockerfile
|