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 +4 -4
- data/app/assets/stylesheets/cardinal.css +22 -1
- data/app/controllers/boards_controller.rb +13 -3
- data/app/controllers/cards_controller.rb +45 -5
- data/app/controllers/runs_controller.rb +11 -1
- data/app/javascript/controllers/dismiss_controller.js +23 -0
- data/app/jobs/compact_job.rb +97 -0
- data/app/jobs/summary_job.rb +88 -0
- data/app/models/board.rb +2 -0
- data/app/models/card.rb +35 -0
- data/app/models/column.rb +16 -7
- data/app/services/agent/runner.rb +21 -2
- data/app/views/boards/brief.html.erb +1 -1
- data/app/views/cards/_card.html.erb +12 -15
- data/app/views/cards/_compact_panel.html.erb +28 -0
- data/app/views/cards/_detail.html.erb +20 -3
- data/app/views/cards/_dismiss_flash.html.erb +5 -0
- data/app/views/cards/_summary_panel.html.erb +28 -0
- data/app/views/columns/edit.html.erb +1 -1
- data/config/routes.rb +2 -0
- data/db/migrate/20260704130000_add_summary_to_cards.rb +7 -0
- data/db/migrate/20260704140000_add_compact_to_cards.rb +7 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +9 -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: 644f883c694c74ad5189ef319f46beaf3e679a7aa47ae43be9bb316560ba46cf
|
|
4
|
+
data.tar.gz: a54a728cece60d820acbaef2ad4abdcb8080d4fd888f122700f1266f85b9713f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"><%=
|
|
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
|
-
<%
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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: \"
|
|
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.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
|
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==
|