cardinal-ai 0.2.4 → 0.2.5

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: 9765ce8a3b88ca8a2d72dcdaa766d67d42b0b1f1304ce44551fb0b31a8b62f84
4
- data.tar.gz: 6bbd3904403e6e91b83a684d134c15ad402a98ab27f4600d6424752033565b0b
3
+ metadata.gz: ea33d976c3ebc53402438e5dae5e6b480842034e1a77a1c58142d62ad1f347e2
4
+ data.tar.gz: 7571417a375ab611c9345a6b2d7ab6cea33233e668d90414edae44ea2d116048
5
5
  SHA512:
6
- metadata.gz: b91c51d2d12d2df7d7d1f81b7d8547545ed68cac112f4d844dd4666626ccb34081397dc038fa4620a8b0e76e804b18c64608d96c92be657238958bf4bf3540ff
7
- data.tar.gz: 1e71c84583f3c6f02b3315ce7fa4abce078117dcb47ceb13a15a1641c3a90651673db22015c63ab8c1ca2e0683c136b50ac278b695e7144d34d7f7d2bf8378fc
6
+ metadata.gz: db9ec21facb032331fd8686f772e1c1313e0e84388f675eb723e92847c0c7638325d2af5ff8d392f2536c292d0929a79f64e53f040ef1206bb74097477c1f9e1
7
+ data.tar.gz: c290a471053fc66929db5fe9b3219bb49dc85f2b953b4a33beab0878e261d7ed4e815dc5b1b57ca9f8346f2ba9a7e73f7607a30f1af08e09a6665311dfdd6811
@@ -52,6 +52,7 @@ a { color: var(--blue); text-decoration: none; }
52
52
  }
53
53
  .topbar h1 { font-size: 16px; margin: 0; font-weight: 600; }
54
54
  .topbar h1 .sep { color: var(--accent); margin: 0 4px; }
55
+ .topbar-left { display: flex; align-items: center; gap: 12px; }
55
56
  .topbar-right { display: flex; align-items: center; gap: 14px; }
56
57
 
57
58
  .theme-toggle {
@@ -60,6 +61,29 @@ a { color: var(--blue); text-decoration: none; }
60
61
  }
61
62
  .theme-toggle:hover { color: var(--text); border-color: var(--text-dim); }
62
63
 
64
+ /* Repo deep dive button (card #12). Inline color/border come from the board's
65
+ staleness gradient (grey → red over 10 commits); .stale-critical flashes to
66
+ nag a refresh once the brief is too far behind HEAD to trust. */
67
+ .topbar-right form.button_to { display: inline-flex; margin: 0; }
68
+ .deep-dive {
69
+ display: inline-flex; align-items: center; gap: 6px;
70
+ background: transparent; border: 1px solid var(--border); border-radius: 6px;
71
+ color: var(--text-dim); font-weight: 600; padding: 5px 10px; line-height: 1;
72
+ cursor: pointer; white-space: nowrap;
73
+ }
74
+ .deep-dive:hover:not(:disabled) { filter: brightness(1.25); }
75
+ .deep-dive:disabled { cursor: default; opacity: .8; }
76
+ .deep-dive.stale-critical { animation: stale-flash 1.1s ease-in-out infinite; }
77
+ @keyframes stale-flash {
78
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(212, 51, 51, .55); }
79
+ 50% { opacity: .7; box-shadow: 0 0 0 3px rgba(212, 51, 51, 0); }
80
+ }
81
+
82
+ .pull-form { display: inline; }
83
+ #repo-pull-status { font-size: 0.85rem; }
84
+ #repo-pull-status .pull-ok { color: var(--green); }
85
+ #repo-pull-status .pull-err { color: var(--red); }
86
+
63
87
  .attention summary {
64
88
  cursor: pointer;
65
89
  color: var(--amber);
@@ -154,6 +178,10 @@ button, input[type="submit"] {
154
178
  body.dragging .drop-hint { display: block; }
155
179
  .ticker { font-size: 11px; color: var(--text-dim); margin: 0 4px 8px; }
156
180
 
181
+ .column-footer { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-dim); }
182
+ .footer-row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 4px; }
183
+ .footer-value { font-variant-numeric: tabular-nums; color: var(--text); }
184
+
157
185
  .cards { min-height: 40px; display: flex; flex-direction: column; gap: 8px; flex: 1; overflow-y: auto; }
