cardinal-ai 0.2.16 → 0.2.18

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: 84bab289a5a9655c54a7e2cbd4587c21275a122903d08892f264c20fc2c0a52d
4
- data.tar.gz: fdf187aa387793ddb12e7e5ccb6d9cb649a3214d894859490217be45585d690b
3
+ metadata.gz: e3382a025a7d0f1ce58e7a8a9f64a6a36705a2e27f2e9e9acd116dfa0c8955e8
4
+ data.tar.gz: 13824a0d4d5680a880151780e33b611e16c2299f9c7465bebbf47af5bf32718b
5
5
  SHA512:
6
- metadata.gz: b128883b680056de0b221bba6df4d8db8c8ea299ef93feec45c30cf3c7df238982b983cc6ffb50db17ed52c7781d71ee2442c4091c1fd1c922e03209ca2af7ea
7
- data.tar.gz: de0ae6c696aa5b3fa490f5f985a9acf81bf45d468660ff47522ec835c9871061b039eee9f5d872570667d8c109dd1b08ca2b71fe861cbea7a775f2ccef70d61e
6
+ metadata.gz: '098257f74f2a65053e822330315ce564e44fddef11ece7cb830364f8f1f8810a8161ae98a3cf439a35d54918b4bcca4670f97ac5f8ced351ed4e0cd8ae724ae2'
7
+ data.tar.gz: 3051666fee426647550bf6abab8abd8d36f79ff8998ca90e03eb0df85b318044aa31389487270bba5a2340e1b48be846cde8253ef40e49e1cf23d0ba4b79b927
@@ -127,7 +127,13 @@ a { color: var(--blue); text-decoration: none; }
127
127
  .archive-meta { display: block; font-size: 11px; color: var(--text-dim); margin-top: 3px; }
128
128
  .restore-btn { white-space: nowrap; }
129
129
 
130
- .note-toggle { display: block; font-size: 11px; color: var(--text-dim); margin-top: 4px; cursor: pointer; }
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; }
131
137
  .note-toggle input { accent-color: var(--amber); }
132
138
  .note-flag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 8px; padding: 0 6px; margin-right: 4px; }
133
139
 
@@ -152,6 +158,24 @@ a { color: var(--blue); text-decoration: none; }
152
158
  .archive-rails .inline-check { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; cursor: pointer; }
153
159
  .archive-rails .hint { display: block; margin-top: 6px; }
154
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
+
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
+
155
179
  .pull-form { display: inline; }
156
180
  #repo-pull-status { font-size: 0.85rem; }
157
181
  #repo-pull-status .pull-ok { color: var(--green); }
