cardinal-ai 0.2.7 → 0.2.8

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: 644f883c694c74ad5189ef319f46beaf3e679a7aa47ae43be9bb316560ba46cf
4
+ data.tar.gz: a54a728cece60d820acbaef2ad4abdcb8080d4fd888f122700f1266f85b9713f
5
5
  SHA512:
6
- metadata.gz: 4bd7c1c24d5969321f07753a3c14e2a09ecc561537dfd6f6365502b07c54aafbf93c22f84ed8a0eec9782ce1b9135606f2bbdf3fd0afc8c21f34d06d87896197
7
- data.tar.gz: 84270e725e825a11eb81abd67de4bbec459cc736fc9cf1e43b41e926f4a0df346f7df15f0afa906ec2b669671ae74f7cc0287a1ac0c7eb25d630d120b1da3ad4
6
+ metadata.gz: c4d36b5b5fc1936cdea3946cc53977708d15c2035fd1e6e54cf9f5d06679d8c1e40fa056e8e955ffa3f9447bc7c8213c990ebcb581ecf862d44fc57b8b0d01d8
7
+ data.tar.gz: 840bb89331ac1d362b81a6194d5887f1729702274e217fd921af5493bffd05f32a0047607b2a4ca0b72046ba4263cb85e93e2f770c3adb499fc703c298ef93b7
@@ -216,9 +216,9 @@ body.dragging .drop-hint { display: block; }
216
216
  }
217
217
  .card-footer:hover .footer-pr { color: var(--blue); }
218
218
  .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); }
219
+ .footer-center { min-width: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; }
220
+ .footer-cost { white-space: nowrap; flex-shrink: 0; }
221
+ .footer-pr { color: var(--text-dim); flex-shrink: 0; }
222
222
 
223
223
  /* Open-card cost tally: sits at the foot of the work panel, live-updating. */
224
224
  .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
 
@@ -81,7 +81,20 @@ class CardsController < ApplicationController
81
81
  @card.update!(status: "approved")
82
82
  @card.log!("status_change", actor: "user", text: "Work approved — drag to Done to ship")
83
83
  end
84
- redirect_to card_path(@card)
84
+
85
+ respond_to do |format|
86
+ # Flash the verdict in place, then let the dismiss controller minimize the
87
+ # modal. Patch the board face too — Turbo suppresses this tab's own refresh
88
+ # broadcast, so without this the card keeps its stale status once we close.
89
+ format.turbo_stream do
90
+ render turbo_stream: [
91
+ turbo_stream.replace(helpers.dom_id(@card), partial: "cards/card", locals: { card: @card }),
92
+ turbo_stream.replace("review-callout", partial: "cards/dismiss_flash",
93
+ locals: { message: "Approved — closing…" })
94
+ ]
95
+ end
96
+ format.html { redirect_to card_path(@card) }
97
+ end
85
98
  end
86
99
 
87
100
  # Generate a customer-friendly summary on demand (card #35). Non-blocking,
@@ -97,6 +110,19 @@ class CardsController < ApplicationController
97
110
  partial: "cards/summary_panel", locals: { card: @card })
98
111
  end
99
112
 
113
+ # Generate a technical "compact" journal on demand (card #34). The AI-readable
114
+ # mirror of #summarize: flip the card into its "working" state, morph the Compact
115
+ # panel so the button reflects it, and let CompactJob do the one-shot synthesis
116
+ # in the background. Skipped when one is already running.
117
+ def compact
118
+ unless @card.compact_working?
119
+ @card.update!(compact_status: "working")
120
+ CompactJob.perform_later(@card)
121
+ end
122
+ render turbo_stream: turbo_stream.replace("card_compact",
123
+ partial: "cards/compact_panel", locals: { card: @card })
124
+ end
125
+
100
126
  def move
101
127
  from_column = @card.column
102
128
  to_column = @card.board.columns.find(params[:column_id])
@@ -120,7 +146,7 @@ class CardsController < ApplicationController
120
146
  end
121
147
 
122
148
  def card_params
123
- attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary)
149
+ attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact)
124
150
  attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
125
151
  attrs.to_h.symbolize_keys
126
152
  end
@@ -128,7 +154,7 @@ class CardsController < ApplicationController
128
154
  # Changelog in the activity timeline (the mechanism already exists). A burst
129
155
  # of autosaves coalesces into one entry instead of one per pause-in-typing.
130
156
  def log_changelog!
