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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c16d402a271ff7a720232c12b775f43784eadd3268c1ce417fd2939d777be42e
4
- data.tar.gz: 808ae0945e6f6104b3ef103468ce78c36406af25c78da223cd380a02336d5ef8
3
+ metadata.gz: 8ad9a65f2b86ce70952431f7f4c7689ddf6791213f7667d4bf87c2230381bce6
4
+ data.tar.gz: adc0e789a102a8382516b9b1eecd06fc911476187ce92ed4030e7e020c7c8ccb
5
5
  SHA512:
6
- metadata.gz: 4bd7c1c24d5969321f07753a3c14e2a09ecc561537dfd6f6365502b07c54aafbf93c22f84ed8a0eec9782ce1b9135606f2bbdf3fd0afc8c21f34d06d87896197
7
- data.tar.gz: 84270e725e825a11eb81abd67de4bbec459cc736fc9cf1e43b41e926f4a0df346f7df15f0afa906ec2b669671ae74f7cc0287a1ac0c7eb25d630d120b1da3ad4
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-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
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
- render turbo_stream: turbo_stream.update(
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
- ["Pulled #{helpers.pluralize(count.strip.to_i, "new commit")}#{note}", true]
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 # the Summary tab shows the card summary, not events
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
- redirect_to card_path(@card)
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
- redirect_to card_path(card)
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
- 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
 
@@ -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
@@ -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
@@ -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
- 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 = {}
@@ -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"><%= { "working" => "⚑", "needs_input" => "❓", "failed" => "βœ–",
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
- <span class="footer-left"><%= card.column.model_label if has_cost %></span>
43
- <span class="footer-right">
44
- <% if has_cost %>
45
- <span class="footer-cost">$<%= card.total_cost.round(2) %> Β· <%= card.total_output_tokens %> out</span>
46
- <% end %>
47
- <% if card.pr_url.present? %>
48
- <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>
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.column.model_label %></span>
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
- <%= 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"
data/config/routes.rb CHANGED
@@ -11,6 +11,7 @@ Rails.application.routes.draw do
11
11
  patch :move
12
12
  post :approve
13
13
  post :summarize
14
+ post :compact
14
15
  end
15
16
  resources :messages, only: [:create]
16
17
  end
@@ -0,0 +1,7 @@
1
+ class AddCompactToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :compact, :text
4
+ add_column :cards, :compact_generated_at, :datetime
5
+ add_column :cards, :compact_status, :string
6
+ end
7
+ end
@@ -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.7"
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.7
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