cardinal-ai 0.2.5 → 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: ea33d976c3ebc53402438e5dae5e6b480842034e1a77a1c58142d62ad1f347e2
4
- data.tar.gz: 7571417a375ab611c9345a6b2d7ab6cea33233e668d90414edae44ea2d116048
3
+ metadata.gz: '079e999400eeaa39a8e7f7d8efdc4b3b114923cf0aaf190dcd15a6117a97d7b2'
4
+ data.tar.gz: 61343cc2e5c42fbf43f3eeb223da83af60d1e634f8424e69d032c9afd287aa02
5
5
  SHA512:
6
- metadata.gz: db9ec21facb032331fd8686f772e1c1313e0e84388f675eb723e92847c0c7638325d2af5ff8d392f2536c292d0929a79f64e53f040ef1206bb74097477c1f9e1
7
- data.tar.gz: c290a471053fc66929db5fe9b3219bb49dc85f2b953b4a33beab0878e261d7ed4e815dc5b1b57ca9f8346f2ba9a7e73f7607a30f1af08e09a6665311dfdd6811
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
 
@@ -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); }
@@ -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
@@ -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
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.
@@ -94,7 +106,11 @@ class Board < ApplicationRecord
94
106
  # underneath later without a migration.
95
107
  BRIEF_STALE_AT = 10 # commits behind → the "refresh me" red/flashing state
96
108
 
97
- def brief_path = Rails.root.join(".cardinal", "repo-brief.md")
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
98
114
 
99
115
  def repo_brief
100
116
  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
data/app/models/column.rb CHANGED
@@ -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.
@@ -113,6 +122,12 @@ class Column < ApplicationRecord
113
122
  execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
114
123
  end
115
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
+
116
131
  # Aggregate a single footer row over the runs/cards in this column (card #18).
117
132
  # Unknown keys return "" so the row shows just its static label.
118
133
  def footer_value(compute)
@@ -30,6 +30,21 @@ module Agent
30
30
  - Finish with a concise report: what you did, what to check, any open questions.
31
31
  RULES
32
32
 
