cardinal-ai 0.2.4 → 0.2.6

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: 9765ce8a3b88ca8a2d72dcdaa766d67d42b0b1f1304ce44551fb0b31a8b62f84
4
- data.tar.gz: 6bbd3904403e6e91b83a684d134c15ad402a98ab27f4600d6424752033565b0b
3
+ metadata.gz: '079e999400eeaa39a8e7f7d8efdc4b3b114923cf0aaf190dcd15a6117a97d7b2'
4
+ data.tar.gz: 61343cc2e5c42fbf43f3eeb223da83af60d1e634f8424e69d032c9afd287aa02
5
5
  SHA512:
6
- metadata.gz: b91c51d2d12d2df7d7d1f81b7d8547545ed68cac112f4d844dd4666626ccb34081397dc038fa4620a8b0e76e804b18c64608d96c92be657238958bf4bf3540ff
7
- data.tar.gz: 1e71c84583f3c6f02b3315ce7fa4abce078117dcb47ceb13a15a1641c3a90651673db22015c63ab8c1ca2e0683c136b50ac278b695e7144d34d7f7d2bf8378fc
6
+ metadata.gz: 1acb0d147c37615d1daf946a8e3aebbbdec636511cf6781a835ed4b73b61b1f3b57aff903273b160b75df66b263857cc83e7ef38f8b71841a7f8f3ebea8877e6
7
+ data.tar.gz: 5ab7bddf1b68f55466825914a1d0eb7a697e02a5c7e0e3a4d0dcfe306ee673c65a17f9f5e2bf09425c002ccd02809d32ce30e2f0a2ececdd2c93b8d0139932c5
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
 
@@ -52,6 +52,7 @@ a { color: var(--blue); text-decoration: none; }
52
52
  }
53
53
  .topbar h1 { font-size: 16px; margin: 0; font-weight: 600; }
54
54
  .topbar h1 .sep { color: var(--accent); margin: 0 4px; }
55
+ .topbar-left { display: flex; align-items: center; gap: 12px; }
55
56
  .topbar-right { display: flex; align-items: center; gap: 14px; }
56
57
 
57
58
  .theme-toggle {
@@ -60,6 +61,37 @@ a { color: var(--blue); text-decoration: none; }
60
61
  }
61
62
  .theme-toggle:hover { color: var(--text); border-color: var(--text-dim); }
62
63
 
64
+ /* Repo deep dive button (card #12). Inline color/border come from the board's
65
+ staleness gradient (grey → red over 10 commits); .stale-critical flashes to
66
+ nag a refresh once the brief is too far behind HEAD to trust. */
67
+ .topbar-right form.button_to { display: inline-flex; margin: 0; }
68
+ .deep-dive {
69
+ display: inline-flex; align-items: center; gap: 6px;
70
+ background: transparent; border: 1px solid var(--border); border-radius: 6px;
71
+ color: var(--text-dim); font-weight: 600; padding: 5px 10px; line-height: 1;
72
+ cursor: pointer; white-space: nowrap;
73
+ }
74
+ .deep-dive:hover:not(:disabled) { filter: brightness(1.25); }
75
+ .deep-dive:disabled { cursor: default; opacity: .8; }
76
+ .deep-dive.stale-critical { animation: stale-flash 1.1s ease-in-out infinite; }
77
+ @keyframes stale-flash {
78
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(212, 51, 51, .55); }
79
+ 50% { opacity: .7; box-shadow: 0 0 0 3px rgba(212, 51, 51, 0); }
80
+ }
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
+
90
+ .pull-form { display: inline; }
91
+ #repo-pull-status { font-size: 0.85rem; }
92
+ #repo-pull-status .pull-ok { color: var(--green); }
93
+ #repo-pull-status .pull-err { color: var(--red); }
94
+
63
95
  .attention summary {
64
96
  cursor: pointer;
65
97
  color: var(--amber);
@@ -154,6 +186,10 @@ button, input[type="submit"] {
154
186
  body.dragging .drop-hint { display: block; }
155
187
  .ticker { font-size: 11px; color: var(--text-dim); margin: 0 4px 8px; }
156
188
 
189
+ .column-footer { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-dim); }
190
+ .footer-row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 4px; }
191
+ .footer-value { font-variant-numeric: tabular-nums; color: var(--text); }
192
+
157
193
  .cards { min-height: 40px; display: flex; flex-direction: column; gap: 8px; flex: 1; overflow-y: auto; }
