cardinal-ai 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/cardinal.css +22 -1
- data/app/controllers/cards_controller.rb +18 -4
- data/app/jobs/summary_job.rb +88 -0
- data/app/models/board.rb +2 -0
- data/app/models/card.rb +8 -0
- data/app/models/column.rb +16 -7
- data/app/views/boards/brief.html.erb +1 -1
- data/app/views/cards/_card.html.erb +13 -8
- data/app/views/cards/_detail.html.erb +14 -1
- data/app/views/cards/_summary_panel.html.erb +28 -0
- data/app/views/columns/edit.html.erb +1 -1
- data/config/routes.rb +1 -0
- data/db/migrate/20260704130000_add_summary_to_cards.rb +7 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +4 -2
- data/config/credentials.yml.enc +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c16d402a271ff7a720232c12b775f43784eadd3268c1ce417fd2939d777be42e
|
|
4
|
+
data.tar.gz: 808ae0945e6f6104b3ef103468ce78c36406af25c78da223cd380a02336d5ef8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4bd7c1c24d5969321f07753a3c14e2a09ecc561537dfd6f6365502b07c54aafbf93c22f84ed8a0eec9782ce1b9135606f2bbdf3fd0afc8c21f34d06d87896197
|
|
7
|
+
data.tar.gz: 84270e725e825a11eb81abd67de4bbec459cc736fc9cf1e43b41e926f4a0df346f7df15f0afa906ec2b669671ae74f7cc0287a1ac0c7eb25d630d120b1da3ad4
|
|
@@ -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-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
220
|
+
.footer-cost { white-space: nowrap; }
|
|
221
|
+
.footer-pr { color: var(--text-dim); }
|
|
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 */
|
|
@@ -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, :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]) || "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
38
|
else @card.events
|
|
38
39
|
end
|
|
39
40
|
|
|
@@ -83,6 +84,19 @@ class CardsController < ApplicationController
|
|
|
83
84
|
redirect_to card_path(@card)
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
# Generate a customer-friendly summary on demand (card #35). Non-blocking,
|
|
88
|
+
# mirroring the board's deep dive: flip the card into its "working" state,
|
|
89
|
+
# morph the Summary panel so the button reflects it, and let SummaryJob do the
|
|
90
|
+
# one-shot synthesis in the background. Skipped when one is already running.
|
|
91
|
+
def summarize
|
|
92
|
+
unless @card.summary_working?
|
|
93
|
+
@card.update!(summary_status: "working")
|
|
94
|
+
SummaryJob.perform_later(@card)
|
|
95
|
+
end
|
|
96
|
+
render turbo_stream: turbo_stream.replace("card_summary",
|
|
97
|
+
partial: "cards/summary_panel", locals: { card: @card })
|
|
98
|
+
end
|
|
99
|
+
|
|
86
100
|
def move
|
|
87
101
|
from_column = @card.column
|
|
88
102
|
to_column = @card.board.columns.find(params[:column_id])
|
|
@@ -106,7 +120,7 @@ class CardsController < ApplicationController
|
|
|
106
120
|
end
|
|
107
121
|
|
|
108
122
|
def card_params
|
|
109
|
-
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url)
|
|
123
|
+
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary)
|
|
110
124
|
attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
|
|
111
125
|
attrs.to_h.symbolize_keys
|
|
112
126
|
end
|
|
@@ -114,7 +128,7 @@ class CardsController < ApplicationController
|
|
|
114
128
|
# Changelog in the activity timeline (the mechanism already exists). A burst
|
|
115
129
|
# of autosaves coalesces into one entry instead of one per pause-in-typing.
|
|
116
130
|
def log_changelog!
|
|
117
|
-
changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url]
|
|
131
|
+
changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary]
|
|
118
132
|
return if changed.empty?
|
|
119
133
|
|
|
120
134
|
last = @card.events.order(:id).last
|
|
@@ -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
|
@@ -39,6 +39,9 @@ class Card < ApplicationRecord
|
|
|
39
39
|
|
|
40
40
|
def needs_attention? = %w[needs_input blocked failed work_complete].include?(status)
|
|
41
41
|
|
|
42
|
+
# A customer-friendly summary is being (re)generated in the background (§card #35).
|
|
43
|
+
def summary_working? = summary_status == "working"
|
|
44
|
+
|
|
42
45
|
def running? = %w[queued working needs_input].include?(status)
|
|
43
46
|
|
|
44
47
|
# Latest one-line progress event, shown live on the card face (§6).
|
|
@@ -46,6 +49,11 @@ class Card < ApplicationRecord
|
|
|
46
49
|
events.where(kind: "progress").last&.payload&.[]("text")
|
|
47
50
|
end
|
|
48
51
|
|
|
52
|
+
# Running tally across every run on the card — the closed-card cost footer
|
|
53
|
+
# (card #20). Sums stopped/restarted segments so the total reflects real spend.
|
|
54
|
+
def total_cost = runs.sum(:cost)
|
|
55
|
+
def total_output_tokens = runs.sum(:output_tokens)
|
|
56
|
+
|
|
49
57
|
# Is the planning assistant expected to post next? True right after entering
|
|
50
58
|
# a planning column (kickoff inspection pending) or after a user message.
|
|
51
59
|
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
|
-
|
|
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
|
|
@@ -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">
|
|
@@ -31,18 +31,23 @@
|
|
|
31
31
|
<% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
|
|
32
32
|
<span class="chip agent-chip">🪶 replied</span>
|
|
33
33
|
<% 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
34
|
<% if card.parent_id %><span class="chip">↑ sub</span><% end %>
|
|
38
35
|
<% if card.children.any? %><span class="chip">↳ <%= card.children.where(status: %w[done archived]).count %>/<%= card.children.count %></span><% end %>
|
|
39
36
|
<% if card.branch_name.present? && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
|
|
40
37
|
</div>
|
|
41
38
|
<% end %>
|
|
42
|
-
<%
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<span class="footer-
|
|
46
|
-
|
|
39
|
+
<% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
|
|
40
|
+
<% if has_cost || card.pr_url.present? %>
|
|
41
|
+
<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>
|
|
51
|
+
</div>
|
|
47
52
|
<% end %>
|
|
48
53
|
</article>
|
|
@@ -31,12 +31,17 @@
|
|
|
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].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
|
+
<% else %>
|
|
40
45
|
<div class="timeline-scroll" data-scroll-target="scroller">
|
|
41
46
|
<% if @card.description.present? %>
|
|
42
47
|
<div class="event event-description"><%= render_markdown @card.description %></div>
|
|
@@ -65,6 +70,7 @@
|
|
|
65
70
|
data: { controller: "composer", action: "keydown->composer#keydown" },
|
|
66
71
|
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
72
|
<% end %>
|
|
73
|
+
<% end %>
|
|
68
74
|
</section>
|
|
69
75
|
|
|
70
76
|
<aside class="work-panel">
|
|
@@ -175,6 +181,13 @@
|
|
|
175
181
|
<p class="empty">No runs yet — drag the card into an execution column to assign an agent.</p>
|
|
176
182
|
<% end %>
|
|
177
183
|
|
|
184
|
+
<% if latest && (latest.cost.positive? || latest.output_tokens.positive?) %>
|
|
185
|
+
<div class="work-footer">
|
|
186
|
+
<span class="footer-left"><%= @card.column.model_label %></span>
|
|
187
|
+
<span class="footer-cost">$<%= latest.cost.round(2) %> · <%= latest.output_tokens %> out</span>
|
|
188
|
+
</div>
|
|
189
|
+
<% end %>
|
|
190
|
+
|
|
178
191
|
<details class="advanced-rules panel-advanced">
|
|
179
192
|
<summary>Advanced</summary>
|
|
180
193
|
<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,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: \"
|
|
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
data/lib/cardinal/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cardinal-ai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jason Ellis
|
|
@@ -190,6 +190,7 @@ files:
|
|
|
190
190
|
- app/jobs/merge_pr_job.rb
|
|
191
191
|
- app/jobs/resume_run_job.rb
|
|
192
192
|
- app/jobs/start_run_job.rb
|
|
193
|
+
- app/jobs/summary_job.rb
|
|
193
194
|
- app/mailers/application_mailer.rb
|
|
194
195
|
- app/models/agent_session.rb
|
|
195
196
|
- app/models/application_record.rb
|
|
@@ -211,6 +212,7 @@ files:
|
|
|
211
212
|
- app/views/boards/show.html.erb
|
|
212
213
|
- app/views/cards/_card.html.erb
|
|
213
214
|
- app/views/cards/_detail.html.erb
|
|
215
|
+
- app/views/cards/_summary_panel.html.erb
|
|
214
216
|
- app/views/cards/_tag_picker.html.erb
|
|
215
217
|
- app/views/cards/new.html.erb
|
|
216
218
|
- app/views/cards/show.html.erb
|
|
@@ -231,7 +233,6 @@ files:
|
|
|
231
233
|
- config/bundler-audit.yml
|
|
232
234
|
- config/cable.yml
|
|
233
235
|
- config/ci.rb
|
|
234
|
-
- config/credentials.yml.enc
|
|
235
236
|
- config/database.yml
|
|
236
237
|
- config/environment.rb
|
|
237
238
|
- config/environments/development.rb
|
|
@@ -257,6 +258,7 @@ files:
|
|
|
257
258
|
- db/migrate/20260704000001_add_parent_to_cards.rb
|
|
258
259
|
- db/migrate/20260704000002_add_assistant_session_to_cards.rb
|
|
259
260
|
- db/migrate/20260704120000_add_repo_brief_to_boards.rb
|
|
261
|
+
- db/migrate/20260704130000_add_summary_to_cards.rb
|
|
260
262
|
- db/queue_schema.rb
|
|
261
263
|
- db/seeds.rb
|
|
262
264
|
- docker/agent/Dockerfile
|
data/config/credentials.yml.enc
DELETED
|
@@ -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==
|