cardinal-ai 0.2.14 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b30a673a110becb4019a1d537443a14ec37ebeb326c29ff310c41edd3dc59dee
4
- data.tar.gz: ca0b8933f66d185cf62b30e9b32fc6b9e6217045654bc2dc61f884be82ff212c
3
+ metadata.gz: 84bab289a5a9655c54a7e2cbd4587c21275a122903d08892f264c20fc2c0a52d
4
+ data.tar.gz: fdf187aa387793ddb12e7e5ccb6d9cb649a3214d894859490217be45585d690b
5
5
  SHA512:
6
- metadata.gz: 585edbeff0fc41cef7ad16fcb76bb61c5bd041fc44a51f0ce3ca71d7dfcfa22144951638f0f9c3f400821fda0a5ffb84eaff2bde32d2e29ecf7448783c689672
7
- data.tar.gz: d00bca1a2862b301a4204545ff0d79dfd14855f36a8a45e538e595a7eb7e1e81983e6aeeda3c0806fce789d7fea69f0fbb2690b8ef6e97e59dfc6774dd20036d
6
+ metadata.gz: b128883b680056de0b221bba6df4d8db8c8ea299ef93feec45c30cf3c7df238982b983cc6ffb50db17ed52c7781d71ee2442c4091c1fd1c922e03209ca2af7ea
7
+ data.tar.gz: de0ae6c696aa5b3fa490f5f985a9acf81bf45d468660ff47522ec835c9871061b039eee9f5d872570667d8c109dd1b08ca2b71fe861cbea7a775f2ccef70d61e
@@ -110,6 +110,48 @@ a { color: var(--blue); text-decoration: none; }
110
110
 
111
111
  .assistant-spend { margin-left: auto; font-size: 11px; color: var(--text-dim); cursor: default; }
112
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
+
113
155
  .pull-form { display: inline; }
114
156
  #repo-pull-status { font-size: 0.85rem; }
115
157
  #repo-pull-status .pull-ok { color: var(--green); }
@@ -408,15 +450,20 @@ body.dragging .drop-hint { display: block; }
408
450
  .zoom-tabs a { padding: 4px 12px; border-radius: 6px; color: var(--text-dim); }
409
451
  .zoom-tabs a.active { background: var(--surface-2); color: var(--text); }
410
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%; }
411
456
  .summary-panel .summary-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
412
457
  .summary-panel .summary-head h3 { margin: 0; }
413
458
  .summary-generate { white-space: nowrap; }
414
- .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; }
415
461
  .summary-form textarea {
416
- width: 100%; background: var(--surface-2); border: 1px solid var(--border);
417
- 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;
418
464
  }
419
- .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; }
420
467
 
421
468
  .event { display: flex; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--surface-2); }
422
469
 
@@ -474,6 +521,22 @@ body.dragging .drop-hint { display: block; }
474
521
  .actor-agent .event-body p { color: #b9c3d8; }
475
522
  [data-theme="light"] .actor-agent .event-body p { color: #3a4a63; }
476
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
+
477
540
  .empty { color: var(--text-dim); font-style: italic; }
478
541
 
479
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() {
@@ -22,6 +22,11 @@ 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)
@@ -70,6 +75,13 @@ class CompactJob < ApplicationJob
70
75
  "starting from scratch:\n#{card.compact}"
71
76
  end
72
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."
73
85
  parts.join("\n")
74
86
  end
75
87
 
@@ -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,6 +16,10 @@ 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)
@@ -61,6 +65,11 @@ class SummaryJob < ApplicationJob
61
65
  "refine and update it with any new work rather than starting from scratch:\n#{card.summary}"
62
66
  end
63
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."
64
73
  parts.join("\n")
65
74
  end
66
75
 
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
@@ -48,6 +48,15 @@ class Card < ApplicationRecord
48
48
  after_commit -> { broadcast_refresh_to board }
49
49
 
50
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
51
60
 
52
61
  def needs_attention? = %w[needs_input blocked failed work_complete].include?(status)
53
62
 
@@ -114,9 +123,19 @@ class Card < ApplicationRecord
114
123
  def awaiting_assistant?
115
124
  return false unless column.planning? && column.ai?
116
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
117
127
  last.present? && %w[user_message column_move].include?(last.kind)
118
128
  end
119
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
+
120
139
  # Some AI is expected to write to this card imminently.
121
140
  def thinking?
122
141
  awaiting_assistant? || working?
data/app/models/column.rb CHANGED
@@ -139,7 +139,7 @@ class Column < ApplicationRecord
139
139
  ActiveSupport::NumberHelper.number_to_delimited(
140
140
  column_runs.sum("input_tokens + output_tokens") + column_ai_calls.sum("input_tokens + output_tokens"))
141
141
  when "count_cards"
142
- cards.count.to_s
142
+ cards.active.count.to_s
143
143
  when "model"
144
144
  # The column's active AI model, short form. Blank when AI is off or no
145
145
  # model is set — the row then shows just its label, telling the truth.
@@ -195,11 +195,11 @@ class Column < ApplicationRecord
195
195
 
196
196
  # Every run belonging to a card in this column, for footer aggregation.
197
197
  def column_runs
198
- 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
199
  end
200
200
 
201
201
  # One-shot AI spend (assistant/ai_task/summary/…) of this column's cards.
202
202
  def column_ai_calls
203
- AiCall.where(card_id: cards.select(:id))
203
+ AiCall.where(card_id: cards.active.select(:id))
204
204
  end
205
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+}])
@@ -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
@@ -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>
@@ -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>
@@ -51,7 +51,7 @@
51
51
  <% else %>