158
194
  .cards-clickable { cursor: pointer; }
159
195
  .agent-chip { color: var(--blue); }
@@ -2,4 +2,103 @@ class BoardsController < ApplicationController
2
2
  def show
3
3
  @board = Board.includes(columns: :cards).first!
4
4
  end
5
+
6
+ # Kick off the repo deep dive (card #12). Non-blocking: flip the board into
7
+ # its "Working" state, morph the topbar so the button reflects it, and let
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).
12
+ def deep_dive
13
+ board = Board.first!
14
+ fresh = board.brief? && board.commits_behind_brief == 0
15
+ unless board.brief_working? || (fresh && params[:force].blank?)
16
+ board.update!(brief_status: "working")
17
+ board.broadcast_refresh_to board
18
+ DeepDiveJob.perform_later(board)
19
+ end
20
+ redirect_to root_path
21
+ end
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
+
56
+ # Cards reaching Done merge PRs on GitHub, so the checkout Cardinal runs
57
+ # against falls behind. The topbar Pull button fast-forwards it. --ff-only
58
+ # on purpose: never invent merge commits or rebase local work — if the tree
59
+ # has diverged, say so and let the human sort it out in a real terminal.
60
+ def pull
61
+ board = Board.first!
62
+ message, ok = pull_repo(board)
63
+ render turbo_stream: turbo_stream.update(
64
+ "repo-pull-status",
65
+ helpers.tag.span(message, class: ok ? "pull-ok" : "pull-err")
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ def pull_repo(board)
72
+ repo = board.local_path.presence
73
+ return ["No local repo path on this board", false] unless repo && Dir.exist?(File.join(repo, ".git"))
74
+
75
+ before, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
76
+ out, status = Open3.capture2e("git", "-C", repo, "pull", "--ff-only")
77
+ unless status.success?
78
+ # Surface git's own reason (diverged, offline, auth) — the last
79
+ # non-blank line is usually the one that matters.
80
+ return [out.lines.map(&:strip).reject(&:blank?).last.to_s.truncate(120), false]
81
+ end
82
+
83
+ after, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
84
+ if before.strip == after.strip
85
+ ["Already up to date", true]
86
+ else
87
+ count, = Open3.capture2e("git", "-C", repo, "rev-list", "--count", "#{before.strip}..#{after.strip}")
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]
91
+ end
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
5
104
  end
@@ -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
- 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
@@ -74,6 +75,14 @@ class ColumnsController < ApplicationController
74
75
  policy.delete("on_entry_text")
75
76
  end
76
77
 
