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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d68fd5849d2147217f98894631cb725af6c4dd786bf4e2c5438f374101f0e34
4
- data.tar.gz: 905b5c3af9bd9fdad3aa2c5f19b69968b495ff2e2788a9ca04567c0e2be15e6a
3
+ metadata.gz: 84bab289a5a9655c54a7e2cbd4587c21275a122903d08892f264c20fc2c0a52d
4
+ data.tar.gz: fdf187aa387793ddb12e7e5ccb6d9cb649a3214d894859490217be45585d690b
5
5
  SHA512:
6
- metadata.gz: 184cbda0e27ce7e49f5b921e39b8e532a77ec27e80105f186161f2d01197ad36a5da1fdf549c2636f31b09b318459dcd9f3b19f4a74ffadd17834c54fbeb04f2
7
- data.tar.gz: 82e3aeb820bbb9252304147f37fd57bb50cb65d40e3ae709b7a9c8b911bb5b51930a19233a528eda0caec09e86181c4d95391349bf70f03bac8791aefa320ce4
6
+ metadata.gz: b128883b680056de0b221bba6df4d8db8c8ea299ef93feec45c30cf3c7df238982b983cc6ffb50db17ed52c7781d71ee2442c4091c1fd1c922e03209ca2af7ea
7
+ data.tar.gz: de0ae6c696aa5b3fa490f5f985a9acf81bf45d468660ff47522ec835c9871061b039eee9f5d872570667d8c109dd1b08ca2b71fe861cbea7a775f2ccef70d61e
@@ -108,6 +108,50 @@ a { color: var(--blue); text-decoration: none; }
108
108
  .col-search.open { display: block; }
109
109
  .filter-hidden { display: none !important; }
110
110
 
111
+ .assistant-spend { margin-left: auto; font-size: 11px; color: var(--text-dim); cursor: default; }
112
+
113
+ /* Quick-add (card #42) */
114
+ .quick-add input[type="text"] {
115
+ width: 100%; margin: 0 0 8px; font-size: 12px;
116
+ background: var(--surface-2); border: 1px dashed var(--border); border-radius: 6px;
117
+ color: var(--text); padding: 5px 8px;
118
+ }
119
+ .quick-add input[type="text"]:focus { border-style: solid; }
120
+
121
+ /* Archive browser (card #42) */
122
+ .archive-page { padding: 18px; max-width: 900px; margin: 0 auto; }
123
+ .archive-tools { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
124
+ .archive-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
125
+ .archive-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: default; }
126
+ .archive-title { font-weight: 600; }
127
+ .archive-meta { display: block; font-size: 11px; color: var(--text-dim); margin-top: 3px; }
128
+ .restore-btn { white-space: nowrap; }
129
+
130
+ .note-toggle { display: block; font-size: 11px; color: var(--text-dim); margin-top: 4px; cursor: pointer; }
131
+ .note-toggle input { accent-color: var(--amber); }
132
+ .note-flag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 8px; padding: 0 6px; margin-right: 4px; }
133
+
134
+ .issue-list { list-style: none; margin: 0; padding: 0; }
135
+ .issue-row { display: flex; align-items: center; justify-content: space-between; gap: 10px;
136
+ padding: 8px 4px; border-bottom: 1px solid var(--border); }
137
+ .issue-row:last-child { border-bottom: 0; }
138
+
139
+ .ready-chip { color: var(--green); border-color: var(--green); }
140
+
141
+ /* ๐Ÿ—„ as a drag target: lit while a drag from an allowed column is live. */
142
+ .archive-drop { display: inline-flex; border-radius: 6px; }
143
+ .archive-drop.drop-ready .archive-link {
144
+ color: var(--amber); border-color: var(--amber);
145
+ animation: pulse-dot 1.2s ease-in-out infinite;
146
+ }
147
+ .archive-drop .card, .archive-drop .card-ghost { display: none !important; }
148
+
149
+ .archive-rails { margin: 0 0 16px; padding: 10px 12px; background: var(--surface);
150
+ border: 1px solid var(--border); border-radius: 8px; font-size: 12px; }
151
+ .archive-rails .rails-label { font-weight: 600; margin-right: 10px; }
152
+ .archive-rails .inline-check { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; cursor: pointer; }
153
+ .archive-rails .hint { display: block; margin-top: 6px; }
154
+
111
155
  .pull-form { display: inline; }
