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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +50 -29
- data/Rakefile +6 -0
- data/app/assets/stylesheets/application.css +10 -0
- data/app/assets/stylesheets/cardinal.css +530 -0
- data/app/controllers/application_controller.rb +7 -0
- data/app/controllers/boards_controller.rb +5 -0
- data/app/controllers/cards_controller.rb +129 -0
- data/app/controllers/columns_controller.rb +130 -0
- data/app/controllers/messages_controller.rb +25 -0
- data/app/controllers/runs_controller.rb +58 -0
- data/app/helpers/application_helper.rb +35 -0
- data/app/javascript/application.js +2 -0
- data/app/javascript/controllers/application.js +7 -0
- data/app/javascript/controllers/autosave_controller.js +43 -0
- data/app/javascript/controllers/board_column_controller.js +96 -0
- data/app/javascript/controllers/clipboard_controller.js +18 -0
- data/app/javascript/controllers/composer_controller.js +10 -0
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/modal_controller.js +45 -0
- data/app/javascript/controllers/reveal_controller.js +15 -0
- data/app/javascript/controllers/scroll_controller.js +44 -0
- data/app/javascript/controllers/tags_controller.js +49 -0
- data/app/javascript/controllers/theme_controller.js +43 -0
- data/app/javascript/controllers/tooltip_controller.js +37 -0
- data/app/jobs/ai_task_job.rb +26 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/assistant_reply_job.rb +132 -0
- data/app/jobs/mark_pr_ready_job.rb +18 -0
- data/app/jobs/merge_pr_job.rb +27 -0
- data/app/jobs/resume_run_job.rb +30 -0
- data/app/jobs/start_run_job.rb +13 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/agent_session.rb +8 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/artifact.rb +8 -0
- data/app/models/board.rb +92 -0
- data/app/models/card.rb +83 -0
- data/app/models/column.rb +134 -0
- data/app/models/event.rb +44 -0
- data/app/models/run.rb +28 -0
- data/app/services/agent/runner.rb +379 -0
- data/app/services/agent/workspace.rb +138 -0
- data/app/services/card_transition.rb +97 -0
- data/app/services/claude_cli.rb +89 -0
- data/app/services/rules/compiler.rb +55 -0
- data/app/services/rules.rb +92 -0
- data/app/services/run_sweeper.rb +53 -0
- data/app/views/boards/show.html.erb +79 -0
- data/app/views/cards/_card.html.erb +48 -0
- data/app/views/cards/_detail.html.erb +190 -0
- data/app/views/cards/_tag_picker.html.erb +12 -0
- data/app/views/cards/new.html.erb +35 -0
- data/app/views/cards/show.html.erb +3 -0
- data/app/views/columns/_column.html.erb +25 -0
- data/app/views/columns/edit.html.erb +146 -0
- data/app/views/events/_event.html.erb +29 -0
- data/app/views/layouts/application.html.erb +46 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/bin/rails +4 -0
- data/bin/rake +4 -0
- data/cardinal.md +695 -0
- data/config/application.rb +60 -0
- data/config/boot.rb +13 -0
- data/config/bundler-audit.yml +5 -0
- data/config/cable.yml +13 -0
- data/config/ci.rb +20 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +31 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +78 -0
- data/config/environments/production.rb +89 -0
- data/config/environments/test.rb +53 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/cardinal_bootstrap.rb +12 -0
- data/config/initializers/cardinal_instance.rb +20 -0
- data/config/initializers/content_security_policy.rb +29 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/initializers/run_sweeper.rb +17 -0
- data/config/locales/en.yml +31 -0
- data/config/puma.rb +42 -0
- data/config/routes.rb +22 -0
- data/config/storage.yml +27 -0
- data/config.ru +6 -0
- data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
- data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
- data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
- data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
- data/db/seeds.rb +13 -0
- data/docker/agent/Dockerfile +16 -0
- data/exe/cardinal +111 -0
- data/lib/cardinal/version.rb +1 -1
- data/public/400.html +135 -0
- data/public/404.html +135 -0
- data/public/406-unsupported-browser.html +135 -0
- data/public/422.html +135 -0
- data/public/500.html +135 -0
- data/public/icon.png +0 -0
- data/public/icon.svg +3 -0
- data/public/robots.txt +1 -0
- data/vendor/javascript/sortablejs.js +3378 -0
- 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
|
data/app/models/board.rb
ADDED
|
@@ -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
|
data/app/models/card.rb
ADDED
|
@@ -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
|