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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/cardinal.css +69 -4
- data/app/controllers/boards_controller.rb +25 -1
- data/app/controllers/cards_controller.rb +21 -1
- data/app/controllers/messages_controller.rb +16 -1
- data/app/helpers/application_helper.rb +54 -0
- data/app/javascript/controllers/archive_drop_controller.js +41 -0
- data/app/javascript/controllers/attach_controller.js +80 -0
- data/app/javascript/controllers/board_column_controller.js +6 -0
- data/app/jobs/ai_task_job.rb +2 -1
- data/app/jobs/assistant_reply_job.rb +2 -1
- data/app/jobs/compact_job.rb +14 -1
- data/app/jobs/deep_dive_job.rb +2 -1
- data/app/jobs/resume_run_job.rb +8 -0
- data/app/jobs/summary_job.rb +11 -1
- data/app/models/ai_call.rb +10 -0
- data/app/models/board.rb +10 -0
- data/app/models/card.rb +26 -2
- data/app/models/column.rb +10 -4
- data/app/services/agent/runner.rb +18 -3
- data/app/services/claude_cli.rb +23 -1
- data/app/services/github_issues.rb +49 -0
- data/app/services/rules/compiler.rb +1 -0
- data/app/services/run_sweeper.rb +12 -3
- data/app/views/boards/archive.html.erb +56 -0
- data/app/views/boards/brief.html.erb +2 -0
- data/app/views/boards/issues.html.erb +41 -0
- data/app/views/boards/show.html.erb +8 -0
- data/app/views/cards/_card.html.erb +3 -1
- data/app/views/cards/_compact_panel.html.erb +5 -3
- data/app/views/cards/_detail.html.erb +15 -3
- data/app/views/cards/_summary_panel.html.erb +5 -3
- data/app/views/columns/_column.html.erb +8 -1
- data/app/views/events/_event.html.erb +2 -1
- data/config/routes.rb +5 -0
- data/db/cable_schema.rb +15 -3
- data/db/migrate/20260705120000_create_ai_calls.rb +17 -0
- data/db/migrate/20260705120001_add_issue_number_to_cards.rb +5 -0
- data/db/migrate/20260705193935_add_settings_to_boards.rb +6 -0
- data/db/queue_schema.rb +74 -62
- data/lib/cardinal/version.rb +1 -1
- 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(
|
|
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
|
-
|
|
78
|
-
|
|
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",
|
|
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+}])
|
data/app/services/claude_cli.rb
CHANGED
|
@@ -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
|
data/app/services/run_sweeper.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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"><%=
|
|
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
|
-
|
|
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
|
-
|
|
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
|