@@ -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
@@ -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,6 +193,20 @@ 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
@@ -0,0 +1,85 @@
1
+ require "net/http"
2
+
3
+ # Asana import (card #7): paste a task URL, get a card. First use walks
4
+ # through connecting a Personal Access Token, which lives as a 0600 file in
5
+ # .cardinal/ (like the Claude token) — never in the database, never in git.
6
+ module Asana
7
+ API = "https://app.asana.com/api/1.0".freeze
8
+
9
+ class Error < StandardError; end
10
+
11
+ def self.token_path
12
+ Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("asana-token")
13
+ end
14
+
15
+ def self.connected? = !!File.size?(token_path)
16
+ def self.token = File.read(token_path).strip
17
+
18
+ def self.save_token!(value)
19
+ FileUtils.mkdir_p(File.dirname(token_path))
20
+ File.write(token_path, value.strip)
21
+ File.chmod(0o600, token_path)
22
+ end
23
+
24
+ def self.disconnect! = FileUtils.rm_f(token_path)
25
+
26
+ # Cheapest possible "does this token work" — also gives us a name to show.
27
+ def self.verify!(candidate)
28
+ data = request("/users/me", candidate)
29
+ data["name"].presence || data["email"].presence || "connected"
30
+ end
31
+
32
+ # Task URLs come in several vintages (/0/<project>/<task>, /0/.../f,
33
+ # /1/<ws>/project/<p>/task/<t>) — the task gid is the last long digit run.
34
+ def self.task_gid(url)
35
+ url.to_s.scan(/\d{6,}/).last or raise Error, "That doesn't look like an Asana task URL"
36
+ end
37
+
38
+ def self.import!(board, url)
39
+ data = request("/tasks/#{task_gid(url)}?opt_fields=name,notes,permalink_url,tags.name", token)
40
+ permalink = data["permalink_url"].presence || url
41
+ if (existing = board.cards.find_by(asana_url: permalink))
42
+ return existing
43
+ end
44
+
45
+ inbox = board.columns.inbox.order(:position).first
46
+ card = board.cards.create!(
47
+ column: inbox,
48
+ title: data["name"].presence || "Asana task",
49
+ asana_url: permalink,
50
+ tags: Array(data["tags"]).filter_map { |t| t["name"] }.first(5),
51
+ description: "#{data["notes"].presence || "(no description on the Asana task)"}\n\n---\n_Imported from Asana: #{permalink}_"
52
+ )
53
+ card.log!("status_change", actor: "user", text: "Imported from Asana: #{permalink}")
54
+ card
55
+ end
56
+
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)
64
+ uri = URI("#{API}#{path}")
65
+ http = Net::HTTP.new(uri.host, uri.port)
66
+ http.use_ssl = true
67
+ http.open_timeout = 10
68
+ http.read_timeout = 15
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)
77
+ raise Error, "Asana said no (HTTP #{response.code}) — check the token, and that it can see this task"
78
+ end
79
+ JSON.parse(response.body)["data"]
80
+ rescue JSON::ParserError
81
+ raise Error, "Asana returned something unreadable"
82
+ rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
83
+ raise Error, "Couldn't reach Asana (#{e.class.name.demodulize}) — check your connection"
84
+ end
85
+ end
@@ -0,0 +1,59 @@
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>◯ New from Asana</h1>
6
+ <button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
7
+ </header>
8
+ <div class="modal-body">
9
+ <% if @error.present? %><p class="form-error"><%= @error %></p><% end %>
10
+
11
+ <% if @connected %>
12
+ <% if @just_connected.present? %>
13
+ <p class="hint asana-connected">✅ Connected as <strong><%= @just_connected %></strong> — you won't need to do that again.</p>
14
+ <% end %>
15
+ <p class="hint">Paste the Asana task's URL (open the task, copy the address bar or
16
+ use "Copy task link"). Cardinal pulls the title, description, and tags into a new
17
+ Tasks card that links back to Asana.</p>
18
+
19
+ <%= form_with url: asana_import_path, class: "card-edit",
20
+ data: { turbo_frame: "_top" } do |f| %>
21
+ <label>Asana task URL</label>
22
+ <%= f.text_field :url, required: true, autofocus: true,
23
+ placeholder: "https://app.asana.com/0/1200000000000000/1205000000000000" %>
24
+ <div class="card-edit-actions">
25
+ <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
26
+ <%= f.submit "Import task" %>
27
+ </div>
28
+ <% end %>
29
+ <details class="advanced-rules">
30
+ <summary>Connection</summary>
31
+ <p class="hint">Your Asana token lives in <code>.cardinal/asana-token</code> on this machine only.</p>
32
+ <%= button_to "Unlink Asana", asana_disconnect_path, class: "btn-cancel",
33
+ form: { data: { turbo_frame: "modal" } } %>
34
+ </details>
35
+
36
+ <% else %>
37
+ <p class="hint"><strong>One-time setup:</strong> Cardinal talks to Asana with a
38
+ Personal Access Token that stays on this machine
39
+ (<code>.cardinal/asana-token</code>, never committed, never in the database).</p>
40
+ <ol class="asana-steps">
41
+ <li>Open <a href="https://app.asana.com/0/my-apps" target="_blank" rel="noopener">app.asana.com/0/my-apps</a> (Asana → Settings → Apps → Developer console)</li>
42
+ <li>Click <strong>Create new token</strong>, name it something like <code>cardinal</code></li>
43
+ <li>Copy the token and paste it below</li>
44
+ </ol>
45
+
46
+ <%= form_with url: asana_connect_path, class: "card-edit" do |f| %>
47
+ <label>Personal Access Token</label>
48
+ <%= f.password_field :token, required: true, autofocus: true,
49
+ placeholder: "1/1234567890:abcdef…", autocomplete: "off" %>
50
+ <div class="card-edit-actions">
51
+ <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
52
+ <%= f.submit "Connect Asana" %>
53
+ </div>
54
+ <% end %>
55
+ <% end %>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <% end %>
@@ -34,10 +34,12 @@
34
34
  </div>
35
35
  <% end %>
36
36
  <% has_cost = card.total_cost.positive? || card.total_output_tokens.positive? %>