158
186
  .cards-clickable { cursor: pointer; }
159
187
  .agent-chip { color: var(--blue); }
@@ -2,4 +2,54 @@ class BoardsController < ApplicationController
2
2
  def show
3
3
  @board = Board.includes(columns: :cards).first!
4
4
  end
5
+
6
+ # Kick off the repo deep dive (card #12). Non-blocking: flip the board into
7
+ # its "Working" state, morph the topbar so the button reflects it, and let
8
+ # DeepDiveJob do the read-only exploration in the background. Ignored if a
9
+ # dive is already running.
10
+ def deep_dive
11
+ board = Board.first!
12
+ unless board.brief_working?
13
+ board.update!(brief_status: "working")
14
+ board.broadcast_refresh_to board
15
+ DeepDiveJob.perform_later(board)
16
+ end
17
+ redirect_to root_path
18
+ end
19
+
20
+ # Cards reaching Done merge PRs on GitHub, so the checkout Cardinal runs
21
+ # against falls behind. The topbar Pull button fast-forwards it. --ff-only
22
+ # on purpose: never invent merge commits or rebase local work — if the tree
23
+ # has diverged, say so and let the human sort it out in a real terminal.
24
+ def pull
25
+ board = Board.first!
26
+ message, ok = pull_repo(board)
27
+ render turbo_stream: turbo_stream.update(
28
+ "repo-pull-status",
29
+ helpers.tag.span(message, class: ok ? "pull-ok" : "pull-err")
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def pull_repo(board)
36
+ repo = board.local_path.presence
37
+ return ["No local repo path on this board", false] unless repo && Dir.exist?(File.join(repo, ".git"))
38
+
39
+ before, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
40
+ out, status = Open3.capture2e("git", "-C", repo, "pull", "--ff-only")
41
+ unless status.success?
42
+ # Surface git's own reason (diverged, offline, auth) — the last
43
+ # non-blank line is usually the one that matters.
44
+ return [out.lines.map(&:strip).reject(&:blank?).last.to_s.truncate(120), false]
45
+ end
46
+
47
+ after, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
48
+ if before.strip == after.strip
49
+ ["Already up to date", true]
50
+ else
51
+ count, = Open3.capture2e("git", "-C", repo, "rev-list", "--count", "#{before.strip}..#{after.strip}")
52
+ ["Pulled #{helpers.pluralize(count.strip.to_i, "new commit")}", true]
53
+ end
54
+ end
5
55
  end
@@ -26,7 +26,7 @@ class ColumnsController < ApplicationController
26
26
  :name, :archetype, :instructions, :model, :effort,
27
27
  :concurrency_limit, :max_turns, :timeout_minutes, :plan_approval,
28
28
  :on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
29
- accepts_from: []
29
+ :footer_text, accepts_from: []
30
30
  )
31
31
 
32
32
  policy = @column.policy.dup
@@ -74,6 +74,14 @@ class ColumnsController < ApplicationController
74
74
  policy.delete("on_entry_text")
75
75
  end
76
76
 
