cardinal-ai 0.0.1 → 0.2.4

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 (108) 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 +530 -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 +130 -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 +43 -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 +45 -0
  22. data/app/javascript/controllers/reveal_controller.js +15 -0
  23. data/app/javascript/controllers/scroll_controller.js +44 -0
  24. data/app/javascript/controllers/tags_controller.js +49 -0
  25. data/app/javascript/controllers/theme_controller.js +43 -0
  26. data/app/javascript/controllers/tooltip_controller.js +37 -0
  27. data/app/jobs/ai_task_job.rb +26 -0
  28. data/app/jobs/application_job.rb +7 -0
  29. data/app/jobs/assistant_reply_job.rb +132 -0
  30. data/app/jobs/mark_pr_ready_job.rb +18 -0
  31. data/app/jobs/merge_pr_job.rb +27 -0
  32. data/app/jobs/resume_run_job.rb +30 -0
  33. data/app/jobs/start_run_job.rb +13 -0
  34. data/app/mailers/application_mailer.rb +4 -0
  35. data/app/models/agent_session.rb +8 -0
  36. data/app/models/application_record.rb +3 -0
  37. data/app/models/artifact.rb +8 -0
  38. data/app/models/board.rb +92 -0
  39. data/app/models/card.rb +83 -0
  40. data/app/models/column.rb +134 -0
  41. data/app/models/event.rb +44 -0
  42. data/app/models/run.rb +28 -0
  43. data/app/services/agent/runner.rb +379 -0
  44. data/app/services/agent/workspace.rb +138 -0
  45. data/app/services/card_transition.rb +97 -0
  46. data/app/services/claude_cli.rb +89 -0
  47. data/app/services/rules/compiler.rb +55 -0
  48. data/app/services/rules.rb +92 -0
  49. data/app/services/run_sweeper.rb +53 -0
  50. data/app/views/boards/show.html.erb +79 -0
  51. data/app/views/cards/_card.html.erb +48 -0
  52. data/app/views/cards/_detail.html.erb +190 -0
  53. data/app/views/cards/_tag_picker.html.erb +12 -0
  54. data/app/views/cards/new.html.erb +35 -0
  55. data/app/views/cards/show.html.erb +3 -0
  56. data/app/views/columns/_column.html.erb +25 -0
  57. data/app/views/columns/edit.html.erb +146 -0
  58. data/app/views/events/_event.html.erb +29 -0
  59. data/app/views/layouts/application.html.erb +46 -0
  60. data/app/views/layouts/mailer.html.erb +13 -0
  61. data/app/views/layouts/mailer.text.erb +1 -0
  62. data/app/views/pwa/manifest.json.erb +22 -0
  63. data/app/views/pwa/service-worker.js +26 -0
  64. data/bin/rails +4 -0
  65. data/bin/rake +4 -0
  66. data/cardinal.md +695 -0
  67. data/config/application.rb +60 -0
  68. data/config/boot.rb +13 -0
  69. data/config/bundler-audit.yml +5 -0
  70. data/config/cable.yml +13 -0
  71. data/config/ci.rb +20 -0
  72. data/config/credentials.yml.enc +1 -0
  73. data/config/database.yml +31 -0
  74. data/config/environment.rb +5 -0
  75. data/config/environments/development.rb +78 -0
  76. data/config/environments/production.rb +89 -0
  77. data/config/environments/test.rb +53 -0
  78. data/config/importmap.rb +6 -0
  79. data/config/initializers/assets.rb +7 -0
  80. data/config/initializers/cardinal_bootstrap.rb +12 -0
  81. data/config/initializers/cardinal_instance.rb +20 -0
  82. data/config/initializers/content_security_policy.rb +29 -0
  83. data/config/initializers/filter_parameter_logging.rb +8 -0
  84. data/config/initializers/inflections.rb +16 -0
  85. data/config/initializers/run_sweeper.rb +17 -0
  86. data/config/locales/en.yml +31 -0
  87. data/config/puma.rb +42 -0
  88. data/config/routes.rb +22 -0
  89. data/config/storage.yml +27 -0
  90. data/config.ru +6 -0
  91. data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
  92. data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
  93. data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
  94. data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
  95. data/db/seeds.rb +13 -0
  96. data/docker/agent/Dockerfile +16 -0
  97. data/exe/cardinal +111 -0
  98. data/lib/cardinal/version.rb +1 -1
  99. data/public/400.html +135 -0
  100. data/public/404.html +135 -0
  101. data/public/406-unsupported-browser.html +135 -0
  102. data/public/422.html +135 -0
  103. data/public/500.html +135 -0
  104. data/public/icon.png +0 -0
  105. data/public/icon.svg +3 -0
  106. data/public/robots.txt +1 -0
  107. data/vendor/javascript/sortablejs.js +3378 -0
  108. metadata +236 -9