37
- <% if has_cost || card.pr_url.present? %>
37
+ <% if has_cost || card.pr_url.present? || card.asana_url.present? %>
38
38
  <div class="card-footer">
39
39
  <% if card.pr_url.present? %>
40
40
  <a class="footer-pr" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</a>
41
+ <% elsif card.asana_url.present? %>
42
+ <a class="footer-pr" href="<%= card.asana_url %>" target="_blank" rel="noopener" title="Open the source task on Asana">◯ Asana ↗</a>
41
43
  <% else %>
42
44
  <span class="footer-pr"></span>
43
45
  <% end %>
@@ -73,12 +73,14 @@
73
73
  data-action="scroll#jump">↓ New messages</button>
74
74
 
75
75
  <%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
76
- <%= f.text_area "message[text]", rows: 2, required: true,
77
- data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
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>
76
+ <div class="composer-box">
77
+ <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.">
78
+ <%= f.check_box "message[note]", {}, "1", "0" %> 📝 note only no AI reply
79
+ </label>
80
+ <%= f.text_area "message[text]", rows: 2, required: true,
81
+ data: { controller: "composer attach", action: "keydown->composer#keydown paste->attach#paste" },
82
+ 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)" %>
83
+ </div>
82
84
  <% end %>
83
85
  <% end %>
84
86
  </section>
@@ -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>
@@ -25,6 +25,9 @@
25
25
  <%= f.text_field "card[pr_url]", placeholder: "https://github.com/owner/repo/pull/123" %>
26
26
  <p class="hint">Leave blank and the agent picks a branch. Set either to point work at an existing branch or PR.</p>
27
27
  <div class="card-edit-actions">
28
+ <%= link_to "◯ New from Asana", asana_new_card_path, class: "asana-entry",
29
+ data: { turbo_frame: "modal" },
30
+ title: "Import an Asana task as a card — first use walks you through connecting" %>
28
31
  <button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
29
32
  <%= f.submit "Save" %>
30
33
  </div>
data/config/routes.rb CHANGED
@@ -15,11 +15,17 @@ 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
21
22
  resources :messages, only: [:create]
22
23
  end
24
+ get "asana/new" => "asana#new_card", as: :asana_new_card
25
+ post "asana/connect" => "asana#connect", as: :asana_connect
26
+ post "asana/import" => "asana#import", as: :asana_import
27
+ post "asana/disconnect" => "asana#disconnect", as: :asana_disconnect
28
+
23
29
  resources :columns, only: [:create, :edit, :update, :destroy]
24
30
  resources :runs, only: [] do
25
31
  member do
@@ -0,0 +1,5 @@
1
+ class AddAsanaUrlToCards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :cards, :asana_url, :string
4
+ end
5
+ end
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.16"
2
+ VERSION = "0.2.18"
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.16
4
+ version: 0.2.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -163,6 +163,7 @@ files:
163
163
  - app/assets/stylesheets/application.css
164
164
  - app/assets/stylesheets/cardinal.css
165
165
  - app/controllers/application_controller.rb
166
+ - app/controllers/asana_controller.rb
166
167
  - app/controllers/boards_controller.rb
167
168
  - app/controllers/cards_controller.rb
168
169
  - app/controllers/columns_controller.rb
@@ -209,12 +210,14 @@ files:
209
210
  - app/models/run.rb
210
211
  - app/services/agent/runner.rb
211
212
  - app/services/agent/workspace.rb
213
+ - app/services/asana.rb
212
214
  - app/services/card_transition.rb
213
215
  - app/services/claude_cli.rb
214
216
  - app/services/github_issues.rb
215
217
  - app/services/rules.rb
216
218
  - app/services/rules/compiler.rb
217
219
  - app/services/run_sweeper.rb
220
+ - app/views/asana/new_card.html.erb
218
221
  - app/views/boards/archive.html.erb
219
222
  - app/views/boards/brief.html.erb
220
223
  - app/views/boards/edit.html.erb
@@ -276,6 +279,7 @@ files:
276
279
  - db/migrate/20260705120000_create_ai_calls.rb
277
280
  - db/migrate/20260705120001_add_issue_number_to_cards.rb
278
281
  - db/migrate/20260705193935_add_settings_to_boards.rb
282
+ - db/migrate/20260705225451_add_asana_url_to_cards.rb
279
283
  - db/queue_schema.rb
280
284
  - db/seeds.rb
281
285
  - docker/agent/Dockerfile