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,89 @@
|
|
|
1
|
+
# The single auth path for all of Cardinal's AI (§17): every tier — planning
|
|
2
|
+
# assistant, maintenance agents, rules compiler, worker agents — goes through
|
|
3
|
+
# the claude CLI, so wherever Claude Code is logged in (or an API key is
|
|
4
|
+
# exported), Cardinal works. No separate key provisioning.
|
|
5
|
+
#
|
|
6
|
+
# This module covers the one-shot tiers; worker agents have their own
|
|
7
|
+
# streaming path in Agent::Runner.
|
|
8
|
+
module ClaudeCli
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
# Human message in #message; raw technical payload in #detail (shown only
|
|
11
|
+
# behind a disclosure in the timeline).
|
|
12
|
+
attr_reader :detail
|
|
13
|
+
|
|
14
|
+
def initialize(message, detail: nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@detail = detail
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Nested-session guards + creds the model never needs. A blank
|
|
21
|
+
# ANTHROPIC_API_KEY is removed too (it would shadow CLI session auth).
|
|
22
|
+
STRIP_ENV = %w[CLAUDECODE CLAUDE_CODE_ENTRYPOINT GH_TOKEN GITHUB_TOKEN].freeze
|
|
23
|
+
|
|
24
|
+
WRAP_UP = "You have hit your exploration limit. Using only what you have already " \
|
|
25
|
+
"learned, give your best complete reply now. Do not use any tools.".freeze
|
|
26
|
+
|
|
27
|
+
def self.available?
|
|
28
|
+
return @available if defined?(@available)
|
|
29
|
+
@available = system("which claude > /dev/null 2>&1")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# tools: comma-separated read-only tool list (e.g. "Read,Glob,Grep") with
|
|
33
|
+
# cwd pointing at the repo. Default remains tool-less single-turn.
|
|
34
|
+
# resume: continue an existing claude session (context carries over).
|
|
35
|
+
# with_session: return [text, session_id] instead of just text, so callers
|
|
36
|
+
# can keep a continuing conversation (the planning assistant does).
|
|
37
|
+
def self.prompt(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1,
|
|
38
|
+
resume: nil, with_session: false)
|
|
39
|
+
raise Error.new("claude CLI not found on PATH") unless available?
|
|
40
|
+
|
|
41
|
+
json = invoke(text, system:, model:, tools:, cwd:, max_turns:, resume:)
|
|
42
|
+
if success?(json)
|
|
43
|
+
return with_session ? [json["result"].to_s, json["session_id"]] : json["result"].to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Ran out of turns mid-exploration: resume the same session tool-less and
|
|
47
|
+
# force an answer from the context it already gathered.
|
|
48
|
+
if json["subtype"] == "error_max_turns" && json["session_id"].present?
|
|
49
|
+
wrapped = invoke(WRAP_UP, model:, cwd:, tools: "", max_turns: 2, resume: json["session_id"])
|
|
50
|
+
if success?(wrapped)
|
|
51
|
+
return with_session ? [wrapped["result"].to_s, wrapped["session_id"] || json["session_id"]] : wrapped["result"].to_s
|
|
52
|
+
end
|
|
53
|
+
raise Error.new("ran out of working turns and couldn't wrap up — try again, or simplify the ask",
|
|
54
|
+
detail: wrapped.to_json)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
raise Error.new(friendly_failure(json), detail: json.to_json)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.success?(json)
|
|
61
|
+
json["subtype"] == "success" && !json["is_error"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.friendly_failure(json)
|
|
65
|
+
case json["subtype"]
|
|
66
|
+
when "error_max_turns" then "ran out of working turns before finishing"
|
|
67
|
+
when "error_during_execution" then "hit an internal error while working"
|
|
68
|
+
else "failed (#{json["subtype"].presence || "unknown error"})"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.invoke(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil)
|
|
73
|
+
cmd = ["claude", "-p", text, "--output-format", "json",
|
|
74
|
+
"--max-turns", max_turns.to_s, "--tools", tools.presence || ""]
|
|
75
|
+
cmd += ["--append-system-prompt", system] if system.present?
|
|
76
|
+
cmd += ["--model", model] if model.present?
|
|
77
|
+
cmd += ["--resume", resume] if resume.present?
|
|
78
|
+
|
|
79
|
+
env = STRIP_ENV.index_with { nil }
|
|
80
|
+
env["ANTHROPIC_API_KEY"] = nil if ENV["ANTHROPIC_API_KEY"].blank?
|
|
81
|
+
|
|
82
|
+
spawn_opts = cwd.present? && Dir.exist?(cwd) ? { chdir: cwd } : {}
|
|
83
|
+
out, err, status = Open3.capture3(env, *cmd, **spawn_opts)
|
|
84
|
+
JSON.parse(out)
|
|
85
|
+
rescue JSON::ParserError
|
|
86
|
+
raise Error.new("claude produced no readable result (exit #{status&.exitstatus || "?"})",
|
|
87
|
+
detail: [err, out].compact_blank.join("\n---\n").truncate(1500))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Rules
|
|
2
|
+
# Turns a plain-English description of a column's on-entry behavior into the
|
|
3
|
+
# rule actions the dispatcher executes (§17). English is the source of
|
|
4
|
+
# truth; the compiled JSON is stored alongside it and shown read-only.
|
|
5
|
+
module Compiler
|
|
6
|
+
Error = Class.new(StandardError)
|
|
7
|
+
|
|
8
|
+
VOCABULARY = <<~DOC.freeze
|
|
9
|
+
Available actions:
|
|
10
|
+
- {"action": "assistant_greeting"} — the planning assistant posts an opening message
|
|
11
|
+
- {"action": "start_agent_run"} — assign a dedicated worker agent to the card and start a run
|
|
12
|
+
- {"action": "ai_task", "prompt": "...", "model": "optional-model-id"} — a one-shot AI maintenance
|
|
13
|
+
task; the prompt may use %{title}, %{description}, %{conversation}; its output is posted to the
|
|
14
|
+
card timeline
|
|
15
|
+
- {"action": "mark_pr_ready"} — take the card's PR out of draft (ready for review on GitHub)
|
|
16
|
+
- {"action": "merge_pr"} — mark the card's PR ready, squash-merge it, delete the branch
|
|
17
|
+
- {"action": "set_status", "status": "..."} — force a card status
|
|
18
|
+
DOC
|
|
19
|
+
|
|
20
|
+
def self.compile(text)
|
|
21
|
+
raise Error, "Rules compiler needs the claude CLI — use the advanced JSON editor instead." unless ClaudeCli.available?
|
|
22
|
+
|
|
23
|
+
raw = ClaudeCli.prompt(
|
|
24
|
+
text,
|
|
25
|
+
model: AssistantReplyJob::FALLBACK_MODEL,
|
|
26
|
+
system: <<~SYS
|
|
27
|
+
You compile plain-English descriptions of Kanban column automation into JSON rule
|
|
28
|
+
arrays for the Cardinal board engine.
|
|
29
|
+
|
|
30
|
+
#{VOCABULARY}
|
|
31
|
+
Respond with ONLY the JSON array — no prose, no code fences. If the description
|
|
32
|
+
asks for something outside the vocabulary, approximate it with an ai_task whose
|
|
33
|
+
prompt captures the intent.
|
|
34
|
+
SYS
|
|
35
|
+
).strip
|
|
36
|
+
raw = raw.sub(/\A```(?:json)?\s*/, "").sub(/```\z/, "").strip
|
|
37
|
+
rules = JSON.parse(raw)
|
|
38
|
+
validate!(rules)
|
|
39
|
+
rules
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
raise Error, "Compiler returned invalid JSON — try rephrasing, or use the advanced editor."
|
|
42
|
+
rescue ClaudeCli::Error => e
|
|
43
|
+
raise Error, "Compiler call failed: #{e.message.truncate(120)}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.validate!(rules)
|
|
47
|
+
raise Error, "Expected a JSON array of rules" unless rules.is_a?(Array)
|
|
48
|
+
known = %w[assistant_greeting start_agent_run ai_task mark_pr_ready merge_pr set_status]
|
|
49
|
+
rules.each do |rule|
|
|
50
|
+
raise Error, "Each rule must be an object with an \"action\"" unless rule.is_a?(Hash) && rule["action"].present?
|
|
51
|
+
raise Error, "Unknown action #{rule["action"].inspect}" unless known.include?(rule["action"])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Column rules (cardinal.md §17): a column's on_entry policy is a list of rule
|
|
2
|
+
# actions fired when a card lands in it. Archetypes only supply defaults —
|
|
3
|
+
# any column can carry any rules, including one-shot AI maintenance tasks.
|
|
4
|
+
module Rules
|
|
5
|
+
DEFAULTS = {
|
|
6
|
+
"planning" => [{ "action" => "assistant_greeting" }],
|
|
7
|
+
"execution" => [{ "action" => "start_agent_run" }],
|
|
8
|
+
"terminal" => [{ "action" => "merge_pr" }]
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
# Shown in the gear modal so the archetype's built-in behavior is visible,
|
|
12
|
+
# not implied (the on-entry box being blank doesn't mean nothing happens).
|
|
13
|
+
DEFAULT_DESCRIPTIONS = {
|
|
14
|
+
"inbox" => "Nothing — cards park here untouched.",
|
|
15
|
+
"planning" => "The planning assistant inspects the card and opens the conversation: it reads the title and description, then asks its sharpest clarifying questions to improve the card before execution. Tune its focus with the Instructions field above.",
|
|
16
|
+
"execution" => "A dedicated worker agent is assigned to the card and a run starts (plan-first if plan approval is on).",
|
|
17
|
+
"review" => "Nothing automatic — the card waits for your verdict.",
|
|
18
|
+
"terminal" => "The card's PR is marked ready, squash-merged, and its branch deleted."
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.fire_entry(card, column)
|
|
22
|
+
each_rule(column.policy["on_entry"], column.archetype) do |rule|
|
|
23
|
+
apply(rule, card, column)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.each_rule(configured, archetype, &block)
|
|
28
|
+
rules = configured.presence || DEFAULTS[archetype] || []
|
|
29
|
+
rules = [rules] if rules.is_a?(Hash) || rules.is_a?(String)
|
|
30
|
+
rules.map { |r| r.is_a?(String) ? { "action" => r } : r }.each(&block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.apply(rule, card, column)
|
|
34
|
+
case rule["action"]
|
|
35
|
+
when "assistant_greeting"
|
|
36
|
+
# Contextual opener: the assistant reads the card and asks targeted
|
|
37
|
+
# questions (AssistantReplyJob falls back to a canned line without a key).
|
|
38
|
+
AssistantReplyJob.perform_later(card, kickoff: true)
|
|
39
|
+
when "start_agent_run"
|
|
40
|
+
card.update!(branch_name: card.branch_name.presence || card.default_branch_name)
|
|
41
|
+
card.log!("status_change", text: "Queued for execution on #{card.branch_name}")
|
|
42
|
+
StartRunJob.perform_later(card.id)
|
|
43
|
+
when "ai_task"
|
|
44
|
+
# One-shot maintenance agent: a bounded Messages API call whose prompt
|
|
45
|
+
# comes from the rule config. No workspace, no session, no tools.
|
|
46
|
+
AiTaskJob.perform_later(card.id, rule["prompt"].to_s, rule["model"])
|
|
47
|
+
when "mark_pr_ready"
|
|
48
|
+
if card.pr_url.present?
|
|
49
|
+
card.log!("status_change", text: "Taking the PR out of draft…")
|
|
50
|
+
MarkPrReadyJob.perform_later(card.id)
|
|
51
|
+
else
|
|
52
|
+
card.log!("status_change", text: "No PR to mark ready")
|
|
53
|
+
end
|
|
54
|
+
when "merge_pr"
|
|
55
|
+
if card.pr_url.present?
|
|
56
|
+
card.log!("status_change", text: "Shipping: merging #{card.pr_url}")
|
|
57
|
+
MergePrJob.perform_later(card.id)
|
|
58
|
+
else
|
|
59
|
+
card.log!("status_change", text: "Card finalized (no PR to merge)")
|
|
60
|
+
end
|
|
61
|
+
when "set_status"
|
|
62
|
+
card.update!(status: rule["status"]) if Card::STATUSES.include?(rule["status"])
|
|
63
|
+
else
|
|
64
|
+
card.log!("error", text: "Unknown column rule: #{rule["action"].inspect}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Reliability layer (cardinal.md §11): no run may stay "running" without a
|
|
2
|
+
# live process behind it. The server boots a sweeper thread (see
|
|
3
|
+
# config/initializers/run_sweeper.rb) that fails silent runs and unsticks
|
|
4
|
+
# their cards, then re-kicks execution queues.
|
|
5
|
+
module RunSweeper
|
|
6
|
+
HEARTBEAT_GRACE = 3.minutes
|
|
7
|
+
|
|
8
|
+
def self.sweep
|
|
9
|
+
fail_dead_runs
|
|
10
|
+
repair_stuck_cards
|
|
11
|
+
kick_queues
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.fail_dead_runs
|
|
15
|
+
Run.where(status: %w[queued running]).find_each do |run|
|
|
16
|
+
next if alive?(run)
|
|
17
|
+
next if run.heartbeat_at && run.heartbeat_at > HEARTBEAT_GRACE.ago
|
|
18
|
+
next if run.heartbeat_at.nil? && run.created_at > HEARTBEAT_GRACE.ago
|
|
19
|
+
|
|
20
|
+
run.update!(status: "failed", finished_at: Time.current,
|
|
21
|
+
result_summary: "Runner died without finishing (swept)")
|
|
22
|
+
card = run.card
|
|
23
|
+
if card.working? || card.queued?
|
|
24
|
+
card.update!(status: "failed")
|
|
25
|
+
card.log!("error", run: run, text: "Run ##{run.id} lost its runner process and was marked failed. Retry by dragging the card out and back into the column.")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Cards left "working" with no live or recorded run — e.g. a crash between
|
|
31
|
+
# state writes.
|
|
32
|
+
def self.repair_stuck_cards
|
|
33
|
+
Card.where(status: "working").find_each do |card|
|
|
34
|
+
next if card.runs.where(status: %w[queued running needs_input]).any? { |r| r.needs_input? || alive?(r) }
|
|
35
|
+
card.update!(status: "failed")
|
|
36
|
+
card.log!("error", text: "Card was stuck working with no live run; marked failed.")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.kick_queues
|
|
41
|
+
Column.where(archetype: "execution").find_each(&:kick_queue)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.alive?(run)
|
|
45
|
+
pid = run.agent_session&.config&.dig("pid")
|
|
46
|
+
return false if pid.blank?
|
|
47
|
+
Process.kill(0, Integer(pid))
|
|
48
|
+
true
|
|
49
|
+
rescue Errno::ESRCH, Errno::EPERM, ArgumentError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<%= turbo_stream_from @board %>
|
|
2
|
+
|
|
3
|
+
<header class="topbar">
|
|
4
|
+
<h1>Cardinal AI <span class="sep">▸</span> <%= @board.name %></h1>
|
|
5
|
+
<div class="topbar-right">
|
|
6
|
+
<button type="button" class="theme-toggle"
|
|
7
|
+
data-controller="theme" data-action="theme#toggle">☀ Light</button>
|
|
8
|
+
<% if ENV["CARDINAL_AUTH"].present? %>
|
|
9
|
+
<span class="auth-chip" title="<%= ENV["CARDINAL_AUTH"] == "dedicated" ? "This board runs as its own linked Claude account (.cardinal/claude). Switch with: cardinal login" : "This board inherits the machine's claude login (CARDINAL_INHERIT_AUTH=1)" %>">
|
|
10
|
+
🔐 <%= ENV["CARDINAL_AUTH"] == "dedicated" ? "board account" : "machine account" %>
|
|
11
|
+
</span>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% attention = @board.attention_cards %>
|
|
14
|
+
<% working = @board.cards.where(status: "working").order(:updated_at) %>
|
|
15
|
+
<% queued = @board.cards.where(status: "queued").order(:position) %>
|
|
16
|
+
<% if attention.any? || working.any? || queued.any? %>
|
|
17
|
+
<details class="attention">
|
|
18
|
+
<summary>
|
|
19
|
+
<% if attention.any? %><span class="attn-part">⚠ <%= attention.size %> need you</span><% end %>
|
|
20
|
+
<% if working.any? %><span class="attn-part working-part"><span class="pulse-dot"></span> <%= working.size %> working</span><% end %>
|
|
21
|
+
<% if queued.any? %><span class="attn-part">⏳ <%= queued.size %> queued</span><% end %>
|
|
22
|
+
</summary>
|
|
23
|
+
<div class="attention-list">
|
|
24
|
+
<% if attention.any? %>
|
|
25
|
+
<p class="attn-header">Needs you</p>
|
|
26
|
+
<ul>
|
|
27
|
+
<% attention.each do |card| %>
|
|
28
|
+
<li><%= link_to "##{card.number} #{card.title} — #{card.status.humanize.downcase}",
|
|
29
|
+
card_path(card), data: { turbo_frame: "modal", turbo_action: "advance" } %></li>
|
|
30
|
+
<% end %>
|
|
31
|
+
</ul>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% if working.any? %>
|
|
34
|
+
<p class="attn-header">Working</p>
|
|
35
|
+
<ul>
|
|
36
|
+
<% working.each do |card| %>
|
|
37
|
+
<li class="attn-working">
|
|
38
|
+
<span class="pulse-dot"></span>
|
|
39
|
+
<%= link_to card_path(card), data: { turbo_frame: "modal", turbo_action: "advance" } do %>
|
|
40
|
+
#<%= card.number %> <%= card.title %><span class="hint"> — <%= card.latest_progress&.truncate(60) || "starting…" %></span>
|
|
41
|
+
<% end %>
|
|
42
|
+
</li>
|
|
43
|
+
<% end %>
|
|
44
|
+
</ul>
|
|
45
|
+
<% end %>
|
|
46
|
+
<% if queued.any? %>
|
|
47
|
+
<p class="attn-header">Queued</p>
|
|
48
|
+
<ul>
|
|
49
|
+
<% queued.each_with_index do |card, index| %>
|
|
50
|
+
<li><%= link_to "##{card.number} #{card.title} — #{index.zero? ? "next up" : "#{index} ahead"}",
|
|
51
|
+
card_path(card), data: { turbo_frame: "modal", turbo_action: "advance" } %></li>
|
|
52
|
+
<% end %>
|
|
53
|
+
</ul>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
56
|
+
</details>
|
|
57
|
+
<% end %>
|
|
58
|
+
<details class="new-column">
|
|
59
|
+
<summary>+ Column</summary>
|
|
60
|
+
<%= form_with url: columns_path, class: "new-column-form" do |f| %>
|
|
61
|
+
<%= f.text_field "column[name]", placeholder: "Column name", required: true %>
|
|
62
|
+
<%# Inbox is the board's single intake — it can't be created a second time (card #17). %>
|
|
63
|
+
<%= f.select "column[archetype]",
|
|
64
|
+
(Column::ARCHETYPES - %w[inbox]).map { |a| [a.capitalize, a] }, selected: "planning" %>
|
|
65
|
+
<%= f.submit "Add" %>
|
|
66
|
+
<% end %>
|
|
67
|
+
</details>
|
|
68
|
+
</div>
|
|
69
|
+
</header>
|
|
70
|
+
|
|
71
|
+
<main class="board">
|
|
72
|
+
<% @board.columns.each do |column| %>
|
|
73
|
+
<%= render "columns/column", column: column %>
|
|
74
|
+
<% end %>
|
|
75
|
+
</main>
|
|
76
|
+
|
|
77
|
+
<%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
|
|
78
|
+
<%= render "cards/detail" if @card %>
|
|
79
|
+
<% end %>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<article class="card status-<%= card.status %>" id="<%= dom_id(card) %>" data-card-id="<%= card.number %>">
|
|
2
|
+
<%= link_to card_path(card), class: "card-link", data: { turbo_frame: "modal", turbo_action: "advance" } do %>
|
|
3
|
+
<div class="card-title">
|
|
4
|
+
<%= card.title %>
|
|
5
|
+
<span class="status-glyph"><%= { "working" => "⚡", "needs_input" => "❓", "failed" => "✖",
|
|
6
|
+
"work_complete" => "✅", "done" => "✓", "queued" => "⏳",
|
|
7
|
+
"discussing" => "💬", "in_review" => "👁", "approved" => "👍",
|
|
8
|
+
"changes_requested" => "🔁" }[card.status] %></span>
|
|
9
|
+
</div>
|
|
10
|
+
<% if card.queued? %>
|
|
11
|
+
<% ahead = card.column.cards.where(status: "queued").where("position < ?", card.position).count %>
|
|
12
|
+
<p class="card-progress">⏳ queued<%= ahead.positive? ? " — #{ahead} ahead" : " — next up" %></p>
|
|
13
|
+
<% elsif card.working? %>
|
|
14
|
+
<p class="card-progress working-line"><span class="spinner"></span> <%= card.latest_progress || "agent starting…" %></p>
|
|
15
|
+
<% elsif card.latest_progress && card.running? %>
|
|
16
|
+
<p class="card-progress">▸ <%= card.latest_progress %></p>
|
|
17
|
+
<% elsif card.approved? %>
|
|
18
|
+
<p class="card-progress approved-text">👍 approved — drag to Done to ship</p>
|
|
19
|
+
<% elsif card.changes_requested? %>
|
|
20
|
+
<p class="card-progress attention-text">🔁 changes requested</p>
|
|
21
|
+
<% elsif card.needs_attention? %>
|
|
22
|
+
<p class="card-progress attention-text"><%= card.status.humanize %></p>
|
|
23
|
+
<% end %>
|
|
24
|
+
<div class="card-meta">
|
|
25
|
+
<% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
|
|
26
|
+
<% if card.running? && card.column.model %>
|
|
27
|
+
<span class="chip agent-chip">🤖 <%= card.column.model_short %><%= " · #{card.column.effort}" if card.column.effort %></span>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% if card.awaiting_assistant? %>
|
|
30
|
+
<span class="chip agent-chip thinking-chip">🪶 <span class="typing-dots mini"><span></span><span></span><span></span></span></span>
|
|
31
|
+
<% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
|
|
32
|
+
<span class="chip agent-chip">🪶 replied</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if (card.running? || card.needs_attention?) && (last_run = card.runs.order(:id).last) && (last_run.cost.to_f.positive? || last_run.output_tokens.positive?) %>
|
|
35
|
+
<span class="chip">$<%= last_run.cost.round(2) %><%= " · #{(last_run.output_tokens / 1000.0).round(1)}k out" if card.working? %></span>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% if card.parent_id %><span class="chip">↑ sub</span><% end %>
|
|
38
|
+
<% if card.children.any? %><span class="chip">↳ <%= card.children.where(status: %w[done archived]).count %>/<%= card.children.count %></span><% end %>
|
|
39
|
+
<% if card.branch_name && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
42
|
+
<% if card.pr_url %>
|
|
43
|
+
<a class="card-footer" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">
|
|
44
|
+
<span class="footer-left"><%# future: Asana / Trello / linked-ticket slot %></span>
|
|
45
|
+
<span class="footer-pr">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</span>
|
|
46
|
+
</a>
|
|
47
|
+
<% end %>
|
|
48
|
+
</article>
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
|
|
2
|
+
<div class="modal card-detail">
|
|
3
|
+
<%= turbo_stream_from @card %>
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>
|
|
6
|
+
<%= @card.title %>
|
|
7
|
+
<span class="chip status-chip status-<%= @card.status %>"><%= @card.status.humanize %></span>
|
|
8
|
+
<span class="card-number-sub" title="Card number — how branches, PRs, and other cards refer to this card">#<%= @card.number %></span>
|
|
9
|
+
</h1>
|
|
10
|
+
<div class="modal-header-right">
|
|
11
|
+
<% if @card.branch_name %>
|
|
12
|
+
<span class="git-line">
|
|
13
|
+
<span class="branch-base"><%= @card.board.default_branch %></span>
|
|
14
|
+
<span class="git-arrow">→</span>
|
|
15
|
+
<span class="branch-pill" data-controller="clipboard" data-clipboard-text-value="<%= @card.branch_name %>">
|
|
16
|
+
<code><%= @card.branch_name %></code>
|
|
17
|
+
<button type="button" class="copy-btn" data-clipboard-target="button"
|
|
18
|
+
data-action="clipboard#copy" title="Copy branch name">⧉</button>
|
|
19
|
+
</span>
|
|
20
|
+
<% if @card.pr_url %>
|
|
21
|
+
<span class="git-arrow">→</span>
|
|
22
|
+
<%= link_to "##{@card.pr_url[%r{/pull/(\d+)}, 1]}", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-link" %>
|
|
23
|
+
<% if @card.pr_state.present? %><span class="pr-state">(<%= @card.pr_state %>)</span><% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</span>
|
|
26
|
+
<% end %>
|
|
27
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
|
|
28
|
+
</div>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
<div class="detail-panes">
|
|
32
|
+
<section class="timeline" data-controller="scroll">
|
|
33
|
+
<nav class="zoom-tabs">
|
|
34
|
+
<% %w[conversation activity debug].each do |zoom| %>
|
|
35
|
+
<%= link_to zoom.capitalize, card_path(@card, zoom: zoom),
|
|
36
|
+
class: ("active" if @zoom == zoom) %>
|
|
37
|
+
<% end %>
|
|
38
|
+
</nav>
|
|
39
|
+
|
|
40
|
+
<div class="timeline-scroll" data-scroll-target="scroller">
|
|
41
|
+
<% if @card.description.present? %>
|
|
42
|
+
<div class="event event-description"><%= render_markdown @card.description %></div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<div id="card_events">
|
|
46
|
+
<%= render partial: "events/event", collection: @events %>
|
|
47
|
+
<% if @events.empty? %>
|
|
48
|
+
<p class="empty">No events yet — this card hasn't been anywhere.</p>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<% if @card.thinking? %>
|
|
53
|
+
<div class="event typing" id="typing-indicator">
|
|
54
|
+
<span class="event-actor"><%= @card.working? ? "🤖" : "🪶" %></span>
|
|
55
|
+
<div class="typing-dots"><span></span><span></span><span></span></div>
|
|
56
|
+
</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<button type="button" class="new-messages-pill" data-scroll-target="pill"
|
|
61
|
+
data-action="scroll#jump">↓ New messages</button>
|
|
62
|
+
|
|
63
|
+
<%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
|
|
64
|
+
<%= f.text_area "message[text]", rows: 2, required: true,
|
|
65
|
+
data: { controller: "composer", action: "keydown->composer#keydown" },
|
|
66
|
+
placeholder: (@card.column.planning? ? "Discuss this card with the planning assistant…" : "Add a note to this card…") + " (Enter sends, Shift+Enter for a new line)" %>
|
|
67
|
+
<% end %>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<aside class="work-panel">
|
|
71
|
+
<div data-controller="autosave">
|
|
72
|
+
<h3>Details <span class="autosave-status" data-autosave-target="status"></span></h3>
|
|
73
|
+
<%= form_with model: @card, class: "card-edit",
|
|
74
|
+
data: { autosave_target: "form", action: "input->autosave#save change->autosave#save" } do |f| %>
|
|
75
|
+
<%= hidden_field_tag :autosave, "1" %>
|
|
76
|
+
<label>Title</label>
|
|
77
|
+
<%= f.text_field :title, required: true %>
|
|
78
|
+
<label>Tags</label>
|
|
79
|
+
<%= render "cards/tag_picker", board: @card.board, tags: @card.tags %>
|
|
80
|
+
<label>Description</label>
|
|
81
|
+
<%= f.text_area :description, rows: 6 %>
|
|
82
|
+
<label>Branch <span class="hint">— optional</span></label>
|
|
83
|
+
<% if @card.branch_name.present? %>
|
|
84
|
+
<%# Locked once set — by the user here or by the agent on run start. %>
|
|
85
|
+
<p class="locked-field"><code><%= @card.branch_name %></code> <span class="hint">set</span></p>
|
|
86
|
+
<% else %>
|
|
87
|
+
<%= f.text_field :branch_name, placeholder: @card.default_branch_name %>
|
|
88
|
+
<% end %>
|
|
89
|
+
<label>Pull request <span class="hint">— optional</span></label>
|
|
90
|
+
<% if @card.pr_url.present? %>
|
|
91
|
+
<p class="locked-field"><%= link_to @card.pr_url, @card.pr_url, target: "_blank", rel: "noopener" %> <span class="hint">set</span></p>
|
|
92
|
+
<% else %>
|
|
93
|
+
<%= f.text_field :pr_url, placeholder: "https://github.com/owner/repo/pull/123" %>
|
|
94
|
+
<% end %>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<% if @card.parent || @card.children.any? %>
|
|
99
|
+
<h3>Related</h3>
|
|
100
|
+
<ul class="related-list">
|
|
101
|
+
<% if @card.parent %>
|
|
102
|
+
<li>↑ <%= link_to @card.parent.title, card_path(@card.parent), data: { turbo_frame: "modal", turbo_action: "advance" } %>
|
|
103
|
+
<span class="hint"><%= @card.parent.status.humanize.downcase %></span></li>
|
|
104
|
+
<% end %>
|
|
105
|
+
<% @card.children.each do |child| %>
|
|
106
|
+
<li>↳ <%= link_to child.title, card_path(child), data: { turbo_frame: "modal", turbo_action: "advance" } %>
|
|
107
|
+
<span class="hint"><%= child.status.humanize.downcase %></span></li>
|
|
108
|
+
<% end %>
|
|
109
|
+
</ul>
|
|
110
|
+
<% end %>
|
|
111
|
+
<%= link_to "+ Child card", new_card_path(parent_id: @card.id),
|
|
112
|
+
class: "child-card-link", data: { turbo_frame: "modal" } %>
|
|
113
|
+
|
|
114
|
+
<% if @card.column.review? %>
|
|
115
|
+
<h3>Review</h3>
|
|
116
|
+
<% if @card.pr_url %>
|
|
117
|
+
<%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
|
|
118
|
+
<% end %>
|
|
119
|
+
<% if @card.in_review? %>
|
|
120
|
+
<div class="panel-callout callout-plan">
|
|
121
|
+
<p><strong>Your verdict.</strong> Check the final report in the timeline.
|
|
122
|
+
Approve, or just say what's wrong in the conversation — that marks it changes-requested,
|
|
123
|
+
and dragging the card back to a work column carries your feedback into the next run.</p>
|
|
124
|
+
<%= button_to "✅ Approve", approve_card_path(@card), class: "approve-btn", form_class: "align-right" %>
|
|
125
|
+
</div>
|
|
126
|
+
<% elsif @card.approved? %>
|
|
127
|
+
<div class="panel-callout callout-plan"><p><strong>Approved.</strong> Drag the card to Done to merge and ship.</p></div>
|
|
128
|
+
<% elsif @card.changes_requested? %>
|
|
129
|
+
<div class="panel-callout callout-question"><p><strong>Changes requested.</strong> Drag the card back to an execution column for a revision run — your conversation feedback rides along.</p></div>
|
|
130
|
+
<% end %>
|
|
131
|
+
<% end %>
|
|
132
|
+
|
|
133
|
+
<h3>Work</h3>
|
|
134
|
+
<% runs = @card.runs.order(:id) %>
|
|
135
|
+
<% parked = runs.select(&:needs_input?).last %>
|
|
136
|
+
<% latest = runs.last %>
|
|
137
|
+
<% if parked&.phase == "plan" %>
|
|
138
|
+
<div class="panel-callout callout-plan">
|
|
139
|
+
<p><strong>Plan proposed.</strong> Approve to let the agent execute, or reply in the timeline to redirect.</p>
|
|
140
|
+
<%= button_to "👍 Approve plan", approve_run_path(parked), class: "approve-btn", form_class: "align-right" %>
|
|
141
|
+
</div>
|
|
142
|
+
<% elsif parked&.restartable? %>
|
|
143
|
+
<div class="panel-callout callout-restart">
|
|
144
|
+
<p><strong>Run hit its turn budget.</strong> Restart to continue with a fresh budget, or raise <strong>Max turns</strong> in the column's gear settings.</p>
|
|
145
|
+
<%= button_to "🔄 Restart run", restart_run_path(parked), class: "restart-btn", form_class: "align-right" %>
|
|
146
|
+
</div>
|
|
147
|
+
<% elsif parked %>
|
|
148
|
+
<div class="panel-callout callout-question">
|
|
149
|
+
<p><strong>The agent has a question</strong> — answer it in the timeline message box.</p>
|
|
150
|
+
</div>
|
|
151
|
+
<% elsif latest&.failed? && latest.restartable? %>
|
|
152
|
+
<div class="panel-callout callout-restart">
|
|
153
|
+
<p><strong>Run failed on its budget.</strong> Restart to try again with a fresh budget, or raise the column's <strong>Max turns</strong> / timeout in its gear settings.</p>
|
|
154
|
+
<%= button_to "🔄 Restart run", restart_run_path(latest), class: "restart-btn", form_class: "align-right" %>
|
|
155
|
+
</div>
|
|
156
|
+
<% end %>
|
|
157
|
+
<% if runs.any? %>
|
|
158
|
+
<ul class="run-list">
|
|
159
|
+
<% runs.each do |run| %>
|
|
160
|
+
<li class="run run-<%= run.status %>">
|
|
161
|
+
<span>Run #<%= run.id %> — <%= run.status %><%= " (#{run.phase})" if run.needs_input? %></span>
|
|
162
|
+
<% if run.cost.positive? %>
|
|
163
|
+
<span class="hint"><%= run.output_tokens %> out · $<%= run.cost.round(2) %></span>
|
|
164
|
+
<% end %>
|
|
165
|
+
<% if run.running? || run.needs_input? %>
|
|
166
|
+
<%= button_to "✖ Cancel", cancel_run_path(run), class: "cancel-btn" %>
|
|
167
|
+
<% end %>
|
|
168
|
+
</li>
|
|
169
|
+
<% end %>
|
|
170
|
+
</ul>
|
|
171
|
+
<% if @card.pr_url %>
|
|
172
|
+
<p>🌿 <%= link_to "View pull request", @card.pr_url, target: "_blank" %><%= " (#{@card.pr_state})" if @card.pr_state.present? %></p>
|
|
173
|
+
<% end %>
|
|
174
|
+
<% else %>
|
|
175
|
+
<p class="empty">No runs yet — drag the card into an execution column to assign an agent.</p>
|
|
176
|
+
<% end %>
|
|
177
|
+
|
|
178
|
+
<details class="advanced-rules panel-advanced">
|
|
179
|
+
<summary>Advanced</summary>
|
|
180
|
+
<p class="hint">Deleting removes the card and its entire history (events, runs, workspace). The remote branch and PR, if any, are left untouched.</p>
|
|
181
|
+
<%= button_to "🗑 Delete card", card_path(@card), method: :delete,
|
|
182
|
+
class: "cancel-btn delete-card",
|
|
183
|
+
form: { data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess",
|
|
184
|
+
turbo_confirm: "Delete ##{@card.number} \"#{@card.title}\" and its entire history? This cannot be undone." } },
|
|
185
|
+
disabled: @card.working? %>
|
|
186
|
+
</details>
|
|
187
|
+
</aside>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# locals: board, tags (currently selected) %>
|
|
2
|
+
<div data-controller="tags">
|
|
3
|
+
<%= hidden_field_tag "card[tags]", tags.join(", "), data: { tags_target: "field" } %>
|
|
4
|
+
<div class="tag-chips" data-tags-target="chips">
|
|
5
|
+
<% (board.tag_pool | tags).each do |tag| %>
|
|
6
|
+
<button type="button" class="tag-chip <%= "on" if tags.include?(tag) %>"
|
|
7
|
+
data-tag="<%= tag %>" data-action="tags#toggle"><%= tag %></button>
|
|
8
|
+
<% end %>
|
|
9
|
+
</div>
|
|
10
|
+
<input type="text" class="new-tag-input" placeholder="+ new tag (Enter)"
|
|
11
|
+
data-tags-target="newTag" data-action="keydown->tags#keydown">
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
|
|
2
|
+
<div class="modal-backdrop" data-controller="modal" data-modal-sticky-value="true" data-action="click->modal#backdrop">
|
|
3
|
+
<div class="modal modal-sm">
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>New card<% if @parent %> <span class="hint">— child of “<%= @parent.title %>”</span><% end %></h1>
|
|
6
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Cancel">✕</button>
|
|
7
|
+
</header>
|
|
8
|
+
<div class="modal-body">
|
|
9
|
+
<%# _top: a full-page redirect after create — Turbo suppresses this
|
|
10
|
+
tab's own refresh broadcast, so a frame-scoped response would
|
|
11
|
+
leave the board stale behind the modal. %>
|
|
12
|
+
<%= form_with url: cards_path, class: "card-edit",
|
|
13
|
+
data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess" } do |f| %>
|
|
14
|
+
<%= hidden_field_tag "card[parent_id]", @parent&.id %>
|
|
15
|
+
<label>Title</label>
|
|
16
|
+
<%= f.text_field "card[title]", required: true, autofocus: true, placeholder: "What needs doing?" %>
|
|
17
|
+
<label>Tags</label>
|
|
18
|
+
<%= render "cards/tag_picker", board: @board, tags: [] %>
|
|
19
|
+
<label>Description</label>
|
|
20
|
+
<%= f.text_area "card[description]", rows: 8,
|
|
21
|
+
placeholder: "Goal, context, acceptance criteria — the planning assistant will help refine this." %>
|
|
22
|
+
<label>Branch <span class="hint">— optional</span></label>
|
|
23
|
+
<%= f.text_field "card[branch_name]", placeholder: "cardinal/#{@board.cards.maximum(:number).to_i + 1}-feature-name" %>
|
|
24
|
+
<label>Pull request <span class="hint">— optional</span></label>
|
|
25
|
+
<%= f.text_field "card[pr_url]", placeholder: "https://github.com/owner/repo/pull/123" %>
|
|
26
|
+
<p class="hint">Leave blank and the agent picks a branch. Set either to point work at an existing branch or PR.</p>
|
|
27
|
+
<div class="card-edit-actions">
|
|
28
|
+
<button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
|
|
29
|
+
<%= f.submit "Save" %>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|