131
- changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary]
157
+ changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary compact]
132
158
  return if changed.empty?
133
159
 
134
160
  last = @card.events.order(:id).last
@@ -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
@@ -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,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/models/card.rb CHANGED
@@ -23,6 +23,15 @@ class Card < ApplicationRecord
23
23
  has_many :agent_sessions, dependent: :destroy
24
24
  has_many :runs, through: :agent_sessions
25
25
 
26
+ # Card-face status glyphs. Keyed on status, except `ready_for_approval?`
27
+ # (a derived plan-park state, not a status) which pre-empts the ❓ below.
28
+ STATUS_GLYPHS = {
29
+ "working" => "⚡", "needs_input" => "❓", "failed" => "✖",
30
+ "work_complete" => "✅", "done" => "✓", "queued" => "⏳",
31
+ "discussing" => "💬", "in_review" => "👁", "approved" => "👍",
32
+ "changes_requested" => "🔁"
33
+ }.freeze
34
+
26
35
  enum :status, STATUSES.index_by(&:itself)
27
36
 
28
37
  validates :title, presence: true
@@ -42,8 +51,26 @@ class Card < ApplicationRecord
42
51
  # A customer-friendly summary is being (re)generated in the background (§card #35).
43
52
  def summary_working? = summary_status == "working"
44
53
 
54
+ # A technical "compact" journal is being (re)generated in the background (§card
55
+ # #34) — the AI-readable context a resuming agent reads to skip re-exploration.
56
+ def compact_working? = compact_status == "working"
57
+
45
58
  def running? = %w[queued working needs_input].include?(status)
46
59
 
60
+ # The agent has proposed a plan and is parked waiting on the user's approve
61
+ # click — distinct from a genuine question. Same underlying `needs_input`
62
+ # status; the plan-phase park is the signal (mirrors the work panel, §detail).
63
+ # The status guard means the run query only fires for already-parked cards.
64
+ def ready_for_approval?
65
+ needs_input? && runs.needs_input.order(:id).last&.phase == "plan"
66
+ end
67
+
68
+ # Card-face glyph: a bell when a plan is awaiting approval, else the plain
69
+ # per-status mapping.
70
+ def status_glyph
71
+ ready_for_approval? ? "🔔" : STATUS_GLYPHS[status]
72
+ end
73
+
47
74
  # Latest one-line progress event, shown live on the card face (§6).
48
75
  def latest_progress
49
76
  events.where(kind: "progress").last&.payload&.[]("text")
@@ -356,7 +356,7 @@ module Agent
356
356
  ## Card conversation so far
357
357
  #{conversation_excerpt.presence || "(none)"}
358
358
 
359
- #{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
359
+ #{compact_section}#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
360
360
  #{execute_rules}
361
361
  PROMPT
362
362
  end
@@ -377,7 +377,7 @@ module Agent
377
377
  ## Card conversation so far
378
378
  #{conversation_excerpt.presence || "(none)"}
379
379
 
380
- #{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
380
+ #{compact_section}#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
381
381
  Explore the repository as needed, then present a short numbered plan-of-attack
382
382
  (files you'll touch, approach, how you'll verify) and stop. The user will approve
383
383
  or redirect before any changes are made.
@@ -398,6 +398,25 @@ module Agent
398
398
  "## Repo brief\n#{brief.strip}\n\n"
399
399
  end
400
400
 
401
+ # The user-generated technical "compact" (card #34), if one exists — a dense
402
+ # journal of prior work on this card written for a resuming agent. Injected so
403
+ # the agent picks up where the last session left off instead of re-deriving
404
+ # context. Explicitly framed as technical resume notes, distinct from the
405
+ # human conversation above and from the customer-facing summary. Empty string
406
+ # (not nil) when absent, so the heredoc stays clean.
407
+ def compact_section
408
+ compact = card.compact.presence or return ""
409
+ <<~SECTION
410
+ ## Technical resume context (compact from a prior work session)
411
+ The notes below were generated (and possibly hand-edited) to capture the
412
+ technical state of prior work on this card — what was built, decided, and
413
+ blocked. Treat them as an authoritative shortcut to avoid re-exploring the
414
+ repo; verify against the actual code before relying on any specific detail.
415
+ #{compact.strip}
416
+
417
+ SECTION
418
+ end
419
+
401
420
  # The planning assistant's distilled "Ready for execution" brief, if the
402
421
  # conversation produced one — the most load-bearing artifact of planning.
