cardinal-ai 0.2.6 → 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: '079e999400eeaa39a8e7f7d8efdc4b3b114923cf0aaf190dcd15a6117a97d7b2'
4
- data.tar.gz: 61343cc2e5c42fbf43f3eeb223da83af60d1e634f8424e69d032c9afd287aa02
3
+ metadata.gz: 644f883c694c74ad5189ef319f46beaf3e679a7aa47ae43be9bb316560ba46cf
4
+ data.tar.gz: a54a728cece60d820acbaef2ad4abdcb8080d4fd888f122700f1266f85b9713f
5
5
  SHA512:
6
- metadata.gz: 1acb0d147c37615d1daf946a8e3aebbbdec636511cf6781a835ed4b73b61b1f3b57aff903273b160b75df66b263857cc83e7ef38f8b71841a7f8f3ebea8877e6
7
- data.tar.gz: 5ab7bddf1b68f55466825914a1d0eb7a697e02a5c7e0e3a4d0dcfe306ee673c65a17f9f5e2bf09425c002ccd02809d32ce30e2f0a2ececdd2c93b8d0139932c5
6
+ metadata.gz: c4d36b5b5fc1936cdea3946cc53977708d15c2035fd1e6e54cf9f5d06679d8c1e40fa056e8e955ffa3f9447bc7c8213c990ebcb581ecf862d44fc57b8b0d01d8
7
+ data.tar.gz: 840bb89331ac1d362b81a6194d5887f1729702274e217fd921af5493bffd05f32a0047607b2a4ca0b72046ba4263cb85e93e2f770c3adb499fc703c298ef93b7
@@ -215,7 +215,18 @@ body.dragging .drop-hint { display: block; }
215
215
  font-size: 11px; color: var(--text-dim); font-weight: 600;
216
216
  }
217
217
  .card-footer:hover .footer-pr { color: var(--blue); }
218
- .footer-left { min-width: 1px; }
218
+ .footer-left { min-width: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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
+
223
+ /* Open-card cost tally: sits at the foot of the work panel, live-updating. */
224
+ .work-footer {
225
+ display: flex; justify-content: space-between; align-items: center; gap: 8px;
226
+ margin-top: 16px; padding-top: 10px; border-top: 1px solid var(--border);
227
+ font-size: 12px; color: var(--text-dim); font-weight: 600;
228
+ }
229
+ .work-footer .footer-cost { white-space: nowrap; }
219
230
  .card-ghost { opacity: .4; }
220
231
  .card-title { font-weight: 600; }
221
232
  .card-number { color: var(--text-dim); font-weight: 400; }
@@ -374,6 +385,16 @@ body.dragging .drop-hint { display: block; }
374
385
  .zoom-tabs a { padding: 4px 12px; border-radius: 6px; color: var(--text-dim); }
375
386
  .zoom-tabs a.active { background: var(--surface-2); color: var(--text); }
376
387
 
388
+ .summary-panel .summary-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
389
+ .summary-panel .summary-head h3 { margin: 0; }
390
+ .summary-generate { white-space: nowrap; }
391
+ .summary-blurb { margin: 6px 0 12px; }
392
+ .summary-form textarea {
393
+ width: 100%; background: var(--surface-2); border: 1px solid var(--border);
394
+ border-radius: 6px; color: var(--text); padding: 8px 10px; font: inherit; resize: vertical;
395
+ }
396
+ .summary-stamp { margin-top: 8px; }
397
+
377
398
  .event { display: flex; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--surface-2); }
378
399
 
379
400
  /* Column moves are chapter markers in the card's story */
@@ -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, :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,10 +30,11 @@ class CardsController < ApplicationController
30
30
  end
31
31
 
32
32
  def show
33
- @zoom = params[:zoom].presence_in(%w[conversation activity debug]) || "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", "compact" then Event.none # these tabs show a card panel, not events
37
38
  else @card.events
38
39
  end
39
40
 
@@ -80,7 +81,46 @@ class CardsController < ApplicationController
80
81
  @card.update!(status: "approved")
81
82
  @card.log!("status_change", actor: "user", text: "Work approved — drag to Done to ship")
82
83
  end