33
+ # Column shell access OFF: the agent can read, search, and edit — nothing
34
+ # else. Enforced by the CLI tool list, not just these words.
35
+ RESTRICTED_EXECUTE_RULES = <<~RULES.freeze
36
+ ## Rules
37
+ - You have FILE TOOLS ONLY: read, search, edit, write. You cannot run shell
38
+ commands or git — do not attempt to; Cardinal commits and pushes your edits for you.
39
+ - Work only inside this repository checkout (you are already on the card's branch).
40
+ - Stay strictly within the card's scope. Prefer the smallest reasonable interpretation and note assumptions.
41
+ - If something must be RUN to finish the job (tests, generators, installs), do the file
42
+ work, then list the exact commands in your final report for the user to run.
43
+ - If you are blocked on a decision only the user can make, output a single line starting with
44
+ "QUESTION:" followed by the question, then stop immediately. Do not guess on genuinely ambiguous choices.
45
+ - Finish with a concise report: what you did, what to check, any open questions.
46
+ RULES
47
+
33
48
  def self.start(run) = new(run).start
34
49
  def self.resume(run, message, approve: false) = new(run).resume(message, approve: approve)
35
50
 
@@ -59,7 +74,7 @@ module Agent
59
74
  begin_segment!
60
75
  if run.phase == "plan" && approve
61
76
  run.update!(phase: "execute")
62
- stream_agent(prompt: "Your plan is approved — execute it now.\n\n#{EXECUTE_RULES}",
77
+ stream_agent(prompt: "Your plan is approved — execute it now.\n\n#{execute_rules}",
63
78
  mode: "execute", resuming: true)
64
79
  elsif run.phase == "plan"
65
80
  stream_agent(prompt: "Feedback on your plan:\n\n#{message}\n\nRevise the plan accordingly, present it, and stop again for approval. Stay in read-only mode.",
@@ -106,6 +121,9 @@ module Agent
106
121
  cmd += ["--max-turns", "3", "--tools", ""]
107
122
  else
108
123
  cmd += ["--max-turns", (column.max_turns.presence || DEFAULT_EXECUTE_TURNS).to_s]
124
+ # Shell access off: restrict to file tools — the sandbox is the tool
125
+ # list itself, not a request in the prompt.
126
+ cmd += ["--tools", "Read,Glob,Grep,Edit,Write"] unless column.shell_access?
109
127
  end
110
128
  cmd += ["--model", column.model] if column.model.present?
111
129
  cmd += ["--effort", column.effort] if column.effort.present?
@@ -205,6 +223,11 @@ module Agent
205
223
 
206
224
  def conclude_execute(workspace, result)
207
225
  accumulate_usage(result)
226
+ # A shell-less agent cannot commit its own edits — do it for it before
227
+ # any commit counting or salvage below.
228
+ unless column.shell_access?
229
+ workspace.commit_all!("Card ##{card.number}: #{card.title.truncate(60)}\n\nCommitted by Cardinal for a shell-less agent.")
230
+ end
208
231
  unless result[:success]
209
232
  # Budget exhaustion isn't failure — park and offer to continue (§8).
210
233
  # The session survives; an answer resumes it with a fresh turn budget.
@@ -334,10 +357,14 @@ module Agent
334
357
  #{conversation_excerpt.presence || "(none)"}
335
358
 
336
359
  #{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
337
- #{EXECUTE_RULES}
360
+ #{execute_rules}
338
361
  PROMPT
339
362
  end
340
363
 
364
+ def execute_rules
365
+ column.shell_access? ? EXECUTE_RULES : RESTRICTED_EXECUTE_RULES
366
+ end
367
+
341
368
  def plan_prompt
342
369
  <<~PROMPT
343
370
  You are the dedicated worker agent for card ##{card.number} of the Cardinal board: "#{card.title}".
@@ -74,6 +74,15 @@ module Agent
74
74
  end
75
75
  end
76
76
 
77
+ # Shell-less agents (column shell access off) edit files but cannot run
78
+ # git — the runner commits on their behalf when a segment ends.
79
+ def commit_all!(message)
80
+ return false if git_out(path, "status", "--porcelain").strip.empty?
81
+ git!(path, "add", "-A")
82
+ git!(path, "commit", "--quiet", "-m", message)
83
+ true
84
+ end
85
+
77
86
  # How the runner should spawn the agent process for this workspace.
78
87
  def agent_spawn(cmd) = [cmd, { chdir: path.to_s }]
79
88
 
@@ -0,0 +1,48 @@
1
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
2
+ <div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
3
+ <div class="modal modal-sm">
4
+ <header class="modal-header">
5
+ <h1>🔍 Repo brief</h1>
6
+ <button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
7
+ </header>
8
+ <div class="modal-body">
9
+ <p class="hint">
10
+ A deep dive is a one-shot, read-only agent (Read/Glob/Grep only — it cannot change
11
+ anything) that maps this repo into the brief below. Every worker agent gets the brief
12
+ in its prompt, so runs skip re-exploring the codebase. It costs one bounded AI call
13
+ using the planning column's model.
14
+ </p>
15
+
16
+ <% if @board.brief? %>
17
+ <p class="brief-meta">
18
+ Generated <%= @board.brief_generated_at&.strftime("%b %-d, %H:%M") %>
19
+ from <code><%= @board.brief_sha&.first(7) %></code>
20
+ with <%= @board.brief_model %>
21
+ <% behind = @board.commits_behind_brief %>
22
+ <% if behind&.positive? %>
23
+ · <span class="brief-behind"><%= pluralize(behind, "commit") %> behind HEAD</span>
24
+ <% else %>
25
+ · current with HEAD
26
+ <% end %>
27
+ </p>
28
+
29
+ <div class="brief-content">
30
+ <%= render_markdown(@board.repo_brief.to_s) %>
31
+ </div>
32
+ <% else %>
33
+ <p class="brief-meta">No brief yet — run a deep dive from the topbar.</p>
34
+ <% end %>
35
+
36
+ <div class="card-edit-actions">
37
+ <%= button_to "↻ Regenerate brief", deep_dive_board_path(force: 1),
38
+ form: { data: { turbo_frame: "_top" } },
39
+ disabled: @board.brief_working? %>
40
+ </div>
41
+ <p class="hint">
42
+ Regenerating replaces the brief. When the brief already matches HEAD the deep dive
43
+ button won't re-run it — only this button forces a fresh dive.
44
+ </p>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ <% end %>
@@ -0,0 +1,29 @@
1
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
2
+ <div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
3
+ <div class="modal modal-sm" data-controller="autosave">
4
+ <header class="modal-header">
5
+ <h1>⚙ Board settings
6
+ <span class="autosave-status" data-autosave-target="status"></span></h1>
7
+ <button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
8
+ </header>
9
+ <div class="modal-body">
10
+ <div id="board-form-errors"></div>
11
+
12
+ <%= form_with model: @board, url: board_path(autosave: 1), class: "card-edit",
13
+ data: { autosave_target: "form" } do |f| %>
14
+ <label>Board name</label>
15
+ <%= f.text_field :name %>
16
+
17
+ <label>Default branch <%= info_tip("The branch agents fork card branches from and Done merges pull requests toward. Detected from the repo at first boot; fix it here if that guess was wrong.") %></label>
18
+ <%= f.text_field :default_branch, placeholder: "main" %>
19
+ <% end %>
20
+
21
+ <label>Repository</label>
22
+ <p class="locked-field"><code><%= @board.repo_url.presence || "(no remote)" %></code></p>
23
+
24
+ <label>Local checkout</label>
25
+ <p class="locked-field"><code><%= @board.local_path %></code></p>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ <% end %>
@@ -2,30 +2,42 @@
2
2
 
3
3
  <header class="topbar">
4
4
  <div class="topbar-left">
5
- <h1>Cardinal AI <span class="sep">▸</span> <%= @board.name %></h1>
5
+ <h1>Cardinal AI <span class="sep">▸</span> <span id="board-name"><%= @board.name %></span></h1>
6
+ <%= link_to "⚙", edit_board_path, data: { turbo_frame: "modal" }, class: "gear",
7
+ title: "Board settings — name, default branch" %>
6
8
  <%= button_to "⇣ Pull", pull_board_path, form_class: "pull-form",
7
9
  form: { title: "git pull --ff-only in #{@board.local_path} — fetch what Done has merged" },
8
10
  data: { turbo_submits_with: "⇣ Pulling…" }, class: "theme-toggle" %>
9
- <span id="repo-pull-status"></span>
10
- </div>
11
- <div class="topbar-right">
12
11
  <%# Repo deep dive (card #12): one-shot read-only mapping of the repo, stored
13
12
  as a brief and injected into worker prompts. The button greys→reds as the
14
- brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+. %>
13
+ brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+.
14
+ Once a brief exists the button opens it for inspection (the modal holds
15
+ the Regenerate action) — a fresh brief is never silently re-run. %>
15
16
  <% behind = @board.commits_behind_brief %>
16
- <%= button_to deep_dive_board_path,
17
- class: "deep-dive#{' stale-critical' if @board.brief_stale?}#{' working' if @board.brief_working?}",
18
- disabled: @board.brief_working?,
19
- style: @board.brief? ? "color: #{@board.brief_staleness_color}; border-color: #{@board.brief_staleness_color}" : nil,
20
- title: @board.brief? ? "Repo brief from #{@board.brief_sha&.first(7)} · #{@board.brief_generated_at&.to_date}" : "Map this repo into a brief agents reuse — saves exploration on every run" do %>
21
- <% if @board.brief_working? %>
17
+ <% if @board.brief_working? %>
18
+ <button type="button" class="deep-dive working" disabled>
22
19
  <span class="pulse-dot"></span> Deep dive · working…
23
- <% elsif behind&.positive? %>
24
- 🔍 Deep dive · <%= behind %> commit<%= "s" if behind != 1 %> behind
25
- <% else %>
20
+ </button>
21
+ <% elsif @board.brief? %>
22
+ <%= link_to brief_board_path, data: { turbo_frame: "modal" },
23
+ class: "deep-dive#{' stale-critical' if @board.brief_stale?}",
24
+ style: "color: #{@board.brief_staleness_color}; border-color: #{@board.brief_staleness_color}",
25
+ title: "Repo brief from #{@board.brief_sha&.first(7)} · #{@board.brief_generated_at&.to_date} — click to inspect" do %>
26
+ <% if behind&.positive? %>
27
+ 🔍 Repo brief · <%= behind %> commit<%= "s" if behind != 1 %> behind
28
+ <% else %>
29
+ 🔍 Repo brief
30
+ <% end %>
31
+ <% end %>
32
+ <% else %>
33
+ <%= button_to deep_dive_board_path, class: "deep-dive",
34
+ title: "Map this repo into a brief agents reuse — saves exploration on every run" do %>
26
35
  🔍 Deep dive
27
36
  <% end %>
28
37
  <% end %>
38
+ <span id="repo-pull-status"></span>
39
+ </div>
40
+ <div class="topbar-right">
29
41
  <button type="button" class="theme-toggle"
30
42
  data-controller="theme" data-action="theme#toggle">☀ Light</button>
31
43
  <% if ENV["CARDINAL_AUTH"].present? %>
@@ -105,6 +105,14 @@
105
105
  <%= info_tip("The agent's first pass is read-only: it explores and proposes a plan, then waits. You approve with one click or reply to redirect. Your main safety rail.") %>
106
106
  </label>
107
107
 
108
+ <% if @column.execution? %>
109
+ <label class="check-row">
110
+ <%= f.check_box :shell, checked: @column.shell_access? %>
111
+ Shell access — the agent may run commands (tests, builds, git)
112
+ <%= info_tip("Unchecked: the agent gets file tools only (read, search, edit, write) and physically cannot execute anything — Cardinal commits and pushes its edits for it. Safer, but it can't run your tests or generators, and the agent process runs on this machine either way.") %>
113
+ </label>
114
+ <% end %>
115
+
108
116
  </div>
109
117
 
110
118
  <label>On-entry rules <%= info_tip("What happens when a card lands in this column, in plain English — e.g. \"start the worker agent\" or \"have an AI suggest tags and a better title\". Cardinal compiles it to rule actions when you save. Blank = the archetype's default behavior shown below.") %></label>
@@ -22,7 +22,7 @@ require "securerandom"
22
22
 
23
23
  if ENV["CARDINAL_GEM"] == "1"
24
24
  # Installed-gem mode: no Bundler — load what Bundler.require would have.
25
- %w[propshaft importmap-rails turbo-rails stimulus-rails redcarpet sqlite3].each { |g| require g }
25
+ %w[propshaft importmap-rails turbo-rails stimulus-rails redcarpet sqlite3 solid_queue solid_cable].each { |g| require g }
26
26
  else
27
27
  # Require the gems listed in Gemfile, including any gems
28
28
  # you've limited to :test, :development, or :production.
data/config/cable.yml CHANGED
@@ -1,5 +1,13 @@
1
+ # Jobs run in SolidQueue worker processes (forked by the puma plugin), so
2
+ # Turbo broadcasts cross process boundaries — the async adapter can't carry
3
+ # them. SolidCable (SQLite-backed pubsub) works in dev and prod alike.
1
4
  development:
2
- adapter: async
5
+ adapter: solid_cable
6
+ connects_to:
7
+ database:
8
+ writing: cable
9
+ polling_interval: 0.1.seconds
10
+ message_retention: 1.day
3
11
 
4
12
  test:
5
13
  adapter: test
data/config/database.yml CHANGED
@@ -6,8 +6,17 @@ default: &default
6
6
  timeout: 5000
7
7
 
8
8
  development:
9
- <<: *default
10
- database: <%= ENV.fetch("CARDINAL_DATA_DIR", ".cardinal") %>/cardinal.db
9
+ primary:
10
+ <<: *default
11
+ database: <%= ENV.fetch("CARDINAL_DATA_DIR", ".cardinal") %>/cardinal.db
12
+ queue:
13
+ <<: *default
14
+ database: <%= ENV.fetch("CARDINAL_DATA_DIR", ".cardinal") %>/queue.sqlite3
15
+ migrations_paths: db/queue_migrate
16
+ cable:
17
+ <<: *default
18
+ database: <%= ENV.fetch("CARDINAL_DATA_DIR", ".cardinal") %>/cable.sqlite3
19
+ migrations_paths: db/cable_migrate
11
20
 
12
21
  test:
13
22
  <<: *default
@@ -1,6 +1,11 @@
1
1
  require "active_support/core_ext/integer/time"
2
2
 
3
3
  Rails.application.configure do
4
+ # Durable jobs even in dev (dogfooding runs real agent work): SolidQueue in
5
+ # its own SQLite db; the puma plugin supervises workers — no extra service.
6
+ config.active_job.queue_adapter = :solid_queue
7
+ config.solid_queue.connects_to = { database: { writing: :queue } }
8
+
4
9
  # Settings specified here will take precedence over those in config/application.rb.
5
10
 
6
11
  # Make code changes take effect immediately without server restart.
@@ -50,7 +50,8 @@ Rails.application.configure do
50
50
  # config.cache_store = :mem_cache_store
51
51
 
52
52
  # Replace the default in-process and non-durable queuing backend for Active Job.
53
- # config.active_job.queue_adapter = :resque
53
+ config.active_job.queue_adapter = :solid_queue
54
+ config.solid_queue.connects_to = { database: { writing: :queue } }
54
55
 
55
56
  # Ignore bad email addresses and do not raise email delivery errors.
56
57
  # Set this to true and configure the email server for immediate delivery to raise delivery errors.
data/config/puma.rb CHANGED
@@ -35,7 +35,7 @@ port ENV.fetch("PORT", 3000)
35
35
  plugin :tmp_restart
36
36
 
37
37
  # Run the Solid Queue supervisor inside of Puma for single-server deployments.
38
- plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
38
+ plugin :solid_queue if ENV.fetch("SOLID_QUEUE_IN_PUMA", "1") == "1" && !ENV["RAILS_ENV"].to_s.start_with?("test")
39
39
 
40
40
  # Specify the PID file. Defaults to tmp/pids/server.pid in development.
41
41
  # In other environments, only set the PID file if requested.
data/config/queue.yml ADDED
@@ -0,0 +1,18 @@
1
+ default: &default
2
+ dispatchers:
3
+ - polling_interval: 1
4
+ batch_size: 500
5
+ workers:
6
+ - queues: "*"
7
+ threads: 3
8
+ processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
9
+ polling_interval: 0.1
10
+
11
+ development:
12
+ <<: *default
13
+
14
+ test:
15
+ <<: *default
16
+
17
+ production:
18
+ <<: *default
@@ -0,0 +1,15 @@
1
+ # examples:
2
+ # periodic_cleanup:
3
+ # class: CleanSoftDeletedRecordsJob
4
+ # queue: background
5
+ # args: [ 1000, { batch_size: 500 } ]
6
+ # schedule: every hour
7
+ # periodic_cleanup_with_command:
8
+ # command: "SoftDeletedRecord.due.delete_all"
9
+ # priority: 2
10
+ # schedule: at 5am every day
11
+
12
+ production:
13
+ clear_solid_queue_finished_jobs:
14
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
15
+ schedule: every hour at minute 12
data/config/routes.rb CHANGED
@@ -1,9 +1,10 @@
1
1
  Rails.application.routes.draw do
2
2
  root "boards#show"
3
3
 
4
- resource :board, only: :show do
4
+ resource :board, only: [:show, :edit, :update] do
5
5
  post :deep_dive
6
6
  post :pull
7
+ get :brief
7
8
  end
8
9
  resources :cards, only: [:new, :create, :show, :update, :destroy] do
9
10
  member do
@@ -0,0 +1,11 @@
1
+ ActiveRecord::Schema[7.1].define(version: 1) do
2
+ create_table "solid_cable_messages", force: :cascade do |t|
3
+ t.binary "channel", limit: 1024, null: false
4
+ t.binary "payload", limit: 536870912, null: false
5
+ t.datetime "created_at", null: false
6
+ t.integer "channel_hash", limit: 8, null: false
7
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
8
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
9
+ t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
10
+ end
11
+ end
@@ -0,0 +1,129 @@
1
+ ActiveRecord::Schema[7.1].define(version: 1) do
2
+ create_table "solid_queue_blocked_executions", force: :cascade do |t|
3
+ t.bigint "job_id", null: false
4
+ t.string "queue_name", null: false
5
+ t.integer "priority", default: 0, null: false
6
+ t.string "concurrency_key", null: false
7
+ t.datetime "expires_at", null: false
8
+ t.datetime "created_at", null: false
9
+ t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
10
+ t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
11
+ t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
12
+ end
13
+
14
+ create_table "solid_queue_claimed_executions", force: :cascade do |t|
15
+ t.bigint "job_id", null: false
16
+ t.bigint "process_id"
17
+ t.datetime "created_at", null: false
18
+ t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
19
+ t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
20
+ end
21
+
22
+ create_table "solid_queue_failed_executions", force: :cascade do |t|
23
+ t.bigint "job_id", null: false
24
+ t.text "error"
25
+ t.datetime "created_at", null: false
26
+ t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
27
+ end
28
+
29
+ create_table "solid_queue_jobs", force: :cascade do |t|
30
+ t.string "queue_name", null: false
31
+ t.string "class_name", null: false
32
+ t.text "arguments"
33
+ t.integer "priority", default: 0, null: false
34
+ t.string "active_job_id"
35
+ t.datetime "scheduled_at"
36
+ t.datetime "finished_at"
37
+ t.string "concurrency_key"
38
+ t.datetime "created_at", null: false
39
+ t.datetime "updated_at", null: false
40
+ t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
41
+ t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
42
+ t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
43
+ t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
44
+ t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
45
+ end
46
+
47
+ create_table "solid_queue_pauses", force: :cascade do |t|
48
+ t.string "queue_name", null: false
49
+ t.datetime "created_at", null: false
50
+ t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
51
+ end
52
+
53
+ create_table "solid_queue_processes", force: :cascade do |t|
54
+ t.string "kind", null: false
55
+ t.datetime "last_heartbeat_at", null: false
56
+ t.bigint "supervisor_id"
57
+ t.integer "pid", null: false
58
+ t.string "hostname"
59
+ t.text "metadata"
60
+ t.datetime "created_at", null: false
61
+ t.string "name", null: false
62
+ t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
63
+ t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
64
+ t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
65
+ end
66
+
67
+ create_table "solid_queue_ready_executions", force: :cascade do |t|
68
+ t.bigint "job_id", null: false
69
+ t.string "queue_name", null: false
70
+ t.integer "priority", default: 0, null: false
71
+ t.datetime "created_at", null: false
72
+ t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
73
+ t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
74
+ t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
75
+ end
76
+
77
+ create_table "solid_queue_recurring_executions", force: :cascade do |t|
78
+ t.bigint "job_id", null: false
79
+ t.string "task_key", null: false
80
+ t.datetime "run_at", null: false
81
+ t.datetime "created_at", null: false
82
+ t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
83
+ t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
84
+ end
85
+
86
+ create_table "solid_queue_recurring_tasks", force: :cascade do |t|
87
+ t.string "key", null: false
88
+ t.string "schedule", null: false
89
+ t.string "command", limit: 2048
90
+ t.string "class_name"
91
+ t.text "arguments"
92
+ t.string "queue_name"
93
+ t.integer "priority", default: 0
94
+ t.boolean "static", default: true, null: false
95
+ t.text "description"
96
+ t.datetime "created_at", null: false
97
+ t.datetime "updated_at", null: false
98
+ t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
99
+ t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
100
+ end
101
+
102
+ create_table "solid_queue_scheduled_executions", force: :cascade do |t|
103
+ t.bigint "job_id", null: false
104
+ t.string "queue_name", null: false
105
+ t.integer "priority", default: 0, null: false
106
+ t.datetime "scheduled_at", null: false
107
+ t.datetime "created_at", null: false
108
+ t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
109
+ t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
110
+ end
111
+
112
+ create_table "solid_queue_semaphores", force: :cascade do |t|
113
+ t.string "key", null: false
114
+ t.integer "value", default: 1, null: false
115
+ t.datetime "expires_at", null: false
116
+ t.datetime "created_at", null: false
117
+ t.datetime "updated_at", null: false
118
+ t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
119
+ t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
120
+ t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
121
+ end
122
+
123
+ add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
124
+ add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
125
+ add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
126
+ add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
127
+ add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
128
+ add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
129
+ end
data/exe/cardinal CHANGED
@@ -106,6 +106,9 @@ end
106
106
  # scaffolding inside the engine/gem directory — read-only for sudo installs).
107
107
  ENV["PIDFILE"] = File.join(TARGET, ".cardinal", "tmp", "server.pid")
108
108
  ENV["CARDINAL_SWEEPER"] = "1" # the sweeper initializer keys off Rails::Server otherwise
109
+ # Localhost only: the board drives your logged-in gh/claude sessions, so it
110
+ # must not be reachable by others on the network. CARDINAL_HOST=0.0.0.0
111
+ # deliberately exposes it (e.g. to browse from a phone on your LAN).
109
112
  exec(RbConfig.ruby, "-S", "puma",
110
- "-b", "tcp://0.0.0.0:#{ENV["PORT"]}",
113
+ "-b", "tcp://#{ENV.fetch("CARDINAL_HOST", "127.0.0.1")}:#{ENV["PORT"]}",
111
114
  File.join(HOME_DIR, "config.ru"))
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.5"
2
+ VERSION = "0.2.6"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardinal-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -121,6 +121,34 @@ dependencies:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
123
  version: '3.6'
124
+ - !ruby/object:Gem::Dependency
125
+ name: solid_queue
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '1.0'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '1.0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: solid_cable
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '3.0'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '3.0'
124
152
  description: 'Cardinal AI is a local, per-repo AI Kanban tool: columns are policies,
125
153
  cards become Claude-powered worker agents, and work ships as pull requests. Run
126
154
  `cardinal` inside any git repository to get a board for it at http://localhost:4000.'
@@ -178,6 +206,8 @@ files:
178
206
  - app/services/rules.rb
179
207
  - app/services/rules/compiler.rb
180
208
  - app/services/run_sweeper.rb
209
+ - app/views/boards/brief.html.erb
210
+ - app/views/boards/edit.html.erb
181
211
  - app/views/boards/show.html.erb
182
212
  - app/views/cards/_card.html.erb
183
213
  - app/views/cards/_detail.html.erb
@@ -217,13 +247,17 @@ files:
217
247
  - config/initializers/run_sweeper.rb
218
248
  - config/locales/en.yml
219
249
  - config/puma.rb
250
+ - config/queue.yml
251
+ - config/recurring.yml
220
252
  - config/routes.rb
221
253
  - config/storage.yml
254
+ - db/cable_schema.rb
222
255
  - db/migrate/20260703000001_create_cardinal_schema.rb
223
256
  - db/migrate/20260703000002_add_agent_runner_fields.rb
224
257
  - db/migrate/20260704000001_add_parent_to_cards.rb
225
258
  - db/migrate/20260704000002_add_assistant_session_to_cards.rb
226
259
  - db/migrate/20260704120000_add_repo_brief_to_boards.rb
260
+ - db/queue_schema.rb
227
261
  - db/seeds.rb
228
262
  - docker/agent/Dockerfile
229
263
  - exe/cardinal