cardinal-ai 0.0.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +50 -29
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/application.css +10 -0
  6. data/app/assets/stylesheets/cardinal.css +514 -0
  7. data/app/controllers/application_controller.rb +7 -0
  8. data/app/controllers/boards_controller.rb +5 -0
  9. data/app/controllers/cards_controller.rb +129 -0
  10. data/app/controllers/columns_controller.rb +95 -0
  11. data/app/controllers/messages_controller.rb +25 -0
  12. data/app/controllers/runs_controller.rb +58 -0
  13. data/app/helpers/application_helper.rb +35 -0
  14. data/app/javascript/application.js +2 -0
  15. data/app/javascript/controllers/application.js +7 -0
  16. data/app/javascript/controllers/autosave_controller.js +28 -0
  17. data/app/javascript/controllers/board_column_controller.js +96 -0
  18. data/app/javascript/controllers/clipboard_controller.js +18 -0
  19. data/app/javascript/controllers/composer_controller.js +10 -0
  20. data/app/javascript/controllers/index.js +3 -0
  21. data/app/javascript/controllers/modal_controller.js +43 -0
  22. data/app/javascript/controllers/scroll_controller.js +44 -0
  23. data/app/javascript/controllers/tags_controller.js +49 -0
  24. data/app/javascript/controllers/theme_controller.js +43 -0
  25. data/app/javascript/controllers/tooltip_controller.js +37 -0
  26. data/app/jobs/ai_task_job.rb +26 -0
  27. data/app/jobs/application_job.rb +7 -0
  28. data/app/jobs/assistant_reply_job.rb +132 -0
  29. data/app/jobs/mark_pr_ready_job.rb +18 -0
  30. data/app/jobs/merge_pr_job.rb +27 -0
  31. data/app/jobs/resume_run_job.rb +30 -0
  32. data/app/jobs/start_run_job.rb +13 -0
  33. data/app/mailers/application_mailer.rb +4 -0
  34. data/app/models/agent_session.rb +8 -0
  35. data/app/models/application_record.rb +3 -0
  36. data/app/models/artifact.rb +8 -0
  37. data/app/models/board.rb +60 -0
  38. data/app/models/card.rb +83 -0
  39. data/app/models/column.rb +83 -0
  40. data/app/models/event.rb +44 -0
  41. data/app/models/run.rb +28 -0
  42. data/app/services/agent/runner.rb +379 -0
  43. data/app/services/agent/workspace.rb +138 -0
  44. data/app/services/card_transition.rb +97 -0
  45. data/app/services/claude_cli.rb +89 -0
  46. data/app/services/rules/compiler.rb +55 -0
  47. data/app/services/rules.rb +67 -0
  48. data/app/services/run_sweeper.rb +52 -0
  49. data/app/views/boards/show.html.erb +79 -0
  50. data/app/views/cards/_card.html.erb +48 -0
  51. data/app/views/cards/_detail.html.erb +190 -0
  52. data/app/views/cards/_tag_picker.html.erb +12 -0
  53. data/app/views/cards/new.html.erb +35 -0
  54. data/app/views/cards/show.html.erb +3 -0
  55. data/app/views/columns/_column.html.erb +25 -0
  56. data/app/views/columns/edit.html.erb +126 -0
  57. data/app/views/events/_event.html.erb +29 -0
  58. data/app/views/layouts/application.html.erb +46 -0
  59. data/app/views/layouts/mailer.html.erb +13 -0
  60. data/app/views/layouts/mailer.text.erb +1 -0
  61. data/app/views/pwa/manifest.json.erb +22 -0
  62. data/app/views/pwa/service-worker.js +26 -0
  63. data/bin/rails +4 -0
  64. data/bin/rake +4 -0
  65. data/cardinal.md +686 -0
  66. data/config/application.rb +60 -0
  67. data/config/boot.rb +13 -0
  68. data/config/bundler-audit.yml +5 -0
  69. data/config/cable.yml +13 -0
  70. data/config/ci.rb +20 -0
  71. data/config/credentials.yml.enc +1 -0
  72. data/config/database.yml +31 -0
  73. data/config/environment.rb +5 -0
  74. data/config/environments/development.rb +78 -0
  75. data/config/environments/production.rb +89 -0
  76. data/config/environments/test.rb +53 -0
  77. data/config/importmap.rb +6 -0
  78. data/config/initializers/assets.rb +7 -0
  79. data/config/initializers/cardinal_bootstrap.rb +12 -0
  80. data/config/initializers/cardinal_instance.rb +20 -0
  81. data/config/initializers/content_security_policy.rb +29 -0
  82. data/config/initializers/filter_parameter_logging.rb +8 -0
  83. data/config/initializers/inflections.rb +16 -0
  84. data/config/initializers/run_sweeper.rb +17 -0
  85. data/config/locales/en.yml +31 -0
  86. data/config/puma.rb +42 -0
  87. data/config/routes.rb +22 -0
  88. data/config/storage.yml +27 -0
  89. data/config.ru +6 -0
  90. data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
  91. data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
  92. data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
  93. data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
  94. data/db/seeds.rb +19 -0
  95. data/docker/agent/Dockerfile +16 -0
  96. data/exe/cardinal +111 -0
  97. data/lib/cardinal/version.rb +1 -1
  98. data/public/400.html +135 -0
  99. data/public/404.html +135 -0
  100. data/public/406-unsupported-browser.html +135 -0
  101. data/public/422.html +135 -0
  102. data/public/500.html +135 -0
  103. data/public/icon.png +0 -0
  104. data/public/icon.svg +3 -0
  105. data/public/robots.txt +1 -0
  106. data/vendor/javascript/sortablejs.js +3378 -0
  107. metadata +235 -9
@@ -0,0 +1,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 %>
@@ -0,0 +1,3 @@
1
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
2
+ <%= render "cards/detail" %>
3
+ <% end %>