cardinal-ai 0.0.1 → 0.2.3

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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +50 -29
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/application.css +10 -0
  6. data/app/assets/stylesheets/cardinal.css +514 -0
  7. data/app/controllers/application_controller.rb +7 -0
  8. data/app/controllers/boards_controller.rb +5 -0
  9. data/app/controllers/cards_controller.rb +129 -0
  10. data/app/controllers/columns_controller.rb +95 -0
  11. data/app/controllers/messages_controller.rb +25 -0
  12. data/app/controllers/runs_controller.rb +58 -0
  13. data/app/helpers/application_helper.rb +35 -0
  14. data/app/javascript/application.js +2 -0
  15. data/app/javascript/controllers/application.js +7 -0
  16. data/app/javascript/controllers/autosave_controller.js +28 -0
  17. data/app/javascript/controllers/board_column_controller.js +96 -0
  18. data/app/javascript/controllers/clipboard_controller.js +18 -0
  19. data/app/javascript/controllers/composer_controller.js +10 -0
  20. data/app/javascript/controllers/index.js +3 -0
  21. data/app/javascript/controllers/modal_controller.js +43 -0
  22. data/app/javascript/controllers/scroll_controller.js +44 -0
  23. data/app/javascript/controllers/tags_controller.js +49 -0
  24. data/app/javascript/controllers/theme_controller.js +43 -0
  25. data/app/javascript/controllers/tooltip_controller.js +37 -0
  26. data/app/jobs/ai_task_job.rb +26 -0
  27. data/app/jobs/application_job.rb +7 -0
  28. data/app/jobs/assistant_reply_job.rb +132 -0
  29. data/app/jobs/mark_pr_ready_job.rb +18 -0
  30. data/app/jobs/merge_pr_job.rb +27 -0
  31. data/app/jobs/resume_run_job.rb +30 -0
  32. data/app/jobs/start_run_job.rb +13 -0
  33. data/app/mailers/application_mailer.rb +4 -0
  34. data/app/models/agent_session.rb +8 -0
  35. data/app/models/application_record.rb +3 -0
  36. data/app/models/artifact.rb +8 -0
  37. data/app/models/board.rb +60 -0
  38. data/app/models/card.rb +83 -0
  39. data/app/models/column.rb +83 -0
  40. data/app/models/event.rb +44 -0
  41. data/app/models/run.rb +28 -0
  42. data/app/services/agent/runner.rb +379 -0
  43. data/app/services/agent/workspace.rb +138 -0
  44. data/app/services/card_transition.rb +97 -0
  45. data/app/services/claude_cli.rb +89 -0
  46. data/app/services/rules/compiler.rb +55 -0
  47. data/app/services/rules.rb +67 -0
  48. data/app/services/run_sweeper.rb +52 -0
  49. data/app/views/boards/show.html.erb +79 -0
  50. data/app/views/cards/_card.html.erb +48 -0
  51. data/app/views/cards/_detail.html.erb +190 -0
  52. data/app/views/cards/_tag_picker.html.erb +12 -0
  53. data/app/views/cards/new.html.erb +35 -0
  54. data/app/views/cards/show.html.erb +3 -0
  55. data/app/views/columns/_column.html.erb +25 -0
  56. data/app/views/columns/edit.html.erb +126 -0
  57. data/app/views/events/_event.html.erb +29 -0
  58. data/app/views/layouts/application.html.erb +46 -0
  59. data/app/views/layouts/mailer.html.erb +13 -0
  60. data/app/views/layouts/mailer.text.erb +1 -0
  61. data/app/views/pwa/manifest.json.erb +22 -0
  62. data/app/views/pwa/service-worker.js +26 -0
  63. data/bin/rails +4 -0
  64. data/bin/rake +4 -0
  65. data/cardinal.md +686 -0
  66. data/config/application.rb +60 -0
  67. data/config/boot.rb +13 -0
  68. data/config/bundler-audit.yml +5 -0
  69. data/config/cable.yml +13 -0
  70. data/config/ci.rb +20 -0
  71. data/config/credentials.yml.enc +1 -0
  72. data/config/database.yml +31 -0
  73. data/config/environment.rb +5 -0
  74. data/config/environments/development.rb +78 -0
  75. data/config/environments/production.rb +89 -0
  76. data/config/environments/test.rb +53 -0
  77. data/config/importmap.rb +6 -0
  78. data/config/initializers/assets.rb +7 -0
  79. data/config/initializers/cardinal_bootstrap.rb +12 -0
  80. data/config/initializers/cardinal_instance.rb +20 -0
  81. data/config/initializers/content_security_policy.rb +29 -0
  82. data/config/initializers/filter_parameter_logging.rb +8 -0
  83. data/config/initializers/inflections.rb +16 -0
  84. data/config/initializers/run_sweeper.rb +17 -0
  85. data/config/locales/en.yml +31 -0
  86. data/config/puma.rb +42 -0
  87. data/config/routes.rb +22 -0
  88. data/config/storage.yml +27 -0
  89. data/config.ru +6 -0
  90. data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
  91. data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
  92. data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
  93. data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
  94. data/db/seeds.rb +19 -0
  95. data/docker/agent/Dockerfile +16 -0
  96. data/exe/cardinal +111 -0
  97. data/lib/cardinal/version.rb +1 -1
  98. data/public/400.html +135 -0
  99. data/public/404.html +135 -0
  100. data/public/406-unsupported-browser.html +135 -0
  101. data/public/422.html +135 -0
  102. data/public/500.html +135 -0
  103. data/public/icon.png +0 -0
  104. data/public/icon.svg +3 -0
  105. data/public/robots.txt +1 -0
  106. data/vendor/javascript/sortablejs.js +3378 -0
  107. metadata +235 -9
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Toggles between dark (default) and light themes by setting data-theme on
4
+ // <html> and persisting the choice to localStorage. The initial theme is
5
+ // applied by an inline boot script in the <head> to avoid a flash of the
6
+ // wrong theme; this controller only handles the toggle and keeps its label
7
+ // in sync with the current theme.
8
+ export default class extends Controller {
9
+ static targets = ["label"]
10
+
11
+ connect() {
12
+ this.render()
13
+ }
14
+
15
+ toggle() {
16
+ const next = this.current === "light" ? "dark" : "light"
17
+ // Dark is the default, so we only persist/mark an explicit light choice.
18
+ if (next === "light") {
19
+ document.documentElement.setAttribute("data-theme", "light")
20
+ localStorage.setItem("theme", "light")
21
+ } else {
22
+ document.documentElement.removeAttribute("data-theme")
23
+ localStorage.setItem("theme", "dark")
24
+ }
25
+ this.render()
26
+ }
27
+
28
+ get current() {
29
+ return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"
30
+ }
31
+
32
+ render() {
33
+ // Show the action the button will take, not the current state.
34
+ const toLight = this.current === "dark"
35
+ const text = toLight ? "☀ Light" : "☾ Dark"
36
+ if (this.hasLabelTarget) {
37
+ this.labelTarget.textContent = text
38
+ } else {
39
+ this.element.textContent = text
40
+ }
41
+ this.element.setAttribute("aria-label", toLight ? "Switch to light mode" : "Switch to dark mode")
42
+ }
43
+ }
@@ -0,0 +1,37 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // (i) tooltips. Rendered into document.body with fixed positioning so they
4
+ // can never be clipped by modal overflow; clamped to the viewport.
5
+ export default class extends Controller {
6
+ static values = { text: String }
7
+
8
+ show() {
9
+ this.hide()
10
+ this.pop = document.createElement("div")
11
+ this.pop.className = "tooltip-pop"
12
+ this.pop.textContent = this.textValue
13
+ document.body.appendChild(this.pop)
14
+
15
+ const icon = this.element.getBoundingClientRect()
16
+ const pop = this.pop.getBoundingClientRect()
17
+ const margin = 8
18
+
19
+ let left = icon.left + icon.width / 2 - pop.width / 2
20
+ left = Math.max(margin, Math.min(left, window.innerWidth - pop.width - margin))
21
+
22
+ let top = icon.top - pop.height - margin
23
+ if (top < margin) top = icon.bottom + margin
24
+
25
+ this.pop.style.left = `${left}px`
26
+ this.pop.style.top = `${top}px`
27
+ }
28
+
29
+ hide() {
30
+ this.pop?.remove()
31
+ this.pop = null
32
+ }
33
+
34
+ disconnect() {
35
+ this.hide()
36
+ }
37
+ }
@@ -0,0 +1,26 @@
1
+ # A maintenance agent (cardinal.md §17): one bounded claude CLI call fired by
2
+ # a column rule. The prompt template may reference the card via %{title},
3
+ # %{description}, %{conversation}. Output lands on the timeline as an
4
+ # assistant_message. Distinct from the worker agent — no workspace, no tools.
5
+ class AiTaskJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform(card_id, prompt_template, model = nil)
9
+ card = Card.find(card_id)
10
+ return if prompt_template.blank? || !ClaudeCli.available?
11
+
12
+ prompt = format(prompt_template,
13
+ title: card.title,
14
+ description: card.description.to_s,
15
+ conversation: card.events.conversation.filter_map(&:text).last(30).join("\n\n"))
16
+
17
+ text = ClaudeCli.prompt(
18
+ prompt,
19
+ system: "You are a maintenance agent on a Cardinal board performing one bounded task on card ##{card.number}. Be concise; your output is posted directly to the card's timeline.",
20
+ model: model.presence || AssistantReplyJob::FALLBACK_MODEL
21
+ )
22
+ card.log!("assistant_message", actor: "assistant", text: text) if text.present?
23
+ rescue ClaudeCli::Error => e
24
+ card.log!("error", text: "The maintenance agent #{e.message}.", detail: e.detail)
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ class ApplicationJob < ActiveJob::Base
2
+ # Automatically retry jobs that encountered a deadlock
3
+ # retry_on ActiveRecord::Deadlocked
4
+
5
+ # Most jobs are safe to ignore if the underlying records are no longer available
6
+ # discard_on ActiveJob::DeserializationError
7
+ end
@@ -0,0 +1,132 @@
1
+ # The shared planning assistant (cardinal.md §5): a CONTINUING claude session
2
+ # per card (assistant_session_id) — context and repo exploration carry across
3
+ # replies instead of being re-paid per message. Falls back to a fresh session
4
+ # (with the transcript embedded) if the stored session can't be resumed.
5
+ class AssistantReplyJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
9
+ MAX_TURNS = 20
10
+
11
+ ROLE_REMINDER = "Reminder: you are the PLANNING assistant. You refine cards and write briefs — " \
12
+ "you never implement. No code files, no full implementations, no 'run these " \
13
+ "commands'. Your tools are READ-ONLY: you are physically incapable of modifying " \
14
+ "any file, so NEVER claim to have made, applied, or reverted a change — any such " \
15
+ "claim would be false. If the user approves an approach, finalize the " \
16
+ "Ready-for-execution brief and tell them to drag the card to a work column.".freeze
17
+
18
+ KICKOFF_TURN = <<~MSG.freeze
19
+ This card just entered the Planning column. Open the discussion: greet me in one short
20
+ sentence, then ask the 2-3 most important clarifying questions that would make THIS
21
+ card execution-ready. Be specific to the card's actual content — never generic. If the
22
+ card is already crystal clear, say so and propose a "Ready for execution" brief instead.
23
+ MSG
24
+
25
+ # kickoff: true generates the opening message when a card enters planning.
26
+ def perform(card, kickoff: false)
27
+ unless ClaudeCli.available?
28
+ card.log!("assistant_message", actor: "assistant",
29
+ text: "I'm here to help shape this card. What's the goal, and how will we know it's done? (Install the claude CLI for a smarter assistant.)")
30
+ return
31
+ end
32
+
33
+ reply, session_id = converse(card, kickoff:)
34
+ card.update!(assistant_session_id: session_id) if session_id.present?
35
+ card.log!("assistant_message", actor: "assistant", text: reply) if reply.present?
36
+ rescue ClaudeCli::Error => e
37
+ card.log!("error", text: "The planning assistant #{e.message}.", detail: e.detail)
38
+ end
39
+
40
+ private
41
+
42
+ # Invariant: the assistant's session must never hold less of the timeline
43
+ # than the user's screen shows. Kickoffs seed ALL prior conversation (notes
44
+ # made before Planning included); resumes send everything said since the
45
+ # assistant's last reply, not just the latest message.
46
+ def converse(card, kickoff:)
47
+ repo = card.board.local_path.presence
48
+ common = { model: card.column.model.presence || FALLBACK_MODEL,
49
+ tools: repo ? "Read,Glob,Grep" : nil,
50
+ cwd: repo, max_turns: MAX_TURNS, with_session: true }
51
+
52
+ if !kickoff && card.assistant_session_id.present?
53
+ begin
54
+ # ROLE_REMINDER rides every resume: long sessions drift, and an
55
+ # affirming reply ("yes, that approach") must not escalate the
56
+ # planner into implementing.
57
+ return ClaudeCli.prompt(unheard_messages(card), resume: card.assistant_session_id,
58
+ system: ROLE_REMINDER, **common)
59
+ rescue ClaudeCli::Error
60
+ card.update!(assistant_session_id: nil) # stale/expired — start fresh below
61
+ end
62
+ end
63
+
64
+ opener = kickoff ? kickoff_prompt(card) : transcript_prompt(card)
65
+ ClaudeCli.prompt(opener, system: system_prompt(card), **common)
66
+ end
67
+
68
+ def kickoff_prompt(card)
69
+ prior = conversation_turns(card)
70
+ return KICKOFF_TURN if prior.empty?
71
+ "#{KICKOFF_TURN}\n\nThe card already carries conversation/notes from before it " \
72
+ "entered Planning — treat these as things I have already told you:\n\n#{prior.join("\n\n")}"
73
+ end
74
+
75
+ # Everything the user said since the assistant last spoke — never just the
76
+ # latest message (bursts and between-column notes must not be dropped).
77
+ def unheard_messages(card)
78
+ last_reply_id = card.events.where(kind: "assistant_message").maximum(:id) || 0
79
+ texts = card.events.where(kind: "user_message").where("id > ?", last_reply_id)
80
+ .order(:id).filter_map(&:text)
81
+ texts.presence&.join("\n\n") || card.events.where(kind: "user_message").order(:id).last&.text.to_s
82
+ end
83
+
84
+ def system_prompt(card)
85
+ <<~PROMPT
86
+ You are the planning assistant on Cardinal, a Kanban board where cards become AI \
87
+ worker agents once they enter an execution column. You are helping the user shape \
88
+ card ##{card.number}: "#{card.title}".
89
+
90
+ #{"Card description:\n#{card.description}\n" if card.description.present?}
91
+ #{"Column instructions: #{card.column.instructions}\n" if card.column.instructions.present?}
92
+ Your job is to refine this card until it is ready for an execution agent: clarify \
93
+ the goal, surface hidden requirements, bound the scope, and drive toward crisp \
94
+ acceptance criteria. Be concise and concrete — a few sentences or a short list per \
95
+ reply. When the card feels well-defined, offer a short "Ready for execution" brief \
96
+ summarizing goal, scope, and acceptance criteria.
97
+
98
+ HARD BOUNDARY: you plan; you NEVER implement. Do not write code files, produce \
99
+ full-file implementations, or tell the user to run commands to apply changes — \
100
+ even if asked. A separate worker agent implements after the card leaves Planning. \
101
+ Small illustrative snippets (a few lines) inside a discussion are fine; anything \
102
+ resembling a deliverable is not. When the user approves an approach or says "do \
103
+ it", your move is: finalize the Ready-for-execution brief and tell them to drag \
104
+ the card onward.
105
+
106
+ Your tools are READ-ONLY (Read/Glob/Grep). You are physically incapable of \
107
+ changing any file. Never state or imply that you made, applied, or reverted a \
108
+ change — such a claim is always false.
109
+
110
+ #{"You have READ-ONLY access to the board's repository (Read/Glob/Grep; you are in \
111
+ its root). Ground your questions and advice in the actual code whenever relevant — \
112
+ check what exists before asking about it. Never promise to look at something later: \
113
+ you cannot act between replies, so look NOW, within this turn, then answer." if card.board.local_path.present?}
114
+ PROMPT
115
+ end
116
+
117
+ def conversation_turns(card)
118
+ card.events.where(kind: %w[user_message assistant_message]).last(30).map do |event|
119
+ "#{event.kind == "user_message" ? "User" : "You"}: #{event.text}"
120
+ end
121
+ end
122
+
123
+ def transcript_prompt(card)
124
+ <<~PROMPT
125
+ Conversation so far:
126
+
127
+ #{conversation_turns(card).join("\n\n")}
128
+
129
+ Reply to the user's latest message as the planning assistant. Output only your reply.
130
+ PROMPT
131
+ end
132
+ end
@@ -0,0 +1,18 @@
1
+ # Column rule action (§17): take the card's PR out of draft — used by QA-style
2
+ # columns where the work should be formally reviewable on GitHub.
3
+ class MarkPrReadyJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(card_id)
7
+ card = Card.find(card_id)
8
+ return if card.pr_url.blank?
9
+
10
+ out, status = Open3.capture2e("gh", "pr", "ready", card.pr_url)
11
+ if status.success? || out.downcase.include?("already")
12
+ card.update!(pr_state: "ready")
13
+ card.log!("status_change", text: "PR marked ready for review (out of draft)")
14
+ else
15
+ card.log!("error", text: "Could not mark PR ready: #{out.truncate(200)}")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,27 @@
1
+ # Done's entry rule (§12.4 decision): dragging to the terminal column is the
2
+ # irreversible act — mark the PR ready, squash-merge it, delete the branch.
3
+ class MergePrJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(card_id)
7
+ card = Card.find(card_id)
8
+ return if card.pr_url.blank? || card.pr_state == "merged"
9
+
10
+ # Best-effort undraft — a QA column may already have done it, and gh
11
+ # errors on an already-ready PR; the merge step is the real gate.
12
+ Open3.capture2e("gh", "pr", "ready", card.pr_url)
13
+ run_step(card, ["gh", "pr", "merge", card.pr_url, "--squash", "--delete-branch"]) or return
14
+
15
+ card.update!(pr_state: "merged")
16
+ card.log!("status_change", text: "PR squash-merged and branch deleted — shipped 🎉")
17
+ end
18
+
19
+ private
20
+
21
+ def run_step(card, cmd)
22
+ out, status = Open3.capture2e(*cmd)
23
+ return true if status.success?
24
+ card.log!("error", text: "Merge step failed (#{cmd[0..2].join(" ")}): #{out.truncate(200)}")
25
+ false
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ class ResumeRunJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ # Resumes honor the column's WIP limit like any other start (§8): if the
5
+ # column is full, the answer is recorded and the card rejoins the queue;
6
+ # Column#kick_queue fires the pending resume when a slot frees.
7
+ def perform(run_id, message, approve: false)
8
+ run = Run.find(run_id)
9
+ return unless run.needs_input?
10
+ card = run.card
11
+
12
+ if card.column.execution? && card.column.at_wip_limit?
13
+ pending = run.briefing["pending_resume"]
14
+ combined = [pending&.dig("message"), message].compact_blank.join("\n\n")
15
+ run.update!(briefing: run.briefing.merge(
16
+ "pending_resume" => { "message" => combined, "approve" => approve || pending&.dig("approve") || false }
17
+ ))
18
+ card.update!(status: "queued")
19
+ card.log!("status_change", run: run, text: "Answer recorded — waiting for a free agent slot")
20
+ return
21
+ end
22
+
23
+ if (pending = run.briefing["pending_resume"])
24
+ run.update!(briefing: run.briefing.except("pending_resume"))
25
+ message = [pending["message"], message].compact_blank.join("\n\n")
26
+ approve ||= pending["approve"]
27
+ end
28
+ Agent::Runner.resume(run, message, approve: approve)
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ class StartRunJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(card_id)
5
+ card = Card.find(card_id)
6
+ return unless card.queued? && card.column.execution?
7
+ return if card.column.at_wip_limit? # stays queued; kicked when a slot frees
8
+
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 })
11
+ Agent::Runner.start(run)
12
+ end
13
+ end
@@ -0,0 +1,4 @@
1
+ class ApplicationMailer < ActionMailer::Base
2
+ default from: "from@example.com"
3
+ layout "mailer"
4
+ end
@@ -0,0 +1,8 @@
1
+ class AgentSession < ApplicationRecord
2
+ STATUSES = %w[provisioning ready torn_down].freeze
3
+
4
+ belongs_to :card
5
+ has_many :runs, dependent: :destroy
6
+
7
+ enum :status, STATUSES.index_by(&:itself)
8
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationRecord < ActiveRecord::Base
2
+ primary_abstract_class
3
+ end
@@ -0,0 +1,8 @@
1
+ class Artifact < ApplicationRecord
2
+ KINDS = %w[pull_request file report link].freeze
3
+
4
+ belongs_to :run
5
+
6
+ validates :kind, inclusion: { in: KINDS }
7
+ validates :name, presence: true
8
+ end
@@ -0,0 +1,60 @@
1
+ class Board < ApplicationRecord
2
+ DEFAULT_COLUMNS = [
3
+ { name: "Tasks", archetype: "inbox", policy: {} },
4
+ { name: "Planning", archetype: "planning", policy: { "model" => "claude-haiku-4-5-20251001" } },
5
+ { name: "In Progress", archetype: "execution",
6
+ policy: { "model" => "claude-sonnet-4-6", "effort" => "high", "concurrency_limit" => 3,
7
+ "plan_approval" => true, "max_turns" => 80, "timeout_minutes" => 30,
8
+ "on_entry" => [{ "action" => "start_agent_run" }] } },
9
+ { name: "Review", archetype: "review", policy: {} },
10
+ { name: "QA", archetype: "review",
11
+ policy: { "on_entry" => [{ "action" => "mark_pr_ready" }],
12
+ "on_entry_text" => "Take the PR out of draft — mark it ready for review on GitHub." } },
13
+ { name: "Done", archetype: "terminal",
14
+ policy: { "on_entry" => [{ "action" => "merge_pr" }], "arrivals" => "top" } }
15
+ ].freeze
16
+
17
+ has_many :columns, -> { order(:position) }, dependent: :destroy
18
+ has_many :cards, dependent: :destroy
19
+
20
+ validates :name, presence: true
21
+
22
+ # First-run setup for a portable instance (cardinal.md §16): build the board
23
+ # from the repo Cardinal was launched inside.
24
+ def self.bootstrap!(repo_path)
25
+ repo_path = File.expand_path(repo_path)
26
+ # Raw configured URL (get-url applies insteadOf rewrites, which can embed
27
+ # credential-helper tokens); strip any userinfo defensively either way.
28
+ origin, origin_ok = Open3.capture2e("git", "-C", repo_path, "config", "--get", "remote.origin.url")
29
+ branch, branch_ok = Open3.capture2e("git", "-C", repo_path, "rev-parse", "--abbrev-ref", "HEAD")
30
+
31
+ board = create!(
32
+ name: File.basename(repo_path),
33
+ repo_url: origin_ok.success? ? sanitize_remote_url(origin.strip) : nil,
34
+ default_branch: branch_ok.success? && branch.strip.present? ? branch.strip : "main",
35
+ local_path: repo_path
36
+ )
37
+ DEFAULT_COLUMNS.each_with_index do |attrs, index|
38
+ board.columns.create!(position: index, **attrs)
39
+ end
40
+ board
41
+ end
42
+
43
+ def self.sanitize_remote_url(url)
44
+ # Drop any userinfo (tokens from credential-helper rewrites). Regex, not
45
+ # URI#userinfo= — Ruby 3.4's RFC3986 parser silently ignores that setter.
46
+ url.sub(%r{\A(\w+://)[^@/]+@}, '\1')
47
+ end
48
+
49
+ # Every tag in use on this board — the pool the tag picker offers.
50
+ def tag_pool
51
+ cards.pluck(:tags).flatten.compact.uniq.sort
52
+ end
53
+
54
+ # Cards currently waiting on the human, ordered by urgency — feeds the
55
+ # attention inbox in the board header.
56
+ def attention_cards
57
+ cards.where(status: %w[needs_input failed work_complete])
58
+ .order(Arel.sql("CASE status WHEN 'needs_input' THEN 0 WHEN 'failed' THEN 1 ELSE 2 END"), updated_at: :asc)
59
+ end
60
+ end
@@ -0,0 +1,83 @@
1
+ class Card < ApplicationRecord
2
+ STATUSES = %w[
3
+ draft discussing queued working needs_input blocked failed
4
+ work_complete in_review changes_requested approved done archived
5
+ ].freeze
6
+
7
+ # Which statuses a card may hold while sitting in each column archetype.
8
+ # The column move is the trigger; this map keeps the state machine honest (§3).
9
+ LEGAL_STATUSES = {
10
+ "inbox" => %w[draft archived],
11
+ "planning" => %w[draft discussing archived],
12
+ "execution" => %w[queued working needs_input blocked failed work_complete archived],
13
+ "review" => %w[in_review changes_requested approved archived],
14
+ "terminal" => %w[done archived]
15
+ }.freeze
16
+
17
+ belongs_to :board
18
+ belongs_to :column
19
+ belongs_to :parent, class_name: "Card", optional: true
20
+ has_many :children, class_name: "Card", foreign_key: :parent_id,
21
+ dependent: :nullify, inverse_of: :parent
22
+ has_many :events, -> { order(:created_at, :id) }, dependent: :destroy
23
+ has_many :agent_sessions, dependent: :destroy
24
+ has_many :runs, through: :agent_sessions
25
+
26
+ enum :status, STATUSES.index_by(&:itself)
27
+
28
+ validates :title, presence: true
29
+ validate :status_legal_for_column
30
+
31
+ before_validation :assign_number_and_position, on: :create
32
+
33
+ after_commit -> { broadcast_refresh_to board }
34
+
35
+ scope :attention, -> { where(status: %w[needs_input blocked failed work_complete]) }
36
+
37
+ def needs_attention? = %w[needs_input blocked failed work_complete].include?(status)
38
+
39
+ def running? = %w[queued working needs_input].include?(status)
40
+
41
+ # Latest one-line progress event, shown live on the card face (§6).
42
+ def latest_progress
43
+ events.where(kind: "progress").last&.payload&.[]("text")
44
+ end
45
+
46
+ # Is the planning assistant expected to post next? True right after entering
47
+ # a planning column (kickoff inspection pending) or after a user message.
48
+ def awaiting_assistant?
49
+ return false unless column.planning?
50
+ last = events.where(kind: %w[user_message assistant_message error column_move]).order(:id).last
51
+ last.present? && %w[user_message column_move].include?(last.kind)
52
+ end
53
+
54
+ # Some AI is expected to write to this card imminently.
55
+ def thinking?
56
+ awaiting_assistant? || working?
57
+ end
58
+
59
+ # URLs speak card numbers, matching every other surface (header #N,
60
+ # branches, PR titles) — not database ids, which drift after deletions.
61
+ def to_param = number.to_s
62
+
63
+ def default_branch_name
64
+ "cardinal/#{number}-#{title.parameterize[0, 40]}"
65
+ end
66
+
67
+ def log!(kind, actor: "system", run: nil, **payload)
68
+ events.create!(kind:, actor:, run:, payload:)
69
+ end
70
+
71
+ private
72
+
73
+ def assign_number_and_position
74
+ self.number ||= (board.cards.maximum(:number) || 0) + 1
75
+ self.position ||= (column.cards.maximum(:position) || -1) + 1
76
+ end
77
+
78
+ def status_legal_for_column
79
+ return if column.blank? || status.blank?
80
+ legal = LEGAL_STATUSES.fetch(column.archetype, STATUSES)
81
+ errors.add(:status, "#{status} is not legal in a #{column.archetype} column") unless legal.include?(status)
82
+ end
83
+ end
@@ -0,0 +1,83 @@
1
+ class Column < ApplicationRecord
2
+ ARCHETYPES = %w[inbox planning execution review terminal].freeze
3
+
4
+ belongs_to :board
5
+ has_many :cards, -> { order(:position) }, dependent: :restrict_with_error
6
+
7
+ enum :archetype, ARCHETYPES.index_by(&:itself)
8
+
9
+ # The policy blob is the column's entire behavior configuration (§1, §14.3).
10
+ store_accessor :policy, :instructions, :model, :effort, :concurrency_limit,
11
+ :plan_approval, :budget_per_run_cents, :timeout_minutes,
12
+ :max_turns, :tools, :on_entry, :on_success, :color, :arrivals,
13
+ :accepts_from
14
+
15
+ # Only ever emit a validated hex color into inline styles.
16
+ def safe_color
17
+ color if color.to_s.match?(/\A#\h{6}\z/)
18
+ end
19
+
20
+ # Which columns may move cards INTO this one (§ accept policy, card #15).
21
+ # Stored as an array of column-id strings; blank = accept from anywhere, so
22
+ # existing boards keep their unrestricted behavior.
23
+ def accepts?(source_column)
24
+ ids = Array(accepts_from).map(&:to_s).reject(&:blank?)
25
+ ids.empty? || ids.include?(source_column.id.to_s)
26
+ end
27
+
28
+ # Start the next queued card when a run slot frees up. A queued card whose
29
+ # run parked and already has its answer recorded resumes instead of
30
+ # starting fresh.
31
+ def kick_queue
32
+ return if at_wip_limit?
33
+ next_card = cards.where(status: "queued").order(:position).first
34
+ return unless next_card
35
+
36
+ parked = next_card.runs.where(status: "needs_input").order(:id).last
37
+ if parked&.briefing&.key?("pending_resume")
38
+ ResumeRunJob.perform_later(parked.id, "")
39
+ else
40
+ StartRunJob.perform_later(next_card.id)
41
+ end
42
+ end
43
+
44
+ # "claude-sonnet-4-6" → "sonnet", for compact chips on card faces.
45
+ def model_short
46
+ model.to_s[/claude-([a-z]+)/, 1] || model
47
+ end
48
+
49
+ validates :name, presence: true
50
+ validates :position, presence: true
51
+
52
+ def running_count = cards.where(status: "working").count
53
+ def queued_count = cards.where(status: "queued").count
54
+
55
+ def at_wip_limit?
56
+ execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
57
+ end
58
+
59
+ # The built-in role contract for AI servicing this archetype — shown
60
+ # read-only in the gear modal so the Instructions field is understood as
61
+ # ADDING to this, never replacing it. Enforced in code, not editable.
62
+ BUILT_IN_ROLES = {
63
+ "planning" => "Plans only, never implements: read-only tools (physically cannot change files), " \
64
+ "drives toward a Ready-for-execution brief, and hands off — approval means " \
65
+ "\"finalize the brief\", not \"do it\".",
66
+ "execution" => "Full toolset in an isolated checkout of the card's branch. Commits as it goes but " \
67
+ "never pushes (the runner pushes); merges the default branch itself on conflict; " \
68
+ "parks with a QUESTION: when genuinely blocked; ends with a final report."
69
+ }.freeze
70
+
71
+ def built_in_role = BUILT_IN_ROLES[archetype]
72
+
73
+ # One-line consequence shown while dragging a card over this column (§14.1).
74
+ def drag_hint
75
+ case archetype
76
+ when "inbox" then "Parked — no agent activity"
77
+ when "planning" then "The board assistant will join the discussion"
78
+ when "execution" then "An agent will be assigned and start work"
79
+ when "review" then "Work stops — ready for your verdict"
80
+ when "terminal" then "Ships it — PR merged, branch deleted"
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,44 @@
1
+ class Event < ApplicationRecord
2
+ KINDS = %w[
3
+ user_message agent_message assistant_message
4
+ status_change column_move move_rejected plan_proposed plan_approved
5
+ question answer progress
6
+ tool_call tool_result artifact_created
7
+ run_started run_finished final_report error
8
+ ].freeze
9
+
10
+ # Which timeline zoom level an event first appears at (§7).
11
+ CONVERSATION_KINDS = %w[user_message agent_message assistant_message question answer
12
+ plan_proposed plan_approved final_report error
13
+ column_move move_rejected].freeze
14
+
15
+ belongs_to :card
16
+ belongs_to :run, optional: true
17
+
18
+ validates :kind, inclusion: { in: KINDS }
19
+ validates :actor, presence: true
20
+
21
+ scope :conversation, -> { where(kind: CONVERSATION_KINDS) }
22
+ scope :activity, -> { where.not(kind: %w[tool_call tool_result]) }
23
+
24
+ # Live-append new events into any open card modal. User-authored events are
25
+ # skipped — they arrive via the form's own redirect re-render.
26
+ after_create_commit -> {
27
+ broadcast_append_to card, target: "card_events", partial: "events/event", locals: { event: self }
28
+ }, unless: -> { actor == "user" }
29
+
30
+ # These kinds mean the AI has delivered what the typing indicator promised.
31
+ RESOLVES_THINKING = %w[assistant_message final_report question plan_proposed error].freeze
32
+
33
+ # Kinds that change what a card FACE shows (progress lines, thinking chip,
34
+ # replied chip) — the board must morph on these, not just the open modal.
35
+ REFRESHES_BOARD = (%w[progress run_started run_finished] + RESOLVES_THINKING).freeze
36
+
37
+ after_create_commit -> { card.broadcast_refresh_to card.board },
38
+ if: -> { REFRESHES_BOARD.include?(kind) }
39
+
40
+ after_create_commit -> { broadcast_remove_to card, target: "typing-indicator" },
41
+ if: -> { RESOLVES_THINKING.include?(kind) }
42
+
43
+ def text = payload["text"]
44
+ end