cardinal-ai 0.2.17 → 0.2.19
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 +8 -0
- data/app/controllers/boards_controller.rb +2 -1
- data/app/controllers/cards_controller.rb +48 -2
- data/app/models/board.rb +9 -1
- data/app/models/card.rb +12 -0
- data/app/services/agent/runner.rb +13 -5
- data/app/services/asana.rb +15 -3
- data/app/views/boards/edit.html.erb +7 -1
- data/app/views/cards/_detail.html.erb +11 -1
- data/app/views/cards/_summary_panel.html.erb +24 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260706002904_add_permission_mode_to_cards.rb +5 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a2b2e4142b5c9c54b8d320d6e36c17832c2bfb69996fce034be52ee2b4849fe
|
|
4
|
+
data.tar.gz: 266f7d5ce81b724643c600582ce744ed48cb8bc24606d98c3b16bc21b515b673
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 94358996801e25b84e34564156a362d3963bf4941c0d195215cf19aee86fd510f4d22ee1ecd3adf4500d2846c811f7df653d808cbceca8c028c38e678e4a3337
|
|
7
|
+
data.tar.gz: 86deb643dae9b203eeb462e0c62c30c3bb7aa85b648b89ef7c53b8436f3a14af013fa231384e1e5f28a74aa0be0e9bab410b22e820fd500af4fd4c4e0ec5ff42
|
|
@@ -168,6 +168,14 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
168
168
|
.asana-steps li { margin: 4px 0; }
|
|
169
169
|
.asana-connected { color: var(--green); }
|
|
170
170
|
|
|
171
|
+
.summary-share { display: flex; align-items: center; margin-top: 8px; flex-shrink: 0; }
|
|
172
|
+
.summary-share button[disabled] { opacity: .4; cursor: not-allowed; pointer-events: none; }
|
|
173
|
+
.share-spacer { flex: 1; text-align: center; }
|
|
174
|
+
.share-status { font-size: 12px; font-weight: 600; color: var(--green);
|
|
175
|
+
animation: share-fade 4s ease forwards; }
|
|
176
|
+
.share-status.share-err { color: var(--red); animation: none; }
|
|
177
|
+
@keyframes share-fade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } }
|
|
178
|
+
|
|
171
179
|
.pull-form { display: inline; }
|
|
172
180
|
#repo-pull-status { font-size: 0.85rem; }
|
|
173
181
|
#repo-pull-status .pull-ok { color: var(--green); }
|
|
@@ -29,8 +29,9 @@ class BoardsController < ApplicationController
|
|
|
29
29
|
|
|
30
30
|
def update
|
|
31
31
|
board = Board.first!
|
|
32
|
-
attrs = params.require(:board).permit(:name, :default_branch, archive_accepts_from: [])
|
|
32
|
+
attrs = params.require(:board).permit(:name, :default_branch, :permission_bypass, archive_accepts_from: [])
|
|
33
33
|
board.archive_accepts_from = attrs[:archive_accepts_from].to_a.map(&:to_s).reject(&:blank?) if attrs.key?(:archive_accepts_from)
|
|
34
|
+
board.permission_bypass = (attrs[:permission_bypass] == "1") if attrs.key?(:permission_bypass)
|
|
34
35
|
board.update!(
|
|
35
36
|
name: attrs[:name].presence || board.name,
|
|
36
37
|
default_branch: attrs[:default_branch].presence || board.default_branch
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
class CardsController < ApplicationController
|
|
2
|
-
before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy, :archive, :unarchive]
|
|
2
|
+
before_action :set_card, only: [:show, :update, :move, :approve, :summarize, :compact, :destroy, :archive, :unarchive, :share_summary]
|
|
3
3
|
|
|
4
4
|
def new
|
|
5
5
|
@board = Board.first!
|
|
@@ -140,6 +140,37 @@ class CardsController < ApplicationController
|
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
# Push the customer summary outward: to the source Asana task as a comment,
|
|
144
|
+
# or to the card's PR. The summary was written for exactly this audience.
|
|
145
|
+
def share_summary
|
|
146
|
+
summary = @card.summary.to_s.strip
|
|
147
|
+
if summary.blank?
|
|
148
|
+
@card.log!("error", text: "Nothing to share — the summary is empty.")
|
|
149
|
+
return redirect_to card_path(@card, zoom: "summary")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
flash_text, flash_error = nil, false
|
|
153
|
+
case params[:to]
|
|
154
|
+
when "asana"
|
|
155
|
+
Asana.comment!(@card.asana_url, "Update from Cardinal:\n\n#{summary}")
|
|
156
|
+
@card.log!("status_change", actor: "user", text: "Summary posted to the Asana task as a comment")
|
|
157
|
+
flash_text = "✓ Posted to Asana"
|
|
158
|
+
when "pr"
|
|
159
|
+
out, status = Open3.capture2e("gh", "pr", "comment", @card.pr_url, "--body", summary)
|
|
160
|
+
if status.success?
|
|
161
|
+
@card.log!("status_change", actor: "user", text: "Summary posted as a PR comment")
|
|
162
|
+
flash_text = "✓ Posted to the PR"
|
|
163
|
+
else
|
|
164
|
+
@card.log!("error", text: "Couldn't comment on the PR: #{out.truncate(160)}")
|
|
165
|
+
flash_text, flash_error = "✗ PR comment failed — see the timeline", true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
respond_with_share_flash(flash_text, flash_error)
|
|
169
|
+
rescue Asana::Error => e
|
|
170
|
+
@card.log!("error", text: "Couldn't post to Asana: #{e.message}")
|
|
171
|
+
respond_with_share_flash("✗ Asana refused — see the timeline", true)
|
|
172
|
+
end
|
|
173
|
+
|
|
143
174
|
# Archive (card #42): off the board, never gone — /board/archive lists,
|
|
144
175
|
# searches, and restores. Running cards can't be archived out from under
|
|
145
176
|
# their agent.
|
|
@@ -162,12 +193,27 @@ class CardsController < ApplicationController
|
|
|
162
193
|
|
|
163
194
|
private
|
|
164
195
|
|
|
196
|
+
# In-place feedback on the Summary tab: replace the panel with a transient
|
|
197
|
+
# ✓/✗ flash next to the share buttons (falls back to a redirect for plain
|
|
198
|
+
# HTML requests).
|
|
199
|
+
def respond_with_share_flash(text, error)
|
|
200
|
+
respond_to do |format|
|
|
201
|
+
format.turbo_stream do
|
|
202
|
+
render turbo_stream: turbo_stream.replace("card_summary",
|
|
203
|
+
partial: "cards/summary_panel",
|
|
204
|
+
locals: { card: @card.reload, share_status: text, share_error: error })
|
|
205
|
+
end
|
|
206
|
+
format.html { redirect_to card_path(@card, zoom: "summary") }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
165
210
|
def set_card
|
|
166
211
|
@card = Board.first!.cards.find_by!(number: params[:id])
|
|
167
212
|
end
|
|
168
213
|
|
|
169
214
|
def card_params
|
|
170
|
-
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact, :model, :effort)
|
|
215
|
+
attrs = params.require(:card).permit(:title, :description, :tags, :branch_name, :pr_url, :summary, :compact, :model, :effort, :permission_mode)
|
|
216
|
+
attrs[:permission_mode] = attrs[:permission_mode].presence_in(Card::PERMISSION_MODES) if attrs.key?(:permission_mode)
|
|
171
217
|
attrs[:tags] = attrs[:tags].to_s.split(",").map(&:strip).reject(&:blank?) if attrs.key?(:tags)
|
|
172
218
|
# Blank model/effort from the "Use column default" option mean "no override" —
|
|
173
219
|
# store nil so effective_* falls back to the column (card #33).
|
data/app/models/board.rb
CHANGED
|
@@ -55,7 +55,15 @@ class Board < ApplicationRecord
|
|
|
55
55
|
# may be DRAGGED onto the topbar 🗄 (explicit-only, like column rails —
|
|
56
56
|
# empty means the archive is not a drop target). The Advanced-panel archive
|
|
57
57
|
# button is always available; this governs only the drag affordance.
|
|
58
|
-
store_accessor :settings, :archive_accepts_from
|
|
58
|
+
store_accessor :settings, :archive_accepts_from, :permission_bypass
|
|
59
|
+
|
|
60
|
+
# Board default for worker autonomy (§ permissions): true (default) lets
|
|
61
|
+
# agents act without asking — full shell inside their workspace. false
|
|
62
|
+
# restricts workers to file tools (read/search/edit/write; Cardinal commits
|
|
63
|
+
# for them) unless a card explicitly overrides back to full autonomy.
|
|
64
|
+
def permission_bypass?
|
|
65
|
+
settings["permission_bypass"] != false
|
|
66
|
+
end
|
|
59
67
|
|
|
60
68
|
def archive_accepts?(column)
|
|
61
69
|
Array(archive_accepts_from).map(&:to_s).include?(column.id.to_s)
|
data/app/models/card.rb
CHANGED
|
@@ -92,6 +92,18 @@ class Card < ApplicationRecord
|
|
|
92
92
|
# default", so existing cards are unaffected. Read fresh at every segment
|
|
93
93
|
# spawn (start, restart, resume) by the runner and the planning assistant, so
|
|
94
94
|
# a change takes effect on the next segment — never on an already-running one.
|
|
95
|
+
# Permission override (nil = board default): "bypass" forces full autonomy
|
|
96
|
+
# for this card, "ask" forces the restricted file-tools mode.
|
|
97
|
+
PERMISSION_MODES = ["bypass", "ask"].freeze
|
|
98
|
+
|
|
99
|
+
def effective_permission_bypass?
|
|
100
|
+
case permission_mode
|
|
101
|
+
when "bypass" then true
|
|
102
|
+
when "ask" then false
|
|
103
|
+
else board.permission_bypass?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
95
107
|
def effective_model = model.presence || column.model
|
|
96
108
|
def effective_effort = effort.presence || column.effort
|
|
97
109
|
|
|
@@ -125,9 +125,10 @@ module Agent
|
|
|
125
125
|
cmd += ["--max-turns", "3", "--tools", ""]
|
|
126
126
|
else
|
|
127
127
|
cmd += ["--max-turns", (column.max_turns.presence || DEFAULT_EXECUTE_TURNS).to_s]
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
128
|
+
# Restricted agents (column shell toggle, or board/card permission
|
|
129
|
+
# setting) get file tools only — the sandbox is the tool list itself,
|
|
130
|
+
# not a request in the prompt.
|
|
131
|
+
cmd += ["--tools", "Read,Glob,Grep,Edit,Write"] if restricted_tools?
|
|
131
132
|
end
|
|
132
133
|
# Effective (possibly card-overridden) config, read fresh here at spawn
|
|
133
134
|
# time — so a mid-run override takes effect on the next segment (start,
|
|
@@ -232,7 +233,7 @@ module Agent
|
|
|
232
233
|
accumulate_usage(result)
|
|
233
234
|
# A shell-less agent cannot commit its own edits — do it for it before
|
|
234
235
|
# any commit counting or salvage below.
|
|
235
|
-
|
|
236
|
+
if restricted_tools?
|
|
236
237
|
workspace.commit_all!("Card ##{card.number}: #{card.title.truncate(60)}\n\nCommitted by Cardinal for a shell-less agent.")
|
|
237
238
|
end
|
|
238
239
|
unless result[:success]
|
|
@@ -380,7 +381,14 @@ module Agent
|
|
|
380
381
|
end
|
|
381
382
|
|
|
382
383
|
def execute_rules
|
|
383
|
-
|
|
384
|
+
restricted_tools? ? RESTRICTED_EXECUTE_RULES : EXECUTE_RULES
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Three voices, one verdict: the column's shell toggle, the board's
|
|
388
|
+
# permission default, and the card's own override (which beats the board
|
|
389
|
+
# but never re-opens a column that turned shell off).
|
|
390
|
+
def restricted_tools?
|
|
391
|
+
!column.shell_access? || !card.effective_permission_bypass?
|
|
384
392
|
end
|
|
385
393
|
|
|
386
394
|
def plan_prompt
|
data/app/services/asana.rb
CHANGED
|
@@ -54,14 +54,26 @@ module Asana
|
|
|
54
54
|
card
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
# Post text to the task as a comment (an Asana "story").
|
|
58
|
+
def self.comment!(task_url, text)
|
|
59
|
+
request("/tasks/#{task_gid(task_url)}/stories", token,
|
|
60
|
+
method: :post, body: { data: { text: text } })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.request(path, auth, method: :get, body: nil)
|
|
58
64
|
uri = URI("#{API}#{path}")
|
|
59
65
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
60
66
|
http.use_ssl = true
|
|
61
67
|
http.open_timeout = 10
|
|
62
68
|
http.read_timeout = 15
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
headers = { "Authorization" => "Bearer #{auth}" }
|
|
70
|
+
response =
|
|
71
|
+
if method == :post
|
|
72
|
+
http.post(uri.request_uri, body.to_json, headers.merge("Content-Type" => "application/json"))
|
|
73
|
+
else
|
|
74
|
+
http.get(uri.request_uri, headers)
|
|
75
|
+
end
|
|
76
|
+
unless [200, 201].include?(response.code.to_i)
|
|
65
77
|
raise Error, "Asana said no (HTTP #{response.code}) — check the token, and that it can see this task"
|
|
66
78
|
end
|
|
67
79
|
JSON.parse(response.body)["data"]
|
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
<div id="board-form-errors"></div>
|
|
11
11
|
|
|
12
12
|
<%= form_with model: @board, url: board_path(autosave: 1), class: "card-edit",
|
|
13
|
-
data: { autosave_target: "form" } do |f| %>
|
|
13
|
+
data: { autosave_target: "form", action: "input->autosave#save change->autosave#save" } do |f| %>
|
|
14
14
|
<label>Board name</label>
|
|
15
15
|
<%= f.text_field :name %>
|
|
16
16
|
|
|
17
17
|
<label>Default branch <%= info_tip("The branch agents fork card branches from and Done merges pull requests toward. Detected from the repo at first boot; fix it here if that guess was wrong.") %></label>
|
|
18
18
|
<%= f.text_field :default_branch, placeholder: "main" %>
|
|
19
|
+
|
|
20
|
+
<label class="check-row">
|
|
21
|
+
<%= f.check_box :permission_bypass, { checked: @board.permission_bypass? }, "1", "0" %>
|
|
22
|
+
Agents act without asking (full autonomy)
|
|
23
|
+
<%= info_tip("On (default): worker agents run shell commands — tests, builds, git — inside their workspace without permission prompts. Off: workers get file tools only (read, search, edit, write); they cannot execute anything, and Cardinal commits their edits for them. Each card can override this in its 🧠 panel; a column whose Shell access is off stays restricted regardless.") %>
|
|
24
|
+
</label>
|
|
19
25
|
<% end %>
|
|
20
26
|
|
|
21
27
|
<label>Repository</label>
|
|
@@ -176,7 +176,17 @@
|
|
|
176
176
|
<%= f.select :effort, [["Use column default", ""], "low", "medium", "high", "max"], selected: @card.effort %>
|
|
177
177
|
</div>
|
|
178
178
|
</div>
|
|
179
|
-
<
|
|
179
|
+
<div class="field-row">
|
|
180
|
+
<div>
|
|
181
|
+
<label>Permissions <%= info_tip("Overrides the board's autonomy default for THIS card. Full autonomy: the worker runs shell commands without asking. Restricted: file tools only — it cannot execute anything, and Cardinal commits its edits. A column whose Shell access is off stays restricted regardless.") %></label>
|
|
182
|
+
<%= f.select :permission_mode,
|
|
183
|
+
[["Board default (#{@card.board.permission_bypass? ? "full autonomy" : "restricted"})", ""],
|
|
184
|
+
["Full autonomy — acts without asking", "bypass"],
|
|
185
|
+
["Restricted — file tools only", "ask"]],
|
|
186
|
+
selected: @card.permission_mode.to_s %>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
<p class="hint">Overrides this card's model, effort, and permissions. Budget, timeout, and concurrency are column policy. Takes effect on the next run segment (a run already going finishes on the settings it started with).</p>
|
|
180
190
|
</details>
|
|
181
191
|
<% end %>
|
|
182
192
|
</div>
|
|
@@ -27,4 +27,28 @@
|
|
|
27
27
|
disabled: card.summary_working? %>
|
|
28
28
|
<% end %>
|
|
29
29
|
|
|
30
|
+
<% can_asana = Asana.connected? && card.asana_url.present? %>
|
|
31
|
+
<% can_pr = card.pr_url.present? %>
|
|
32
|
+
<% if can_asana || can_pr %>
|
|
33
|
+
<div class="summary-share">
|
|
34
|
+
<% if can_asana %>
|
|
35
|
+
<%= button_to "◯ Post as comment on Asana task", share_summary_card_path(card, to: "asana"),
|
|
36
|
+
class: "theme-toggle", disabled: card.summary.blank?,
|
|
37
|
+
data: { turbo_submits_with: "Posting…" },
|
|
38
|
+
title: "Post this summary as a comment on #{card.asana_url}" %>
|
|
39
|
+
<% end %>
|
|
40
|
+
<span class="share-spacer">
|
|
41
|
+
<% if local_assigns[:share_status].present? %>
|
|
42
|
+
<span class="share-status<%= " share-err" if local_assigns[:share_error] %>"><%= share_status %></span>
|
|
43
|
+
<% end %>
|
|
44
|
+
</span>
|
|
45
|
+
<% if can_pr %>
|
|
46
|
+
<%= button_to "💬 Post as comment on PR", share_summary_card_path(card, to: "pr"),
|
|
47
|
+
class: "theme-toggle", disabled: card.summary.blank?,
|
|
48
|
+
data: { turbo_submits_with: "Posting…" },
|
|
49
|
+
title: "Post this summary as a comment on #{card.pr_url}" %>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
30
54
|
</div>
|
data/config/routes.rb
CHANGED
data/lib/cardinal/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.19
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jason Ellis
|
|
@@ -280,6 +280,7 @@ files:
|
|
|
280
280
|
- db/migrate/20260705120001_add_issue_number_to_cards.rb
|
|
281
281
|
- db/migrate/20260705193935_add_settings_to_boards.rb
|
|
282
282
|
- db/migrate/20260705225451_add_asana_url_to_cards.rb
|
|
283
|
+
- db/migrate/20260706002904_add_permission_mode_to_cards.rb
|
|
283
284
|
- db/queue_schema.rb
|
|
284
285
|
- db/seeds.rb
|
|
285
286
|
- docker/agent/Dockerfile
|