cardinal-ai 0.2.14 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/cardinal.css +83 -4
- data/app/controllers/asana_controller.rb +31 -0
- data/app/controllers/boards_controller.rb +25 -1
- data/app/controllers/cards_controller.rb +21 -1
- data/app/controllers/messages_controller.rb +16 -1
- data/app/helpers/application_helper.rb +54 -0
- data/app/javascript/controllers/archive_drop_controller.js +41 -0
- data/app/javascript/controllers/attach_controller.js +80 -0
- data/app/javascript/controllers/board_column_controller.js +6 -0
- data/app/jobs/compact_job.rb +12 -0
- data/app/jobs/resume_run_job.rb +8 -0
- data/app/jobs/summary_job.rb +9 -0
- data/app/models/board.rb +10 -0
- data/app/models/card.rb +19 -0
- data/app/models/column.rb +3 -3
- data/app/services/agent/runner.rb +18 -3
- data/app/services/asana.rb +73 -0
- data/app/services/github_issues.rb +49 -0
- data/app/views/asana/new_card.html.erb +59 -0
- data/app/views/boards/archive.html.erb +56 -0
- data/app/views/boards/issues.html.erb +41 -0
- data/app/views/boards/show.html.erb +8 -0
- data/app/views/cards/_card.html.erb +6 -2
- data/app/views/cards/_compact_panel.html.erb +5 -3
- data/app/views/cards/_detail.html.erb +16 -5
- data/app/views/cards/_summary_panel.html.erb +5 -3
- data/app/views/cards/new.html.erb +3 -0
- data/app/views/columns/_column.html.erb +8 -1
- data/app/views/events/_event.html.erb +2 -1
- data/config/routes.rb +10 -0
- data/db/cable_schema.rb +15 -3
- data/db/migrate/20260705120001_add_issue_number_to_cards.rb +5 -0
- data/db/migrate/20260705193935_add_settings_to_boards.rb +6 -0
- data/db/migrate/20260705225451_add_asana_url_to_cards.rb +5 -0
- data/db/queue_schema.rb +74 -62
- data/lib/cardinal/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa2760a909ef2ee8cef012432e1a15311d1d3db2b1f37a5f2374e4322fc0c92
|
|
4
|
+
data.tar.gz: 6f995d0029b94d19dbb7f26fccf1dd01014ceab5973a766a91ef72aec043335d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58d4a91169f2b8ab305e86b49d4d839cb16b275904d672525352258845af6ab70b6c47777d6a9b56dfc45b1db583b19fbb5e7bcce3d63cd8ec578e2d5a1e06ae
|
|
7
|
+
data.tar.gz: bd8de891a41f0a7841d371d342173639b7548988b8c25377804207971da1735f6e9ea9b2a8e6e17a736a6487d0d394b0160a5aa18ce60638e51b78b1bce5c693
|
|
@@ -110,6 +110,64 @@ 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
|
+
.composer-box { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
|
131
|
+
.composer-box textarea { width: 100%; }
|
|
132
|
+
.note-toggle {
|
|
133
|
+
font-size: 11px; color: var(--text-dim); cursor: pointer; align-self: flex-start;
|
|
134
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
135
|
+
}
|
|
136
|
+
.note-toggle input[type="checkbox"] { margin: 0; }
|
|
137
|
+
.note-toggle input { accent-color: var(--amber); }
|
|
138
|
+
.note-flag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 8px; padding: 0 6px; margin-right: 4px; }
|
|
139
|
+
|
|
140
|
+
.issue-list { list-style: none; margin: 0; padding: 0; }
|
|
141
|
+
.issue-row { display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
142
|
+
padding: 8px 4px; border-bottom: 1px solid var(--border); }
|
|
143
|
+
.issue-row:last-child { border-bottom: 0; }
|
|
144
|
+
|
|
145
|
+
.ready-chip { color: var(--green); border-color: var(--green); }
|
|
146
|
+
|
|
147
|
+
/* 🗄 as a drag target: lit while a drag from an allowed column is live. */
|
|
148
|
+
.archive-drop { display: inline-flex; border-radius: 6px; }
|
|
149
|
+
.archive-drop.drop-ready .archive-link {
|
|
150
|
+
color: var(--amber); border-color: var(--amber);
|
|
151
|
+
animation: pulse-dot 1.2s ease-in-out infinite;
|
|
152
|
+
}
|
|
153
|
+
.archive-drop .card, .archive-drop .card-ghost { display: none !important; }
|
|
154
|
+
|
|
155
|
+
.archive-rails { margin: 0 0 16px; padding: 10px 12px; background: var(--surface);
|
|
156
|
+
border: 1px solid var(--border); border-radius: 8px; font-size: 12px; }
|
|
157
|
+
.archive-rails .rails-label { font-weight: 600; margin-right: 10px; }
|
|
158
|
+
.archive-rails .inline-check { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; cursor: pointer; }
|
|
159
|
+
.archive-rails .hint { display: block; margin-top: 6px; }
|
|
160
|
+
|
|
161
|
+
.asana-entry {
|
|
162
|
+
margin-right: auto; display: inline-flex; align-items: center;
|
|
163
|
+
background: transparent; color: var(--text-dim); border: 1px solid var(--border);
|
|
164
|
+
border-radius: 6px; padding: 6px 12px; font-weight: 600; font-size: inherit;
|
|
165
|
+
}
|
|
166
|
+
.asana-entry:hover { color: var(--text); border-color: var(--text-dim); }
|
|
167
|
+
.asana-steps { font-size: 13px; margin: 8px 0 14px; padding-left: 20px; }
|
|
168
|
+
.asana-steps li { margin: 4px 0; }
|
|
169
|
+
.asana-connected { color: var(--green); }
|
|
170
|
+
|
|
113
171
|
.pull-form { display: inline; }
|
|
114
172
|
#repo-pull-status { font-size: 0.85rem; }
|
|
115
173
|
#repo-pull-status .pull-ok { color: var(--green); }
|
|
@@ -408,15 +466,20 @@ body.dragging .drop-hint { display: block; }
|
|
|
408
466
|
.zoom-tabs a { padding: 4px 12px; border-radius: 6px; color: var(--text-dim); }
|
|
409
467
|
.zoom-tabs a.active { background: var(--surface-2); color: var(--text); }
|
|
410
468
|
|
|
469
|
+
/* Summary/Compact panels fill the pane: header + blurb on top, the textarea
|
|
470
|
+
flexes to all remaining height, the last-generated stamp holds the floor. */
|
|
471
|
+
.summary-panel { display: flex; flex-direction: column; height: 100%; }
|
|
411
472
|
.summary-panel .summary-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
412
473
|
.summary-panel .summary-head h3 { margin: 0; }
|
|
413
474
|
.summary-generate { white-space: nowrap; }
|
|
414
|
-
.summary-blurb { margin: 6px 0 12px; }
|
|
475
|
+
.summary-blurb { margin: 6px 0 12px; flex-shrink: 0; }
|
|
476
|
+
.summary-form { flex: 1; min-height: 0; display: flex; }
|
|
415
477
|
.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:
|
|
478
|
+
width: 100%; height: 100%; flex: 1; background: var(--surface-2); border: 1px solid var(--border);
|
|
479
|
+
border-radius: 6px; color: var(--text); padding: 8px 10px; font: inherit; resize: none;
|
|
418
480
|
}
|
|
419
|
-
.summary-
|
|
481
|
+
.summary-head-right { display: flex; align-items: center; gap: 10px; }
|
|
482
|
+
.summary-stamp { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
|
|
420
483
|
|
|
421
484
|
.event { display: flex; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--surface-2); }
|
|
422
485
|
|
|
@@ -474,6 +537,22 @@ body.dragging .drop-hint { display: block; }
|
|
|
474
537
|
.actor-agent .event-body p { color: #b9c3d8; }
|
|
475
538
|
[data-theme="light"] .actor-agent .event-body p { color: #3a4a63; }
|
|
476
539
|
|
|
540
|
+
/* Pasted attachments (card #21): image thumbnails and file badges, inline in
|
|
541
|
+
descriptions and timeline messages. */
|
|
542
|
+
.attachment { margin: 6px 0; }
|
|
543
|
+
.attachment-image { display: inline-block; margin: 6px 8px 6px 0; }
|
|
544
|
+
.attachment-thumb {
|
|
545
|
+
display: block; max-width: 120px; max-height: 120px; width: auto; height: auto;
|
|
546
|
+
border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2);
|
|
547
|
+
}
|
|
548
|
+
.attachment-image figcaption { font-size: 11px; color: var(--text-dim); margin-top: 3px; }
|
|
549
|
+
.attachment-file {
|
|
550
|
+
display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px;
|
|
551
|
+
background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px;
|
|
552
|
+
font-size: 12px; color: var(--text-dim);
|
|
553
|
+
}
|
|
554
|
+
.attachment-icon { font-size: 14px; }
|
|
555
|
+
|
|
477
556
|
.empty { color: var(--text-dim); font-style: italic; }
|
|
478
557
|
|
|
479
558
|
/* AI typing indicator */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Asana import (card #7). First visit is a connect wizard (PAT walkthrough);
|
|
2
|
+
# once connected it's a one-field modal: paste the task URL, get a card.
|
|
3
|
+
class AsanaController < ApplicationController
|
|
4
|
+
def new_card
|
|
5
|
+
@board = Board.first!
|
|
6
|
+
redirect_to root_path and return unless turbo_frame_request?
|
|
7
|
+
@connected = Asana.connected?
|
|
8
|
+
@error = params[:error]
|
|
9
|
+
@just_connected = params[:connected]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def connect
|
|
13
|
+
name = Asana.verify!(params.require(:token))
|
|
14
|
+
Asana.save_token!(params[:token])
|
|
15
|
+
redirect_to asana_new_card_path(connected: name)
|
|
16
|
+
rescue Asana::Error => e
|
|
17
|
+
redirect_to asana_new_card_path(error: e.message)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def import
|
|
21
|
+
card = Asana.import!(Board.first!, params.require(:url))
|
|
22
|
+
redirect_to card_path(card)
|
|
23
|
+
rescue Asana::Error => e
|
|
24
|
+
redirect_to asana_new_card_path(error: e.message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def disconnect
|
|
28
|
+
Asana.disconnect!
|
|
29
|
+
redirect_to asana_new_card_path
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -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
|
|
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() {
|
data/app/jobs/compact_job.rb
CHANGED
|
@@ -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
|
|
data/app/jobs/resume_run_job.rb
CHANGED
|
@@ -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
|
data/app/jobs/summary_job.rb
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
77
|
+
# Approve-with-notes: an approval may carry final adjustments — fold
|
|
78
|
+
# them into the execute prompt instead of silently dropping them.
|
|
79
|
+
prompt = ["Your plan is approved — execute it now.",
|
|
80
|
+
(message.present? ? "Notes from the user to fold in as you execute:\n\n#{message}" : nil),
|
|
81
|
+
execute_rules].compact.join("\n\n")
|
|
82
|
+
stream_agent(prompt: prompt, mode: "execute", resuming: true)
|
|
79
83
|
elsif run.phase == "plan"
|
|
80
84
|
stream_agent(prompt: "Feedback on your plan:\n\n#{message}\n\nRevise the plan accordingly, present it, and stop again for approval. Stay in read-only mode.",
|
|
81
85
|
mode: "plan", resuming: true)
|
|
@@ -263,6 +267,10 @@ module Agent
|
|
|
263
267
|
ensure_pull_request(workspace)
|
|
264
268
|
end
|
|
265
269
|
|
|
270
|
+
if (undelivered = Array(run.briefing["steering"])).any?
|
|
271
|
+
card.log!("status_change", run: run,
|
|
272
|
+
text: "#{undelivered.size} queued note(s) never reached the agent — the run finished before its next check-in. They stay in the conversation, so the next run will see them.")
|
|
273
|
+
end
|
|
266
274
|
run.update!(status: "succeeded", finished_at: Time.current,
|
|
267
275
|
result_summary: report.presence&.truncate(2000))
|
|
268
276
|
card.log!("final_report", actor: "agent", run: run,
|
|
@@ -331,13 +339,20 @@ module Agent
|
|
|
331
339
|
|
|
332
340
|
def base_sha = run.briefing.fetch("base_sha")
|
|
333
341
|
|
|
342
|
+
# "Closes #N" makes GitHub close the source issue on merge (card #49).
|
|
343
|
+
def pr_body
|
|
344
|
+
[("Closes ##{card.issue_number}" if card.issue_number.present?),
|
|
345
|
+
"Automated work by Cardinal card ##{card.number}'s agent.",
|
|
346
|
+
card.description].compact.join("\n\n")
|
|
347
|
+
end
|
|
348
|
+
|
|
334
349
|
def ensure_pull_request(workspace)
|
|
335
350
|
return if card.pr_url.present?
|
|
336
351
|
out, status = Open3.capture2e(
|
|
337
352
|
"gh", "pr", "create", "--draft",
|
|
338
353
|
"--head", card.branch_name,
|
|
339
354
|
"--title", "##{card.number} #{card.title}",
|
|
340
|
-
"--body",
|
|
355
|
+
"--body", pr_body,
|
|
341
356
|
chdir: workspace.path.to_s
|
|
342
357
|
)
|
|
343
358
|
if status.success? && (url = out[%r{https://github\.com/\S+/pull/\d+}])
|