@@ -0,0 +1,44 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Chat-style timeline scrolling: pinned to the latest entry while you're at
4
+ // (or near) the bottom; if you've scrolled up to read, new entries show a
5
+ // "new messages" pill instead of yanking you down.
6
+ export default class extends Controller {
7
+ static targets = ["scroller", "pill"]
8
+
9
+ NEAR = 90 // px from the bottom that still counts as "at the bottom"
10
+
11
+ connect() {
12
+ this.scrollToBottom()
13
+ this.observer = new MutationObserver(() => this.onAppend())
14
+ this.observer.observe(this.scrollerTarget, { childList: true, subtree: true })
15
+ this.onScroll = () => { if (this.nearBottom()) this.hidePill() }
16
+ this.scrollerTarget.addEventListener("scroll", this.onScroll)
17
+ }
18
+
19
+ disconnect() {
20
+ this.observer?.disconnect()
21
+ this.scrollerTarget?.removeEventListener("scroll", this.onScroll)
22
+ }
23
+
24
+ onAppend() {
25
+ this.nearBottom() ? this.scrollToBottom() : this.showPill()
26
+ }
27
+
28
+ jump() {
29
+ this.scrollToBottom()
30
+ this.hidePill()
31
+ }
32
+
33
+ nearBottom() {
34
+ const el = this.scrollerTarget
35
+ return el.scrollHeight - el.scrollTop - el.clientHeight < this.NEAR
36
+ }
37
+
38
+ scrollToBottom() {
39
+ this.scrollerTarget.scrollTop = this.scrollerTarget.scrollHeight
40
+ }
41
+
42
+ showPill() { this.pillTarget.classList.add("visible") }
43
+ hidePill() { this.pillTarget.classList.remove("visible") }
44
+ }
@@ -0,0 +1,49 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Tag picker: toggle existing tags, create new ones. Keeps a hidden
4
+ // comma-joined field in sync and fires an input event so autosave notices.
5
+ export default class extends Controller {
6
+ static targets = ["field", "chips", "newTag"]
7
+
8
+ toggle(event) {
9
+ event.preventDefault()
10
+ event.currentTarget.classList.toggle("on")
11
+ this.sync()
12
+ }
13
+
14
+ keydown(event) {
15
+ if (event.key !== "Enter") return
16
+ event.preventDefault()
17
+ this.add()
18
+ }
19
+
20
+ add() {
21
+ const name = this.newTagTarget.value.trim().toLowerCase()
22
+ this.newTagTarget.value = ""
23
+ if (name === "") return
24
+
25
+ const existing = this.chipTagged(name)
26
+ if (existing) {
27
+ existing.classList.add("on")
28
+ } else {
29
+ const chip = document.createElement("button")
30
+ chip.type = "button"
31
+ chip.className = "tag-chip on"
32
+ chip.dataset.tag = name
33
+ chip.dataset.action = "tags#toggle"
34
+ chip.textContent = name
35
+ this.chipsTarget.appendChild(chip)
36
+ }
37
+ this.sync()
38
+ }
39
+
40
+ chipTagged(name) {
41
+ return [...this.chipsTarget.querySelectorAll(".tag-chip")].find(c => c.dataset.tag === name)
42
+ }
43
+
44
+ sync() {
45
+ const selected = [...this.chipsTarget.querySelectorAll(".tag-chip.on")].map(c => c.dataset.tag)
46
+ this.fieldTarget.value = selected.join(", ")
47
+ this.fieldTarget.dispatchEvent(new Event("input", { bubbles: true }))
48
+ }
49
+ }
@@ -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? && card.column.ai?
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,92 @@
1
+ class Board < ApplicationRecord
2
+ # Captured from the author's live board (2026-07-05) — the battle-tested
3
+ # layout. accepts_from is stored as NAMES here and resolved to column ids
4
+ # by install_default_columns! (ids don't exist until creation).
5
+ DEFAULT_COLUMNS = [
6
+ { name: "Tasks", archetype: "inbox",
7
+ policy: { "plan_approval" => false,
8
+ "accepts_from_names" => ["Planning", "Review", "QA", "Done"] } },
9
+ { name: "Planning", archetype: "planning",
10
+ policy: { "ai" => true, "model" => "claude-haiku-4-5-20251001", "plan_approval" => false,
11
+ "on_entry" => [{ "action" => "assistant_greeting" }],
12
+ "on_entry_text" => "The planning assistant reads the card and opens the discussion.",
13
+ "accepts_from_names" => ["Tasks", "In Progress", "Review", "QA"] } },
14
+ { name: "In Progress", archetype: "execution",
15
+ policy: { "ai" => true, "model" => "claude-opus-4-8", "effort" => "high",
16
+ "concurrency_limit" => 3, "plan_approval" => true,
17
+ "budget_per_run_cents" => 200, "timeout_minutes" => 90, "max_turns" => 80,
18
+ "tools" => %w[read edit run_commands git_commit_push],
19
+ "on_entry" => [{ "action" => "start_agent_run" }],
20
+ "accepts_from_names" => ["Planning", "Review", "QA"],
21
+ "instructions" => "Follow repo conventions. Write tests when the repo has a suite." } },
22
+ { name: "Review", archetype: "review",
23
+ policy: { "ai" => true, "plan_approval" => false,
24
+ "accepts_from_names" => ["In Progress", "QA"],
25
+ "on_entry" => [{ "action" => "mark_pr_ready" }],
26
+ "on_entry_text" => "Take the PR out of draft — mark it ready for review on GitHub." } },
27
+ { name: "QA", archetype: "review",
28
+ policy: { "ai" => true, "plan_approval" => false,
29
+ "accepts_from_names" => ["Review"],
30
+ "on_entry" => [{ "action" => "mark_pr_ready" }] } },
31
+ { name: "Done", archetype: "terminal",
32
+ policy: { "ai" => false, "plan_approval" => false, "arrivals" => "top",
33
+ "accepts_from_names" => ["Review", "QA", "Planning"],
34
+ "on_entry" => [{ "action" => "merge_pr" }] } }
35
+ ].freeze
36
+
37
+ # Create the default columns, then resolve accepts_from_names -> ids.
38
+ def install_default_columns!
39
+ DEFAULT_COLUMNS.each_with_index do |attrs, index|
40
+ columns.create!(name: attrs[:name], archetype: attrs[:archetype], position: index,
41
+ policy: attrs[:policy].except("accepts_from_names"))
42
+ end
43
+ DEFAULT_COLUMNS.each do |attrs|
44
+ names = attrs[:policy]["accepts_from_names"] or next
45
+ col = columns.find_by!(name: attrs[:name])
46
+ ids = columns.where(name: names).pluck(:id).map(&:to_s)
47
+ col.update!(policy: col.policy.merge("accepts_from" => ids))
48
+ end
49
+ end
50
+
51
+ has_many :columns, -> { order(:position) }, dependent: :destroy
52
+ has_many :cards, dependent: :destroy
53
+
54
+ validates :name, presence: true
55
+
56
+ # First-run setup for a portable instance (cardinal.md §16): build the board
57
+ # from the repo Cardinal was launched inside.
58
+ def self.bootstrap!(repo_path)
59
+ repo_path = File.expand_path(repo_path)
60
+ # Raw configured URL (get-url applies insteadOf rewrites, which can embed
61
+ # credential-helper tokens); strip any userinfo defensively either way.
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
+
65
+ board = create!(
66
+ name: File.basename(repo_path),
67
+ repo_url: origin_ok.success? ? sanitize_remote_url(origin.strip) : nil,
68
+ default_branch: branch_ok.success? && branch.strip.present? ? branch.strip : "main",
69
+ local_path: repo_path
70
+ )
71
+ board.install_default_columns!
72
+ board
73
+ end
74
+
75
+ def self.sanitize_remote_url(url)
76
+ # Drop any userinfo (tokens from credential-helper rewrites). Regex, not
77
+ # URI#userinfo= — Ruby 3.4's RFC3986 parser silently ignores that setter.
78
+ url.sub(%r{\A(\w+://)[^@/]+@}, '\1')
79
+ end
80
+
81
+ # Every tag in use on this board — the pool the tag picker offers.
82
+ def tag_pool
83
+ cards.pluck(:tags).flatten.compact.uniq.sort
84
+ end
85
+
86
+ # Cards currently waiting on the human, ordered by urgency — feeds the
87
+ # attention inbox in the board header.
88
+ def attention_cards
89
+ cards.where(status: %w[needs_input failed work_complete])
90
+ .order(Arel.sql("CASE status WHEN 'needs_input' THEN 0 WHEN 'failed' THEN 1 ELSE 2 END"), updated_at: :asc)
91
+ end
92
+ 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? && column.ai?
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