78
+ # Footer (card #18): one row per line as "Label | compute". A blank compute
79
+ # is a static label; a compute must be one Column knows how to aggregate.
80
+ begin
81
+ policy["footer"] = parse_footer(attrs[:footer_text])
82
+ rescue ArgumentError => e
83
+ return column_error(e.message)
84
+ end
85
+
77
86
  @column.update!(
78
87
  name: attrs[:name].presence || @column.name,
79
88
  archetype: new_archetype,
@@ -126,5 +135,21 @@ class ColumnsController < ApplicationController
126
135
 
127
136
  private
128
137
 
138
+ # "Label | compute" lines → [{"label" =>, "compute" =>}]. Returns nil when
139
+ # empty so the key is dropped by policy.compact. Raises ArgumentError on an
140
+ # unknown compute key, mirroring the on_entry validation path.
141
+ def parse_footer(text)
142
+ rows = text.to_s.lines.filter_map do |line|
143
+ next if line.strip.blank?
144
+ label, compute = line.split("|", 2).map(&:strip)
145
+ compute = compute.to_s
146
+ if compute.present? && !Column::FOOTER_COMPUTES.include?(compute)
147
+ raise ArgumentError, "Footer compute \"#{compute}\" is not one of: #{Column::FOOTER_COMPUTES.join(', ')}"
148
+ end
149
+ { "label" => label.to_s, "compute" => compute.presence }.compact
150
+ end
151
+ rows.presence
152
+ end
153
+
129
154
  def set_column = @column = Column.find(params[:id])
130
155
  end
@@ -26,6 +26,14 @@ module ApplicationHelper
26
26
  MARKDOWN.render(text.to_s).html_safe
27
27
  end
28
28
 
29
+ # Render a column's stored footer config (array of {label, compute} hashes)
30
+ # back into the "Label | compute" line format the gear textarea edits.
31
+ def footer_config_text(column)
32
+ Array(column.footer).map do |row|
33
+ [row["label"], row["compute"].presence].compact.join(" | ")
34
+ end.join("\n")
35
+ end
36
+
29
37
  def info_tip(text)
30
38
  tag.span("i", class: "info",
31
39
  data: { controller: "tooltip", tooltip_text_value: text,
@@ -0,0 +1,64 @@
1
+ # Opt-in repo deep dive (card #12): a one-shot, read-only agent (the same
2
+ # cheap ClaudeCli tier the planning assistant uses — Read/Glob/Grep, no
3
+ # workspace) that maps the board's repo into a compact "repo brief". The brief
4
+ # is written to .cardinal/repo-brief.md and injected into every worker prompt,
5
+ # converting the per-run exploration tax into a one-time cost.
6
+ class DeepDiveJob < ApplicationJob
7
+ queue_as :default
8
+
9
+ # Enough turns to walk the tree and read a handful of key files, not enough
10
+ # to wander. ClaudeCli wraps up from context if it hits the cap.
11
+ MAX_TURNS = 30
12
+ FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
13
+
14
+ PROMPT = <<~PROMPT.freeze
15
+ Map this repository as a concise "repo brief" for other AI agents who will
16
+ work in it. The whole point is to save tokens later, so be dense and skip
17
+ the obvious — every line must earn its place.
18
+
19
+ Explore with your read-only tools, then output ONLY flat markdown with these
20
+ sections (drop any that genuinely don't apply):
21
+
22
+ ## Overview
23
+ One short paragraph: what this project is and its shape.
24
+ ## Directory Structure
25
+ The top-level directories and what each is for (one line each).
26
+ ## Key Directories
27
+ The few places most work actually happens, and what lives there.
28
+ ## Build & Test
29
+ The exact commands to install, build, run, and test.
30
+ ## Key Conventions
31
+ Naming, patterns, and idioms an agent must follow to match the codebase.
32
+ ## Tech Stack
33
+ Languages, frameworks, and notable libraries with their role.
34
+ ## Gotchas
35
+ Non-obvious traps, footguns, and constraints worth knowing before editing.
36
+
37
+ Do not include a preamble, closing remarks, or anything outside these sections.
38
+ PROMPT
39
+
40
+ def perform(board)
41
+ repo = board.local_path.presence
42
+ return clear_working(board) unless ClaudeCli.available? && repo
43
+
44
+ sha = board.head_sha
45
+ model = board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
46
+
47
+ brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS)
48
+
49
+ File.write(board.brief_path, brief.to_s)
50
+ board.update!(brief_sha: sha, brief_generated_at: Time.current,
51
+ brief_model: model, brief_status: nil)
52
+ board.broadcast_refresh_to board
53
+ rescue StandardError
54
+ # A failed dive must not leave the button stuck on "Working" forever.
55
+ clear_working(board)
56
+ end
57
+
58
+ private
59
+
60
+ def clear_working(board)
61
+ board.update!(brief_status: nil)
62
+ board.broadcast_refresh_to board
63
+ end
64
+ end
@@ -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
data/app/models/board.rb CHANGED
@@ -60,18 +60,30 @@ class Board < ApplicationRecord
60
60
  # Raw configured URL (get-url applies insteadOf rewrites, which can embed
61
61
  # credential-helper tokens); strip any userinfo defensively either way.
62
62
  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
63
 
65
64
  board = create!(
66
65
  name: File.basename(repo_path),
67
66
  repo_url: origin_ok.success? ? sanitize_remote_url(origin.strip) : nil,
68
- default_branch: branch_ok.success? && branch.strip.present? ? branch.strip : "main",
67
+ default_branch: detect_default_branch(repo_path),
69
68
  local_path: repo_path
70
69
  )
71
70
  board.install_default_columns!
72
71
  board
73
72
  end
74
73
 
74
+ # The REMOTE's default branch, not whatever happened to be checked out when
75
+ # `cardinal up` first ran — launching from a feature branch must not make
76
+ # Done merge toward that feature branch forever. Fallback chain: origin's
77
+ # HEAD → current branch → "main". Editable later in board settings.
78
+ def self.detect_default_branch(repo_path)
79
+ head, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "refs/remotes/origin/HEAD")
80
+ return head.strip.delete_prefix("refs/remotes/origin/") if ok.success? && head.strip.present?
81
+
82
+ # symbolic-ref, not rev-parse: works even on an unborn branch (fresh init).
83
+ branch, ok = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "--short", "HEAD")
84
+ ok.success? && branch.strip.present? ? branch.strip : "main"
85
+ end
86
+
75
87
  def self.sanitize_remote_url(url)
76
88
  # Drop any userinfo (tokens from credential-helper rewrites). Regex, not
77
89
  # URI#userinfo= — Ruby 3.4's RFC3986 parser silently ignores that setter.
@@ -83,6 +95,67 @@ class Board < ApplicationRecord
83
95
  cards.pluck(:tags).flatten.compact.uniq.sort
84
96
  end
85
97
 
98
+ # --- Repo brief (card #12) ---------------------------------------------
99
+ # A one-time deep dive that maps the repo, stored as flat markdown in
100
+ # .cardinal/ (never the host repo) and injected into worker prompts to
101
+ # spare each run the exploration tax. Metadata (which SHA/model/when) lives
102
+ # on the board so staleness can be judged against the current HEAD.
103
+ #
104
+ # Storage is a file + metadata, not one text column, so a structure
105
+ # provider (the Graphify child card) can slot a richer representation in
106
+ # underneath later without a migration.
107
+ BRIEF_STALE_AT = 10 # commits behind → the "refresh me" red/flashing state
108
+
109
+ # Honor CARDINAL_DATA_DIR: in gem mode Rails.root is the installed gem
110
+ # (read-only); the instance's data lives in the target repo's .cardinal/.
111
+ def brief_path
112
+ Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("repo-brief.md")
113
+ end
114
+
115
+ def repo_brief
116
+ File.read(brief_path) if File.exist?(brief_path)
117
+ end
118
+
119
+ def brief?
120
+ brief_sha.present? && File.exist?(brief_path)
121
+ end
122
+
123
+ def brief_working? = brief_status == "working"
124
+
125
+ # HEAD of the board's repo right now — the yardstick staleness measures against.
126
+ def head_sha
127
+ return nil if local_path.blank?
128
+ out, ok = Open3.capture2e("git", "-C", local_path, "rev-parse", "HEAD")
129
+ ok.success? ? out.strip : nil
130
+ end
131
+
132
+ # How many commits landed since the brief was generated. nil when there's
133
+ # no brief (nothing to be stale against) or the SHA is unknown to the repo.
134
+ def commits_behind_brief
135
+ return @commits_behind_brief if defined?(@commits_behind_brief)
136
+ @commits_behind_brief =
137
+ if brief_sha.blank? || local_path.blank?
138
+ nil
139
+ else
140
+ out, ok = Open3.capture2e("git", "-C", local_path, "rev-list", "--count", "#{brief_sha}..HEAD")
141
+ ok.success? ? out.strip.to_i : nil
142
+ end
143
+ end
144
+
145
+ def brief_stale? = (commits_behind_brief || 0) >= BRIEF_STALE_AT
146
+
147
+ # Grey → red interpolation over 0..BRIEF_STALE_AT commits behind, emitted
148
+ # as a validated hex into the button's inline style (mirrors Column#safe_color).
149
+ # Deep red once the brief is stale enough to over-anchor on.
150
+ def brief_staleness_color
151
+ behind = commits_behind_brief || 0
152
+ grey = [0x8a, 0x8a, 0x8a]
153
+ red = [0xd4, 0x33, 0x33]
154
+ t = [behind.to_f / BRIEF_STALE_AT, 1.0].min
155
+ rgb = grey.zip(red).map { |g, r| (g + (r - g) * t).round }
156
+ format("#%02x%02x%02x", *rgb)
157
+ end
158
+
86
159
  # Cards currently waiting on the human, ordered by urgency — feeds the
87
160
  # attention inbox in the board header.
88
161
  def attention_cards
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
@@ -29,6 +29,9 @@ class Card < ApplicationRecord
29
29
  validate :status_legal_for_column
30
30
 
31
31
  before_validation :assign_number_and_position, on: :create
32
+ # Optional git fields left blank on the new-card form arrive as "" — store
33
+ # them as nil so "no PR/branch" is one state, not two ("" renders footers).
34
+ normalizes :branch_name, :pr_url, with: ->(v) { v.strip.presence }
32
35
 
33
36
  after_commit -> { broadcast_refresh_to board }
34
37
 
data/app/models/column.rb CHANGED
@@ -35,7 +35,12 @@ class Column < ApplicationRecord
35
35
  store_accessor :policy, :instructions, :model, :effort, :concurrency_limit,
36
36
  :plan_approval, :budget_per_run_cents, :timeout_minutes,
37
37
  :max_turns, :tools, :on_entry, :on_success, :color, :arrivals,
38
- :accepts_from
38
+ :accepts_from, :footer
39
+
40
+ # Aggregations a footer row may compute over the column's cards (card #18).
41
+ # A compute key not listed here renders blank, so config that outruns the
42
+ # code degrades gracefully instead of erroring.
43
+ FOOTER_COMPUTES = %w[sum_cost sum_tokens count_cards].freeze
39
44
 
40
45
  # Only ever emit a validated hex color into inline styles.
41
46
  def safe_color
@@ -51,6 +56,15 @@ class Column < ApplicationRecord
51
56
  policy["ai"] != false
52
57
  end
53
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
+
54
68
  # Which columns may move cards INTO this one (§ accept policy, card #15).
55
69
  # Stored as an array of column-id strings. EXPLICIT ONLY: an empty list
56
70
  # means this column accepts from nowhere — there is no permissive default.
@@ -58,6 +72,24 @@ class Column < ApplicationRecord
58
72
  Array(accepts_from).map(&:to_s).include?(source_column.id.to_s)
59
73
  end
60
74
 
75
+ # Rows rendered under the cards (card #18). Footer config is an array of
76
+ # {"label" => "Total cost:", "compute" => "sum_cost"} hashes; each row pairs
77
+ # static label text with an optional computed aggregate over this column's
78
+ # cards. Returns [] when unconfigured, so existing columns render no footer.
79
+ def footer_rows
80
+ rows = Array(footer).filter_map do |row|
81
+ label = row["label"].to_s
82
+ value = footer_value(row["compute"])
83
+ next if label.blank? && value.blank?
84
+ { label:, value: }
85
+ end
86
+ # AI columns advertise their active model as a final auto-row (card #32).
87
+ # Guarded on model presence so an AI column without one adds nothing,
88
+ # rather than emitting a "Model:" row with a blank value.
89
+ rows << { label: "Model:", value: model_short } if ai? && model.present?
90
+ rows
91
+ end
92
+
61
93
  # Start the next queued card when a run slot frees up. A queued card whose
62
94
  # run parked and already has its answer recorded resumes instead of
63
95
  # starting fresh.
@@ -90,6 +122,27 @@ class Column < ApplicationRecord
90
122
  execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
91
123
  end
92
124
 
125
+ # Post-claim variant (§ races): a starter first claims working atomically,
126
+ # THEN checks — over-subscription reads as strictly more running than allowed.
127
+ def at_wip_limit_exceeded?
128
+ execution? && concurrency_limit.present? && running_count > concurrency_limit.to_i
129
+ end
130
+
131
+ # Aggregate a single footer row over the runs/cards in this column (card #18).
132
+ # Unknown keys return "" so the row shows just its static label.
133
+ def footer_value(compute)
134
+ case compute.to_s
135
+ when "sum_cost"
136
+ "$%.2f" % column_runs.sum(:cost)
137
+ when "sum_tokens"
138
+ ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
139
+ when "count_cards"
140
+ cards.count.to_s
141
+ else
142
+ ""
143
+ end
144
+ end
145
+
93
146
  # The built-in role contract for AI servicing this archetype — shown
94
147
  # read-only in the gear modal so the Instructions field is understood as
95
148
  # ADDING to this, never replacing it. Enforced in code, not editable.
@@ -131,4 +184,11 @@ class Column < ApplicationRecord
131
184
  when "terminal" then "Closes it — PR merged and branch deleted, if there is one"
132
185
  end
133
186
  end
187
+
188
+ private
189
+
190
+ # Every run belonging to a card in this column, for footer aggregation.
191
+ def column_runs
192
+ Run.joins(agent_session: :card).where(cards: { column_id: id })
193
+ end
134
194
  end