cardinal-ai 0.0.1 → 0.2.4
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 +530 -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 +130 -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 +43 -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 +45 -0
- data/app/javascript/controllers/reveal_controller.js +15 -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 +92 -0
- data/app/models/card.rb +83 -0
- data/app/models/column.rb +134 -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 +92 -0
- data/app/services/run_sweeper.rb +53 -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 +146 -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 +695 -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 +13 -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 +236 -9
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
|
|
2
|
+
<div class="modal card-detail">
|
|
3
|
+
<%= turbo_stream_from @card %>
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>
|
|
6
|
+
<%= @card.title %>
|
|
7
|
+
<span class="chip status-chip status-<%= @card.status %>"><%= @card.status.humanize %></span>
|
|
8
|
+
<span class="card-number-sub" title="Card number — how branches, PRs, and other cards refer to this card">#<%= @card.number %></span>
|
|
9
|
+
</h1>
|
|
10
|
+
<div class="modal-header-right">
|
|
11
|
+
<% if @card.branch_name %>
|
|
12
|
+
<span class="git-line">
|
|
13
|
+
<span class="branch-base"><%= @card.board.default_branch %></span>
|
|
14
|
+
<span class="git-arrow">→</span>
|
|
15
|
+
<span class="branch-pill" data-controller="clipboard" data-clipboard-text-value="<%= @card.branch_name %>">
|
|
16
|
+
<code><%= @card.branch_name %></code>
|
|
17
|
+
<button type="button" class="copy-btn" data-clipboard-target="button"
|
|
18
|
+
data-action="clipboard#copy" title="Copy branch name">⧉</button>
|
|
19
|
+
</span>
|
|
20
|
+
<% if @card.pr_url %>
|
|
21
|
+
<span class="git-arrow">→</span>
|
|
22
|
+
<%= link_to "##{@card.pr_url[%r{/pull/(\d+)}, 1]}", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-link" %>
|
|
23
|
+
<% if @card.pr_state.present? %><span class="pr-state">(<%= @card.pr_state %>)</span><% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</span>
|
|
26
|
+
<% end %>
|
|
27
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
|
|
28
|
+
</div>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
<div class="detail-panes">
|
|
32
|
+
<section class="timeline" data-controller="scroll">
|
|
33
|
+
<nav class="zoom-tabs">
|
|
34
|
+
<% %w[conversation activity debug].each do |zoom| %>
|
|
35
|
+
<%= link_to zoom.capitalize, card_path(@card, zoom: zoom),
|
|
36
|
+
class: ("active" if @zoom == zoom) %>
|
|
37
|
+
<% end %>
|
|
38
|
+
</nav>
|
|
39
|
+
|
|
40
|
+
<div class="timeline-scroll" data-scroll-target="scroller">
|
|
41
|
+
<% if @card.description.present? %>
|
|
42
|
+
<div class="event event-description"><%= render_markdown @card.description %></div>
|
|
43
|
+
<% end %>
|
|
44
|
+
|
|
45
|
+
<div id="card_events">
|
|
46
|
+
<%= render partial: "events/event", collection: @events %>
|
|
47
|
+
<% if @events.empty? %>
|
|
48
|
+
<p class="empty">No events yet — this card hasn't been anywhere.</p>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<% if @card.thinking? %>
|
|
53
|
+
<div class="event typing" id="typing-indicator">
|
|
54
|
+
<span class="event-actor"><%= @card.working? ? "🤖" : "🪶" %></span>
|
|
55
|
+
<div class="typing-dots"><span></span><span></span><span></span></div>
|
|
56
|
+
</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<button type="button" class="new-messages-pill" data-scroll-target="pill"
|
|
61
|
+
data-action="scroll#jump">↓ New messages</button>
|
|
62
|
+
|
|
63
|
+
<%= form_with url: card_messages_path(@card), class: "message-form" do |f| %>
|
|
64
|
+
<%= f.text_area "message[text]", rows: 2, required: true,
|
|
65
|
+
data: { controller: "composer", action: "keydown->composer#keydown" },
|
|
66
|
+
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)" %>
|
|
67
|
+
<% end %>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<aside class="work-panel">
|
|
71
|
+
<div data-controller="autosave">
|
|
72
|
+
<h3>Details <span class="autosave-status" data-autosave-target="status"></span></h3>
|
|
73
|
+
<%= form_with model: @card, class: "card-edit",
|
|
74
|
+
data: { autosave_target: "form", action: "input->autosave#save change->autosave#save" } do |f| %>
|
|
75
|
+
<%= hidden_field_tag :autosave, "1" %>
|
|
76
|
+
<label>Title</label>
|
|
77
|
+
<%= f.text_field :title, required: true %>
|
|
78
|
+
<label>Tags</label>
|
|
79
|
+
<%= render "cards/tag_picker", board: @card.board, tags: @card.tags %>
|
|
80
|
+
<label>Description</label>
|
|
81
|
+
<%= f.text_area :description, rows: 6 %>
|
|
82
|
+
<label>Branch <span class="hint">— optional</span></label>
|
|
83
|
+
<% if @card.branch_name.present? %>
|
|
84
|
+
<%# Locked once set — by the user here or by the agent on run start. %>
|
|
85
|
+
<p class="locked-field"><code><%= @card.branch_name %></code> <span class="hint">set</span></p>
|
|
86
|
+
<% else %>
|
|
87
|
+
<%= f.text_field :branch_name, placeholder: @card.default_branch_name %>
|
|
88
|
+
<% end %>
|
|
89
|
+
<label>Pull request <span class="hint">— optional</span></label>
|
|
90
|
+
<% if @card.pr_url.present? %>
|
|
91
|
+
<p class="locked-field"><%= link_to @card.pr_url, @card.pr_url, target: "_blank", rel: "noopener" %> <span class="hint">set</span></p>
|
|
92
|
+
<% else %>
|
|
93
|
+
<%= f.text_field :pr_url, placeholder: "https://github.com/owner/repo/pull/123" %>
|
|
94
|
+
<% end %>
|
|
95
|
+
<% end %>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<% if @card.parent || @card.children.any? %>
|
|
99
|
+
<h3>Related</h3>
|
|
100
|
+
<ul class="related-list">
|
|
101
|
+
<% if @card.parent %>
|
|
102
|
+
<li>↑ <%= link_to @card.parent.title, card_path(@card.parent), data: { turbo_frame: "modal", turbo_action: "advance" } %>
|
|
103
|
+
<span class="hint"><%= @card.parent.status.humanize.downcase %></span></li>
|
|
104
|
+
<% end %>
|
|
105
|
+
<% @card.children.each do |child| %>
|
|
106
|
+
<li>↳ <%= link_to child.title, card_path(child), data: { turbo_frame: "modal", turbo_action: "advance" } %>
|
|
107
|
+
<span class="hint"><%= child.status.humanize.downcase %></span></li>
|
|
108
|
+
<% end %>
|
|
109
|
+
</ul>
|
|
110
|
+
<% end %>
|
|
111
|
+
<%= link_to "+ Child card", new_card_path(parent_id: @card.id),
|
|
112
|
+
class: "child-card-link", data: { turbo_frame: "modal" } %>
|
|
113
|
+
|
|
114
|
+
<% if @card.column.review? %>
|
|
115
|
+
<h3>Review</h3>
|
|
116
|
+
<% if @card.pr_url %>
|
|
117
|
+
<%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
|
|
118
|
+
<% end %>
|
|
119
|
+
<% if @card.in_review? %>
|
|
120
|
+
<div class="panel-callout callout-plan">
|
|
121
|
+
<p><strong>Your verdict.</strong> Check the final report in the timeline.
|
|
122
|
+
Approve, or just say what's wrong in the conversation — that marks it changes-requested,
|
|
123
|
+
and dragging the card back to a work column carries your feedback into the next run.</p>
|
|
124
|
+
<%= button_to "✅ Approve", approve_card_path(@card), class: "approve-btn", form_class: "align-right" %>
|
|
125
|
+
</div>
|
|
126
|
+
<% elsif @card.approved? %>
|
|
127
|
+
<div class="panel-callout callout-plan"><p><strong>Approved.</strong> Drag the card to Done to merge and ship.</p></div>
|
|
128
|
+
<% elsif @card.changes_requested? %>
|
|
129
|
+
<div class="panel-callout callout-question"><p><strong>Changes requested.</strong> Drag the card back to an execution column for a revision run — your conversation feedback rides along.</p></div>
|
|
130
|
+
<% end %>
|
|
131
|
+
<% end %>
|
|
132
|
+
|
|
133
|
+
<h3>Work</h3>
|
|
134
|
+
<% runs = @card.runs.order(:id) %>
|
|
135
|
+
<% parked = runs.select(&:needs_input?).last %>
|
|
136
|
+
<% latest = runs.last %>
|
|
137
|
+
<% if parked&.phase == "plan" %>
|
|
138
|
+
<div class="panel-callout callout-plan">
|
|
139
|
+
<p><strong>Plan proposed.</strong> Approve to let the agent execute, or reply in the timeline to redirect.</p>
|
|
140
|
+
<%= button_to "👍 Approve plan", approve_run_path(parked), class: "approve-btn", form_class: "align-right" %>
|
|
141
|
+
</div>
|
|
142
|
+
<% elsif parked&.restartable? %>
|
|
143
|
+
<div class="panel-callout callout-restart">
|
|
144
|
+
<p><strong>Run hit its turn budget.</strong> Restart to continue with a fresh budget, or raise <strong>Max turns</strong> in the column's gear settings.</p>
|
|
145
|
+
<%= button_to "🔄 Restart run", restart_run_path(parked), class: "restart-btn", form_class: "align-right" %>
|
|
146
|
+
</div>
|
|
147
|
+
<% elsif parked %>
|
|
148
|
+
<div class="panel-callout callout-question">
|
|
149
|
+
<p><strong>The agent has a question</strong> — answer it in the timeline message box.</p>
|
|
150
|
+
</div>
|
|
151
|
+
<% elsif latest&.failed? && latest.restartable? %>
|
|
152
|
+
<div class="panel-callout callout-restart">
|
|
153
|
+
<p><strong>Run failed on its budget.</strong> Restart to try again with a fresh budget, or raise the column's <strong>Max turns</strong> / timeout in its gear settings.</p>
|
|
154
|
+
<%= button_to "🔄 Restart run", restart_run_path(latest), class: "restart-btn", form_class: "align-right" %>
|
|
155
|
+
</div>
|
|
156
|
+
<% end %>
|
|
157
|
+
<% if runs.any? %>
|
|
158
|
+
<ul class="run-list">
|
|
159
|
+
<% runs.each do |run| %>
|
|
160
|
+
<li class="run run-<%= run.status %>">
|
|
161
|
+
<span>Run #<%= run.id %> — <%= run.status %><%= " (#{run.phase})" if run.needs_input? %></span>
|
|
162
|
+
<% if run.cost.positive? %>
|
|
163
|
+
<span class="hint"><%= run.output_tokens %> out · $<%= run.cost.round(2) %></span>
|
|
164
|
+
<% end %>
|
|
165
|
+
<% if run.running? || run.needs_input? %>
|
|
166
|
+
<%= button_to "✖ Cancel", cancel_run_path(run), class: "cancel-btn" %>
|
|
167
|
+
<% end %>
|
|
168
|
+
</li>
|
|
169
|
+
<% end %>
|
|
170
|
+
</ul>
|
|
171
|
+
<% if @card.pr_url %>
|
|
172
|
+
<p>🌿 <%= link_to "View pull request", @card.pr_url, target: "_blank" %><%= " (#{@card.pr_state})" if @card.pr_state.present? %></p>
|
|
173
|
+
<% end %>
|
|
174
|
+
<% else %>
|
|
175
|
+
<p class="empty">No runs yet — drag the card into an execution column to assign an agent.</p>
|
|
176
|
+
<% end %>
|
|
177
|
+
|
|
178
|
+
<details class="advanced-rules panel-advanced">
|
|
179
|
+
<summary>Advanced</summary>
|
|
180
|
+
<p class="hint">Deleting removes the card and its entire history (events, runs, workspace). The remote branch and PR, if any, are left untouched.</p>
|
|
181
|
+
<%= button_to "🗑 Delete card", card_path(@card), method: :delete,
|
|
182
|
+
class: "cancel-btn delete-card",
|
|
183
|
+
form: { data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess",
|
|
184
|
+
turbo_confirm: "Delete ##{@card.number} \"#{@card.title}\" and its entire history? This cannot be undone." } },
|
|
185
|
+
disabled: @card.working? %>
|
|
186
|
+
</details>
|
|
187
|
+
</aside>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# locals: board, tags (currently selected) %>
|
|
2
|
+
<div data-controller="tags">
|
|
3
|
+
<%= hidden_field_tag "card[tags]", tags.join(", "), data: { tags_target: "field" } %>
|
|
4
|
+
<div class="tag-chips" data-tags-target="chips">
|
|
5
|
+
<% (board.tag_pool | tags).each do |tag| %>
|
|
6
|
+
<button type="button" class="tag-chip <%= "on" if tags.include?(tag) %>"
|
|
7
|
+
data-tag="<%= tag %>" data-action="tags#toggle"><%= tag %></button>
|
|
8
|
+
<% end %>
|
|
9
|
+
</div>
|
|
10
|
+
<input type="text" class="new-tag-input" placeholder="+ new tag (Enter)"
|
|
11
|
+
data-tags-target="newTag" data-action="keydown->tags#keydown">
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
|
|
2
|
+
<div class="modal-backdrop" data-controller="modal" data-modal-sticky-value="true" data-action="click->modal#backdrop">
|
|
3
|
+
<div class="modal modal-sm">
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>New card<% if @parent %> <span class="hint">— child of “<%= @parent.title %>”</span><% end %></h1>
|
|
6
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Cancel">✕</button>
|
|
7
|
+
</header>
|
|
8
|
+
<div class="modal-body">
|
|
9
|
+
<%# _top: a full-page redirect after create — Turbo suppresses this
|
|
10
|
+
tab's own refresh broadcast, so a frame-scoped response would
|
|
11
|
+
leave the board stale behind the modal. %>
|
|
12
|
+
<%= form_with url: cards_path, class: "card-edit",
|
|
13
|
+
data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess" } do |f| %>
|
|
14
|
+
<%= hidden_field_tag "card[parent_id]", @parent&.id %>
|
|
15
|
+
<label>Title</label>
|
|
16
|
+
<%= f.text_field "card[title]", required: true, autofocus: true, placeholder: "What needs doing?" %>
|
|
17
|
+
<label>Tags</label>
|
|
18
|
+
<%= render "cards/tag_picker", board: @board, tags: [] %>
|
|
19
|
+
<label>Description</label>
|
|
20
|
+
<%= f.text_area "card[description]", rows: 8,
|
|
21
|
+
placeholder: "Goal, context, acceptance criteria — the planning assistant will help refine this." %>
|
|
22
|
+
<label>Branch <span class="hint">— optional</span></label>
|
|
23
|
+
<%= f.text_field "card[branch_name]", placeholder: "cardinal/#{@board.cards.maximum(:number).to_i + 1}-feature-name" %>
|
|
24
|
+
<label>Pull request <span class="hint">— optional</span></label>
|
|
25
|
+
<%= f.text_field "card[pr_url]", placeholder: "https://github.com/owner/repo/pull/123" %>
|
|
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
|
+
<div class="card-edit-actions">
|
|
28
|
+
<button type="button" class="btn-cancel" data-action="modal#close">Cancel</button>
|
|
29
|
+
<%= f.submit "Save" %>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
@@ -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,146 @@
|
|
|
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" data-controller="autosave">
|
|
4
|
+
<header class="modal-header">
|
|
5
|
+
<h1>⚙ <%= @column.name %> <span class="chip"><%= @column.archetype %></span>
|
|
6
|
+
<span class="autosave-status" data-autosave-target="status"></span></h1>
|
|
7
|
+
<button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
|
|
8
|
+
</header>
|
|
9
|
+
<div class="modal-body">
|
|
10
|
+
<div id="column-form-errors"><% if @json_error %><p class="form-error"><%= @json_error %></p><% end %></div>
|
|
11
|
+
|
|
12
|
+
<%= form_with model: @column, class: "card-edit",
|
|
13
|
+
data: { autosave_target: "form", action: "input->autosave#save change->autosave#save" } do |f| %>
|
|
14
|
+
<%= hidden_field_tag :autosave, "1" %>
|
|
15
|
+
<div class="field-row">
|
|
16
|
+
<div>
|
|
17
|
+
<label>Name <%= info_tip("The column's display name on the board.") %></label>
|
|
18
|
+
<%= f.text_field :name, required: true %>
|
|
19
|
+
</div>
|
|
20
|
+
<div>
|
|
21
|
+
<label>Archetype <%= info_tip("What kind of stage this is. 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). Inbox isn't selectable — the board has exactly one, the far-left intake.") %></label>
|
|
22
|
+
<% if @column.inbox? %>
|
|
23
|
+
<p class="archetype-locked">Inbox — the board's single intake (fixed)</p>
|
|
24
|
+
<% else %>
|
|
25
|
+
<%= f.select :archetype, (Column::ARCHETYPES - ["inbox"]).map { |a| [a.capitalize, a] } %>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
<div>
|
|
29
|
+
<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>
|
|
30
|
+
<%= f.select :arrivals, [["Where dropped", ""], ["Top of column", "top"], ["Bottom of column", "bottom"]], selected: @column.arrivals %>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="color-cell">
|
|
33
|
+
<label>Background <%= info_tip("Custom background color for this column on the board. Uncheck to fall back to the archetype's default tint.") %></label>
|
|
34
|
+
<span class="color-row">
|
|
35
|
+
<%= f.color_field :color, value: @column.safe_color || "#1e2128" %>
|
|
36
|
+
<label class="check-row inline"><%= check_box_tag "column[custom_color]", "1", @column.safe_color.present? %> use</label>
|
|
37
|
+
</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<% siblings = @column.board.columns.where.not(id: @column.id).order(:position) %>
|
|
42
|
+
<% if siblings.any? %>
|
|
43
|
+
<% allowed = Array(@column.accepts_from).map(&:to_s) %>
|
|
44
|
+
<label>Accepts moves from (inbound) <%= info_tip("INBOUND rule: which columns may drag cards INTO this one. EXPLICIT ONLY — nothing checked means nothing may enter (there is no accept-from-anywhere). It does not control where this column's cards may go; set that on the destinations.") %></label>
|
|
45
|
+
<div class="accepts-from">
|
|
46
|
+
<% siblings.each do |sib| %>
|
|
47
|
+
<label class="check-row inline">
|
|
48
|
+
<%= check_box_tag "column[accepts_from][]", sib.id, allowed.include?(sib.id.to_s) %>
|
|
49
|
+
<%= sib.name %>
|
|
50
|
+
</label>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
<% end %>
|
|
54
|
+
|
|
55
|
+
<%# The Tasks/inbox column is the board's intake — cards park here untouched,
|
|
56
|
+
so none of the AI-work or on-entry settings below apply to it (card #17). %>
|
|
57
|
+
<% if @column.inbox? %>
|
|
58
|
+
<p class="default-rule">This column never uses AI — it's the board's intake.</p>
|
|
59
|
+
<% else %>
|
|
60
|
+
<div data-controller="reveal">
|
|
61
|
+
<label class="check-row">
|
|
62
|
+
<%= hidden_field_tag "column[ai]", "0", id: nil %>
|
|
63
|
+
<%= check_box_tag "column[ai]", "1", @column.ai?, data: { reveal_target: "toggle", action: "change->reveal#sync" } %>
|
|
64
|
+
🤖 Use AI in this column — <%= @column.ai_mode_description %>
|
|
65
|
+
<%= info_tip("Off = this column is fully inert AI-wise: no assistant, no worker agents, no AI rules. Cards here are human work (a card dragged into a non-AI work column reads as being worked by a person). Non-AI on-entry rules like merge_pr still run.") %>
|
|
66
|
+
</label>
|
|
67
|
+
|
|
68
|
+
<div data-reveal-target="panel">
|
|
69
|
+
<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>
|
|
70
|
+
<% if @column.built_in_role %>
|
|
71
|
+
<p class="default-rule">Built-in role: <%= @column.built_in_role %></p>
|
|
72
|
+
<% end %>
|
|
73
|
+
<%= f.text_area :instructions, rows: 3, value: @column.instructions,
|
|
74
|
+
placeholder: "Your additions — e.g. “always press for acceptance criteria”, “follow the repo style guide”…" %>
|
|
75
|
+
|
|
76
|
+
<div class="field-row">
|
|
77
|
+
<div>
|
|
78
|
+
<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>
|
|
79
|
+
<%= f.select :model, model_options(@column.model), selected: @column.model.to_s %>
|
|
80
|
+
</div>
|
|
81
|
+
<div>
|
|
82
|
+
<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>
|
|
83
|
+
<%= f.select :effort, [["(default)", ""], "low", "medium", "high", "max"], selected: @column.effort %>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="field-row">
|
|
88
|
+
<div>
|
|
89
|
+
<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>
|
|
90
|
+
<%= f.number_field :concurrency_limit, value: @column.concurrency_limit, min: 1, placeholder: "∞" %>
|
|
91
|
+
</div>
|
|
92
|
+
<div>
|
|
93
|
+
<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>
|
|
94
|
+
<%= f.number_field :max_turns, value: @column.max_turns, min: 1, placeholder: "80" %>
|
|
95
|
+
</div>
|
|
96
|
+
<div>
|
|
97
|
+
<label>Timeout (min) <%= info_tip("Wall-clock limit per run segment. The agent process is killed when it expires — never a runaway.") %></label>
|
|
98
|
+
<%= f.number_field :timeout_minutes, value: @column.timeout_minutes, min: 1, placeholder: "30" %>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<label class="check-row">
|
|
103
|
+
<%= f.check_box :plan_approval, checked: ActiveModel::Type::Boolean.new.cast(@column.plan_approval) %>
|
|
104
|
+
Require plan approval before the agent makes changes
|
|
105
|
+
<%= 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.") %>
|
|
106
|
+
</label>
|
|
107
|
+
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<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>
|
|
111
|
+
<p class="default-rule">Archetype template: <%= Rules::DEFAULT_DESCRIPTIONS[@column.archetype] %></p>
|
|
112
|
+
<% if @column.policy["on_entry"].present? %>
|
|
113
|
+
<p class="default-rule active-rule">Currently active: <%= Rules.describe(@column.policy["on_entry"]) %><%= " — from your English rules below" if @column.policy["on_entry_text"].present? %></p>
|
|
114
|
+
<% end %>
|
|
115
|
+
<%= f.text_area :on_entry_text, rows: 3,
|
|
116
|
+
value: @column.policy["on_entry_text"],
|
|
117
|
+
placeholder: "Describe what should happen when a card arrives — replaces the active behavior shown above. Blank = archetype default." %>
|
|
118
|
+
|
|
119
|
+
<details class="advanced-rules">
|
|
120
|
+
<summary>Advanced: compiled rules (JSON)</summary>
|
|
121
|
+
<p class="hint">Applied only when the English box above is empty. Current compiled form:</p>
|
|
122
|
+
<%= f.text_area :on_entry_json, rows: 4, class: "mono",
|
|
123
|
+
value: @column.policy["on_entry"] ? JSON.pretty_generate(@column.policy["on_entry"]) : "",
|
|
124
|
+
placeholder: '[{"action": "ai_task", "prompt": "Suggest tags for: %{title}"}]' %>
|
|
125
|
+
</details>
|
|
126
|
+
</div>
|
|
127
|
+
<% end %>
|
|
128
|
+
<% end %>
|
|
129
|
+
|
|
130
|
+
<details class="advanced-rules panel-advanced">
|
|
131
|
+
<summary>Advanced</summary>
|
|
132
|
+
<% if @column.inbox? %>
|
|
133
|
+
<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>
|
|
134
|
+
<% elsif @column.cards.none? %>
|
|
135
|
+
<p class="hint">Deleting removes this column and its policy. Cards are unaffected — the column must already be empty.</p>
|
|
136
|
+
<%= button_to "🗑 Delete column", column_path(@column), method: :delete, class: "cancel-btn delete-column",
|
|
137
|
+
form: { data: { turbo_frame: "_top", action: "turbo:submit-end->modal#closeOnSuccess",
|
|
138
|
+
turbo_confirm: "Delete the \"#{@column.name}\" column and its policy?" } } %>
|
|
139
|
+
<% else %>
|
|
140
|
+
<p class="hint">This column still has <%= pluralize(@column.cards.count, "card") %> — move them before it can be deleted.</p>
|
|
141
|
+
<% end %>
|
|
142
|
+
</details>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<% 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