52
52
  <div class="timeline-scroll" data-scroll-target="scroller">
53
53
  <% if @card.description.present? %>
54
- <div class="event event-description"><%= render_markdown @card.description %></div>
54
+ <div class="event event-description"><%= render_with_attachments @card.description %></div>
55
55
  <% end %>
56
56
 
57
57
  <div id="card_events">
@@ -74,8 +74,11 @@
74
74
 
75
75
  <%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
76
76
  <%= f.text_area "message[text]", rows: 2, required: true,
77
- data: { controller: "composer", action: "keydown->composer#keydown" },
77
+ data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
78
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>
79
82
  <% end %>
80
83
  <% end %>
81
84
  </section>
@@ -91,7 +94,8 @@
91
94
  <label>Tags</label>
92
95
  <%= render "cards/tag_picker", board: @card.board, tags: @card.tags %>
93
96
  <label>Description</label>
94
- <%= f.text_area :description, rows: 6 %>
97
+ <%= f.text_area :description, rows: 6,
98
+ data: { controller: "attach", action: "paste->attach#paste" } %>
95
99
  <label>Branch <span class="hint">— optional</span></label>
96
100
  <% if @card.branch_name.present? %>
97
101
  <%# Locked once set — by the user here or by the agent on run start. %>
@@ -229,6 +233,11 @@
229
233
 
230
234
  <details class="advanced-rules panel-advanced">
231
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? %>
232
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>
233
242
  <%= button_to "🗑 Delete card", card_path(@card), method: :delete,
234
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,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
data/db/queue_schema.rb CHANGED
@@ -1,123 +1,135 @@
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_queue_blocked_executions", force: :cascade do |t|
3
- t.bigint "job_id", null: false
4
- t.string "queue_name", null: false
5
- t.integer "priority", default: 0, null: false
6
15
  t.string "concurrency_key", null: false
7
- t.datetime "expires_at", null: false
8
16
  t.datetime "created_at", null: false
9
- t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
10
- t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
11
- t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
17
+ t.datetime "expires_at", null: false
18
+ t.bigint "job_id", null: false
19
+ t.integer "priority", default: 0, null: false
20
+ t.string "queue_name", null: false
21
+ t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
22
+ t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
23
+ t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
12
24
  end
13
25
 
14
26
  create_table "solid_queue_claimed_executions", force: :cascade do |t|
27
+ t.datetime "created_at", null: false
15
28
  t.bigint "job_id", null: false
16
29
  t.bigint "process_id"
17
- t.datetime "created_at", null: false
18
- t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
19
- t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
30
+ t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
31
+ t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
20
32
  end
21
33
 
22
34
  create_table "solid_queue_failed_executions", force: :cascade do |t|
23
- t.bigint "job_id", null: false
24
- t.text "error"
25
35
  t.datetime "created_at", null: false
26
- t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
36
+ t.text "error"
37
+ t.bigint "job_id", null: false
38
+ t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
27
39
  end
28
40
 
29
41
  create_table "solid_queue_jobs", force: :cascade do |t|
30
- t.string "queue_name", null: false
31
- t.string "class_name", null: false
32
- t.text "arguments"
33
- t.integer "priority", default: 0, null: false
34
42
  t.string "active_job_id"
35
- t.datetime "scheduled_at"
36
- t.datetime "finished_at"
43
+ t.text "arguments"
44
+ t.string "class_name", null: false
37
45
  t.string "concurrency_key"
38
46
  t.datetime "created_at", null: false
47
+ t.datetime "finished_at"
48
+ t.integer "priority", default: 0, null: false
49
+ t.string "queue_name", null: false
50
+ t.datetime "scheduled_at"
39
51
  t.datetime "updated_at", null: false
40
- t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
41
- t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
42
- t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
43
- t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
44
- t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
52
+ t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
53
+ t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
54
+ t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
55
+ t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
56
+ t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
45
57
  end
46
58
 
47
59
  create_table "solid_queue_pauses", force: :cascade do |t|
48
- t.string "queue_name", null: false
49
60
  t.datetime "created_at", null: false
50
- t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
61
+ t.string "queue_name", null: false
62
+ t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
51
63
  end
52
64
 
53
65
  create_table "solid_queue_processes", force: :cascade do |t|
66
+ t.datetime "created_at", null: false
67
+ t.string "hostname"
54
68
  t.string "kind", null: false
55
69
  t.datetime "last_heartbeat_at", null: false
56
- t.bigint "supervisor_id"
57
- t.integer "pid", null: false
58
- t.string "hostname"
59
70
  t.text "metadata"
