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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aaa2760a909ef2ee8cef012432e1a15311d1d3db2b1f37a5f2374e4322fc0c92
4
- data.tar.gz: 6f995d0029b94d19dbb7f26fccf1dd01014ceab5973a766a91ef72aec043335d
3
+ metadata.gz: 0a2b2e4142b5c9c54b8d320d6e36c17832c2bfb69996fce034be52ee2b4849fe
4
+ data.tar.gz: 266f7d5ce81b724643c600582ce744ed48cb8bc24606d98c3b16bc21b515b673
5
5
  SHA512:
6
- metadata.gz: 58d4a91169f2b8ab305e86b49d4d839cb16b275904d672525352258845af6ab70b6c47777d6a9b56dfc45b1db583b19fbb5e7bcce3d63cd8ec578e2d5a1e06ae
7
- data.tar.gz: bd8de891a41f0a7841d371d342173639b7548988b8c25377804207971da1735f6e9ea9b2a8e6e17a736a6487d0d394b0160a5aa18ce60638e51b78b1bce5c693
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
- # Shell access off: restrict to file tools — the sandbox is the tool
129
- # list itself, not a request in the prompt.
130
- cmd += ["--tools", "Read,Glob,Grep,Edit,Write"] unless column.shell_access?
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
- unless column.shell_access?
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
- column.shell_access? ? EXECUTE_RULES : RESTRICTED_EXECUTE_RULES
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
@@ -54,14 +54,26 @@ module Asana
54
54
  card
55
55
  end
56
56
 
57
- def self.request(path, auth)
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
- response = http.get(uri.request_uri, { "Authorization" => "Bearer #{auth}" })
64
- unless response.code.to_i == 200
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
- <p class="hint">Overrides this card's model and effort. Only model + effort — budget, timeout, and concurrency are column policy. Takes effect on the next run segment (a run already going finishes on the model it started with).</p>
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
@@ -15,6 +15,7 @@ Rails.application.routes.draw do
15
15
  post :approve
16
16
  post :archive
17
17
  post :unarchive
18
+ post :share_summary
18
19
  post :summarize
19
20
  post :compact
20
21
  end
@@ -0,0 +1,5 @@
1
+ class AddPermissionModeToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :permission_mode, :string
4
+ end
5
+ end
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.17"
2
+ VERSION = "0.2.19"
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.17
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