83
- 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
98
+ end
99
+
100
+ # Generate a customer-friendly summary on demand (card #35). Non-blocking,
101
+ # mirroring the board's deep dive: flip the card into its "working" state,
102
+ # morph the Summary panel so the button reflects it, and let SummaryJob do the
103
+ # one-shot synthesis in the background. Skipped when one is already running.
104
+ def summarize
105
+ unless @card.summary_working?
106
+ @card.update!(summary_status: "working")
107
+ SummaryJob.perform_later(@card)
108
+ end
109
+ render turbo_stream: turbo_stream.replace("card_summary",
110
+ partial: "cards/summary_panel", locals: { card: @card })
111
+ end
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 })
84
124
  end
85
125
 
86
126
  def move
@@ -106,7 +146,7 @@ class CardsController < ApplicationController
106
146
  end
107
147
 
108
148
  def card_params
109
- attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url)
149
+ attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact)
110
150
  attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
111
151
  attrs.to_h.symbolize_keys
112
152
  end
@@ -114,7 +154,7 @@ class CardsController < ApplicationController
114
154
  # Changelog in the activity timeline (the mechanism already exists). A burst
115
155
  # of autosaves coalesces into one entry instead of one per pause-in-typing.
116
156
  def log_changelog!
117
- changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url]
157
+ changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary compact]
118
158
  return if changed.empty?
119
159
 