112
156
  #repo-pull-status { font-size: 0.85rem; }
113
157
  #repo-pull-status .pull-ok { color: var(--green); }
@@ -406,15 +450,20 @@ body.dragging .drop-hint { display: block; }
406
450
  .zoom-tabs a { padding: 4px 12px; border-radius: 6px; color: var(--text-dim); }
407
451
  .zoom-tabs a.active { background: var(--surface-2); color: var(--text); }
408
452
 
453
+ /* Summary/Compact panels fill the pane: header + blurb on top, the textarea
454
+ flexes to all remaining height, the last-generated stamp holds the floor. */
455
+ .summary-panel { display: flex; flex-direction: column; height: 100%; }
409
456
  .summary-panel .summary-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
410
457
  .summary-panel .summary-head h3 { margin: 0; }
411
458
  .summary-generate { white-space: nowrap; }
412
- .summary-blurb { margin: 6px 0 12px; }
459
+ .summary-blurb { margin: 6px 0 12px; flex-shrink: 0; }
460
+ .summary-form { flex: 1; min-height: 0; display: flex; }
413
461
  .summary-form textarea {
414
- width: 100%; background: var(--surface-2); border: 1px solid var(--border);
415
- border-radius: 6px; color: var(--text); padding: 8px 10px; font: inherit; resize: vertical;
462
+ width: 100%; height: 100%; flex: 1; background: var(--surface-2); border: 1px solid var(--border);
463
+ border-radius: 6px; color: var(--text); padding: 8px 10px; font: inherit; resize: none;
416
464
  }
417
- .summary-stamp { margin-top: 8px; }
465
+ .summary-head-right { display: flex; align-items: center; gap: 10px; }
466
+ .summary-stamp { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
418
467
 
419
468
  .event { display: flex; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--surface-2); }
420
469
 
@@ -472,6 +521,22 @@ body.dragging .drop-hint { display: block; }
472
521
  .actor-agent .event-body p { color: #b9c3d8; }
473
522
  [data-theme="light"] .actor-agent .event-body p { color: #3a4a63; }
474
523
 
524
+ /* Pasted attachments (card #21): image thumbnails and file badges, inline in
525
+ descriptions and timeline messages. */
526
+ .attachment { margin: 6px 0; }
527
+ .attachment-image { display: inline-block; margin: 6px 8px 6px 0; }
528
+ .attachment-thumb {
529
+ display: block; max-width: 120px; max-height: 120px; width: auto; height: auto;
530
+ border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2);
531
+ }
532
+ .attachment-image figcaption { font-size: 11px; color: var(--text-dim); margin-top: 3px; }
533
+ .attachment-file {
534
+ display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px;
535
+ background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px;
536
+ font-size: 12px; color: var(--text-dim);
537
+ }
538
+ .attachment-icon { font-size: 14px; }
539
+
475
540
  .empty { color: var(--text-dim); font-style: italic; }
476
541
 
477
542
  /* AI typing indicator */
@@ -29,7 +29,8 @@ class BoardsController < ApplicationController
29
29
 
30
30
  def update
31
31
  board = Board.first!
32
- attrs = params.require(:board).permit(:name, :default_branch)
32
+ attrs = params.require(:board).permit(:name, :default_branch, archive_accepts_from: [])
33
+ board.archive_accepts_from = attrs[:archive_accepts_from].to_a.map(&:to_s).reject(&:blank?) if attrs.key?(:archive_accepts_from)
33
34
  board.update!(
34
35
  name: attrs[:name].presence || board.name,
35
36
  default_branch: attrs[:default_branch].presence || board.default_branch
@@ -45,6 +46,29 @@ class BoardsController < ApplicationController
45
46
  end
46
47
  end
47
48
 
49
+ # GitHub Issues sync (card #49): list open issues, one click imports one as
50
+ # an inbox card; the card's eventual PR carries "Closes #N".
51
+ def issues
52
+ @board = Board.first!
53
+ redirect_to root_path and return unless turbo_frame_request?
54
+ @issues = GithubIssues.available?(@board) ? GithubIssues.list(@board) : []
55
+ @imported = @board.cards.where.not(issue_number: nil).pluck(:issue_number, :number).to_h
56
+ end
57
+
58
+ def import_issue
59
+ board = Board.first!
60
+ card = GithubIssues.import!(board, params.require(:number))
61
+ redirect_to card_path(card)
62
+ rescue ArgumentError => e
63
+ redirect_to root_path, alert: e.message
64
+ end
65
+
66
+ # The archive browser (card #42): everything archived, searchable, restorable.
67
+ def archive
68
+ @board = Board.first!
69
+ @cards = @board.cards.archived.includes(:column).order(updated_at: :desc)
70
+ end
71
+
48
72
  # Inspect the repo brief: what the deep dive wrote, when, from which SHA.
49
73
  def brief
50
74
  @board = Board.first!
@@ -1,5 +1,5 @@
1
1
  class CardsController < ApplicationController
2
- before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy]
2
+ before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy, :archive, :unarchive]
3
3
 