60
- t.datetime "created_at", null: false
61
71
  t.string "name", null: false
62
- t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
63
- t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
64
- t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
72
+ t.integer "pid", null: false
73
+ t.bigint "supervisor_id"
74
+ t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
75
+ t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
76
+ t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
65
77
  end
66
78
 
67
79
  create_table "solid_queue_ready_executions", force: :cascade do |t|
80
+ t.datetime "created_at", null: false
68
81
  t.bigint "job_id", null: false
69
- t.string "queue_name", null: false
70
82
  t.integer "priority", default: 0, null: false
71
- t.datetime "created_at", null: false
72
- t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
73
- t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
74
- t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
83
+ t.string "queue_name", null: false
84
+ t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
85
+ t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
86
+ t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
75
87
  end
76
88
 
77
89
  create_table "solid_queue_recurring_executions", force: :cascade do |t|
90
+ t.datetime "created_at", null: false
78
91
  t.bigint "job_id", null: false
79
- t.string "task_key", null: false
80
92
  t.datetime "run_at", null: false
81
- t.datetime "created_at", null: false
82
- t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
83
- t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
93
+ t.string "task_key", null: false
94
+ t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
95
+ t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
84
96
  end
85
97
 
86
98
  create_table "solid_queue_recurring_tasks", force: :cascade do |t|
87
- t.string "key", null: false
88
- t.string "schedule", null: false
89
- t.string "command", limit: 2048
90
- t.string "class_name"
91
99
  t.text "arguments"
92
- t.string "queue_name"
100
+ t.string "class_name"
101
+ t.string "command", limit: 2048
102
+ t.datetime "created_at", null: false
103
+ t.text "description"
104
+ t.string "key", null: false
93
105
  t.integer "priority", default: 0
106
+ t.string "queue_name"
107
+ t.string "schedule", null: false
94
108
  t.boolean "static", default: true, null: false
95
- t.text "description"
96
- t.datetime "created_at", null: false
97
109
  t.datetime "updated_at", null: false
98
- t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
99
- t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
110
+ t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
111
+ t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
100
112
  end
101
113
 
102
114
  create_table "solid_queue_scheduled_executions", force: :cascade do |t|
115
+ t.datetime "created_at", null: false
103
116
  t.bigint "job_id", null: false
104
- t.string "queue_name", null: false
105
117
  t.integer "priority", default: 0, null: false
118
+ t.string "queue_name", null: false
106
119
  t.datetime "scheduled_at", null: false
107
- t.datetime "created_at", null: false
108
- t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
109
- t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
120
+ t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
121
+ t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
110
122
  end
111
123
 
112
124
  create_table "solid_queue_semaphores", force: :cascade do |t|
113
- t.string "key", null: false
114
- t.integer "value", default: 1, null: false
115
- t.datetime "expires_at", null: false
116
125
  t.datetime "created_at", null: false
126
+ t.datetime "expires_at", null: false
127
+ t.string "key", null: false
117
128
  t.datetime "updated_at", null: false
118
- t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
119
- t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
120
- t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
129
+ t.integer "value", default: 1, null: false
130
+ t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
131
+ t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
132
+ t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
121
133
  end
122
134
 
123
135
  add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.14"
2
+ VERSION = "0.2.16"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardinal-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.14
4
+ version: 0.2.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -171,6 +171,8 @@ files:
171
171
  - app/helpers/application_helper.rb
172
172
  - app/javascript/application.js
173
173
  - app/javascript/controllers/application.js
174
+ - app/javascript/controllers/archive_drop_controller.js
175
+ - app/javascript/controllers/attach_controller.js
174
176
  - app/javascript/controllers/autosave_controller.js
175
177
  - app/javascript/controllers/board_column_controller.js
176
178
  - app/javascript/controllers/clipboard_controller.js
@@ -209,11 +211,14 @@ files:
209
211
  - app/services/agent/workspace.rb
210
212
  - app/services/card_transition.rb
211
213
  - app/services/claude_cli.rb
214
+ - app/services/github_issues.rb
212
215
  - app/services/rules.rb
213
216
  - app/services/rules/compiler.rb
214
217
  - app/services/run_sweeper.rb
218
+ - app/views/boards/archive.html.erb
215
219
  - app/views/boards/brief.html.erb
216
220
  - app/views/boards/edit.html.erb
221
+ - app/views/boards/issues.html.erb
217
222
  - app/views/boards/show.html.erb
218
223
  - app/views/cards/_card.html.erb
219
224
  - app/views/cards/_compact_panel.html.erb
@@ -269,6 +274,8 @@ files:
269
274
  - db/migrate/20260704140000_add_compact_to_cards.rb
270
275
  - db/migrate/20260704231436_add_model_and_effort_to_cards.rb
271
276
  - db/migrate/20260705120000_create_ai_calls.rb
277
+ - db/migrate/20260705120001_add_issue_number_to_cards.rb
278
+ - db/migrate/20260705193935_add_settings_to_boards.rb
272
279
  - db/queue_schema.rb
273
280
  - db/seeds.rb
274
281
  - docker/agent/Dockerfile