cardinal-ai 0.2.5 → 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/README.md +20 -1
- data/app/assets/stylesheets/cardinal.css +30 -1
- data/app/controllers/boards_controller.rb +53 -4
- data/app/controllers/cards_controller.rb +18 -4
- data/app/controllers/columns_controller.rb +2 -1
- data/app/jobs/merge_pr_job.rb +22 -0
- data/app/jobs/resume_run_job.rb +7 -0
- data/app/jobs/start_run_job.rb +19 -4
- data/app/jobs/summary_job.rb +88 -0
- data/app/models/board.rb +21 -3
- data/app/models/card.rb +9 -1
- data/app/models/column.rb +31 -7
- data/app/services/agent/runner.rb +29 -2
- data/app/services/agent/workspace.rb +9 -0
- data/app/views/boards/brief.html.erb +48 -0
- data/app/views/boards/edit.html.erb +29 -0
- data/app/views/boards/show.html.erb +26 -14
- 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 +9 -1
- data/config/application.rb +1 -1
- data/config/cable.yml +9 -1
- data/config/database.yml +11 -2
- data/config/environments/development.rb +5 -0
- data/config/environments/production.rb +2 -1
- data/config/puma.rb +1 -1
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +3 -1
- data/db/cable_schema.rb +11 -0
- data/db/migrate/20260704130000_add_summary_to_cards.rb +7 -0
- data/db/queue_schema.rb +129 -0
- data/exe/cardinal +4 -1
- data/lib/cardinal/version.rb +1 -1
- metadata +38 -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
|
data/README.md
CHANGED
|
@@ -43,12 +43,26 @@ That's the whole setup. Now:
|
|
|
43
43
|
4. **Review** — read the final report and the pull request. Say what's wrong in the
|
|
44
44
|
card's conversation to send it back, or approve.
|
|
45
45
|
5. **QA** — the pull request goes live for formal review on GitHub.
|
|
46
|
-
6. **Drag to Done** — the pull request merges. Shipped.
|
|
46
|
+
6. **Drag to Done** — the pull request merges. Shipped. (If the project has CI and it's
|
|
47
|
+
red or still running, Cardinal refuses to merge and tells you why on the card.)
|
|
47
48
|
|
|
48
49
|
Every column has a ⚙ gear where you can change the rules — which AI model works there,
|
|
49
50
|
how many cards can run at once, spending limits, and what happens when a card arrives
|
|
50
51
|
(written in plain English; Cardinal figures out the rest).
|
|
51
52
|
|
|
53
|
+
### The deep dive
|
|
54
|
+
|
|
55
|
+
The **🔍 Deep dive** button in the topbar sends a read-only agent (it can look, never
|
|
56
|
+
touch) through your repo once and saves what it learns as a **repo brief** — what the
|
|
57
|
+
project is, where things live, how to build and test it, the traps to avoid. Every worker
|
|
58
|
+
agent gets the brief with its assignment, so agents skip re-exploring your codebase on
|
|
59
|
+
every single card. It costs one AI call.
|
|
60
|
+
|
|
61
|
+
Once a brief exists the button shows **🔍 Repo brief** — click it to read exactly what
|
|
62
|
+
agents are being told, and to regenerate it. The button drifts from grey toward red as
|
|
63
|
+
commits land that the brief hasn't seen; Cardinal won't silently re-run a dive that's
|
|
64
|
+
already current.
|
|
65
|
+
|
|
52
66
|
## Good to know
|
|
53
67
|
|
|
54
68
|
- Everything Cardinal knows about a project lives in a `.cardinal/` folder inside it,
|
|
@@ -57,6 +71,11 @@ how many cards can run at once, spending limits, and what happens when a card ar
|
|
|
57
71
|
`cardinal logout` to unlink).
|
|
58
72
|
- Agents can only push to their own card branches — merging is always your drag.
|
|
59
73
|
- AI usage bills the Claude account you linked, the same as using Claude Code.
|
|
74
|
+
- The board is only reachable from **your own machine** (localhost). To browse it from
|
|
75
|
+
another device on your network — a phone or tablet — start with `CARDINAL_HOST=0.0.0.0
|
|
76
|
+
cardinal`, and know that anyone on that network can then drive your board.
|
|
77
|
+
- In a worker column's ⚙ gear you can turn off **Shell access**: the agent can then only
|
|
78
|
+
read and edit files — it can't run commands — and Cardinal commits its work for it.
|
|
60
79
|
|
|
61
80
|
## For developers
|
|
62
81
|
|
|
@@ -79,6 +79,14 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
79
79
|
50% { opacity: .7; box-shadow: 0 0 0 3px rgba(212, 51, 51, 0); }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
.brief-meta { font-size: 12px; color: var(--text-dim); }
|
|
83
|
+
.brief-behind { color: var(--amber); }
|
|
84
|
+
.brief-content {
|
|
85
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
86
|
+
padding: 4px 14px; margin: 10px 0; max-height: 50vh; overflow-y: auto;
|
|
87
|
+
font-size: 13px;
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
.pull-form { display: inline; }
|
|
83
91
|
#repo-pull-status { font-size: 0.85rem; }
|
|
84
92
|
#repo-pull-status .pull-ok { color: var(--green); }
|
|
@@ -207,7 +215,18 @@ body.dragging .drop-hint { display: block; }
|
|
|
207
215
|
font-size: 11px; color: var(--text-dim); font-weight: 600;
|
|
208
216
|
}
|
|
209
217
|
.card-footer:hover .footer-pr { color: var(--blue); }
|
|
210
|
-
.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; }
|
|
211
230
|
.card-ghost { opacity: .4; }
|
|
212
231
|
.card-title { font-weight: 600; }
|
|
213
232
|
.card-number { color: var(--text-dim); font-weight: 400; }
|
|
@@ -366,6 +385,16 @@ body.dragging .drop-hint { display: block; }
|
|
|
366
385
|
.zoom-tabs a { padding: 4px 12px; border-radius: 6px; color: var(--text-dim); }
|
|
367
386
|
.zoom-tabs a.active { background: var(--surface-2); color: var(--text); }
|
|
368
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
|
+
|
|
369
398
|
.event { display: flex; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--surface-2); }
|
|
370
399
|
|
|
371
400
|
/* Column moves are chapter markers in the card's story */
|
|
@@ -5,11 +5,14 @@ class BoardsController < ApplicationController
|
|
|
5
5
|
|
|
6
6
|
# Kick off the repo deep dive (card #12). Non-blocking: flip the board into
|
|
7
7
|
# its "Working" state, morph the topbar so the button reflects it, and let
|
|
8
|
-
# DeepDiveJob do the read-only exploration in the background.
|
|
9
|
-
# dive is already running
|
|
8
|
+
# DeepDiveJob do the read-only exploration in the background. Skipped when a
|
|
9
|
+
# dive is already running, or when the brief already matches HEAD — nothing
|
|
10
|
+
# changed, so a re-dive would just burn a run (the brief modal's Regenerate
|
|
11
|
+
# button passes force=1 to override).
|
|
10
12
|
def deep_dive
|
|
11
13
|
board = Board.first!
|
|
12
|
-
|
|
14
|
+
fresh = board.brief? && board.commits_behind_brief == 0
|
|
15
|
+
unless board.brief_working? || (fresh && params[:force].blank?)
|
|
13
16
|
board.update!(brief_status: "working")
|
|
14
17
|
board.broadcast_refresh_to board
|
|
15
18
|
DeepDiveJob.perform_later(board)
|
|
@@ -17,6 +20,39 @@ class BoardsController < ApplicationController
|
|
|
17
20
|
redirect_to root_path
|
|
18
21
|
end
|
|
19
22
|
|
|
23
|
+
# Board settings gear (the board-level analog of the column gear): name and
|
|
24
|
+
# default branch — the branch agents fork from and Done merges toward.
|
|
25
|
+
def edit
|
|
26
|
+
@board = Board.first!
|
|
27
|
+
redirect_to root_path and return unless turbo_frame_request?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update
|
|
31
|
+
board = Board.first!
|
|
32
|
+
attrs = params.require(:board).permit(:name, :default_branch)
|
|
33
|
+
board.update!(
|
|
34
|
+
name: attrs[:name].presence || board.name,
|
|
35
|
+
default_branch: attrs[:default_branch].presence || board.default_branch
|
|
36
|
+
)
|
|
37
|
+
board.broadcast_refresh_to board
|
|
38
|
+
if params[:autosave]
|
|
39
|
+
render turbo_stream: [
|
|
40
|
+
turbo_stream.update("board-name", board.name),
|
|
41
|
+
turbo_stream.update("board-form-errors", "")
|
|
42
|
+
]
|
|
43
|
+
else
|
|
44
|
+
redirect_to root_path
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Inspect the repo brief: what the deep dive wrote, when, from which SHA.
|
|
49
|
+
def brief
|
|
50
|
+
@board = Board.first!
|
|
51
|
+
redirect_to root_path and return unless turbo_frame_request?
|
|
52
|
+
|
|
53
|
+
render :brief
|
|
54
|
+
end
|
|
55
|
+
|
|
20
56
|
# Cards reaching Done merge PRs on GitHub, so the checkout Cardinal runs
|
|
21
57
|
# against falls behind. The topbar Pull button fast-forwards it. --ff-only
|
|
22
58
|
# on purpose: never invent merge commits or rebase local work — if the tree
|
|
@@ -49,7 +85,20 @@ class BoardsController < ApplicationController
|
|
|
49
85
|
["Already up to date", true]
|
|
50
86
|
else
|
|
51
87
|
count, = Open3.capture2e("git", "-C", repo, "rev-list", "--count", "#{before.strip}..#{after.strip}")
|
|
52
|
-
|
|
88
|
+
migrated = run_pending_migrations
|
|
89
|
+
note = migrated.positive? ? " · ran #{helpers.pluralize(migrated, "migration")}" : ""
|
|
90
|
+
["Pulled #{helpers.pluralize(count.strip.to_i, "new commit")}#{note}", true]
|
|
53
91
|
end
|
|
54
92
|
end
|
|
93
|
+
|
|
94
|
+
# When the board's repo IS this Cardinal instance (dogfooding) a pull can
|
|
95
|
+
# bring schema changes; without this the running server 500s until someone
|
|
96
|
+
# runs db:migrate by hand. A no-op everywhere else — `cardinal up` already
|
|
97
|
+
# covers cold boots via db:prepare.
|
|
98
|
+
def run_pending_migrations
|
|
99
|
+
context = ActiveRecord::Base.connection_pool.migration_context
|
|
100
|
+
pending = context.migrations.map(&:version) - context.get_all_versions
|
|
101
|
+
context.migrate if pending.any?
|
|
102
|
+
pending.size
|
|
103
|
+
end
|
|
55
104
|
end
|
|
@@ -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
|
|
@@ -26,7 +26,7 @@ class ColumnsController < ApplicationController
|
|
|
26
26
|
:name, :archetype, :instructions, :model, :effort,
|
|
27
27
|
:concurrency_limit, :max_turns, :timeout_minutes, :plan_approval,
|
|
28
28
|
:on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
|
|
29
|
-
:footer_text, accepts_from: []
|
|
29
|
+
:shell, :footer_text, accepts_from: []
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
policy = @column.policy.dup
|
|
@@ -38,6 +38,7 @@ class ColumnsController < ApplicationController
|
|
|
38
38
|
policy["plan_approval"] = attrs[:plan_approval] == "1"
|
|
39
39
|
policy["arrivals"] = attrs[:arrivals].presence_in(%w[top bottom])
|
|
40
40
|
policy["ai"] = (attrs[:ai] == "1") if attrs.key?(:ai) # inbox forms omit it — never AI anyway
|
|
41
|
+
policy["shell"] = (attrs[:shell] == "1") if attrs.key?(:shell) # worker shell access (execution gear only)
|
|
41
42
|
# Accept policy (card #15): store allowed source column ids as strings.
|
|
42
43
|
# EXPLICIT ONLY — an empty list means the column accepts from nowhere.
|
|
43
44
|
policy["accepts_from"] = attrs[:accepts_from].to_a.map(&:to_s).reject(&:blank?).presence
|
data/app/jobs/merge_pr_job.rb
CHANGED
|
@@ -6,6 +6,7 @@ class MergePrJob < ApplicationJob
|
|
|
6
6
|
def perform(card_id)
|
|
7
7
|
card = Card.find(card_id)
|
|
8
8
|
return if card.pr_url.blank? || card.pr_state == "merged"
|
|
9
|
+
return unless checks_green?(card)
|
|
9
10
|
|
|
10
11
|
# Best-effort undraft — a QA column may already have done it, and gh
|
|
11
12
|
# errors on an already-ready PR; the merge step is the real gate.
|
|
@@ -18,6 +19,27 @@ class MergePrJob < ApplicationJob
|
|
|
18
19
|
|
|
19
20
|
private
|
|
20
21
|
|
|
22
|
+
# The merge gate: never ship over failing CI. A repo with no checks
|
|
23
|
+
# configured passes (nothing to gate on); failing or still-running checks
|
|
24
|
+
# park the card as blocked with the reason — drag it out and back into Done
|
|
25
|
+
# to retry once CI is green.
|
|
26
|
+
def checks_green?(card)
|
|
27
|
+
out, status = Open3.capture2e("gh", "pr", "checks", card.pr_url)
|
|
28
|
+
return true if status.success?
|
|
29
|
+
return true if out.match?(/no checks reported/i)
|
|
30
|
+
|
|
31
|
+
reason =
|
|
32
|
+
if status.exitstatus == 8
|
|
33
|
+
"CI checks are still running — not merged. Drag out of Done and back once they finish."
|
|
34
|
+
else
|
|
35
|
+
failing = out.lines.map(&:strip).grep(/fail/i).first(3).join("; ").presence || out.strip.truncate(160)
|
|
36
|
+
"CI checks failing — not merged. #{failing}"
|
|
37
|
+
end
|
|
38
|
+
card.log!("error", text: reason)
|
|
39
|
+
card.update!(status: "blocked")
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
21
43
|
def run_step(card, cmd)
|
|
22
44
|
out, status = Open3.capture2e(*cmd)
|
|
23
45
|
return true if status.success?
|
data/app/jobs/resume_run_job.rb
CHANGED
|
@@ -20,6 +20,13 @@ class ResumeRunJob < ApplicationJob
|
|
|
20
20
|
return
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Atomic claim (§ races): two finishing runs can both kick the queue and
|
|
24
|
+
# double-fire this job — exactly one claimer resumes the session. The
|
|
25
|
+
# claim value is "running", which is what a resume sets anyway.
|
|
26
|
+
return unless Run.where(id: run.id, status: "needs_input")
|
|
27
|
+
.update_all(status: "running", updated_at: Time.current) == 1
|
|
28
|
+
run.reload
|
|
29
|
+
|
|
23
30
|
if (pending = run.briefing["pending_resume"])
|
|
24
31
|
run.update!(briefing: run.briefing.except("pending_resume"))
|
|
25
32
|
message = [pending["message"], message].compact_blank.join("\n\n")
|
data/app/jobs/start_run_job.rb
CHANGED
|
@@ -3,11 +3,26 @@ class StartRunJob < ApplicationJob
|
|
|
3
3
|
|
|
4
4
|
def perform(card_id)
|
|
5
5
|
card = Card.find(card_id)
|
|
6
|
-
|
|
7
|
-
return
|
|
6
|
+
column = card.column
|
|
7
|
+
return unless column.execution? && column.ai?
|
|
8
|
+
return if column.at_wip_limit? # stays queued; kicked when a slot frees
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
# Atomic claim (§ races): two kicks can enqueue this job twice for the
|
|
11
|
+
# same card; exactly one claimer flips queued→working, the rest no-op.
|
|
12
|
+
return unless Card.where(id: card.id, status: "queued")
|
|
13
|
+
.update_all(status: "working", updated_at: Time.current) == 1
|
|
14
|
+
card.reload.touch # update_all skips callbacks — nudge the board broadcast
|
|
15
|
+
|
|
16
|
+
# Re-check AFTER claiming: claims are atomic, so an over-subscribed slot
|
|
17
|
+
# shows up as strictly more running than allowed and the loser un-claims
|
|
18
|
+
# back into the queue (the next kick retries it).
|
|
19
|
+
if column.at_wip_limit_exceeded?
|
|
20
|
+
card.update!(status: "queued")
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
session = card.agent_sessions.create!(status: "provisioning", model: column.model)
|
|
25
|
+
run = session.runs.create!(status: "queued", briefing: { "card" => card.title, "column" => column.name })
|
|
11
26
|
Agent::Runner.start(run)
|
|
12
27
|
end
|
|
13
28
|
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,
|
|
@@ -60,18 +62,30 @@ class Board < ApplicationRecord
|
|
|
60
62
|
# Raw configured URL (get-url applies insteadOf rewrites, which can embed
|
|
61
63
|
# credential-helper tokens); strip any userinfo defensively either way.
|
|
62
64
|
origin, origin_ok = Open3.capture2e("git", "-C", repo_path, "config", "--get", "remote.origin.url")
|
|
63
|
-
branch, branch_ok = Open3.capture2e("git", "-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
64
65
|
|
|
65
66
|
board = create!(
|
|
66
67
|
name: File.basename(repo_path),
|
|
67
68
|
repo_url: origin_ok.success? ? sanitize_remote_url(origin.strip) : nil,
|
|
68
|
-
default_branch:
|
|
69
|
+
default_branch: detect_default_branch(repo_path),
|
|
69
70
|
local_path: repo_path
|
|
70
71
|
)
|
|
71
72
|
board.install_default_columns!
|
|
72
73
|
board
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
# The REMOTE's default branch, not whatever happened to be checked out when
|
|
77
|
+
# `cardinal up` first ran — launching from a feature branch must not make
|
|
78
|
+
# Done merge toward that feature branch forever. Fallback chain: origin's
|
|
79
|
+
# HEAD → current branch → "main". Editable later in board settings.
|
|
80
|
+
def self.detect_default_branch(repo_path)
|
|
81
|
+
head, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "refs/remotes/origin/HEAD")
|
|
82
|
+
return head.strip.delete_prefix("refs/remotes/origin/") if ok.success? && head.strip.present?
|
|
83
|
+
|
|
84
|
+
# symbolic-ref, not rev-parse: works even on an unborn branch (fresh init).
|
|
85
|
+
branch, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "--short", "HEAD")
|
|
86
|
+
ok.success? && branch.strip.present? ? branch.strip : "main"
|
|
87
|
+
end
|
|
88
|
+
|
|
75
89
|
def self.sanitize_remote_url(url)
|
|
76
90
|
# Drop any userinfo (tokens from credential-helper rewrites). Regex, not
|
|
77
91
|
# URI#userinfo= — Ruby 3.4's RFC3986 parser silently ignores that setter.
|
|
@@ -94,7 +108,11 @@ class Board < ApplicationRecord
|
|
|
94
108
|
# underneath later without a migration.
|
|
95
109
|
BRIEF_STALE_AT = 10 # commits behind → the "refresh me" red/flashing state
|
|
96
110
|
|
|
97
|
-
|
|
111
|
+
# Honor CARDINAL_DATA_DIR: in gem mode Rails.root is the installed gem
|
|
112
|
+
# (read-only); the instance's data lives in the target repo's .cardinal/.
|
|
113
|
+
def brief_path
|
|
114
|
+
Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("repo-brief.md")
|
|
115
|
+
end
|
|
98
116
|
|
|
99
117
|
def repo_brief
|
|
100
118
|
File.read(brief_path) if File.exist?(brief_path)
|
data/app/models/card.rb
CHANGED
|
@@ -11,7 +11,7 @@ class Card < ApplicationRecord
|
|
|
11
11
|
"planning" => %w[draft discussing archived],
|
|
12
12
|
"execution" => %w[queued working needs_input blocked failed work_complete archived],
|
|
13
13
|
"review" => %w[in_review changes_requested approved archived],
|
|
14
|
-
"terminal" => %w[done archived]
|
|
14
|
+
"terminal" => %w[done blocked archived] # blocked: merge gate refused (CI red/pending)
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
17
|
belongs_to :board
|
|
@@ -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
|
|
@@ -56,6 +56,15 @@ class Column < ApplicationRecord
|
|
|
56
56
|
policy["ai"] != false
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Worker shell access (execution columns). ON (default): the agent gets the
|
|
60
|
+
# full toolset — shell, git, everything — inside its workspace clone, which
|
|
61
|
+
# means it can also touch the host beyond the clone. OFF: file tools only
|
|
62
|
+
# (read/search/edit); it physically cannot execute commands, and Cardinal
|
|
63
|
+
# commits and pushes its edits for it.
|
|
64
|
+
def shell_access?
|
|
65
|
+
policy["shell"] != false
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
# Which columns may move cards INTO this one (§ accept policy, card #15).
|
|
60
69
|
# Stored as an array of column-id strings. EXPLICIT ONLY: an empty list
|
|
61
70
|
# means this column accepts from nowhere — there is no permissive default.
|
|
@@ -67,18 +76,15 @@ class Column < ApplicationRecord
|
|
|
67
76
|
# {"label" => "Total cost:", "compute" => "sum_cost"} hashes; each row pairs
|
|
68
77
|
# static label text with an optional computed aggregate over this column's
|
|
69
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.
|
|
70
81
|
def footer_rows
|
|
71
|
-
|
|
82
|
+
Array(footer).filter_map do |row|
|
|
72
83
|
label = row["label"].to_s
|
|
73
84
|
value = footer_value(row["compute"])
|
|
74
85
|
next if label.blank? && value.blank?
|
|
75
86
|
{ label:, value: }
|
|
76
87
|
end
|
|
77
|
-
# AI columns advertise their active model as a final auto-row (card #32).
|
|
78
|
-
# Guarded on model presence so an AI column without one adds nothing,
|
|
79
|
-
# rather than emitting a "Model:" row with a blank value.
|
|
80
|
-
rows << { label: "Model:", value: model_short } if ai? && model.present?
|
|
81
|
-
rows
|
|
82
88
|
end
|
|
83
89
|
|
|
84
90
|
# Start the next queued card when a run slot frees up. A queued card whose
|
|
@@ -103,6 +109,14 @@ class Column < ApplicationRecord
|
|
|
103
109
|
model.to_s[/claude-([a-z]+)/, 1] || model
|
|
104
110
|
end
|
|
105
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
|
+
|
|
106
120
|
validates :name, presence: true
|
|
107
121
|
validates :position, presence: true
|
|
108
122
|
|
|
@@ -113,6 +127,12 @@ class Column < ApplicationRecord
|
|
|
113
127
|
execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
|
|
114
128
|
end
|
|
115
129
|
|
|
130
|
+
# Post-claim variant (§ races): a starter first claims working atomically,
|
|
131
|
+
# THEN checks — over-subscription reads as strictly more running than allowed.
|
|
132
|
+
def at_wip_limit_exceeded?
|
|
133
|
+
execution? && concurrency_limit.present? && running_count > concurrency_limit.to_i
|
|
134
|
+
end
|
|
135
|
+
|
|
116
136
|
# Aggregate a single footer row over the runs/cards in this column (card #18).
|
|
117
137
|
# Unknown keys return "" so the row shows just its static label.
|
|
118
138
|
def footer_value(compute)
|
|
@@ -123,6 +143,10 @@ class Column < ApplicationRecord
|
|
|
123
143
|
ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
|
|
124
144
|
when "count_cards"
|
|
125
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 : ""
|
|
126
150
|
else
|
|
127
151
|
""
|
|
128
152
|
end
|