77
+ # Footer (card #18): one row per line as "Label | compute". A blank compute
78
+ # is a static label; a compute must be one Column knows how to aggregate.
79
+ begin
80
+ policy["footer"] = parse_footer(attrs[:footer_text])
81
+ rescue ArgumentError => e
82
+ return column_error(e.message)
83
+ end
84
+
77
85
  @column.update!(
78
86
  name: attrs[:name].presence || @column.name,
79
87
  archetype: new_archetype,
@@ -126,5 +134,21 @@ class ColumnsController < ApplicationController
126
134
 
127
135
  private
128
136
 
137
+ # "Label | compute" lines → [{"label" =>, "compute" =>}]. Returns nil when
138
+ # empty so the key is dropped by policy.compact. Raises ArgumentError on an
139
+ # unknown compute key, mirroring the on_entry validation path.
140
+ def parse_footer(text)
141
+ rows = text.to_s.lines.filter_map do |line|
142
+ next if line.strip.blank?
143
+ label, compute = line.split("|", 2).map(&:strip)
144
+ compute = compute.to_s
145
+ if compute.present? && !Column::FOOTER_COMPUTES.include?(compute)
146
+ raise ArgumentError, "Footer compute \"#{compute}\" is not one of: #{Column::FOOTER_COMPUTES.join(', ')}"
147
+ end
148
+ { "label" => label.to_s, "compute" => compute.presence }.compact
149
+ end
150
+ rows.presence
151
+ end
152
+
129
153
  def set_column = @column = Column.find(params[:id])
130
154
  end
@@ -26,6 +26,14 @@ module ApplicationHelper
26
26
  MARKDOWN.render(text.to_s).html_safe
27
27
  end
28
28
 
29
+ # Render a column's stored footer config (array of {label, compute} hashes)
30
+ # back into the "Label | compute" line format the gear textarea edits.
31
+ def footer_config_text(column)
32
+ Array(column.footer).map do |row|
33
+ [row["label"], row["compute"].presence].compact.join(" | ")
34
+ end.join("\n")
35
+ end
36
+
29
37
  def info_tip(text)
30
38
  tag.span("i", class: "info",
31
39
  data: { controller: "tooltip", tooltip_text_value: text,
@@ -0,0 +1,64 @@
1
+ # Opt-in repo deep dive (card #12): a one-shot, read-only agent (the same
2
+ # cheap ClaudeCli tier the planning assistant uses — Read/Glob/Grep, no
3
+ # workspace) that maps the board's repo into a compact "repo brief". The brief
4
+ # is written to .cardinal/repo-brief.md and injected into every worker prompt,
5
+ # converting the per-run exploration tax into a one-time cost.
6
+ class DeepDiveJob < ApplicationJob
7
+ queue_as :default
8
+
9
+ # Enough turns to walk the tree and read a handful of key files, not enough
10
+ # to wander. ClaudeCli wraps up from context if it hits the cap.
11
+ MAX_TURNS = 30
12
+ FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
13
+
14
+ PROMPT = <<~PROMPT.freeze
15
+ Map this repository as a concise "repo brief" for other AI agents who will
16
+ work in it. The whole point is to save tokens later, so be dense and skip
17
+ the obvious — every line must earn its place.
18
+
19
+ Explore with your read-only tools, then output ONLY flat markdown with these
20
+ sections (drop any that genuinely don't apply):
21
+
22
+ ## Overview
23
+ One short paragraph: what this project is and its shape.
24
+ ## Directory Structure
25
+ The top-level directories and what each is for (one line each).
26
+ ## Key Directories
27
+ The few places most work actually happens, and what lives there.
28
+ ## Build & Test
29
+ The exact commands to install, build, run, and test.
30
+ ## Key Conventions
31
+ Naming, patterns, and idioms an agent must follow to match the codebase.
32
+ ## Tech Stack
33
+ Languages, frameworks, and notable libraries with their role.
34
+ ## Gotchas
35
+ Non-obvious traps, footguns, and constraints worth knowing before editing.
36
+
37
+ Do not include a preamble, closing remarks, or anything outside these sections.
38
+ PROMPT
39
+
40
+ def perform(board)
41
+ repo = board.local_path.presence
42
+ return clear_working(board) unless ClaudeCli.available? && repo
43
+
44
+ sha = board.head_sha
45
+ model = board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
46
+
47
+ brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS)
48
+
49
+ File.write(board.brief_path, brief.to_s)
50
+ board.update!(brief_sha: sha, brief_generated_at: Time.current,
51
+ brief_model: model, brief_status: nil)
52
+ board.broadcast_refresh_to board
53
+ rescue StandardError
54
+ # A failed dive must not leave the button stuck on "Working" forever.
55
+ clear_working(board)
56
+ end
57
+
58
+ private
59
+
60
+ def clear_working(board)
61
+ board.update!(brief_status: nil)
62
+ board.broadcast_refresh_to board
63
+ end
64
+ end
data/app/models/board.rb CHANGED
@@ -83,6 +83,63 @@ class Board < ApplicationRecord
83
83
  cards.pluck(:tags).flatten.compact.uniq.sort
84
84
  end
85
85
 
86
+ # --- Repo brief (card #12) ---------------------------------------------
87
+ # A one-time deep dive that maps the repo, stored as flat markdown in
88
+ # .cardinal/ (never the host repo) and injected into worker prompts to
89
+ # spare each run the exploration tax. Metadata (which SHA/model/when) lives
90
+ # on the board so staleness can be judged against the current HEAD.
91
+ #
92
+ # Storage is a file + metadata, not one text column, so a structure
93
+ # provider (the Graphify child card) can slot a richer representation in
94
+ # underneath later without a migration.
95
+ BRIEF_STALE_AT = 10 # commits behind → the "refresh me" red/flashing state
96
+
97
+ def brief_path = Rails.root.join(".cardinal", "repo-brief.md")
98
+
99
+ def repo_brief
100
+ File.read(brief_path) if File.exist?(brief_path)
101
+ end
102
+
103
+ def brief?
104
+ brief_sha.present? && File.exist?(brief_path)
105
+ end
106
+
107
+ def brief_working? = brief_status == "working"
108
+
109
+ # HEAD of the board's repo right now — the yardstick staleness measures against.
110
+ def head_sha
111
+ return nil if local_path.blank?
112
+ out, ok = Open3.capture2e("git", "-C", local_path, "rev-parse", "HEAD")
113
+ ok.success? ? out.strip : nil
114
+ end
115
+
116
+ # How many commits landed since the brief was generated. nil when there's
117
+ # no brief (nothing to be stale against) or the SHA is unknown to the repo.
118
+ def commits_behind_brief
119
+ return @commits_behind_brief if defined?(@commits_behind_brief)
120
+ @commits_behind_brief =
121
+ if brief_sha.blank? || local_path.blank?
122
+ nil
123
+ else
124
+ out, ok = Open3.capture2e("git", "-C", local_path, "rev-list", "--count", "#{brief_sha}..HEAD")
125
+ ok.success? ? out.strip.to_i : nil
126
+ end
127
+ end
128
+
129
+ def brief_stale? = (commits_behind_brief || 0) >= BRIEF_STALE_AT
130
+
131
+ # Grey → red interpolation over 0..BRIEF_STALE_AT commits behind, emitted
132
+ # as a validated hex into the button's inline style (mirrors Column#safe_color).
133
+ # Deep red once the brief is stale enough to over-anchor on.
134
+ def brief_staleness_color
135
+ behind = commits_behind_brief || 0
136
+ grey = [0x8a, 0x8a, 0x8a]
137
+ red = [0xd4, 0x33, 0x33]
138
+ t = [behind.to_f / BRIEF_STALE_AT, 1.0].min
139
+ rgb = grey.zip(red).map { |g, r| (g + (r - g) * t).round }
140
+ format("#%02x%02x%02x", *rgb)
141
+ end
142
+
86
143
  # Cards currently waiting on the human, ordered by urgency — feeds the
87
144
  # attention inbox in the board header.
88
145
  def attention_cards
data/app/models/card.rb CHANGED
@@ -29,6 +29,9 @@ class Card < ApplicationRecord
29
29
  validate :status_legal_for_column
30
30
 
31
31
  before_validation :assign_number_and_position, on: :create
32
+ # Optional git fields left blank on the new-card form arrive as "" — store
33
+ # them as nil so "no PR/branch" is one state, not two ("" renders footers).
34
+ normalizes :branch_name, :pr_url, with: ->(v) { v.strip.presence }
32
35
 
33
36
  after_commit -> { broadcast_refresh_to board }
34
37
 
data/app/models/column.rb CHANGED
@@ -35,7 +35,12 @@ class Column < ApplicationRecord
35
35
  store_accessor :policy, :instructions, :model, :effort, :concurrency_limit,
36
36
  :plan_approval, :budget_per_run_cents, :timeout_minutes,
37
37
  :max_turns, :tools, :on_entry, :on_success, :color, :arrivals,
38
- :accepts_from
38
+ :accepts_from, :footer
39
+
40
+ # Aggregations a footer row may compute over the column's cards (card #18).
41
+ # A compute key not listed here renders blank, so config that outruns the
42
+ # code degrades gracefully instead of erroring.
43
+ FOOTER_COMPUTES = %w[sum_cost sum_tokens count_cards].freeze
39
44
 
40
45
  # Only ever emit a validated hex color into inline styles.
41
46
  def safe_color
@@ -58,6 +63,24 @@ class Column < ApplicationRecord
58
63
  Array(accepts_from).map(&:to_s).include?(source_column.id.to_s)
59
64
  end
60
65
 
66
+ # Rows rendered under the cards (card #18). Footer config is an array of
67
+ # {"label" => "Total cost:", "compute" => "sum_cost"} hashes; each row pairs
68
+ # static label text with an optional computed aggregate over this column's
69
+ # cards. Returns [] when unconfigured, so existing columns render no footer.
70
+ def footer_rows
71
+ rows = Array(footer).filter_map do |row|
72
+ label = row["label"].to_s
73
+ value = footer_value(row["compute"])
74
+ next if label.blank? && value.blank?
75
+ { label:, value: }
76
+ end
77
+ # AI columns advertise their active model as a final auto-row (card #32).
78
+ # Guarded on model presence so an AI column without one adds nothing,
79
+ # rather than emitting a "Model:" row with a blank value.
80
+ rows << { label: "Model:", value: model_short } if ai? && model.present?
81
+ rows
82
+ end
83
+
61
84
  # Start the next queued card when a run slot frees up. A queued card whose
62
85
  # run parked and already has its answer recorded resumes instead of
63
86
  # starting fresh.
@@ -90,6 +113,21 @@ class Column < ApplicationRecord
90
113
  execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
91
114
  end
92
115
 
116
+ # Aggregate a single footer row over the runs/cards in this column (card #18).
117
+ # Unknown keys return "" so the row shows just its static label.
118
+ def footer_value(compute)
119
+ case compute.to_s
120
+ when "sum_cost"
121
+ "$%.2f" % column_runs.sum(:cost)
122
+ when "sum_tokens"
123
+ ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
124
+ when "count_cards"
125
+ cards.count.to_s
126
+ else
127
+ ""
128
+ end
129
+ end
130
+
93
131
  # The built-in role contract for AI servicing this archetype — shown
94
132
  # read-only in the gear modal so the Instructions field is understood as
95
133
  # ADDING to this, never replacing it. Enforced in code, not editable.
@@ -131,4 +169,11 @@ class Column < ApplicationRecord
131
169
  when "terminal" then "Closes it — PR merged and branch deleted, if there is one"
132
170
  end
133
171
  end
172
+
173
+ private
174
+
175
+ # Every run belonging to a card in this column, for footer aggregation.
176
+ def column_runs
177
+ Run.joins(agent_session: :card).where(cards: { column_id: id })
178
+ end
134
179
  end
@@ -329,7 +329,7 @@ module Agent
329
329
  ## Brief
330
330
  #{card.description.presence || "(no description — infer scope from the title and conversation)"}
331
331
 
332
- #{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
332
+ #{repo_brief_section}#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
333
333
  ## Card conversation so far
334
334
  #{conversation_excerpt.presence || "(none)"}
335
335
 
@@ -346,7 +346,7 @@ module Agent
346
346
  ## Brief
347
347
  #{card.description.presence || "(no description — infer scope from the title and conversation)"}
348
348
 
349
- #{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
349
+ #{repo_brief_section}#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
350
350
  ## Card conversation so far
351
351
  #{conversation_excerpt.presence || "(none)"}
352
352
 
@@ -363,6 +363,14 @@ module Agent
363
363
  PROMPT
364
364
  end
365
365
 
366
+ # The one-time repo deep dive (card #12), injected ahead of the planning
367
+ # brief so the agent starts oriented instead of re-exploring the tree each
368
+ # run. Empty string (not nil) when there's no brief, so the heredoc stays clean.
369
+ def repo_brief_section
370
+ brief = card.board.repo_brief.presence or return ""
371
+ "## Repo brief\n#{brief.strip}\n\n"
372
+ end
373
+
366
374
  # The planning assistant's distilled "Ready for execution" brief, if the
367
375
  # conversation produced one — the most load-bearing artifact of planning.
368
376
  def planning_brief
@@ -1,8 +1,31 @@
1
1
  <%= turbo_stream_from @board %>
2
2
 
3
3
  <header class="topbar">
4
- <h1>Cardinal AI <span class="sep">▸</span> <%= @board.name %></h1>
4
+ <div class="topbar-left">
5
+ <h1>Cardinal AI <span class="sep">▸</span> <%= @board.name %></h1>
6
+ <%= button_to "⇣ Pull", pull_board_path, form_class: "pull-form",
7
+ form: { title: "git pull --ff-only in #{@board.local_path} — fetch what Done has merged" },
8
+ data: { turbo_submits_with: "⇣ Pulling…" }, class: "theme-toggle" %>
9
+ <span id="repo-pull-status"></span>
10
+ </div>
5
11
  <div class="topbar-right">
12
+ <%# Repo deep dive (card #12): one-shot read-only mapping of the repo, stored
13
+ as a brief and injected into worker prompts. The button greys→reds as the
14
+ brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+. %>
15
+ <% behind = @board.commits_behind_brief %>
16
+ <%= button_to deep_dive_board_path,
17
+ class: "deep-dive#{' stale-critical' if @board.brief_stale?}#{' working' if @board.brief_working?}",
18
+ disabled: @board.brief_working?,
19
+ style: @board.brief? ? "color: #{@board.brief_staleness_color}; border-color: #{@board.brief_staleness_color}" : nil,
20
+ title: @board.brief? ? "Repo brief from #{@board.brief_sha&.first(7)} · #{@board.brief_generated_at&.to_date}" : "Map this repo into a brief agents reuse — saves exploration on every run" do %>
21
+ <% if @board.brief_working? %>
22
+ <span class="pulse-dot"></span> Deep dive · working…
23
+ <% elsif behind&.positive? %>
24
+ 🔍 Deep dive · <%= behind %> commit<%= "s" if behind != 1 %> behind
25
+ <% else %>
26
+ 🔍 Deep dive
27
+ <% end %>
28
+ <% end %>
6
29
  <button type="button" class="theme-toggle"
7
30
  data-controller="theme" data-action="theme#toggle">☀ Light</button>
8
31
  <% if ENV["CARDINAL_AUTH"].present? %>
@@ -36,10 +36,10 @@
36
36
  <% end %>
37
37
  <% if card.parent_id %><span class="chip">↑ sub</span><% end %>
38
38
  <% if card.children.any? %><span class="chip">↳ <%= card.children.where(status: %w[done archived]).count %>/<%= card.children.count %></span><% end %>
39
- <% if card.branch_name && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
39
+ <% if card.branch_name.present? && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
40
40
  </div>
41
41
  <% end %>
42
- <% if card.pr_url %>
42
+ <% if card.pr_url.present? %>
43
43
  <a class="card-footer" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">
44
44
  <span class="footer-left"><%# future: Asana / Trello / linked-ticket slot %></span>
45
45
  <span class="footer-pr">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</span>
@@ -8,7 +8,7 @@
8
8
  <span class="card-number-sub" title="Card number — how branches, PRs, and other cards refer to this card">#<%= @card.number %></span>
9
9
  </h1>
10
10
  <div class="modal-header-right">
11
- <% if @card.branch_name %>
11
+ <% if @card.branch_name.present? %>
12
12
  <span class="git-line">
13
13
  <span class="branch-base"><%= @card.board.default_branch %></span>
14
14
  <span class="git-arrow">→</span>
@@ -17,7 +17,7 @@
17
17
  <button type="button" class="copy-btn" data-clipboard-target="button"
18
18
  data-action="clipboard#copy" title="Copy branch name">⧉</button>
19
19
  </span>
20
- <% if @card.pr_url %>
20
+ <% if @card.pr_url.present? %>
21
21
  <span class="git-arrow">→</span>
22
22
  <%= link_to "##{@card.pr_url[%r{/pull/(\d+)}, 1]}", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-link" %>
23
23
  <% if @card.pr_state.present? %><span class="pr-state">(<%= @card.pr_state %>)</span><% end %>
@@ -113,7 +113,7 @@
113
113
 
114
114
  <% if @card.column.review? %>
115
115
  <h3>Review</h3>
116
- <% if @card.pr_url %>
116
+ <% if @card.pr_url.present? %>
117
117
  <%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
118
118
  <% end %>
119
119
  <% if @card.in_review? %>
@@ -168,7 +168,7 @@
168
168
  </li>
169
169
  <% end %>
170
170
  </ul>
171
- <% if @card.pr_url %>
171
+ <% if @card.pr_url.present? %>
172
172
  <p>🌿 <%= link_to "View pull request", @card.pr_url, target: "_blank" %><%= " (#{@card.pr_state})" if @card.pr_state.present? %></p>
173
173
  <% end %>
174
174
  <% else %>
@@ -22,4 +22,15 @@
22
22
  <%= render "cards/card", card: card %>
23
23
  <% end %>
24
24
  </div>
25
+ <% rows = column.footer_rows %>
26
+ <% if rows.any? %>
27
+ <footer class="column-footer">
28
+ <% rows.each do |row| %>
29
+ <div class="footer-row">
30
+ <span class="footer-label"><%= row[:label] %></span>
31
+ <span class="footer-value"><%= row[:value] %></span>
32
+ </div>
33
+ <% end %>
34
+ </footer>
35
+ <% end %>
25
36
  </section>
@@ -125,6 +125,11 @@
125
125
  </details>
126
126
  </div>
127
127
  <% end %>
128
+
129
+ <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>
130
+ <%= f.text_area :footer_text, rows: 3, class: "mono",
131
+ value: footer_config_text(@column),
132
+ placeholder: "Total cost: | sum_cost" %>
128
133
  <% end %>
129
134
 
130
135
  <details class="advanced-rules panel-advanced">
data/config/routes.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  Rails.application.routes.draw do
2
2
  root "boards#show"
3
3
 
4
- resource :board, only: :show
4
+ resource :board, only: :show do
5
+ post :deep_dive
6
+ post :pull
7
+ end
5
8
  resources :cards, only: [:new, :create, :show, :update, :destroy] do
6
9
  member do
7
10
  patch :move
@@ -0,0 +1,8 @@
1
+ class AddRepoBriefToBoards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :boards, :brief_sha, :string
4
+ add_column :boards, :brief_generated_at, :datetime
5
+ add_column :boards, :brief_model, :string
6
+ add_column :boards, :brief_status, :string
7
+ end
8
+ end
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.4"
2
+ VERSION = "0.2.5"
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.4
4
+ version: 0.2.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -157,6 +157,7 @@ files:
157
157
  - app/jobs/ai_task_job.rb
158
158
  - app/jobs/application_job.rb
159
159
  - app/jobs/assistant_reply_job.rb
160
+ - app/jobs/deep_dive_job.rb
160
161
  - app/jobs/mark_pr_ready_job.rb
161
162
  - app/jobs/merge_pr_job.rb
162
163
  - app/jobs/resume_run_job.rb
@@ -222,6 +223,7 @@ files:
222
223
  - db/migrate/20260703000002_add_agent_runner_fields.rb
223
224
  - db/migrate/20260704000001_add_parent_to_cards.rb
224
225
  - db/migrate/20260704000002_add_assistant_session_to_cards.rb
226
+ - db/migrate/20260704120000_add_repo_brief_to_boards.rb
225
227
  - db/seeds.rb
226
228
  - docker/agent/Dockerfile
227
229
  - exe/cardinal