4
4
  def new
5
5
  @board = Board.first!
@@ -140,6 +140,26 @@ class CardsController < ApplicationController
140
140
  end
141
141
  end
142
142
 
143
+ # Archive (card #42): off the board, never gone โ€” /board/archive lists,
144
+ # searches, and restores. Running cards can't be archived out from under
145
+ # their agent.
146
+ def archive
147
+ if @card.running?
148
+ redirect_to card_path(@card), alert: "Card is running โ€” cancel or finish the run first."
149
+ else
150
+ @card.update!(status: "archived")
151
+ @card.log!("status_change", actor: "user", text: "Archived")
152
+ redirect_to root_path
153
+ end
154
+ end
155
+
156
+ def unarchive
157
+ restore = Card::ARCHIVE_RESTORE.fetch(@card.column.archetype, "draft")
158
+ @card.update!(status: restore, position: (@card.column.cards.active.maximum(:position) || -1) + 1)
159
+ @card.log!("status_change", actor: "user", text: "Restored from the archive to #{@card.column.name} (#{restore.humanize.downcase})")
160
+ redirect_to archive_board_path
161
+ end
162
+
143
163
  private
144
164
 
145
165
  def set_card
@@ -3,12 +3,27 @@ class MessagesController < ApplicationController
3
3
  card = Board.first!.cards.find_by!(number: params[:card_id])
4
4
  text = params.require(:message)[:text]
5
5
  parked_run = card.runs.where(status: "needs_input").order(:id).last
6
+ live_run = card.runs.where(status: "running").order(:id).last
6
7
 
7
- if parked_run
8
+ if params.dig(:message, :note) == "1"
9
+ # Note only (card #47): on the record for any FUTURE reader โ€” the next
10
+ # column's assistant or worker sees it in conversation context โ€” but no
11
+ # AI is invoked now and no verdict changes. Type, drag, done.
12
+ card.log!("user_message", actor: "user", text: text, note: true)
13
+ elsif parked_run
8
14
  # Answer / plan feedback: goes back into the same agent session.
9
15
  kind = parked_run.phase == "plan" ? "user_message" : "answer"
10
16
  card.log!(kind, actor: "user", run: parked_run, text: text)
11
17
  ResumeRunJob.perform_later(parked_run.id, text)
18
+ elsif card.column.execution? && card.working? && live_run
19
+ # Mid-run steering (card #47): the run is streaming and can't hear you
20
+ # yet โ€” queue the note on the run; it delivers at the next segment
21
+ # boundary (question, plan approval, budget pause) via ResumeRunJob.
22
+ notes = Array(live_run.briefing["steering"]) << text
23
+ live_run.update!(briefing: live_run.briefing.merge("steering" => notes))
24
+ card.log!("user_message", actor: "user", run: live_run, text: text)
25
+ card.log!("status_change", run: live_run,
26
+ text: "๐Ÿงญ Note queued for the agent โ€” delivers at its next check-in")
12
27
  elsif card.column.review? && %w[in_review approved].include?(card.status)
13
28
  # Review is entirely human: feedback IS the conversation. A message on a
14
29
  # card under review marks it changes_requested; dragging it back to a
@@ -36,6 +36,60 @@ module ApplicationHelper
36
36
  MARKDOWN.render(text.to_s).html_safe
