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.
- 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 +514 -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 +95 -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 +28 -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 +43 -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 +60 -0
- data/app/models/card.rb +83 -0
- data/app/models/column.rb +83 -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 +67 -0
- data/app/services/run_sweeper.rb +52 -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 +126 -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 +686 -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 +19 -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 +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
|
data/app/models/board.rb
ADDED
|
@@ -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
|
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?
|
|
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
|
data/app/models/event.rb
ADDED
|
@@ -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
|