120
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
@@ -0,0 +1,88 @@
1
+ # On-demand, customer-friendly card summary (card #35): a one-shot, tool-less
2
+ # Claude call (the same cheap tier the planning assistant and deep dive use)
3
+ # that compresses everything a card did — its brief, timeline, runs, and code
4
+ # commits — into a couple of non-technical lines you can drop into a customer
5
+ # chat. Generation is user-triggered only; the result persists on the card and
6
+ # stays fully editable. A prior summary (possibly hand-edited) rides along as
7
+ # context so a regeneration refines rather than discards what the user cared about.
8
+ class SummaryJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
12
+
13
+ SYSTEM = <<~SYS.freeze
14
+ You write short, non-technical status updates for customers. Given everything
15
+ that happened on a work item, produce a plain-language recap the reader can
16
+ drop straight into a Teams or Slack message — what was asked for and what was
17
+ delivered, in outcome terms. No jargon, no file names, no code, no headings.
18
+ A couple of sentences up to a short paragraph. Write only the recap itself.
19
+ SYS
20
+
21
+ def perform(card)
22
+ return clear_working(card) unless ClaudeCli.available?
23
+
24
+ model = card.board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
25
+ summary = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1)
26
+
27
+ card.update!(summary: summary.to_s.strip, summary_generated_at: Time.current, summary_status: nil)
28
+ card.broadcast_replace_to card, target: "card_summary",
29
+ partial: "cards/summary_panel", locals: { card: card }
30
+ rescue StandardError
31
+ # A failed generation must not leave the button stuck on "Generating…".
32
+ clear_working(card)
33
+ end
34
+
35
+ private
36
+
37
+ def clear_working(card)
38
+ card.update!(summary_status: nil)
39
+ card.broadcast_replace_to card, target: "card_summary",
40
+ partial: "cards/summary_panel", locals: { card: card }
41
+ end
42
+
43
+ def build_prompt(card)
44
+ parts = ["Work item ##{card.number}: #{card.title}"]
45
+ parts << "Tags: #{card.tags.join(", ")}" if card.tags.any?
46
+ parts << "\nDescription:\n#{card.description}" if card.description.present?
47
+
48
+ timeline = card.events.activity.filter_map { |e| event_line(e) }
49
+ parts << "\nWhat happened (timeline):\n#{timeline.join("\n")}" if timeline.any?
50
+
51
+ runs = card.runs.order(:id).map { |r| "- Run ##{r.id}: #{r.status}#{" (#{r.phase})" if r.phase.present?}" }
52
+ parts << "\nRuns:\n#{runs.join("\n")}" if runs.any?
53
+
54
+ commits = commit_lines(card)
55
+ parts << "\nCode changes (commit messages):\n#{commits.join("\n")}" if commits.any?
56
+
57
+ if card.summary.present?
58
+ parts << "\nThe user's current summary is below. They may have edited it by hand, " \
59
+ "so treat its wording and emphasis as a signal of what they care about — " \
60
+ "refine and update it with any new work rather than starting from scratch:\n#{card.summary}"
61
+ end
62
+
63
+ parts.join("\n")
64
+ end
65
+
66
+ def event_line(event)
67
+ text = event.payload["text"].to_s.strip
68
+ return nil if text.blank?
69
+ "- #{event.actor}: #{text.truncate(400)}"
70
+ end
71
+
72
+ # Commit messages for the card's branch, read from the per-card workspace
73
+ # checkout when it still exists. The checkout isn't guaranteed to be present
74
+ # (it's left in place after a run but may be pruned), so this is best-effort —
75
+ # the timeline already narrates the work when commits are unavailable.
76
+ def commit_lines(card)
77
+ return [] if card.branch_name.blank?
78
+ path = Agent::Workspace::Local.new(card).path
79
+ return [] unless File.directory?(path.join(".git"))
80
+
81
+ base = "origin/#{card.board.default_branch}"
82
+ out, ok = Open3.capture2e("git", "-C", path.to_s, "log", "--oneline", "--no-decorate", "#{base}..HEAD")
83
+ return [] unless ok.success?
84
+ out.lines.map(&:strip).reject(&:blank?).map { |l| "- #{l}" }
85
+ rescue StandardError
86
+ []
87
+ end
88
+ end
data/app/models/board.rb CHANGED
@@ -10,6 +10,7 @@ class Board < ApplicationRecord
10
10
  policy: { "ai" => true, "model" => "claude-haiku-4-5-20251001", "plan_approval" => false,
11
11
  "on_entry" => [{ "action" => "assistant_greeting" }],
12
12
  "on_entry_text" => "The planning assistant reads the card and opens the discussion.",
13
+ "footer" => [{ "label" => "Model:", "compute" => "model" }],
13
14
  "accepts_from_names" => ["Tasks", "In Progress", "Review", "QA"] } },
14
15
  { name: "In Progress", archetype: "execution",
15
16
  policy: { "ai" => true, "model" => "claude-opus-4-8", "effort" => "high",
@@ -18,6 +19,7 @@ class Board < ApplicationRecord
18
19
  "tools" => %w[read edit run_commands git_commit_push],
19
20
  "on_entry" => [{ "action" => "start_agent_run" }],
20
21
  "accepts_from_names" => ["Planning", "Review", "QA"],
22
+ "footer" => [{ "label" => "Model:", "compute" => "model" }],
21
23
  "instructions" => "Follow repo conventions. Write tests when the repo has a suite." } },
22
24
  { name: "Review", archetype: "review",
23
25
  policy: { "ai" => true, "plan_approval" => false,
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
@@ -39,13 +48,39 @@ class Card < ApplicationRecord
39
48
 
40
49
  def needs_attention? = %w[needs_input blocked failed work_complete].include?(status)
41
50
 
51
+ # A customer-friendly summary is being (re)generated in the background (§card #35).
52
+ def summary_working? = summary_status == "working"
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
+
42
58
  def running? = %w[queued working needs_input].include?(status)
43
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
+
44
74
  # Latest one-line progress event, shown live on the card face (§6).
45
75
  def latest_progress
46
76
  events.where(kind: "progress").last&.payload&.[]("text")
47
77
  end
48
78
 
79
+ # Running tally across every run on the card — the closed-card cost footer
80
+ # (card #20). Sums stopped/restarted segments so the total reflects real spend.
81
+ def total_cost = runs.sum(:cost)
82
+ def total_output_tokens = runs.sum(:output_tokens)
83
+
49
84
  # Is the planning assistant expected to post next? True right after entering
50
85
  # a planning column (kickoff inspection pending) or after a user message.
51
86
  def awaiting_assistant?
data/app/models/column.rb CHANGED
@@ -40,7 +40,7 @@ class Column < ApplicationRecord
40
40
  # Aggregations a footer row may compute over the column's cards (card #18).
41
41
  # A compute key not listed here renders blank, so config that outruns the
42
42
  # code degrades gracefully instead of erroring.
43
- FOOTER_COMPUTES = %w[sum_cost sum_tokens count_cards].freeze
43
+ FOOTER_COMPUTES = %w[sum_cost sum_tokens count_cards model].freeze
44
44
 
45
45
  # Only ever emit a validated hex color into inline styles.
46
46
  def safe_color
@@ -76,18 +76,15 @@ class Column < ApplicationRecord
76
76
  # {"label" => "Total cost:", "compute" => "sum_cost"} hashes; each row pairs
77
77
  # static label text with an optional computed aggregate over this column's
78
78
  # cards. Returns [] when unconfigured, so existing columns render no footer.
79
+ # No auto-rows (de-magic): the model row that used to be hardcoded for AI
80
+ # columns is now the "model" compute — visible in the gear, deletable.
79
81
  def footer_rows
80
- rows = Array(footer).filter_map do |row|
82
+ Array(footer).filter_map do |row|
81
83
  label = row["label"].to_s
82
84
  value = footer_value(row["compute"])
83
85
  next if label.blank? && value.blank?
84
86
  { label:, value: }
85
87
  end
86
- # AI columns advertise their active model as a final auto-row (card #32).
87
- # Guarded on model presence so an AI column without one adds nothing,
88
- # rather than emitting a "Model:" row with a blank value.
89
- rows << { label: "Model:", value: model_short } if ai? && model.present?
90
- rows
91
88
  end
92
89
 
93
90
  # Start the next queued card when a run slot frees up. A queued card whose
@@ -112,6 +109,14 @@ class Column < ApplicationRecord
112
109
  model.to_s[/claude-([a-z]+)/, 1] || model
113
110
  end
114
111
 
112
+ # "Opus - High" — human label for cost footers (card #20). Effort is optional,
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
119
+
115
120
  validates :name, presence: true
116
121
  validates :position, presence: true
117
122
 
@@ -138,6 +143,10 @@ class Column < ApplicationRecord
138
143
  ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
139
144
  when "count_cards"
140
145
  cards.count.to_s
146
+ when "model"
147
+ # The column's active AI model, short form. Blank when AI is off or no
148
+ # model is set — the row then shows just its label, telling the truth.
149
+ ai? ? model_short.to_s : ""
141
150
  else
142
151
  ""
143
152
  end
@@ -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
@@ -35,7 +35,7 @@
35
35
 
36
36
  <div class="card-edit-actions">
37
37
  <%= button_to "↻ Regenerate brief", deep_dive_board_path(force: 1),
38
- form: { data: { turbo_frame: "_top" } },
38
+ form: { data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess" } },
39
39
  disabled: @board.brief_working? %>
40
40
  </div>
41
41
  <p class="hint">
@@ -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,26 +20,26 @@
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" %>
32
26
  <span class="chip agent-chip">🪶 replied</span>
33
27
  <% end %>
34
- <% if (card.running? || card.needs_attention?) && (last_run = card.runs.order(:id).last) && (last_run.cost.to_f.positive? || last_run.output_tokens.positive?) %>
35
- <span class="chip">$<%= last_run.cost.round(2) %><%= " · #{(last_run.output_tokens / 1000.0).round(1)}k out" if card.working? %></span>
36
- <% end %>
37
28
  <% if card.parent_id %><span class="chip">↑ sub</span><% end %>
38
29
  <% if card.children.any? %><span class="chip">↳ <%= card.children.where(status: %w[done archived]).count %>/<%= card.children.count %></span><% end %>
39
30
  <% if card.branch_name.present? && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
40
31
  </div>
41
32
  <% end %>
42
- <% if card.pr_url.present? %>
43
- <a class="card-footer" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">
44
- <span class="footer-left"><%# future: Asana / Trello / linked-ticket slot %></span>
45
- <span class="footer-pr">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</span>
46
- </a>
33
+ <% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
34
+ <% if has_cost || card.pr_url.present? %>
35
+ <div class="card-footer">
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>
43
+ </div>
47
44
  <% end %>
48
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,12 +31,21 @@
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].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 %>
38
38
  </nav>
39
39
 
40
+ <% if @zoom == "summary" %>
41
+ <div class="timeline-scroll">
42
+ <%= render "cards/summary_panel", card: @card %>
43
+ </div>
44
+ <% elsif @zoom == "compact" %>
45
+ <div class="timeline-scroll">
46
+ <%= render "cards/compact_panel", card: @card %>
47
+ </div>
48
+ <% else %>
40
49
  <div class="timeline-scroll" data-scroll-target="scroller">
41
50
  <% if @card.description.present? %>
42
51
  <div class="event event-description"><%= render_markdown @card.description %></div>
@@ -65,6 +74,7 @@
65
74
  data: { controller: "composer", action: "keydown->composer#keydown" },
66
75
  placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
67
76
  <% end %>
77
+ <% end %>
68
78
  </section>
69
79
 
70
80
  <aside class="work-panel">
@@ -117,7 +127,7 @@
117
127
  <%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
118
128
  <% end %>
119
129
  <% if @card.in_review? %>
120
- <div class="panel-callout callout-plan">
130
+ <div class="panel-callout callout-plan" id="review-callout">
121
131
  <p><strong>Your verdict.</strong> Check the final report in the timeline.
122
132
  Approve, or just say what's wrong in the conversation — that marks it changes-requested,
123
133
  and dragging the card back to a work column carries your feedback into the next run.</p>
@@ -135,7 +145,7 @@
135
145
  <% parked = runs.select(&:needs_input?).last %>
136
146
  <% latest = runs.last %>
137
147
  <% if parked&.phase == "plan" %>
138
- <div class="panel-callout callout-plan">
148
+ <div class="panel-callout callout-plan" id="plan-callout">
139
149
  <p><strong>Plan proposed.</strong> Approve to let the agent execute, or reply in the timeline to redirect.</p>
140
150
  <%= button_to "👍 Approve plan", approve_run_path(parked), class: "approve-btn", form_class: "align-right" %>
141
151
  </div>
@@ -175,6 +185,13 @@
175
185
  <p class="empty">No runs yet — drag the card into an execution column to assign an agent.</p>
176
186
  <% end %>
177
187
 
188
+ <% if latest && (latest.cost.positive? || latest.output_tokens.positive?) %>
189
+ <div class="work-footer">
190
+ <span class="footer-left"><%= "🤖 #{@card.column.model_label}" if @card.column.model_label %></span>
191
+ <span class="footer-cost">$<%= latest.cost.round(2) %> · <%= latest.output_tokens %> out</span>
192
+ </div>
193
+ <% end %>
194
+
178
195
  <details class="advanced-rules panel-advanced">
179
196
  <summary>Advanced</summary>
180
197
  <p class="hint">Deleting removes the card and its entire history (events, runs, workspace). The remote branch and PR, if any, are left untouched.</p>
@@ -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>
@@ -0,0 +1,28 @@
1
+ <div id="card_summary" class="summary-panel" data-controller="autosave">
2
+ <div class="summary-head">
3
+ <h3>Customer summary <span class="autosave-status" data-autosave-target="status"></span></h3>
4
+ <% if card.summary_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 summarize_card_path(card), class: "deep-dive summary-generate",
10
+ title: "Compress everything this card did into a couple of non-technical lines" do %>
11
+ ✨ <%= card.summary.present? ? "Regenerate" : "Generate summary" %>
12
+ <% end %>
13
+ <% end %>
14
+ </div>
15
+
16
+ <p class="hint summary-blurb">A plain-language recap of what this card delivered — ready to drop into a customer chat. 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 :summary, rows: 10, placeholder: "No summary yet — click Generate summary, or write your own.",
22
+ disabled: card.summary_working? %>
23
+ <% end %>
24
+
25
+ <% if card.summary_generated_at.present? %>
26
+ <p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.summary_generated_at) %> ago</p>
27
+ <% end %>
28
+ </div>
@@ -134,7 +134,7 @@
134
134
  </div>
135
135
  <% end %>
136
136
 
137
- <label>Footer <%= info_tip("A summary strip under the cards. One row per line as \"Label | compute\", where compute is sum_cost (total run cost), sum_tokens (total input+output tokens), count_cards, or blank for a static label. Example: \"Total cost: | sum_cost\".") %></label>
137
+ <label>Footer <%= info_tip("A summary strip under the cards. One row per line as \"Label | compute\", where compute is sum_cost (total run cost), sum_tokens (total input+output tokens), count_cards, model (this column's active AI model), or blank for a static label. Example: \"Model: | model\".") %></label>
138
138
  <%= f.text_area :footer_text, rows: 3, class: "mono",
139
139
  value: footer_config_text(@column),
140
140
  placeholder: "Total cost: | sum_cost" %>
data/config/routes.rb CHANGED
@@ -10,6 +10,8 @@ Rails.application.routes.draw do
10
10
  member do
11
11
  patch :move
12
12
  post :approve
13
+ post :summarize
14
+ post :compact
13
15
  end
14
16
  resources :messages, only: [:create]
15
17
  end
@@ -0,0 +1,7 @@
1
+ class AddSummaryToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :summary, :text
4
+ add_column :cards, :summary_generated_at, :datetime
5
+ add_column :cards, :summary_status, :string
6
+ end
7
+ 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.6"
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.6
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,11 +186,13 @@ 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
191
193
  - app/jobs/resume_run_job.rb
192
194
  - app/jobs/start_run_job.rb
195
+ - app/jobs/summary_job.rb
193
196
  - app/mailers/application_mailer.rb
194
197
  - app/models/agent_session.rb
195
198
  - app/models/application_record.rb
@@ -210,7 +213,10 @@ files:
210
213
  - app/views/boards/edit.html.erb
211
214
  - app/views/boards/show.html.erb
212
215
  - app/views/cards/_card.html.erb
216
+ - app/views/cards/_compact_panel.html.erb
213
217
  - app/views/cards/_detail.html.erb
218
+ - app/views/cards/_dismiss_flash.html.erb
219
+ - app/views/cards/_summary_panel.html.erb
214
220
  - app/views/cards/_tag_picker.html.erb
215
221
  - app/views/cards/new.html.erb
216
222
  - app/views/cards/show.html.erb
@@ -231,7 +237,6 @@ files:
231
237
  - config/bundler-audit.yml
232
238
  - config/cable.yml
233
239
  - config/ci.rb
234
- - config/credentials.yml.enc
235
240
  - config/database.yml
236
241
  - config/environment.rb
237
242
  - config/environments/development.rb
@@ -257,6 +262,8 @@ files:
257
262
  - db/migrate/20260704000001_add_parent_to_cards.rb
258
263
  - db/migrate/20260704000002_add_assistant_session_to_cards.rb
259
264
  - db/migrate/20260704120000_add_repo_brief_to_boards.rb
265
+ - db/migrate/20260704130000_add_summary_to_cards.rb
266
+ - db/migrate/20260704140000_add_compact_to_cards.rb
260
267
  - db/queue_schema.rb
261
268
  - db/seeds.rb
262
269
  - docker/agent/Dockerfile
@@ -1 +0,0 @@
1
- gRbdqLwcuHenzfNXCmiHivELrqct/iaMFWc11UxhtcwPIAyQYYOpoUGziW347shIgbHjK4zVpJQ/la/Te51SO6G1NgGZyJZrFRKB8kebFYcqktQzMRTGRl0ykjWf3y0iN6YPcYC6C/0M3Zr7hasRYG8hX9MOnLFjYUYQw/yZRpM/RR2XBslITX1FubnB/CZrzQzz9WDSYG+TOmR5IUBdn28ZrQxSz8u3oXzuhwibUGf2GNffWM4nHpDmHAiwDq7o8yMrZHipJiiUmo3ffe7YbLR+RdTy3AmIatai8wCEruPvzopfV3hn8b5alhk6g8wZQMW29rV5zwKJhnfIJ/IH8LJJWtudfckYkhh4KDam6TppBdqc8rXWCqodYIt2voYM/ARkQt+CTgVZQCLjlvW2Qm4NTzQCcZZkMllQ7vvpsyZ/vzpaRyYSHIl/9Hzip/orWM7g/SNIm44mDLC8+6IoiVVDSomOaQVEEfRKO7Rny3KahfXpqEiUSfmf--B7qWHwiaPzgA3yLD--x/FY/DNjNt+OyLs4ca9tQQ==