cardinal-ai 0.0.1 → 0.2.3
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/LICENSE +21 -0
- data/README.md +50 -29
- data/Rakefile +6 -0
- data/app/assets/stylesheets/application.css +10 -0
- data/app/assets/stylesheets/cardinal.css +514 -0
- data/app/controllers/application_controller.rb +7 -0
- data/app/controllers/boards_controller.rb +5 -0
- data/app/controllers/cards_controller.rb +129 -0
- data/app/controllers/columns_controller.rb +95 -0
- data/app/controllers/messages_controller.rb +25 -0
- data/app/controllers/runs_controller.rb +58 -0
- data/app/helpers/application_helper.rb +35 -0
- data/app/javascript/application.js +2 -0
- data/app/javascript/controllers/application.js +7 -0
- data/app/javascript/controllers/autosave_controller.js +28 -0
- data/app/javascript/controllers/board_column_controller.js +96 -0
- data/app/javascript/controllers/clipboard_controller.js +18 -0
- data/app/javascript/controllers/composer_controller.js +10 -0
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/modal_controller.js +43 -0
- data/app/javascript/controllers/scroll_controller.js +44 -0
- data/app/javascript/controllers/tags_controller.js +49 -0
- data/app/javascript/controllers/theme_controller.js +43 -0
- data/app/javascript/controllers/tooltip_controller.js +37 -0
- data/app/jobs/ai_task_job.rb +26 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/assistant_reply_job.rb +132 -0
- data/app/jobs/mark_pr_ready_job.rb +18 -0
- data/app/jobs/merge_pr_job.rb +27 -0
- data/app/jobs/resume_run_job.rb +30 -0
- data/app/jobs/start_run_job.rb +13 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/agent_session.rb +8 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/artifact.rb +8 -0
- data/app/models/board.rb +60 -0
- data/app/models/card.rb +83 -0
- data/app/models/column.rb +83 -0
- data/app/models/event.rb +44 -0
- data/app/models/run.rb +28 -0
- data/app/services/agent/runner.rb +379 -0
- data/app/services/agent/workspace.rb +138 -0
- data/app/services/card_transition.rb +97 -0
- data/app/services/claude_cli.rb +89 -0
- data/app/services/rules/compiler.rb +55 -0
- data/app/services/rules.rb +67 -0
- data/app/services/run_sweeper.rb +52 -0
- data/app/views/boards/show.html.erb +79 -0
- data/app/views/cards/_card.html.erb +48 -0
- data/app/views/cards/_detail.html.erb +190 -0
- data/app/views/cards/_tag_picker.html.erb +12 -0
- data/app/views/cards/new.html.erb +35 -0
- data/app/views/cards/show.html.erb +3 -0
- data/app/views/columns/_column.html.erb +25 -0
- data/app/views/columns/edit.html.erb +126 -0
- data/app/views/events/_event.html.erb +29 -0
- data/app/views/layouts/application.html.erb +46 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/bin/rails +4 -0
- data/bin/rake +4 -0
- data/cardinal.md +686 -0
- data/config/application.rb +60 -0
- data/config/boot.rb +13 -0
- data/config/bundler-audit.yml +5 -0
- data/config/cable.yml +13 -0
- data/config/ci.rb +20 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +31 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +78 -0
- data/config/environments/production.rb +89 -0
- data/config/environments/test.rb +53 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/cardinal_bootstrap.rb +12 -0
- data/config/initializers/cardinal_instance.rb +20 -0
- data/config/initializers/content_security_policy.rb +29 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/initializers/run_sweeper.rb +17 -0
- data/config/locales/en.yml +31 -0
- data/config/puma.rb +42 -0
- data/config/routes.rb +22 -0
- data/config/storage.yml +27 -0
- data/config.ru +6 -0
- data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
- data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
- data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
- data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
- data/db/seeds.rb +19 -0
- data/docker/agent/Dockerfile +16 -0
- data/exe/cardinal +111 -0
- data/lib/cardinal/version.rb +1 -1
- data/public/400.html +135 -0
- data/public/404.html +135 -0
- data/public/406-unsupported-browser.html +135 -0
- data/public/422.html +135 -0
- data/public/500.html +135 -0
- data/public/icon.png +0 -0
- data/public/icon.svg +3 -0
- data/public/robots.txt +1 -0
- data/vendor/javascript/sortablejs.js +3378 -0
- metadata +235 -9
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<section class="column column-<%= column.archetype %>" id="<%= dom_id(column) %>"
|
|
2
|
+
data-col-id="<%= column.id %>" data-accepts="<%= Array(column.accepts_from).join(",") %>"<%= " style=\"background-color: #{column.safe_color}\"".html_safe if column.safe_color %>>
|
|
3
|
+
<header class="column-header">
|
|
4
|
+
<span class="column-title">
|
|
5
|
+
<h2><%= column.name %></h2>
|
|
6
|
+
<% if column.inbox? %>
|
|
7
|
+
<%= link_to "+", new_card_path, class: "add-card", title: "New card",
|
|
8
|
+
data: { turbo_frame: "modal" } %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</span>
|
|
11
|
+
<%= link_to "⚙", edit_column_path(column), class: "gear", title: "Column settings",
|
|
12
|
+
data: { turbo_frame: "modal" } %>
|
|
13
|
+
</header>
|
|
14
|
+
<p class="drop-hint">→ <%= column.drag_hint %></p>
|
|
15
|
+
<div class="cards<%= " cards-clickable" if column.inbox? %>"
|
|
16
|
+
data-controller="board-column"
|
|
17
|
+
data-board-column-hint-value="<%= column.drag_hint %>"
|
|
18
|
+
<% if column.inbox? %>data-action="click->board-column#newCard"
|
|
19
|
+
data-board-column-new-url-value="<%= new_card_path %>"<% end %>
|
|
20
|
+
data-column-id="<%= column.id %>">
|
|
21
|
+
<% column.cards.each do |card| %>
|
|
22
|
+
<%= render "cards/card", card: card %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
@@ -0,0 +1,126 @@
|
|
|
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>⚙ <%= @column.name %> <span class="chip"><%= @column.archetype %></span></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 @json_error %><p class="form-error"><%= @json_error %></p><% end %>
|
|
10
|
+
|
|
11
|
+
<%= form_with model: @column, class: "card-edit" do |f| %>
|
|
12
|
+
<div class="field-row">
|
|
13
|
+
<div>
|
|
14
|
+
<label>Name <%= info_tip("The column's display name on the board.") %></label>
|
|
15
|
+
<%= f.text_field :name, required: true %>
|
|
16
|
+
</div>
|
|
17
|
+
<div>
|
|
18
|
+
<label>Archetype <%= info_tip("What kind of stage this is. Inbox: parking lot, no AI. Planning: a shared assistant discusses cards with you. Execution: each card gets its own worker agent that does the work. Review: work stops for your verdict. Terminal: finalizes the card (Done merges the PR).") %></label>
|
|
19
|
+
<%= f.select :archetype, Column::ARCHETYPES.map { |a| [a.capitalize, a] } %>
|
|
20
|
+
</div>
|
|
21
|
+
<div>
|
|
22
|
+
<label>Arrivals <%= info_tip("Where cards land when dragged into this column. 'Top' makes it a feed — newest first, no matter where you drop (the Done default). Reordering inside the column stays manual.") %></label>
|
|
23
|
+
<%= f.select :arrivals, [["Where dropped", ""], ["Top of column", "top"], ["Bottom of column", "bottom"]], selected: @column.arrivals %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="color-cell">
|
|
26
|
+
<label>Background <%= info_tip("Custom background color for this column on the board. Uncheck to fall back to the archetype's default tint.") %></label>
|
|
27
|
+
<span class="color-row">
|
|
28
|
+
<%= f.color_field :color, value: @column.safe_color || "#1e2128" %>
|
|
29
|
+
<label class="check-row inline"><%= check_box_tag "column[custom_color]", "1", @column.safe_color.present? %> use</label>
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<% siblings = @column.board.columns.where.not(id: @column.id).order(:position) %>
|
|
35
|
+
<% if siblings.any? %>
|
|
36
|
+
<% allowed = Array(@column.accepts_from).map(&:to_s) %>
|
|
37
|
+
<label>Accepts moves from (inbound) <%= info_tip("INBOUND rule: which columns may drag cards INTO this one. It does not control where this column's cards may go — set that on the destination columns. Leave all unchecked to accept from anywhere. Example: check only Planning on In Progress so work can't skip triage.") %></label>
|
|
38
|
+
<div class="accepts-from">
|
|
39
|
+
<% siblings.each do |sib| %>
|
|
40
|
+
<label class="check-row inline">
|
|
41
|
+
<%= check_box_tag "column[accepts_from][]", sib.id, allowed.include?(sib.id.to_s) %>
|
|
42
|
+
<%= sib.name %>
|
|
43
|
+
</label>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
|
|
48
|
+
<%# The Tasks/inbox column is the board's intake — cards park here untouched,
|
|
49
|
+
so none of the AI-work or on-entry settings below apply to it (card #17). %>
|
|
50
|
+
<% unless @column.inbox? %>
|
|
51
|
+
<label>Instructions <%= info_tip("Extra guidance given to any AI servicing cards in this column — added ON TOP of the built-in role shown below (which is enforced in code and can't be edited away).") %></label>
|
|
52
|
+
<% if @column.built_in_role %>
|
|
53
|
+
<p class="default-rule">Built-in role: <%= @column.built_in_role %></p>
|
|
54
|
+
<% end %>
|
|
55
|
+
<%= f.text_area :instructions, rows: 3, value: @column.instructions,
|
|
56
|
+
placeholder: "Your additions — e.g. “always press for acceptance criteria”, “follow the repo style guide”…" %>
|
|
57
|
+
|
|
58
|
+
<div class="field-row">
|
|
59
|
+
<div>
|
|
60
|
+
<label>Model <%= info_tip("Which Claude model works cards in this column. Haiku for cheap chat and maintenance, Sonnet for most real work, Opus/Fable when the work is hard and worth it.") %></label>
|
|
61
|
+
<%= f.select :model, model_options(@column.model), selected: @column.model.to_s %>
|
|
62
|
+
</div>
|
|
63
|
+
<div>
|
|
64
|
+
<label>Effort <%= info_tip("How hard the model thinks per step. Higher = smarter but slower and pricier. Max is supported on Sonnet/Opus tiers only.") %></label>
|
|
65
|
+
<%= f.select :effort, [["(default)", ""], "low", "medium", "high", "max"], selected: @column.effort %>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="field-row">
|
|
70
|
+
<div>
|
|
71
|
+
<label>Concurrency <%= info_tip("WIP limit: how many cards may be actively worked at once here. Extra cards queue in order; drag to reprioritize.") %></label>
|
|
72
|
+
<%= f.number_field :concurrency_limit, value: @column.concurrency_limit, min: 1, placeholder: "∞" %>
|
|
73
|
+
</div>
|
|
74
|
+
<div>
|
|
75
|
+
<label>Max turns <%= info_tip("Check-in cadence and loop guard: after this many actions the agent pauses, saves progress, and asks whether to continue (one reply = fresh budget). Catches busy-but-stuck loops a wall-clock timeout can't. Not a work limit.") %></label>
|
|
76
|
+
<%= f.number_field :max_turns, value: @column.max_turns, min: 1, placeholder: "80" %>
|
|
77
|
+
</div>
|
|
78
|
+
<div>
|
|
79
|
+
<label>Timeout (min) <%= info_tip("Wall-clock limit per run segment. The agent process is killed when it expires — never a runaway.") %></label>
|
|
80
|
+
<%= f.number_field :timeout_minutes, value: @column.timeout_minutes, min: 1, placeholder: "30" %>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<label class="check-row">
|
|
85
|
+
<%= f.check_box :plan_approval, checked: ActiveModel::Type::Boolean.new.cast(@column.plan_approval) %>
|
|
86
|
+
Require plan approval before the agent makes changes
|
|
87
|
+
<%= info_tip("The agent's first pass is read-only: it explores and proposes a plan, then waits. You approve with one click or reply to redirect. Your main safety rail.") %>
|
|
88
|
+
</label>
|
|
89
|
+
|
|
90
|
+
<label>On-entry rules <%= info_tip("What happens when a card lands in this column, in plain English — e.g. \"start the worker agent\" or \"have an AI suggest tags and a better title\". Cardinal compiles it to rule actions when you save. Blank = the archetype's default behavior shown below.") %></label>
|
|
91
|
+
<% if @column.policy["on_entry_text"].blank? && @column.policy["on_entry"].blank? %>
|
|
92
|
+
<p class="default-rule">Default: <%= Rules::DEFAULT_DESCRIPTIONS[@column.archetype] %></p>
|
|
93
|
+
<% end %>
|
|
94
|
+
<%= f.text_area :on_entry_text, rows: 3,
|
|
95
|
+
value: @column.policy["on_entry_text"],
|
|
96
|
+
placeholder: "Describe what should happen instead of the default…" %>
|
|
97
|
+
|
|
98
|
+
<details class="advanced-rules">
|
|
99
|
+
<summary>Advanced: compiled rules (JSON)</summary>
|
|
100
|
+
<p class="hint">Applied only when the English box above is empty. Current compiled form:</p>
|
|
101
|
+
<%= f.text_area :on_entry_json, rows: 4, class: "mono",
|
|
102
|
+
value: @column.policy["on_entry"] ? JSON.pretty_generate(@column.policy["on_entry"]) : "",
|
|
103
|
+
placeholder: '[{"action": "ai_task", "prompt": "Suggest tags for: %{title}"}]' %>
|
|
104
|
+
</details>
|
|
105
|
+
<% end %>
|
|
106
|
+
|
|
107
|
+
<%= f.submit "Save" %>
|
|
108
|
+
<% end %>
|
|
109
|
+
|
|
110
|
+
<details class="advanced-rules panel-advanced">
|
|
111
|
+
<summary>Advanced</summary>
|
|
112
|
+
<% if @column.inbox? %>
|
|
113
|
+
<p class="hint">The Tasks column is the board's intake — cards enter the flow here to be triaged. It can't be deleted.</p>
|
|
114
|
+
<% elsif @column.cards.none? %>
|
|
115
|
+
<p class="hint">Deleting removes this column and its policy. Cards are unaffected — the column must already be empty.</p>
|
|
116
|
+
<%= button_to "🗑 Delete column", column_path(@column), method: :delete, class: "cancel-btn delete-column",
|
|
117
|
+
form: { data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess",
|
|
118
|
+
turbo_confirm: "Delete the \"#{@column.name}\" column and its policy?" } } %>
|
|
119
|
+
<% else %>
|
|
120
|
+
<p class="hint">This column still has <%= pluralize(@column.cards.count, "card") %> — move them before it can be deleted.</p>
|
|
121
|
+
<% end %>
|
|
122
|
+
</details>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<% end %>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<% if event.kind == "column_move" %>
|
|
2
|
+
<div class="stage-divider">
|
|
3
|
+
<span><%= event.payload["from"] %> → <%= event.payload["to"] %></span>
|
|
4
|
+
<time><%= event.created_at.strftime("%b %-d %H:%M") %></time>
|
|
5
|
+
</div>
|
|
6
|
+
<% elsif event.kind == "move_rejected" %>
|
|
7
|
+
<div class="move-rejected-row">
|
|
8
|
+
<span>⤺ <%= event.text %></span>
|
|
9
|
+
<time><%= event.created_at.strftime("%b %-d %H:%M") %></time>
|
|
10
|
+
</div>
|
|
11
|
+
<% else %>
|
|
12
|
+
<div class="event event-<%= event.kind %> actor-<%= event.actor %>">
|
|
13
|
+
<span class="event-actor"><%= { "user" => "👤", "agent" => "🤖", "assistant" => "🪶", "system" => "·" }[event.actor] || event.actor %></span>
|
|
14
|
+
<div class="event-body">
|
|
15
|
+
<% if event.text %>
|
|
16
|
+
<%= render_markdown event.text %>
|
|
17
|
+
<% else %>
|
|
18
|
+
<code class="event-kind"><%= event.kind %></code>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% if event.kind == "error" && event.payload["detail"].present? %>
|
|
21
|
+
<details class="error-detail">
|
|
22
|
+
<summary>technical detail</summary>
|
|
23
|
+
<pre><%= event.payload["detail"] %></pre>
|
|
24
|
+
</details>
|
|
25
|
+
<% end %>
|
|
26
|
+
<time><%= event.created_at.strftime("%b %-d %H:%M") %></time>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= content_for(:title) || "Cardinal AI" %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
+
<meta name="application-name" content="Cardinal AI">
|
|
8
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
9
|
+
<%= csrf_meta_tags %>
|
|
10
|
+
<%= csp_meta_tag %>
|
|
11
|
+
|
|
12
|
+
<%# Live board sync: server broadcasts trigger a page refresh, morphed in place %>
|
|
13
|
+
<meta name="turbo-refresh-method" content="morph">
|
|
14
|
+
<meta name="turbo-refresh-scroll" content="preserve">
|
|
15
|
+
|
|
16
|
+
<%= yield :head %>
|
|
17
|
+
|
|
18
|
+
<%# Apply the saved theme before first paint to avoid a flash of dark. Dark is the
|
|
19
|
+
default. Turbo morph refreshes reconcile <html> against the server markup (which
|
|
20
|
+
has no data-theme), so re-apply after each render to keep the choice sticky. %>
|
|
21
|
+
<script>
|
|
22
|
+
(function () {
|
|
23
|
+
function applyTheme() {
|
|
24
|
+
if (localStorage.getItem("theme") === "light") {
|
|
25
|
+
document.documentElement.setAttribute("data-theme", "light")
|
|
26
|
+
} else {
|
|
27
|
+
document.documentElement.removeAttribute("data-theme")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
applyTheme()
|
|
31
|
+
document.addEventListener("turbo:render", applyTheme)
|
|
32
|
+
})()
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<link rel="icon" href="/icon.png" type="image/png">
|
|
36
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
|
37
|
+
<link rel="apple-touch-icon" href="/icon.png">
|
|
38
|
+
|
|
39
|
+
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
|
40
|
+
<%= javascript_importmap_tags %>
|
|
41
|
+
</head>
|
|
42
|
+
|
|
43
|
+
<body>
|
|
44
|
+
<%= yield %>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= yield %>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Work",
|
|
3
|
+
"icons": [
|
|
4
|
+
{
|
|
5
|
+
"src": "/icon.png",
|
|
6
|
+
"type": "image/png",
|
|
7
|
+
"sizes": "512x512"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"src": "/icon.png",
|
|
11
|
+
"type": "image/png",
|
|
12
|
+
"sizes": "512x512",
|
|
13
|
+
"purpose": "maskable"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"start_url": "/",
|
|
17
|
+
"display": "standalone",
|
|
18
|
+
"scope": "/",
|
|
19
|
+
"description": "Work.",
|
|
20
|
+
"theme_color": "red",
|
|
21
|
+
"background_color": "red"
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Add a service worker for processing Web Push notifications:
|
|
2
|
+
//
|
|
3
|
+
// self.addEventListener("push", async (event) => {
|
|
4
|
+
// const { title, options } = await event.data.json()
|
|
5
|
+
// event.waitUntil(self.registration.showNotification(title, options))
|
|
6
|
+
// })
|
|
7
|
+
//
|
|
8
|
+
// self.addEventListener("notificationclick", function(event) {
|
|
9
|
+
// event.notification.close()
|
|
10
|
+
// event.waitUntil(
|
|
11
|
+
// clients.matchAll({ type: "window" }).then((clientList) => {
|
|
12
|
+
// for (let i = 0; i < clientList.length; i++) {
|
|
13
|
+
// let client = clientList[i]
|
|
14
|
+
// let clientPath = (new URL(client.url)).pathname
|
|
15
|
+
//
|
|
16
|
+
// if (clientPath == event.notification.data.path && "focus" in client) {
|
|
17
|
+
// return client.focus()
|
|
18
|
+
// }
|
|
19
|
+
// }
|
|
20
|
+
//
|
|
21
|
+
// if (clients.openWindow) {
|
|
22
|
+
// return clients.openWindow(event.notification.data.path)
|
|
23
|
+
// }
|
|
24
|
+
// })
|
|
25
|
+
// )
|
|
26
|
+
// })
|
data/bin/rails
ADDED
data/bin/rake
ADDED