cardinal-ai 0.2.13 → 0.2.16

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/cardinal.css +69 -4
  3. data/app/controllers/boards_controller.rb +25 -1
  4. data/app/controllers/cards_controller.rb +21 -1
  5. data/app/controllers/messages_controller.rb +16 -1
  6. data/app/helpers/application_helper.rb +54 -0
  7. data/app/javascript/controllers/archive_drop_controller.js +41 -0
  8. data/app/javascript/controllers/attach_controller.js +80 -0
  9. data/app/javascript/controllers/board_column_controller.js +6 -0
  10. data/app/jobs/ai_task_job.rb +2 -1
  11. data/app/jobs/assistant_reply_job.rb +2 -1
  12. data/app/jobs/compact_job.rb +14 -1
  13. data/app/jobs/deep_dive_job.rb +2 -1
  14. data/app/jobs/resume_run_job.rb +8 -0
  15. data/app/jobs/summary_job.rb +11 -1
  16. data/app/models/ai_call.rb +10 -0
  17. data/app/models/board.rb +10 -0
  18. data/app/models/card.rb +26 -2
  19. data/app/models/column.rb +10 -4
  20. data/app/services/agent/runner.rb +18 -3
  21. data/app/services/claude_cli.rb +23 -1
  22. data/app/services/github_issues.rb +49 -0
  23. data/app/services/rules/compiler.rb +1 -0
  24. data/app/services/run_sweeper.rb +12 -3
  25. data/app/views/boards/archive.html.erb +56 -0
  26. data/app/views/boards/brief.html.erb +2 -0
  27. data/app/views/boards/issues.html.erb +41 -0
  28. data/app/views/boards/show.html.erb +8 -0
  29. data/app/views/cards/_card.html.erb +3 -1
  30. data/app/views/cards/_compact_panel.html.erb +5 -3
  31. data/app/views/cards/_detail.html.erb +15 -3
  32. data/app/views/cards/_summary_panel.html.erb +5 -3
  33. data/app/views/columns/_column.html.erb +8 -1
  34. data/app/views/events/_event.html.erb +2 -1
  35. data/config/routes.rb +5 -0
  36. data/db/cable_schema.rb +15 -3
  37. data/db/migrate/20260705120000_create_ai_calls.rb +17 -0
  38. data/db/migrate/20260705120001_add_issue_number_to_cards.rb +5 -0
  39. data/db/migrate/20260705193935_add_settings_to_boards.rb +6 -0
  40. data/db/queue_schema.rb +74 -62
  41. data/lib/cardinal/version.rb +1 -1
  42. metadata +10 -1
data/app/models/column.rb CHANGED
@@ -134,11 +134,12 @@ class Column < ApplicationRecord
134
134
  def footer_value(compute)
135
135
  case compute.to_s
136
136
  when "sum_cost"
137
- "$%.2f" % column_runs.sum(:cost)
137
+ "$%.2f" % (column_runs.sum(:cost) + column_ai_calls.sum(:cost))
138
138
  when "sum_tokens"
139
- ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
139
+ ActiveSupport::NumberHelper.number_to_delimited(
140
+ column_runs.sum("input_tokens + output_tokens") + column_ai_calls.sum("input_tokens + output_tokens"))
140
141
  when "count_cards"
141
- cards.count.to_s
142
+ cards.active.count.to_s
142
143
  when "model"
143
144
  # The column's active AI model, short form. Blank when AI is off or no
144
145
  # model is set — the row then shows just its label, telling the truth.
@@ -194,6 +195,11 @@ class Column < ApplicationRecord
194
195
 
195
196
  # Every run belonging to a card in this column, for footer aggregation.
196
197
  def column_runs
197
- Run.joins(agent_session: :card).where(cards: { column_id: id })
198
+ Run.joins(agent_session: :card).where(cards: { column_id: id }).where.not(cards: { status: "archived" })
199
+ end
200
+
201
+ # One-shot AI spend (assistant/ai_task/summary/…) of this column's cards.
202
+ def column_ai_calls
203
+ AiCall.where(card_id: cards.active.select(:id))
198
204
  end
199
205
  end