37
37
  end
38
38
 
39
+ # Pasted files live inline in message/description text as attachment tokens
40
+ # (card #21). Format, kept in sync with attach_controller.js:
41
+ # [[cardinal:file name="foo.png" mime="image/png" size="12345"]]<base64>[[/cardinal:file]]
42
+ # The regex is anchored on delimiters base64 can never contain ([, ], ").
43
+ ATTACHMENT_TOKEN =
44
+ /\[\[cardinal:file name="([^"]*)" mime="([^"]*)" size="(\d+)"\]\]([A-Za-z0-9+\/=\s]*?)\[\[\/cardinal:file\]\]/
45
+
46
+ # Only these render as an inline <img> off a data: URL. Anything else โ€” including
47
+ # a spoofed mime like text/html โ€” falls through to an inert badge, never an image.
48
+ ATTACHMENT_IMAGE_MIMES = %w[image/png image/jpeg image/gif image/webp].freeze
49
+
50
+ # Render text that may carry attachment tokens: markdown for the prose, a
51
+ # thumbnail (images) or a badge (everything else) for each attachment. Tokens
52
+ # are pulled out BEFORE markdown so the raw base64 never floods the timeline and
53
+ # a token can never be interpreted as HTML.
54
+ def render_with_attachments(text)
55
+ text = text.to_s
56
+ return render_markdown(text) unless text.match?(ATTACHMENT_TOKEN)
57
+
58
+ html = +""
59
+ last = 0
60
+ text.scan(ATTACHMENT_TOKEN) do
61
+ match = Regexp.last_match
62
+ html << render_markdown(text[last...match.begin(0)])
63
+ html << render_attachment(match[1], match[2], match[3].to_i, match[4].gsub(/\s+/, ""))
64
+ last = match.end(0)
65
+ end
66
+ html << render_markdown(text[last..])
67
+ html.html_safe
68
+ end
69
+
70
+ # One attachment's HTML. All interpolated values are escaped; the base64 is
71
+ # only ever used as an <img> data: URL for a whitelisted image mime.
72
+ def render_attachment(name, mime, size, base64)
73
+ caption = "#{name} ยท #{number_to_human_size(size)}"
74
+ if ATTACHMENT_IMAGE_MIMES.include?(mime) && base64.present?
75
+ tag.figure(class: "attachment attachment-image") do
76
+ image_tag("data:#{mime};base64,#{base64}", alt: name, class: "attachment-thumb") +
77
+ tag.figcaption(caption)
78
+ end
79
+ else
80
+ tag.span(class: "attachment attachment-file", title: caption) do
81
+ tag.span("๐Ÿ“„", class: "attachment-icon") + tag.span(caption, class: "attachment-name")
82
+ end
83
+ end
84
+ end
85
+
86
+ # Strip attachment tokens down to just their filename for plain-text contexts
87
+ # like the card-face search haystack โ€” a pasted image must not turn a card's
88
+ # searchable text into base64 noise (card #21).
89
+ def strip_attachment_tokens(text)
90
+ text.to_s.gsub(ATTACHMENT_TOKEN) { Regexp.last_match(1) }
91
+ end
92
+
39
93
  # Render a column's stored footer config (array of {label, compute} hashes)
40
94
  # back into the "Label | compute" line format the gear textarea edits.
41
95
  def footer_config_text(column)
