cardinal-ai 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/cardinal.css +3 -3
- data/app/controllers/boards_controller.rb +13 -3
- data/app/controllers/cards_controller.rb +32 -6
- 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/models/card.rb +27 -0
- data/app/services/agent/runner.rb +21 -2
- data/app/views/cards/_card.html.erb +8 -16
- data/app/views/cards/_compact_panel.html.erb +28 -0
- data/app/views/cards/_detail.html.erb +8 -4
- data/app/views/cards/_dismiss_flash.html.erb +5 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260704140000_add_compact_to_cards.rb +7 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +6 -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
|
|
@@ -216,9 +216,9 @@ body.dragging .drop-hint { display: block; }
|
|
|
216
216
|
}
|
|
217
217
|
.card-footer:hover .footer-pr { color: var(--blue); }
|
|
218
218
|
.footer-left { min-width: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
219
|
-
.footer-
|
|
220
|
-
.footer-cost { white-space: nowrap; }
|
|
221
|
-
.footer-pr { color: var(--text-dim); }
|
|
219
|
+
.footer-center { min-width: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; }
|
|
220
|
+
.footer-cost { white-space: nowrap; flex-shrink: 0; }
|
|
221
|
+
.footer-pr { color: var(--text-dim); flex-shrink: 0; }
|
|
222
222
|
|
|
223
223
|
/* Open-card cost tally: sits at the foot of the work panel, live-updating. */
|
|
224
224
|
.work-footer {
|
|
@@ -60,10 +60,19 @@ class BoardsController < ApplicationController
|
|
|
60
60
|
def pull
|
|
61
61
|
board = Board.first!
|
|
62
62
|
message, ok = pull_repo(board)
|
|
63
|
-
|
|
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, :summarize, :destroy]
|
|
2
|
+
before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy]
|
|
3
3
|
|
|
4
4
|
def new
|
|
5
5
|
@board = Board.first!
|
|
@@ -30,11 +30,11 @@ class CardsController < ApplicationController
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def show
|
|
33
|
-
@zoom = params[:zoom].presence_in(%w[conversation activity debug summary]) || "conversation"
|
|
33
|
+
@zoom = params[:zoom].presence_in(%w[conversation activity debug summary compact]) || "conversation"
|
|
34
34
|
@events = case @zoom
|
|
35
35
|
when "conversation" then @card.events.conversation
|
|
36
36
|
when "activity" then @card.events.activity
|
|
37
|
-
when "summary" then Event.none #
|
|
37
|
+
when "summary", "compact" then Event.none # these tabs show a card panel, not events
|
|
38
38
|
else @card.events
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -81,7 +81,20 @@ class CardsController < ApplicationController
|
|
|
81
81
|
@card.update!(status: "approved")
|
|
82
82
|
@card.log!("status_change", actor: "user", text: "Work approved — drag to Done to ship")
|
|
83
83
|
end
|
|
84
|
-
|
|
84
|
+
|
|
85
|
+
respond_to do |format|
|
|
86
|
+
# Flash the verdict in place, then let the dismiss controller minimize the
|
|
87
|
+
# modal. Patch the board face too — Turbo suppresses this tab's own refresh
|
|
88
|
+
# broadcast, so without this the card keeps its stale status once we close.
|
|
89
|
+
format.turbo_stream do
|
|
90
|
+
render turbo_stream: [
|
|
91
|
+
turbo_stream.replace(helpers.dom_id(@card), partial: "cards/card", locals: { card: @card }),
|
|
92
|
+
turbo_stream.replace("review-callout", partial: "cards/dismiss_flash",
|
|
93
|
+
locals: { message: "Approved — closing…" })
|
|
94
|
+
]
|
|
95
|
+
end
|
|
96
|
+
format.html { redirect_to card_path(@card) }
|
|
97
|
+
end
|
|
85
98
|
end
|
|
86
99
|
|
|
87
100
|
# Generate a customer-friendly summary on demand (card #35). Non-blocking,
|
|
@@ -97,6 +110,19 @@ class CardsController < ApplicationController
|
|
|
97
110
|
partial: "cards/summary_panel", locals: { card: @card })
|
|
98
111
|
end
|
|
99
112
|
|
|
113
|
+
# Generate a technical "compact" journal on demand (card #34). The AI-readable
|
|
114
|
+
# mirror of #summarize: flip the card into its "working" state, morph the Compact
|
|
115
|
+
# panel so the button reflects it, and let CompactJob do the one-shot synthesis
|
|
116
|
+
# in the background. Skipped when one is already running.
|
|
117
|
+
def compact
|
|
118
|
+
unless @card.compact_working?
|
|
119
|
+
@card.update!(compact_status: "working")
|
|
120
|
+
CompactJob.perform_later(@card)
|
|
121
|
+
end
|
|
122
|
+
render turbo_stream: turbo_stream.replace("card_compact",
|
|
123
|
+
partial: "cards/compact_panel", locals: { card: @card })
|
|
124
|
+
end
|
|
125
|
+
|
|
100
126
|
def move
|
|
101
127
|
from_column = @card.column
|
|
102
128
|
to_column = @card.board.columns.find(params[:column_id])
|
|
@@ -120,7 +146,7 @@ class CardsController < ApplicationController
|
|
|
120
146
|
end
|
|
121
147
|
|
|
122
148
|
def card_params
|
|
123
|
-
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary)
|
|
149
|
+
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact)
|
|
124
150
|
attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
|
|
125
151
|
attrs.to_h.symbolize_keys
|
|
126
152
|
end
|
|
@@ -128,7 +154,7 @@ class CardsController < ApplicationController
|
|
|
128
154
|
# Changelog in the activity timeline (the mechanism already exists). A burst
|
|
129
155
|
# of autosaves coalesces into one entry instead of one per pause-in-typing.
|
|
130
156
|
def log_changelog!
|
|
131
|
-
changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary]
|
|
157
|
+
changed = @card.previous_changes.keys & %w[title description tags branch_name pr_url summary compact]
|
|
132
158
|
return if changed.empty?
|
|
133
159
|
|
|
134
160
|
last = @card.events.order(:id).last
|
|
@@ -21,7 +21,17 @@ class RunsController < ApplicationController
|
|
|
21
21
|
card.log!("plan_approved", actor: "user", run: run, text: "Plan approved")
|
|
22
22
|
ResumeRunJob.perform_later(run.id, "", approve: true)
|
|
23
23
|
end
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
respond_to do |format|
|
|
26
|
+
# Flash the approval in place, then the dismiss controller minimizes the
|
|
27
|
+
# modal. The run advances asynchronously (ResumeRunJob), so there's no
|
|
28
|
+
# synchronous card state to morph here — just confirm and close.
|
|
29
|
+
format.turbo_stream do
|
|
30
|
+
render turbo_stream: turbo_stream.replace("plan-callout", partial: "cards/dismiss_flash",
|
|
31
|
+
locals: { message: "Plan approved — running…" })
|
|
32
|
+
end
|
|
33
|
+
format.html { redirect_to card_path(card) }
|
|
34
|
+
end
|
|
25
35
|
end
|
|
26
36
|
|
|
27
37
|
# Restart a run that parked or failed on its turn budget / timeout. Mirrors
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Briefly show a confirmation, then minimize the surrounding card modal.
|
|
4
|
+
// Rendered into an approval callout so the user sees the verdict land before
|
|
5
|
+
// the card snaps back to the columns view. On connect we start a timer; when
|
|
6
|
+
// it fires we hand off to the enclosing modal controller's close().
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static values = { delay: { type: Number, default: 1100 } }
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.timer = setTimeout(() => this.dismiss(), this.delayValue)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
clearTimeout(this.timer)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dismiss() {
|
|
19
|
+
const el = this.element.closest("[data-controller~='modal']")
|
|
20
|
+
if (!el) return
|
|
21
|
+
this.application.getControllerForElementAndIdentifier(el, "modal")?.close()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# On-demand, AI-readable "compact" of a card (card #34): the technical mirror of
|
|
2
|
+
# SummaryJob. Where Summary compresses a card into a couple of non-technical lines
|
|
3
|
+
# for a customer chat, Compact distills everything that happened — the brief,
|
|
4
|
+
# timeline, runs, final reports, and code commits — into a dense technical journal
|
|
5
|
+
# a *resuming agent* can read instead of re-exploring the repo and re-deriving
|
|
6
|
+
# context. That's the whole point: spend a few tokens now to save many later.
|
|
7
|
+
# Generation is user-triggered only; the result persists on the card and stays
|
|
8
|
+
# fully editable. A prior compact (possibly hand-edited) rides along as context so
|
|
9
|
+
# a regeneration refines rather than discards what the user kept.
|
|
10
|
+
class CompactJob < ApplicationJob
|
|
11
|
+
queue_as :default
|
|
12
|
+
|
|
13
|
+
FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
|
|
14
|
+
|
|
15
|
+
SYSTEM = <<~SYS.freeze
|
|
16
|
+
You write a dense, technical engineering journal for an AI agent that will
|
|
17
|
+
resume work on this card later. Your reader is a machine, not a customer:
|
|
18
|
+
optimize for signal, not readability. Capture, in compact form, everything a
|
|
19
|
+
resuming engineer needs so they do NOT have to re-explore the repo or re-derive
|
|
20
|
+
context — what was built and how, files and components touched, key APIs and
|
|
21
|
+
libraries used, decisions made and the reasons behind them, issues found,
|
|
22
|
+
blockers hit, and anything left unfinished or deliberately deferred. Prefer
|
|
23
|
+
terse structured notes (short headings, bullets, file paths, symbol names) over
|
|
24
|
+
prose. Omit pleasantries and customer-facing framing. Write only the journal.
|
|
25
|
+
SYS
|
|
26
|
+
|
|
27
|
+
def perform(card)
|
|
28
|
+
return clear_working(card) unless ClaudeCli.available?
|
|
29
|
+
|
|
30
|
+
model = card.board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
|
|
31
|
+
compact = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1)
|
|
32
|
+
|
|
33
|
+
card.update!(compact: compact.to_s.strip, compact_generated_at: Time.current, compact_status: nil)
|
|
34
|
+
card.broadcast_replace_to card, target: "card_compact",
|
|
35
|
+
partial: "cards/compact_panel", locals: { card: card }
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# A failed generation must not leave the button stuck on "Generating…".
|
|
38
|
+
clear_working(card)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def clear_working(card)
|
|
44
|
+
card.update!(compact_status: nil)
|
|
45
|
+
card.broadcast_replace_to card, target: "card_compact",
|
|
46
|
+
partial: "cards/compact_panel", locals: { card: card }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_prompt(card)
|
|
50
|
+
parts = ["Card ##{card.number}: #{card.title}"]
|
|
51
|
+
parts << "Tags: #{card.tags.join(", ")}" if card.tags.any?
|
|
52
|
+
parts << "\nBrief / description:\n#{card.description}" if card.description.present?
|
|
53
|
+
|
|
54
|
+
timeline = card.events.activity.filter_map { |e| event_line(e) }
|
|
55
|
+
parts << "\nWhat happened (technical timeline):\n#{timeline.join("\n")}" if timeline.any?
|
|
56
|
+
|
|
57
|
+
reports = card.events.where(kind: "final_report").filter_map(&:text).map(&:strip).reject(&:blank?)
|
|
58
|
+
parts << "\nFinal reports from runs:\n#{reports.join("\n\n---\n\n")}" if reports.any?
|
|
59
|
+
|
|
60
|
+
runs = card.runs.order(:id).map { |r| "- Run ##{r.id}: #{r.status}#{" (#{r.phase})" if r.phase.present?}" }
|
|
61
|
+
parts << "\nRuns:\n#{runs.join("\n")}" if runs.any?
|
|
62
|
+
|
|
63
|
+
commits = commit_lines(card)
|
|
64
|
+
parts << "\nCode changes (commit messages):\n#{commits.join("\n")}" if commits.any?
|
|
65
|
+
|
|
66
|
+
if card.compact.present?
|
|
67
|
+
parts << "\nThe existing technical compact is below. It may have been edited by hand, " \
|
|
68
|
+
"so preserve details the user kept and fold in any new work rather than " \
|
|
69
|
+
"starting from scratch:\n#{card.compact}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
parts.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def event_line(event)
|
|
76
|
+
text = event.text.to_s.strip
|
|
77
|
+
return nil if text.blank?
|
|
78
|
+
"- #{event.actor}: #{text.truncate(600)}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Commit messages for the card's branch, read from the per-card workspace
|
|
82
|
+
# checkout when it still exists. The checkout isn't guaranteed to be present
|
|
83
|
+
# (it's left in place after a run but may be pruned), so this is best-effort —
|
|
84
|
+
# the timeline already narrates the work when commits are unavailable.
|
|
85
|
+
def commit_lines(card)
|
|
86
|
+
return [] if card.branch_name.blank?
|
|
87
|
+
path = Agent::Workspace::Local.new(card).path
|
|
88
|
+
return [] unless File.directory?(path.join(".git"))
|
|
89
|
+
|
|
90
|
+
base = "origin/#{card.board.default_branch}"
|
|
91
|
+
out, ok = Open3.capture2e("git", "-C", path.to_s, "log", "--oneline", "--no-decorate", "#{base}..HEAD")
|
|
92
|
+
return [] unless ok.success?
|
|
93
|
+
out.lines.map(&:strip).reject(&:blank?).map { |l| "- #{l}" }
|
|
94
|
+
rescue StandardError
|
|
95
|
+
[]
|
|
96
|
+
end
|
|
97
|
+
end
|
data/app/models/card.rb
CHANGED
|
@@ -23,6 +23,15 @@ class Card < ApplicationRecord
|
|
|
23
23
|
has_many :agent_sessions, dependent: :destroy
|
|
24
24
|
has_many :runs, through: :agent_sessions
|
|
25
25
|
|
|
26
|
+
# Card-face status glyphs. Keyed on status, except `ready_for_approval?`
|
|
27
|
+
# (a derived plan-park state, not a status) which pre-empts the ❓ below.
|
|
28
|
+
STATUS_GLYPHS = {
|
|
29
|
+
"working" => "⚡", "needs_input" => "❓", "failed" => "✖",
|
|
30
|
+
"work_complete" => "✅", "done" => "✓", "queued" => "⏳",
|
|
31
|
+
"discussing" => "💬", "in_review" => "👁", "approved" => "👍",
|
|
32
|
+
"changes_requested" => "🔁"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
26
35
|
enum :status, STATUSES.index_by(&:itself)
|
|
27
36
|
|
|
28
37
|
validates :title, presence: true
|
|
@@ -42,8 +51,26 @@ class Card < ApplicationRecord
|
|
|
42
51
|
# A customer-friendly summary is being (re)generated in the background (§card #35).
|
|
43
52
|
def summary_working? = summary_status == "working"
|
|
44
53
|
|
|
54
|
+
# A technical "compact" journal is being (re)generated in the background (§card
|
|
55
|
+
# #34) — the AI-readable context a resuming agent reads to skip re-exploration.
|
|
56
|
+
def compact_working? = compact_status == "working"
|
|
57
|
+
|
|
45
58
|
def running? = %w[queued working needs_input].include?(status)
|
|
46
59
|
|
|
60
|
+
# The agent has proposed a plan and is parked waiting on the user's approve
|
|
61
|
+
# click — distinct from a genuine question. Same underlying `needs_input`
|
|
62
|
+
# status; the plan-phase park is the signal (mirrors the work panel, §detail).
|
|
63
|
+
# The status guard means the run query only fires for already-parked cards.
|
|
64
|
+
def ready_for_approval?
|
|
65
|
+
needs_input? && runs.needs_input.order(:id).last&.phase == "plan"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Card-face glyph: a bell when a plan is awaiting approval, else the plain
|
|
69
|
+
# per-status mapping.
|
|
70
|
+
def status_glyph
|
|
71
|
+
ready_for_approval? ? "🔔" : STATUS_GLYPHS[status]
|
|
72
|
+
end
|
|
73
|
+
|
|
47
74
|
# Latest one-line progress event, shown live on the card face (§6).
|
|
48
75
|
def latest_progress
|
|
49
76
|
events.where(kind: "progress").last&.payload&.[]("text")
|
|
@@ -356,7 +356,7 @@ module Agent
|
|
|
356
356
|
## Card conversation so far
|
|
357
357
|
#{conversation_excerpt.presence || "(none)"}
|
|
358
358
|
|
|
359
|
-
#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
359
|
+
#{compact_section}#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
360
360
|
#{execute_rules}
|
|
361
361
|
PROMPT
|
|
362
362
|
end
|
|
@@ -377,7 +377,7 @@ module Agent
|
|
|
377
377
|
## Card conversation so far
|
|
378
378
|
#{conversation_excerpt.presence || "(none)"}
|
|
379
379
|
|
|
380
|
-
#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
380
|
+
#{compact_section}#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
381
381
|
Explore the repository as needed, then present a short numbered plan-of-attack
|
|
382
382
|
(files you'll touch, approach, how you'll verify) and stop. The user will approve
|
|
383
383
|
or redirect before any changes are made.
|
|
@@ -398,6 +398,25 @@ module Agent
|
|
|
398
398
|
"## Repo brief\n#{brief.strip}\n\n"
|
|
399
399
|
end
|
|
400
400
|
|
|
401
|
+
# The user-generated technical "compact" (card #34), if one exists — a dense
|
|
402
|
+
# journal of prior work on this card written for a resuming agent. Injected so
|
|
403
|
+
# the agent picks up where the last session left off instead of re-deriving
|
|
404
|
+
# context. Explicitly framed as technical resume notes, distinct from the
|
|
405
|
+
# human conversation above and from the customer-facing summary. Empty string
|
|
406
|
+
# (not nil) when absent, so the heredoc stays clean.
|
|
407
|
+
def compact_section
|
|
408
|
+
compact = card.compact.presence or return ""
|
|
409
|
+
<<~SECTION
|
|
410
|
+
## Technical resume context (compact from a prior work session)
|
|
411
|
+
The notes below were generated (and possibly hand-edited) to capture the
|
|
412
|
+
technical state of prior work on this card — what was built, decided, and
|
|
413
|
+
blocked. Treat them as an authoritative shortcut to avoid re-exploring the
|
|
414
|
+
repo; verify against the actual code before relying on any specific detail.
|
|
415
|
+
#{compact.strip}
|
|
416
|
+
|
|
417
|
+
SECTION
|
|
418
|
+
end
|
|
419
|
+
|
|
401
420
|
# The planning assistant's distilled "Ready for execution" brief, if the
|
|
402
421
|
# conversation produced one — the most load-bearing artifact of planning.
|
|
403
422
|
def planning_brief
|
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
<%= link_to card_path(card), class: "card-link", data: { turbo_frame: "modal", turbo_action: "advance" } do %>
|
|
3
3
|
<div class="card-title">
|
|
4
4
|
<%= card.title %>
|
|
5
|
-
<span class="status-glyph"><%=
|
|
6
|
-
"work_complete" => "✅", "done" => "✓", "queued" => "⏳",
|
|
7
|
-
"discussing" => "💬", "in_review" => "👁", "approved" => "👍",
|
|
8
|
-
"changes_requested" => "🔁" }[card.status] %></span>
|
|
5
|
+
<span class="status-glyph"><%= card.status_glyph %></span>
|
|
9
6
|
</div>
|
|
10
7
|
<% if card.queued? %>
|
|
11
8
|
<% ahead = card.column.cards.where(status: "queued").where("position < ?", card.position).count %>
|
|
@@ -23,9 +20,6 @@
|
|
|
23
20
|
<% end %>
|
|
24
21
|
<div class="card-meta">
|
|
25
22
|
<% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
|
|
26
|
-
<% if card.running? && card.column.ai? && card.column.model %>
|
|
27
|
-
<span class="chip agent-chip">🤖 <%= card.column.model_short %><%= " · #{card.column.effort}" if card.column.effort %></span>
|
|
28
|
-
<% end %>
|
|
29
23
|
<% if card.awaiting_assistant? %>
|
|
30
24
|
<span class="chip agent-chip thinking-chip">🪶 <span class="typing-dots mini"><span></span><span></span><span></span></span></span>
|
|
31
25
|
<% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
|
|
@@ -39,15 +33,13 @@
|
|
|
39
33
|
<% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
|
|
40
34
|
<% if has_cost || card.pr_url.present? %>
|
|
41
35
|
<div class="card-footer">
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<% end %>
|
|
50
|
-
</span>
|
|
36
|
+
<% if card.pr_url.present? %>
|
|
37
|
+
<a class="footer-pr" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</a>
|
|
38
|
+
<% else %>
|
|
39
|
+
<span class="footer-pr"></span>
|
|
40
|
+
<% end %>
|
|
41
|
+
<span class="footer-center"><%= "🤖 #{card.column.model_label}" if has_cost && card.column.model_label %></span>
|
|
42
|
+
<span class="footer-cost"><%= "$#{card.total_cost.round(2)}" if has_cost %></span>
|
|
51
43
|
</div>
|
|
52
44
|
<% end %>
|
|
53
45
|
</article>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<div id="card_compact" class="summary-panel compact-panel" data-controller="autosave">
|
|
2
|
+
<div class="summary-head">
|
|
3
|
+
<h3>Technical compact <span class="autosave-status" data-autosave-target="status"></span></h3>
|
|
4
|
+
<% if card.compact_working? %>
|
|
5
|
+
<button type="button" class="deep-dive working summary-generate" disabled>
|
|
6
|
+
<span class="pulse-dot"></span> Generating…
|
|
7
|
+
</button>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= button_to compact_card_path(card), class: "deep-dive summary-generate",
|
|
10
|
+
title: "Distill everything this card did into a dense technical journal a resuming agent can read" do %>
|
|
11
|
+
✨ <%= card.compact.present? ? "Regenerate" : "Generate compact" %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<p class="hint summary-blurb">A dense, technical journal of what was built, decided, and blocked — the AI reads this to resume work without re-exploring the repo. Fully editable; your edits are respected when you regenerate.</p>
|
|
17
|
+
|
|
18
|
+
<%= form_with model: card, class: "summary-form",
|
|
19
|
+
data: { autosave_target: "form", action: "input->autosave#save change->autosave#save" } do |f| %>
|
|
20
|
+
<%= hidden_field_tag :autosave, "1" %>
|
|
21
|
+
<%= f.text_area :compact, rows: 10, placeholder: "No compact yet — click Generate compact, or write your own technical notes.",
|
|
22
|
+
disabled: card.compact_working? %>
|
|
23
|
+
<% end %>
|
|
24
|
+
|
|
25
|
+
<% if card.compact_generated_at.present? %>
|
|
26
|
+
<p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.compact_generated_at) %> ago</p>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
<div class="detail-panes">
|
|
32
32
|
<section class="timeline" data-controller="scroll">
|
|
33
33
|
<nav class="zoom-tabs">
|
|
34
|
-
<% %w[conversation activity debug summary].each do |zoom| %>
|
|
34
|
+
<% %w[conversation activity debug summary compact].each do |zoom| %>
|
|
35
35
|
<%= link_to zoom.capitalize, card_path(@card, zoom: zoom),
|
|
36
36
|
class: ("active" if @zoom == zoom) %>
|
|
37
37
|
<% end %>
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
<div class="timeline-scroll">
|
|
42
42
|
<%= render "cards/summary_panel", card: @card %>
|
|
43
43
|
</div>
|
|
44
|
+
<% elsif @zoom == "compact" %>
|
|
45
|
+
<div class="timeline-scroll">
|
|
46
|
+
<%= render "cards/compact_panel", card: @card %>
|
|
47
|
+
</div>
|
|
44
48
|
<% else %>
|
|
45
49
|
<div class="timeline-scroll" data-scroll-target="scroller">
|
|
46
50
|
<% if @card.description.present? %>
|
|
@@ -123,7 +127,7 @@
|
|
|
123
127
|
<%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
|
|
124
128
|
<% end %>
|
|
125
129
|
<% if @card.in_review? %>
|
|
126
|
-
<div class="panel-callout callout-plan">
|
|
130
|
+
<div class="panel-callout callout-plan" id="review-callout">
|
|
127
131
|
<p><strong>Your verdict.</strong> Check the final report in the timeline.
|
|
128
132
|
Approve, or just say what's wrong in the conversation — that marks it changes-requested,
|
|
129
133
|
and dragging the card back to a work column carries your feedback into the next run.</p>
|
|
@@ -141,7 +145,7 @@
|
|
|
141
145
|
<% parked = runs.select(&:needs_input?).last %>
|
|
142
146
|
<% latest = runs.last %>
|
|
143
147
|
<% if parked&.phase == "plan" %>
|
|
144
|
-
<div class="panel-callout callout-plan">
|
|
148
|
+
<div class="panel-callout callout-plan" id="plan-callout">
|
|
145
149
|
<p><strong>Plan proposed.</strong> Approve to let the agent execute, or reply in the timeline to redirect.</p>
|
|
146
150
|
<%= button_to "👍 Approve plan", approve_run_path(parked), class: "approve-btn", form_class: "align-right" %>
|
|
147
151
|
</div>
|
|
@@ -183,7 +187,7 @@
|
|
|
183
187
|
|
|
184
188
|
<% if latest && (latest.cost.positive? || latest.output_tokens.positive?) %>
|
|
185
189
|
<div class="work-footer">
|
|
186
|
-
<span class="footer-left"><%= @card.column.model_label %></span>
|
|
190
|
+
<span class="footer-left"><%= "🤖 #{@card.column.model_label}" if @card.column.model_label %></span>
|
|
187
191
|
<span class="footer-cost">$<%= latest.cost.round(2) %> · <%= latest.output_tokens %> out</span>
|
|
188
192
|
</div>
|
|
189
193
|
<% end %>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%# Momentary confirmation shown in place of an approval callout, then it
|
|
2
|
+
auto-closes the card modal (see dismiss_controller). %>
|
|
3
|
+
<div class="panel-callout callout-plan" data-controller="dismiss" data-dismiss-delay-value="1100">
|
|
4
|
+
<p><strong><%= message %></strong></p>
|
|
5
|
+
</div>
|
data/config/routes.rb
CHANGED
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,6 +186,7 @@ files:
|
|
|
185
186
|
- app/jobs/ai_task_job.rb
|
|
186
187
|
- app/jobs/application_job.rb
|
|
187
188
|
- app/jobs/assistant_reply_job.rb
|
|
189
|
+
- app/jobs/compact_job.rb
|
|
188
190
|
- app/jobs/deep_dive_job.rb
|
|
189
191
|
- app/jobs/mark_pr_ready_job.rb
|
|
190
192
|
- app/jobs/merge_pr_job.rb
|
|
@@ -211,7 +213,9 @@ files:
|
|
|
211
213
|
- app/views/boards/edit.html.erb
|
|
212
214
|
- app/views/boards/show.html.erb
|
|
213
215
|
- app/views/cards/_card.html.erb
|
|
216
|
+
- app/views/cards/_compact_panel.html.erb
|
|
214
217
|
- app/views/cards/_detail.html.erb
|
|
218
|
+
- app/views/cards/_dismiss_flash.html.erb
|
|
215
219
|
- app/views/cards/_summary_panel.html.erb
|
|
216
220
|
- app/views/cards/_tag_picker.html.erb
|
|
217
221
|
- app/views/cards/new.html.erb
|
|
@@ -259,6 +263,7 @@ files:
|
|
|
259
263
|
- db/migrate/20260704000002_add_assistant_session_to_cards.rb
|
|
260
264
|
- db/migrate/20260704120000_add_repo_brief_to_boards.rb
|
|
261
265
|
- db/migrate/20260704130000_add_summary_to_cards.rb
|
|
266
|
+
- db/migrate/20260704140000_add_compact_to_cards.rb
|
|
262
267
|
- db/queue_schema.rb
|
|
263
268
|
- db/seeds.rb
|
|
264
269
|
- docker/agent/Dockerfile
|