@@ -74,8 +74,12 @@ module Agent
74
74
  begin_segment!
75
75
  if run.phase == "plan" && approve
76
76
  run.update!(phase: "execute")
77
- stream_agent(prompt: "Your plan is approved execute it now.\n\n#{execute_rules}",
78
- mode: "execute", resuming: true)
77
+ # Approve-with-notes: an approval may carry final adjustments fold
78
+ # them into the execute prompt instead of silently dropping them.
79
+ prompt = ["Your plan is approved — execute it now.",
80
+ (message.present? ? "Notes from the user to fold in as you execute:\n\n#{message}" : nil),
81
+ execute_rules].compact.join("\n\n")
82
+ stream_agent(prompt: prompt, mode: "execute", resuming: true)
79
83
  elsif run.phase == "plan"
80
84
  stream_agent(prompt: "Feedback on your plan:\n\n#{message}\n\nRevise the plan accordingly, present it, and stop again for approval. Stay in read-only mode.",
81
85
  mode: "plan", resuming: true)
@@ -263,6 +267,10 @@ module Agent
263
267
  ensure_pull_request(workspace)
264
268
  end
265
269
 
270
+ if (undelivered = Array(run.briefing["steering"])).any?
271
+ card.log!("status_change", run: run,
272
+ text: "#{undelivered.size} queued note(s) never reached the agent — the run finished before its next check-in. They stay in the conversation, so the next run will see them.")
273
+ end
266
274
  run.update!(status: "succeeded", finished_at: Time.current,
267
275
  result_summary: report.presence&.truncate(2000))