@@ -0,0 +1,41 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import Sortable from "sortablejs"
3
+
4
+ // The topbar ๐Ÿ—„ as a drop bin: cards dragged from whitelisted columns
5
+ // (board.archive_accepts_from, set on the archive page) can be dropped here
6
+ // to archive in one motion. The link inside keeps its normal click.
7
+ export default class extends Controller {
8
+ connect() {
9
+ this.sortable = Sortable.create(this.element, {
10
+ group: {
11
+ name: "cards",
12
+ pull: false,
13
+ put: (_to, from) => this.accepts(from.el.dataset.columnId)
14
+ },
15
+ onAdd: (event) => this.archive(event)
16
+ })
17
+ }
18
+
19
+ disconnect() {
20
+ this.sortable?.destroy()
21
+ }
22
+
23
+ accepts(columnId) {
24
+ const allowed = (this.element.dataset.accepts || "").split(",").filter(Boolean)
25
+ return allowed.includes(columnId)
26
+ }
27
+
28
+ async archive(event) {
29
+ const number = event.item.dataset.cardId
30
+ // Drop the dragged face immediately; the server's refresh broadcast is
31
+ // the durable truth (board re-renders active cards + the ๐Ÿ—„ count).
32
+ event.item.remove()
33
+ await fetch(`/cards/${number}/archive`, {
34
+ method: "POST",
35
+ headers: {
36
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content,
37
+ "Accept": "text/html"
38
+ }
39
+ })
40
+ }
41
+ }
@@ -0,0 +1,80 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Paste files (images + text/code) into a textarea as base64 attachment tokens.
4
+ // The token stays inline in the field text โ€” that is how the agent, the timeline
5
+ // renderer, and autosave all see it. Format (kept in sync with ApplicationHelper):
6
+ // [[cardinal:file name="foo.png" mime="image/png" size="12345"]]<base64>[[/cardinal:file]]
7
+ export default class extends Controller {
8
+ static values = {
9
+ // Hard ceiling and the point where a paste is worth warning about (bytes).
10
+ max: { type: Number, default: 500 * 1024 },
11
+ warn: { type: Number, default: 250 * 1024 }
12
+ }
13
+
14
+ paste(event) {
15
+ const files = Array.from(event.clipboardData?.items || [])
16
+ .filter((item) => item.kind === "file")
17
+ .map((item) => item.getAsFile())
18
+ .filter(Boolean)
19
+
20
+ // No files on the clipboard โ€” let the browser paste text as usual.
21
+ if (files.length === 0) return
22
+
23
+ event.preventDefault()
24
+ files.forEach((file) => this.attach(file))
25
+ }
26
+
27
+ attach(file) {
28
+ if (file.size > this.maxValue) {
29
+ this.notify(`"${file.name}" is ${this.kb(file.size)}KB โ€” over the ${this.kb(this.maxValue)}KB limit. Not attached.`)
30
+ return
31
+ }
32
+ if (file.size >= this.warnValue) {
33
+ const proceed = window.confirm(
34
+ `"${file.name}" is ${this.kb(file.size)}KB. Pasting it embeds the full file in this card, ` +
35
+ `and every agent run and planning reply re-sends it as context โ€” that consumes tokens and ` +
36
+ `increases run cost each time. Attach it anyway?`
37
+ )
38
+ if (!proceed) return
39
+ }
40
+
41
+ const reader = new FileReader()
42
+ reader.onload = () => {
43
+ // FileReader gives us a data: URL; the base64 payload is after the comma.
44
+ const base64 = String(reader.result).split(",", 2)[1] || ""
45
+ const mime = file.type || "application/octet-stream"
46
+ this.insert(this.token(file.name, mime, file.size, base64))
47
+ }
48
+ reader.readAsDataURL(file)
49
+ }
50
+
51
+ // Build one attachment token. The name is sanitized so quotes/brackets can
52
+ // never break the delimiter the renderer parses back out.
53
+ token(name, mime, size, base64) {
54
+ const safeName = String(name || "file").replace(/["\[\]]/g, "").trim() || "file"
55
+ return `[[cardinal:file name="${safeName}" mime="${mime}" size="${size}"]]${base64}[[/cardinal:file]]`
56
+ }
57
+
58
+ // Splice the token in at the cursor, then fire input so autosave (description)
59
+ // and any other listeners react as if the user typed it.
60
+ insert(token) {
61
+ const el = this.element
62
+ const start = el.selectionStart ?? el.value.length
63
+ const end = el.selectionEnd ?? el.value.length
64
+ const pad = start > 0 && !/\s$/.test(el.value.slice(0, start)) ? "\n" : ""
65
+ const chunk = `${pad}${token}\n`
66
+ el.value = el.value.slice(0, start) + chunk + el.value.slice(end)
67
+ const caret = start + chunk.length
68
+ el.setSelectionRange(caret, caret)
69
+ el.dispatchEvent(new Event("input", { bubbles: true }))
70
+ el.focus()
71
+ }
72
+
73
+ notify(message) {
74
+ window.alert(message)
75
+ }
76
+
77
+ kb(bytes) {
78
+ return Math.round(bytes / 1024)
79
+ }
80
+ }
@@ -36,6 +36,11 @@ export default class extends Controller {
36
36
  // card from THIS column gray out with a blocked hint. Server still rules.
37
37
  markBlockedColumns() {
38
38
  const sourceId = this.element.dataset.columnId
39
+ // The topbar ๐Ÿ—„ lights up as a drop bin when this column may archive by drag.
40
+ document.querySelectorAll(".archive-drop").forEach(bin => {
41
+ const allowed = (bin.dataset.accepts || "").split(",").filter(Boolean)
42
+ bin.classList.toggle("drop-ready", allowed.includes(sourceId))
43
+ })
39
44
  document.querySelectorAll(".column").forEach(section => {
40
45
  if (section.dataset.colId === sourceId) return // reorder within is always fine
41
46
  const allowed = (section.dataset.accepts || "").split(",").filter(Boolean)
@@ -45,6 +50,7 @@ export default class extends Controller {
45
50
 
46
51
  clearBlockedColumns() {
47
52
  document.querySelectorAll(".column.drop-blocked").forEach(s => s.classList.remove("drop-blocked"))
53
+ document.querySelectorAll(".archive-drop.drop-ready").forEach(s => s.classList.remove("drop-ready"))
48
54
  }
49
55
 
50
56
  disconnect() {
@@ -17,7 +17,8 @@ class AiTaskJob < ApplicationJob
17
17
  text = ClaudeCli.prompt(
18
18
  prompt,
19
19
  system: "You are a maintenance agent on a Cardinal board performing one bounded task on card ##{card.number}. Be concise; your output is posted directly to the card's timeline.",
20
- model: model.presence || AssistantReplyJob::FALLBACK_MODEL
20
+ model: model.presence || AssistantReplyJob::FALLBACK_MODEL,
21
+ ledger: { kind: "ai_task", card: card }
21
22
  )
22
23
  card.log!("assistant_message", actor: "assistant", text: text) if text.present?
23
24
  rescue ClaudeCli::Error => e
@@ -57,6 +57,7 @@ class AssistantReplyJob < ApplicationJob
57
57
  # affirming reply ("yes, that approach") must not escalate the
58
58
  # planner into implementing.
59
59
  return ClaudeCli.prompt(unheard_messages(card), resume: card.assistant_session_id,
60
+ ledger: { kind: "assistant", card: card },
60
61
  system: ROLE_REMINDER, **common)
61
62
  rescue ClaudeCli::Error
62
63
  card.update!(assistant_session_id: nil) # stale/expired โ€” start fresh below
@@ -64,7 +65,7 @@ class AssistantReplyJob < ApplicationJob
64
65
  end
65
66
 
66
67
  opener = kickoff ? kickoff_prompt(card) : transcript_prompt(card)
67
- ClaudeCli.prompt(opener, system: system_prompt(card), **common)
68
+ ClaudeCli.prompt(opener, system: system_prompt(card), ledger: { kind: "assistant", card: card }, **common)
68
69
  end
69
70
 
70
71
  def kickoff_prompt(card)
@@ -22,13 +22,19 @@ class CompactJob < ApplicationJob
22
22
  blockers hit, and anything left unfinished or deliberately deferred. Prefer
23
23
  terse structured notes (short headings, bullets, file paths, symbol names) over
24
24
  prose. Omit pleasantries and customer-facing framing. Write only the journal.
25
+
26
+ You are NOT in a conversation. The material you receive is a data dump, not a
27
+ message to you โ€” some of it contains dialogue; do not reply to it, do not ask
28
+ questions, do not offer next steps, do not address anyone. Your entire output
29
+ is the journal document itself, starting with its first heading.
25
30
  SYS
26
31
 
27
32
  def perform(card)
28
33
  return clear_working(card) unless ClaudeCli.available?
29
34
 
30
35
  model = card.board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
31
- compact = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1)
36
+ compact = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1,
37
+ ledger: { kind: "compact", card: card })
32
38
 
33
39
  card.update!(compact: compact.to_s.strip, compact_generated_at: Time.current, compact_status: nil)
34
40
  card.broadcast_replace_to card, target: "card_compact",
@@ -69,6 +75,13 @@ class CompactJob < ApplicationJob
69
75
  "starting from scratch:\n#{card.compact}"
70
76
  end
71
77
 
78
+ # The dump above often ENDS in dialogue โ€” restate the task last, where it
79
+ # carries the most weight, or the model answers the transcript instead of
80
+ # journaling it ("What would you like to work on next?").
81
+ parts << "\n---\nEND OF SOURCE MATERIAL. Now write the technical compact journal " \
82
+ "described in your instructions. Output ONLY the journal, beginning " \
83
+ "directly with its first heading โ€” no preamble, no questions, no reply " \
84
+ "to anything above."
72
85
  parts.join("\n")
73
86
  end
74
87
 
@@ -44,7 +44,8 @@ class DeepDiveJob < ApplicationJob
44
44
  sha = board.head_sha
45
45
  model = board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
46
46
 
47
- brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS)
47
+ brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS,
48
+ ledger: { kind: "deep_dive" })
48
49
 
49
50
  File.write(board.brief_path, brief.to_s)
50
51
  board.update!(brief_sha: sha, brief_generated_at: Time.current,
@@ -32,6 +32,14 @@ class ResumeRunJob < ApplicationJob
32
32
  message = [pending["message"], message].compact_blank.join("\n\n")
33
33
  approve ||= pending["approve"]
34
34
  end
35
+
36
+ # Steering notes queued while the agent streamed (card #47) ride along on
37
+ # whatever resumes the session โ€” an answer, plan feedback, or an approval.
38
+ if (steering = Array(run.briefing["steering"])).any?
39
+ run.update!(briefing: run.briefing.except("steering"))
40
+ notes = "Notes the user left while you were working:\n- #{steering.join("\n- ")}"
41
+ message = [notes, message].compact_blank.join("\n\n")
42
+ end
35
43
  Agent::Runner.resume(run, message, approve: approve)
36
44
  end
37
45
  end
@@ -16,13 +16,18 @@ class SummaryJob < ApplicationJob
16
16
  drop straight into a Teams or Slack message โ€” what was asked for and what was
17
17
  delivered, in outcome terms. No jargon, no file names, no code, no headings.
18
18
  A couple of sentences up to a short paragraph. Write only the recap itself.
19
+
20
+ You are NOT in a conversation. The material you receive is a data dump, not a
21
+ message to you โ€” do not reply to it, ask questions, or address anyone. Your
22
+ entire output is the recap text and nothing else.
19
23
  SYS
20
24
 
21
25
  def perform(card)
22
26
  return clear_working(card) unless ClaudeCli.available?
23
27
 
24
28
  model = card.board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
25
- summary = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1)
29
+ summary = ClaudeCli.prompt(build_prompt(card), system: SYSTEM, model: model, max_turns: 1,
30
+ ledger: { kind: "summary", card: card })
26
31
 
27
32
  card.update!(summary: summary.to_s.strip, summary_generated_at: Time.current, summary_status: nil)
28
33
  card.broadcast_replace_to card, target: "card_summary",
@@ -60,6 +65,11 @@ class SummaryJob < ApplicationJob
60
65
  "refine and update it with any new work rather than starting from scratch:\n#{card.summary}"
61
66
  end
62
67
 
68
+ # Restate the task LAST โ€” the dump can end in dialogue, and a model answers
69
+ # what it read most recently unless told otherwise (see CompactJob).
70
+ parts << "\n---\nEND OF SOURCE MATERIAL. Now write the customer recap described in " \
71
+ "your instructions โ€” output only the recap itself, no preamble, no reply " \
72
+ "to anything above."
63
73
  parts.join("\n")
64
74
  end
65
75
 
@@ -0,0 +1,10 @@
1
+ # One row per one-shot AI call (ยง money honesty): what it was for, what it
2
+ # cost. Worker runs keep their usage on Run; everything that goes through
3
+ # ClaudeCli lands here โ€” including board-level calls with no card (deep dive).
4
+ class AiCall < ApplicationRecord
5
+ KINDS = %w[assistant ai_task deep_dive summary compact rules_compile].freeze
6
+
7
+ belongs_to :card, optional: true
8
+
9
+ validates :kind, inclusion: { in: KINDS }
10
+ end
data/app/models/board.rb CHANGED
@@ -51,6 +51,16 @@ class Board < ApplicationRecord
51
51
  end
52
52
  end
53
53
 
54
+ # Board-level knobs. archive_accepts_from: column ids (strings) whose cards
55
+ # may be DRAGGED onto the topbar ๐Ÿ—„ (explicit-only, like column rails โ€”
56
+ # empty means the archive is not a drop target). The Advanced-panel archive
57
+ # button is always available; this governs only the drag affordance.
58
+ store_accessor :settings, :archive_accepts_from
59
+
60
+ def archive_accepts?(column)
61
+ Array(archive_accepts_from).map(&:to_s).include?(column.id.to_s)
62
+ end
63
+
54
64
  has_many :columns, -> { order(:position) }, dependent: :destroy
55
65
  has_many :cards, dependent: :destroy
56
66
 
data/app/models/card.rb CHANGED
@@ -23,6 +23,7 @@ class Card < ApplicationRecord
23
23
  dependent: :nullify, inverse_of: :parent
24
24
  has_many :events, -> { order(:created_at, :id) }, dependent: :destroy
25
25
  has_many :agent_sessions, dependent: :destroy
26
+ has_many :ai_calls, dependent: :delete_all
26
27
  has_many :runs, through: :agent_sessions
27
28
 
28
29
  # Card-face status glyphs. Keyed on status, except `ready_for_approval?`
@@ -47,6 +48,15 @@ class Card < ApplicationRecord
47
48
  after_commit -> { broadcast_refresh_to board }
48
49
 
49
50
  scope :attention, -> { where(status: %w[needs_input blocked failed work_complete]) }
51
+ # The board shows active cards; archived ones live in /board/archive.
52
+ scope :active, -> { where.not(status: "archived") }
53
+ scope :archived, -> { where(status: "archived") }
54
+
55
+ # Where an unarchived card lands: back in its column, in that column's most
56
+ # inert legal status โ€” nothing fires, no agent starts.
57
+ ARCHIVE_RESTORE = { "inbox" => "draft", "planning" => "discussing",
58
+ "execution" => "work_complete", "review" => "in_review",
59
+ "terminal" => "done" }.freeze
50
60
 
51
61
  def needs_attention? = %w[needs_input blocked failed work_complete].include?(status)
52
62
 
@@ -101,17 +111,31 @@ class Card < ApplicationRecord
101
111
 
102
112
  # Running tally across every run on the card โ€” the closed-card cost footer
103
113
  # (card #20). Sums stopped/restarted segments so the total reflects real spend.
104
- def total_cost = runs.sum(:cost)
105
- def total_output_tokens = runs.sum(:output_tokens)
114
+ # Honest money: worker runs PLUS every one-shot call made on this card's
115
+ # behalf (planning assistant, ai_task, summary/compact) โ€” see AiCall.
116
+ def total_cost = runs.sum(:cost) + ai_calls.sum(:cost)
117
+ def total_output_tokens = runs.sum(:output_tokens) + ai_calls.sum(:output_tokens)
118
+
119
+ def assistant_cost = ai_calls.where(kind: "assistant").sum(:cost)
106
120
 
107
121
  # Is the planning assistant expected to post next? True right after entering
108
122
  # a planning column (kickoff inspection pending) or after a user message.
109
123
  def awaiting_assistant?
110
124
  return false unless column.planning? && column.ai?
111
125
  last = events.where(kind: %w[user_message assistant_message error column_move]).order(:id).last
126
+ return false if last&.payload&.[]("note") # a note-only message expects no reply
112
127
  last.present? && %w[user_message column_move].include?(last.kind)
113
128
  end
114
129
 
130
+ # Planning chip state (card #25): the assistant ends discussion by posting
131
+ # a "Ready for execution" brief โ€” the SAME marker Agent::Runner promotes
132
+ # into worker briefings. When that's the assistant's latest word, the card
133
+ # face flips from "replied" to "ready".
134
+ def planning_ready?
135
+ discussing? &&
136
+ events.where(kind: "assistant_message").order(:id).last&.text.to_s.match?(/ready for execution/i)
137
+ end
138
+
115
139
  # Some AI is expected to write to this card imminently.
116
140
  def thinking?
117
141
  awaiting_assistant? || working?