403
422
  def planning_brief
@@ -2,10 +2,7 @@
2
2
  <%= link_to card_path(card), class: "card-link", data: { turbo_frame: "modal", turbo_action: "advance" } do %>
3
3
  <div class="card-title">
4
4
  <%= 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>
5
+ <span class="status-glyph"><%= card.status_glyph %></span>
9
6
  </div>
10
7
  <% if card.queued? %>
11
8
  <% ahead = card.column.cards.where(status: "queued").where("position < ?", card.position).count %>
@@ -23,9 +20,6 @@
23
20
  <% end %>
24
21
  <div class="card-meta">
25
22
  <% 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
23
  <% if card.awaiting_assistant? %>
30
24
  <span class="chip agent-chip thinking-chip">🪶 <span class="typing-dots mini"><span></span><span></span><span></span></span></span>
31
25
  <% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
@@ -39,15 +33,13 @@
39
33
  <% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
40
34
  <% if has_cost || card.pr_url.present? %>
41
35
  <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>
36
+ <% if card.pr_url.present? %>
37
+ <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>
38
+ <% else %>
39
+ <span class="footer-pr"></span>
40
+ <% end %>
41
+ <span class="footer-center"><%= "🤖 #{card.column.model_label}" if has_cost && card.column.model_label %></span>
42
+ <span class="footer-cost"><%= "$#{card.total_cost.round(2)}" if has_cost %></span>
51
43
  </div>
52
44
  <% end %>
53
45
  </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>
@@ -141,7 +145,7 @@
141
145
  <% parked = runs.select(&:needs_input?).last %>
142
146
  <% latest = runs.last %>
143
147
  <% if parked&.phase == "plan" %>
144
- <div class="panel-callout callout-plan">
148
+ <div class="panel-callout callout-plan" id="plan-callout">
145
149
  <p><strong>Plan proposed.</strong> Approve to let the agent execute, or reply in the timeline to redirect.</p>
146
150
  <%= button_to "👍 Approve plan", approve_run_path(parked), class: "approve-btn", form_class: "align-right" %>
147
151
  </div>
@@ -183,7 +187,7 @@
183
187
 
184
188
  <% if latest && (latest.cost.positive? || latest.output_tokens.positive?) %>
185
189
  <div class="work-footer">
186
- <span class="footer-left"><%= @card.column.model_label %></span>
190
+ <span class="footer-left"><%= "🤖 #{@card.column.model_label}" if @card.column.model_label %></span>
187
191
  <span class="footer-cost">$<%= latest.cost.round(2) %> · <%= latest.output_tokens %> out</span>
188
192
  </div>
189
193
  <% 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>
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
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.7"
2
+ VERSION = "0.2.8"
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.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -175,6 +175,7 @@ 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
178
179
  - app/javascript/controllers/index.js
179
180
  - app/javascript/controllers/modal_controller.js
180
181
  - app/javascript/controllers/reveal_controller.js
@@ -185,6 +186,7 @@ files:
185
186
  - app/jobs/ai_task_job.rb
186
187
  - app/jobs/application_job.rb
187
188
  - app/jobs/assistant_reply_job.rb
189
+ - app/jobs/compact_job.rb
188
190
  - app/jobs/deep_dive_job.rb
189
191
  - app/jobs/mark_pr_ready_job.rb
190
192
  - app/jobs/merge_pr_job.rb
@@ -211,7 +213,9 @@ files:
211
213
  - app/views/boards/edit.html.erb
212
214
  - app/views/boards/show.html.erb
213
215
  - app/views/cards/_card.html.erb
216
+ - app/views/cards/_compact_panel.html.erb
214
217
  - app/views/cards/_detail.html.erb
218
+ - app/views/cards/_dismiss_flash.html.erb
215
219
  - app/views/cards/_summary_panel.html.erb
216
220
  - app/views/cards/_tag_picker.html.erb
217
221
  - app/views/cards/new.html.erb
@@ -259,6 +263,7 @@ files:
259
263
  - db/migrate/20260704000002_add_assistant_session_to_cards.rb
260
264
  - db/migrate/20260704120000_add_repo_brief_to_boards.rb
261
265
  - db/migrate/20260704130000_add_summary_to_cards.rb
266
+ - db/migrate/20260704140000_add_compact_to_cards.rb
262
267
  - db/queue_schema.rb
263
268
  - db/seeds.rb
264
269
  - docker/agent/Dockerfile