268
276
  card.log!("final_report", actor: "agent", run: run,
@@ -331,13 +339,20 @@ module Agent
331
339
 
332
340
  def base_sha = run.briefing.fetch("base_sha")
333
341
 
342
+ # "Closes #N" makes GitHub close the source issue on merge (card #49).
343
+ def pr_body
344
+ [("Closes ##{card.issue_number}" if card.issue_number.present?),
345
+ "Automated work by Cardinal card ##{card.number}'s agent.",
346
+ card.description].compact.join("\n\n")
347
+ end
348
+
334
349
  def ensure_pull_request(workspace)
335
350
  return if card.pr_url.present?
336
351
  out, status = Open3.capture2e(
337
352
  "gh", "pr", "create", "--draft",
338
353
  "--head", card.branch_name,
339
354
  "--title", "##{card.number} #{card.title}",
340
- "--body", "Automated work by Cardinal card ##{card.number}'s agent.\n\n#{card.description}",
355
+ "--body", pr_body,
341
356
  chdir: workspace.path.to_s
342
357
  )
343
358
  if status.success? && (url = out[%r{https://github\.com/\S+/pull/\d+}])
@@ -34,11 +34,15 @@ module ClaudeCli
34
34
  # resume: continue an existing claude session (context carries over).
35
35
  # with_session: return [text, session_id] instead of just text, so callers
36
36
  # can keep a continuing conversation (the planning assistant does).
37
+ # ledger: { kind:, card: } — record this call's tokens/cost as an AiCall
38
+ # (§ money honesty: planning conversations and maintenance calls spend real
39
+ # dollars; only worker runs used to be counted).
37
40
  def self.prompt(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1,
38
- resume: nil, with_session: false)
41
+ resume: nil, with_session: false, ledger: nil)
39
42
  raise Error.new("claude CLI not found on PATH") unless available?
40
43
 
41
44
  json = invoke(text, system:, model:, tools:, cwd:, max_turns:, resume:)
45
+ record_usage!(json, ledger, model)
42
46
  if success?(json)
43
47
  return with_session ? [json["result"].to_s, json["session_id"]] : json["result"].to_s
44
48
  end
@@ -47,6 +51,7 @@ module ClaudeCli
47
51
  # force an answer from the context it already gathered.
48
52
  if json["subtype"] == "error_max_turns" && json["session_id"].present?
49
53
  wrapped = invoke(WRAP_UP, model:, cwd:, tools: "", max_turns: 2, resume: json["session_id"])
54
+ record_usage!(wrapped, ledger, model)
50
55
  if success?(wrapped)
51
56
  return with_session ? [wrapped["result"].to_s, wrapped["session_id"] || json["session_id"]] : wrapped["result"].to_s
52
57
  end
@@ -57,6 +62,23 @@ module ClaudeCli
57
62
  raise Error.new(friendly_failure(json), detail: json.to_json)
58
63
  end
59
64
 
65
+ # Best-effort by design: a ledger failure must never break the AI call that
66
+ # already succeeded. Failed calls are recorded too — they cost money.
67
+ def self.record_usage!(json, ledger, model)
68
+ return unless ledger.is_a?(Hash) && ledger[:kind].present?
69
+ usage = json["usage"] || {}
70
+ AiCall.create!(
71
+ card: ledger[:card],
72
+ kind: ledger[:kind].to_s,
73
+ model: json["model"] || model,
74
+ input_tokens: usage["input_tokens"].to_i,
75
+ output_tokens: usage["output_tokens"].to_i,
76
+ cost: json["total_cost_usd"].to_f
77
+ )
78
+ rescue StandardError => e
79
+ Rails.logger.warn("AiCall ledger write failed: #{e.class}: #{e.message}")
80
+ end
81
+
60
82
  def self.success?(json)
61
83
  json["subtype"] == "success" && !json["is_error"]
62
84
  end
@@ -0,0 +1,49 @@
1
+ # GitHub Issues sync (card #49): issues are the adjacent primitive to cards —
2
+ # gh is already authenticated, so import is a listing + a click, and closing
3
+ # happens naturally via "Closes #N" in the card's PR body.
4
+ module GithubIssues
5
+ Issue = Struct.new(:number, :title, :body, :labels, keyword_init: true)
6
+
7
+ def self.available?(board)
8
+ board.repo_url.present? && board.local_path.present?
9
+ end
10
+
11
+ # Open issues, newest first. Empty array on any failure (no remote, gh not
12
+ # authed, offline) — the modal explains instead of erroring.
13
+ def self.list(board)
14
+ out, status = Open3.capture2e(
15
+ "gh", "issue", "list", "--state", "open", "--limit", "50",
16
+ "--json", "number,title,body,labels", chdir: board.local_path
17
+ )
18
+ return [] unless status.success?
19
+ JSON.parse(out).map do |i|
20
+ Issue.new(number: i["number"], title: i["title"], body: i["body"].to_s,
21
+ labels: Array(i["labels"]).map { |l| l["name"] })
22
+ end
23
+ rescue JSON::ParserError
24
+ []
25
+ end
26
+
27
+ # One click → one card in the inbox, tagged with the issue's labels, body
28
+ # carried as the description with provenance. Best-effort backlink comment
29
+ # on the issue so the GitHub side knows where the work went.
30
+ def self.import!(board, number)
31
+ issue = list(board).find { |i| i.number == number.to_i }
32
+ raise ArgumentError, "issue ##{number} not found among open issues" unless issue
33
+
34
+ existing = board.cards.find_by(issue_number: issue.number)
35
+ return existing if existing
36
+
37
+ inbox = board.columns.inbox.order(:position).first
38
+ card = board.cards.create!(
39
+ column: inbox, title: issue.title, issue_number: issue.number,
40
+ tags: issue.labels.first(5),
41
+ description: "#{issue.body.presence || "(no issue body)"}\n\n---\n_Imported from GitHub issue ##{issue.number}._"
42
+ )
43
+ card.log!("status_change", actor: "user", text: "Imported from GitHub issue ##{issue.number}")
44
+ Open3.capture2e("gh", "issue", "comment", issue.number.to_s,
45
+ "--body", "Tracking in Cardinal as card ##{card.number}. The pull request, when opened, will link back and close this issue on merge.",
46
+ chdir: board.local_path)
47
+ card
48
+ end
49
+ end
@@ -22,6 +22,7 @@ module Rules
22
22
 
23
23
  raw = ClaudeCli.prompt(
24
24
  text,
25
+ ledger: { kind: "rules_compile" },
25
26
  model: AssistantReplyJob::FALLBACK_MODEL,
26
27
  system: <<~SYS
27
28
  You compile plain-English descriptions of Kanban column automation into JSON rule
@@ -14,8 +14,7 @@ module RunSweeper
14
14
  def self.fail_dead_runs
15
15
  Run.where(status: %w[queued running]).find_each do |run|
16
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
17
+ next if recently_active?(run)
19
18
 
20
19
  run.update!(status: "failed", finished_at: Time.current,
21
20
  result_summary: "Runner died without finishing (swept)")
@@ -32,7 +31,11 @@ module RunSweeper
32
31
  def self.repair_stuck_cards
33
32
  Card.where(status: "working").find_each do |card|
34
33
  next unless card.column.ai? # non-AI columns: "working" means a human is
35
- next if card.runs.where(status: %w[queued running needs_input]).any? { |r| r.needs_input? || alive?(r) }
34
+ # Same grace as fail_dead_runs: a freshly started run has no pid until
35
+ # AFTER workspace provisioning (clone/fetch) — recency is its proof of
36
+ # life, or every just-dragged card risks a bogus "stuck" verdict.
37
+ next if card.runs.where(status: %w[queued running needs_input])
38
+ .any? { |r| r.needs_input? || alive?(r) || recently_active?(r) }
36
39
  card.update!(status: "failed")
37
40
  card.log!("error", text: "Card was stuck working with no live run; marked failed.")
38
41
  end
@@ -42,6 +45,12 @@ module RunSweeper
42
45
  Column.where(archetype: "execution").find_each(&:kick_queue)
43
46
  end
44
47
 
48
+ # Between state writes (provisioning, spawn) a live run has no pid yet —
49
+ # a recent heartbeat or recent birth counts as alive.
50
+ def self.recently_active?(run)
51
+ (run.heartbeat_at || run.created_at) > HEARTBEAT_GRACE.ago
52
+ end
53
+
45
54
  def self.alive?(run)
46
55
  pid = run.agent_session&.config&.dig("pid")
47
56
  return false if pid.blank?
@@ -0,0 +1,56 @@
1
+ <header class="topbar">
2
+ <div class="topbar-left">
3
+ <h1><%= link_to "Cardinal AI", root_path %> <span class="sep">▸</span> <%= @board.name %> <span class="sep">▸</span> Archive</h1>
4
+ </div>
5
+ <div class="topbar-right">
6
+ <%= link_to "← Back to board", root_path, class: "theme-toggle" %>
7
+ </div>
8
+ </header>
9
+
10
+ <main class="archive-page" data-controller="filter">
11
+ <div class="archive-tools">
12
+ <input type="search" class="global-search" placeholder="Search the archive… /"
13
+ data-filter-target="global"
14
+ data-action="input->filter#global keydown->filter#clear">
15
+ <span class="hint"><%= pluralize(@cards.size, "archived card") %></span>
16
+ </div>
17
+
18
+ <div class="archive-rails" data-controller="autosave">
19
+ <%= form_with url: board_path(autosave: 1), method: :patch,
20
+ data: { autosave_target: "form" } do %>
21
+ <%= hidden_field_tag "board[archive_accepts_from][]", "" %>
22
+ <span class="rails-label">Drag-to-archive from:
23
+ <span class="autosave-status" data-autosave-target="status"></span></span>
24
+ <% @board.columns.each do |column| %>
25
+ <label class="check-row inline-check">
26
+ <%= check_box_tag "board[archive_accepts_from][]", column.id,
27
+ @board.archive_accepts?(column), data: { action: "change->autosave#save" } %>
28
+ <%= column.name %>
29
+ </label>
30
+ <% end %>
31
+ <span class="hint">Checked columns can drop cards straight onto the 🗄 button on the board. Explicit only — nothing checked means no drag target. The card's own Archive button always works.</span>
32
+ <% end %>
33
+ </div>
34
+
35
+ <% if @cards.any? %>
36
+ <ul class="archive-list">
37
+ <% @cards.each do |card| %>
38
+ <li class="card archive-row" data-search="<%= [card.title, "##{card.number}", Array(card.tags).join(" "), card.description.to_s.truncate(400)].join(" ").downcase %>">
39
+ <div class="archive-main">
40
+ <span class="archive-title">#<%= card.number %> <%= card.title %></span>
41
+ <span class="archive-meta">
42
+ <% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
43
+ from <%= card.column.name %> · archived <%= card.updated_at.strftime("%b %-d") %>
44
+ <% if card.total_cost.positive? %> · $<%= card.total_cost.round(2) %><% end %>
45
+ <% if card.pr_url.present? %> · <a href="<%= card.pr_url %>" target="_blank" rel="noopener">PR ↗</a><% end %>
46
+ </span>
47
+ </div>
48
+ <%= button_to "↩ Restore", unarchive_card_path(card), class: "theme-toggle restore-btn",
49
+ title: "Back to #{card.column.name}" %>
50
+ </li>
51
+ <% end %>
52
+ </ul>
53
+ <% else %>
54
+ <p class="empty">Nothing archived yet. Cards can be archived from their detail view (Advanced) once they're done.</p>
55
+ <% end %>
56
+ </main>
@@ -18,6 +18,8 @@
18
18
  Generated <%= @board.brief_generated_at&.strftime("%b %-d, %H:%M") %>
19
19
  from <code><%= @board.brief_sha&.first(7) %></code>
20
20
  with <%= @board.brief_model %>
21
+ <% dive_cost = AiCall.where(kind: "deep_dive").order(:id).last&.cost.to_f %>
22
+ <% if dive_cost.positive? %> for $<%= sprintf("%.2f", dive_cost) %><% end %>
21
23
  <% behind = @board.commits_behind_brief %>
22
24
  <% if behind&.positive? %>
23
25
  · <span class="brief-behind"><%= pluralize(behind, "commit") %> behind HEAD</span>
@@ -0,0 +1,41 @@
1
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
2
+ <div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
3
+ <div class="modal modal-sm">
4
+ <header class="modal-header">
5
+ <h1>⬇ GitHub Issues</h1>
6
+ <button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
7
+ </header>
8
+ <div class="modal-body">
9
+ <p class="hint">
10
+ Open issues on <code><%= @board.repo_url.presence || "(no remote)" %></code>. Importing
11
+ creates a Tasks card carrying the issue body and labels; the card's pull request will
12
+ say <code>Closes #N</code>, so merging through Done closes the issue on GitHub.
13
+ </p>
14
+
15
+ <% if @issues.any? %>
16
+ <ul class="issue-list">
17
+ <% @issues.each do |issue| %>
18
+ <li class="issue-row">
19
+ <div>
20
+ <strong>#<%= issue.number %></strong> <%= issue.title %>
21
+ <% issue.labels.first(4).each do |label| %><span class="tag"><%= label %></span><% end %>
22
+ </div>
23
+ <% if (card_number = @imported[issue.number]) %>
24
+ <%= link_to "card ##{card_number}", card_path(card_number),
25
+ class: "hint", data: { turbo_frame: "modal", turbo_action: "advance" } %>
26
+ <% else %>
27
+ <%= button_to "Import", import_issue_board_path(number: issue.number),
28
+ form: { data: { turbo_frame: "modal" } } %>
29
+ <% end %>
30
+ </li>
31
+ <% end %>
32
+ </ul>
33
+ <% elsif !GithubIssues.available?(@board) %>
34
+ <p class="empty">This board's repo has no GitHub remote — nothing to sync.</p>
35
+ <% else %>
36
+ <p class="empty">No open issues found (or gh isn't authenticated — try <code>gh auth status</code>).</p>
37
+ <% end %>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ <% end %>
@@ -9,6 +9,8 @@
9
9
  <%= button_to "⇣ Pull", pull_board_path, form_class: "pull-form",
10
10
  form: { title: "git pull --ff-only in #{@board.local_path} — fetch what Done has merged" },
11
11
  data: { turbo_submits_with: "⇣ Pulling…" }, class: "theme-toggle" %>
12
+ <%= link_to "⬇ Issues", issues_board_path, data: { turbo_frame: "modal" },
13
+ class: "theme-toggle", title: "Import open GitHub issues as cards" %>
12
14
  <%# Repo deep dive (card #12): one-shot read-only mapping of the repo, stored
13
15
  as a brief and injected into worker prompts. The button greys→reds as the
14
16
  brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+.
@@ -96,6 +98,12 @@
96
98
  </div>
97
99
  </details>
98
100
  <% end %>
101
+ <% archived_count = @board.cards.archived.count %>
102
+ <span class="archive-drop" data-controller="archive-drop"
103
+ data-accepts="<%= Array(@board.archive_accepts_from).join(",") %>">
104
+ <%= link_to "🗄#{" #{archived_count}" if archived_count.positive?}", archive_board_path,
105
+ class: "theme-toggle archive-link", title: "Archive — click to browse; drag a card here to archive it (allowed columns are set on the archive page)" %>
106
+ </span>
99
107
  <details class="new-column">
100
108
  <summary>+ Column</summary>
101
109
  <%= form_with url: columns_path, class: "new-column-form" do |f| %>
@@ -1,5 +1,5 @@
1
1
  <article class="card status-<%= card.status %>" id="<%= dom_id(card) %>" data-card-id="<%= card.number %>"
2
- data-search="<%= [card.title, "##{card.number}", Array(card.tags).join(" "), card.description.to_s.truncate(400)].join(" ").downcase %>">
2
+ data-search="<%= [card.title, "##{card.number}", Array(card.tags).join(" "), strip_attachment_tokens(card.description).truncate(400)].join(" ").downcase %>">
3
3
  <%= link_to card_path(card), class: "card-link", data: { turbo_frame: "modal", turbo_action: "advance" } do %>
4
4
  <div class="card-title">
5
5
  <%= card.title %>
@@ -23,6 +23,8 @@
23
23
  <% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
24
24
  <% if card.awaiting_assistant? %>
25
25
  <span class="chip agent-chip thinking-chip">🪶 <span class="typing-dots mini"><span></span><span></span><span></span></span></span>
26
+ <% elsif card.planning_ready? %>
27
+ <span class="chip agent-chip ready-chip" title="The assistant has posted a Ready-for-execution brief — drag onward when you are">🪶 ready</span>
26
28
  <% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
27
29
  <span class="chip agent-chip">🪶 replied</span>
28
30
  <% end %>
@@ -1,6 +1,10 @@
1
1
  <div id="card_compact" class="summary-panel compact-panel" data-controller="autosave">
2
2
  <div class="summary-head">
3
3
  <h3>Technical compact <span class="autosave-status" data-autosave-target="status"></span></h3>
4
+ <div class="summary-head-right">
5
+ <% if card.compact_generated_at.present? %>
6
+ <span class="summary-stamp">generated <%= time_ago_in_words(card.compact_generated_at) %> ago</span>
7
+ <% end %>
4
8
  <% if card.compact_working? %>
5
9
  <button type="button" class="deep-dive working summary-generate" disabled>
6
10
  <span class="pulse-dot"></span> Generating…
@@ -11,6 +15,7 @@
11
15
  ✨ <%= card.compact.present? ? "Regenerate" : "Generate compact" %>
12
16
  <% end %>
13
17
  <% end %>
18
+ </div>
14
19
  </div>
15
20
 
16
21
  <p class="hint summary-blurb">A dense, technical journal of what was built, decided, and blocked — the AI reads this to resume work without re-exploring the repo. Fully editable; your edits are respected when you regenerate.</p>
@@ -22,7 +27,4 @@
22
27
  disabled: card.compact_working? %>
23
28
  <% end %>
24
29
 
25
- <% if card.compact_generated_at.present? %>
26
- <p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.compact_generated_at) %> ago</p>
27
- <% end %>
28
30
  </div>
@@ -35,6 +35,9 @@
35
35
  <%= link_to zoom.capitalize, card_path(@card, zoom: zoom),
36
36
  class: ("active" if @zoom == zoom) %>
37
37
  <% end %>
38
+ <% if (spend = @card.assistant_cost).positive? %>
39
+ <span class="assistant-spend" title="What this card's planning conversation has cost so far (assistant replies — separate from agent runs)">🪶 $<%= sprintf("%.2f", spend) %></span>
40
+ <% end %>
38
41
  </nav>
39
42
 
40
43
  <% if @zoom == "summary" %>
@@ -48,7 +51,7 @@
48
51
  <% else %>
49
52
  <div class="timeline-scroll" data-scroll-target="scroller">
50
53
  <% if @card.description.present? %>
51
- <div class="event event-description"><%= render_markdown @card.description %></div>
54
+ <div class="event event-description"><%= render_with_attachments @card.description %></div>
52
55
  <% end %>
53
56
 
54
57
  <div id="card_events">
@@ -71,8 +74,11 @@
71
74
 
72
75
  <%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
73
76
  <%= f.text_area "message[text]", rows: 2, required: true,
74
- data: { controller: "composer", action: "keydown->composer#keydown" },
77
+ data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
75
78
  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)" %>
79
+ <label class="note-toggle" title="Record this message without asking any AI to respond. Future agents (the next column's assistant or worker) still read it as context — perfect for a parting instruction right before you drag the card onward.">
80
+ <%= f.check_box "message[note]", {}, "1", "0" %> 📝 note only — no AI reply
81
+ </label>
76
82
  <% end %>
77
83
  <% end %>
78
84
  </section>
@@ -88,7 +94,8 @@
88
94
  <label>Tags</label>
89
95
  <%= render "cards/tag_picker", board: @card.board, tags: @card.tags %>
90
96
  <label>Description</label>
91
- <%= f.text_area :description, rows: 6 %>
97
+ <%= f.text_area :description, rows: 6,
98
+ data: { controller: "attach", action: "paste->attach#paste" } %>
92
99
  <label>Branch <span class="hint">— optional</span></label>
93
100
  <% if @card.branch_name.present? %>
94
101
  <%# Locked once set — by the user here or by the agent on run start. %>
@@ -226,6 +233,11 @@
226
233
 
227
234
  <details class="advanced-rules panel-advanced">
228
235
  <summary>Advanced</summary>
236
+ <p class="hint">Archiving takes the card off the board but keeps everything — find and restore it under 🗄 Archive in the topbar.</p>
237
+ <%= button_to "🗄 Archive card", archive_card_path(@card),
238
+ class: "cancel-btn",
239
+ form: { data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess" } },
240
+ disabled: @card.running? %>
229
241
  <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>
230
242
  <%= button_to "🗑 Delete card", card_path(@card), method: :delete,
231
243
  class: "cancel-btn delete-card",
@@ -1,6 +1,10 @@
1
1
  <div id="card_summary" class="summary-panel" data-controller="autosave">
2
2
  <div class="summary-head">
3
3
  <h3>Customer summary <span class="autosave-status" data-autosave-target="status"></span></h3>
4
+ <div class="summary-head-right">
5
+ <% if card.summary_generated_at.present? %>
6
+ <span class="summary-stamp">generated <%= time_ago_in_words(card.summary_generated_at) %> ago</span>
7
+ <% end %>
4
8
  <% if card.summary_working? %>
5
9
  <button type="button" class="deep-dive working summary-generate" disabled>
6
10
  <span class="pulse-dot"></span> Generating…
@@ -11,6 +15,7 @@
11
15
  ✨ <%= card.summary.present? ? "Regenerate" : "Generate summary" %>
12
16
  <% end %>
13
17
  <% end %>
18
+ </div>
14
19
  </div>
15
20
 
16
21
  <p class="hint summary-blurb">A plain-language recap of what this card delivered — ready to drop into a customer chat. Fully editable; your edits are respected when you regenerate.</p>
@@ -22,7 +27,4 @@
22
27
  disabled: card.summary_working? %>
23
28
  <% end %>
24
29
 
25
- <% if card.summary_generated_at.present? %>
26
- <p class="hint summary-stamp">Last generated <%= time_ago_in_words(card.summary_generated_at) %> ago</p>
27
- <% end %>
28
30
  </div>
@@ -18,6 +18,13 @@
18
18
  <input type="search" class="col-search" placeholder="Filter <%= column.name %>…"
19
19
  data-col-id="<%= column.id %>"
20
20
  data-action="input->filter#column keydown->filter#clear">
21
+ <% if column.inbox? %>
22
+ <%# Quick-add (card #42): title, Enter, done — the modal stays for real write-ups. %>
23
+ <%= form_with url: cards_path, class: "quick-add", data: { turbo_frame: "_top" } do |f| %>
24
+ <%= f.text_field "card[title]", placeholder: "Quick add — type a title, press Enter",
25
+ autocomplete: "off", required: true %>
26
+ <% end %>
27
+ <% end %>
21
28
  <p class="drop-hint">→ <%= column.drag_hint %></p>
22
29
  <div class="cards<%= " cards-clickable" if column.inbox? %>"
23
30
  data-controller="board-column"
@@ -25,7 +32,7 @@
25
32
  <% if column.inbox? %>data-action="click->board-column#newCard"
26
33
  data-board-column-new-url-value="<%= new_card_path %>"<% end %>
27
34
  data-column-id="<%= column.id %>">
28
- <% column.cards.each do |card| %>
35
+ <% column.cards.active.each do |card| %>
29
36
  <%= render "cards/card", card: card %>
30
37
  <% end %>
31
38
  </div>
@@ -13,7 +13,8 @@
13
13
  <span class="event-actor"><%= { "user" => "👤", "agent" => "🤖", "assistant" => "🪶", "system" => "·" }[event.actor] || event.actor %></span>
14
14
  <div class="event-body">
15
15
  <% if event.text %>
16
- <%= render_markdown event.text %>
16
+ <% if event.payload["note"] %><span class="note-flag" title="Note only — no AI was asked to reply; future agents read it as context">📝 note</span><% end %>
17
+ <%= render_with_attachments event.text %>
17
18
  <% else %>
18
19
  <code class="event-kind"><%= event.kind %></code>
19
20
  <% end %>
data/config/routes.rb CHANGED
@@ -5,11 +5,16 @@ Rails.application.routes.draw do
5
5
  post :deep_dive
6
6
  post :pull
7
7
  get :brief
8
+ get :archive
9
+ get :issues
10
+ post :import_issue
8
11
  end
9
12
  resources :cards, only: [:new, :create, :show, :update, :destroy] do
10
13
  member do
11
14
  patch :move
12
15
  post :approve
16
+ post :archive
17
+ post :unarchive
13
18
  post :summarize
14
19
  post :compact
15
20
  end
data/db/cable_schema.rb CHANGED
@@ -1,9 +1,21 @@
1
- ActiveRecord::Schema[7.1].define(version: 1) do
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema[8.1].define(version: 1) do
2
14
  create_table "solid_cable_messages", force: :cascade do |t|
3
15
  t.binary "channel", limit: 1024, null: false
4
- t.binary "payload", limit: 536870912, null: false
5
- t.datetime "created_at", null: false
6
16
  t.integer "channel_hash", limit: 8, null: false
17
+ t.datetime "created_at", null: false
18
+ t.binary "payload", limit: 536870912, null: false
7
19
  t.index ["channel"], name: "index_solid_cable_messages_on_channel"
8
20
  t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
9
21
  t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
@@ -0,0 +1,17 @@
1
+ # Usage ledger for every one-shot AI call (card #-less deep dives included).
2
+ # Worker runs track their own usage on Run; this covers the ClaudeCli tier:
3
+ # planning assistant, ai_task rules, deep dive, summary/compact, compiler.
4
+ class CreateAiCalls < ActiveRecord::Migration[8.1]
5
+ def change
6
+ create_table :ai_calls do |t|
7
+ t.references :card, foreign_key: true, null: true
8
+ t.string :kind, null: false
9
+ t.string :model
10
+ t.integer :input_tokens, default: 0, null: false
11
+ t.integer :output_tokens, default: 0, null: false
12
+ t.decimal :cost, precision: 10, scale: 6, default: 0, null: false
13
+ t.datetime :created_at, null: false
14
+ end
15
+ add_index :ai_calls, :kind
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ class AddIssueNumberToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :issue_number, :integer
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ # Board-level knobs (first tenant: which columns may drag-to-archive).
2
+ class AddSettingsToBoards < ActiveRecord::Migration[8.1]
3
+ def change
4
+ add_column :boards, :settings, :json, default: {}, null: false
5
+ end
6
+ end