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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea33d976c3ebc53402438e5dae5e6b480842034e1a77a1c58142d62ad1f347e2
4
- data.tar.gz: 7571417a375ab611c9345a6b2d7ab6cea33233e668d90414edae44ea2d116048
3
+ metadata.gz: c16d402a271ff7a720232c12b775f43784eadd3268c1ce417fd2939d777be42e
4
+ data.tar.gz: 808ae0945e6f6104b3ef103468ce78c36406af25c78da223cd380a02336d5ef8
5
5
  SHA512:
6
- metadata.gz: db9ec21facb032331fd8686f772e1c1313e0e84388f675eb723e92847c0c7638325d2af5ff8d392f2536c292d0929a79f64e53f040ef1206bb74097477c1f9e1
7
- data.tar.gz: c290a471053fc66929db5fe9b3219bb49dc85f2b953b4a33beab0878e261d7ed4e815dc5b1b57ca9f8346f2ba9a7e73f7607a30f1af08e09a6665311dfdd6811
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. Ignored if a
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
- unless board.brief_working?
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
- ["Pulled #{helpers.pluralize(count.strip.to_i, "new commit")}", true]
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
@@ -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?
@@ -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")
@@ -3,11 +3,26 @@ class StartRunJob < ApplicationJob
3
3
 
4
4
  def perform(card_id)
5
5
  card = Card.find(card_id)
6
- return unless card.queued? && card.column.execution? && card.column.ai?
7
- return if card.column.at_wip_limit? # stays queued; kicked when a slot frees
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
- session = card.agent_sessions.create!(status: "provisioning", model: card.column.model)
10
- run = session.runs.create!(status: "queued", briefing: { "card" => card.title, "column" => card.column.name })
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: branch_ok.success? && branch.strip.present? ? branch.strip : "main",
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
- def brief_path = Rails.root.join(".cardinal", "repo-brief.md")
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
- rows = Array(footer).filter_map do |row|
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