cardinal-ai 0.2.3 → 0.2.5
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 +45 -1
- data/app/controllers/boards_controller.rb +50 -0
- data/app/controllers/columns_controller.rb +73 -14
- data/app/controllers/messages_controller.rb +1 -1
- data/app/helpers/application_helper.rb +8 -0
- data/app/javascript/controllers/autosave_controller.js +18 -3
- data/app/javascript/controllers/board_column_controller.js +3 -3
- data/app/javascript/controllers/modal_controller.js +2 -0
- data/app/javascript/controllers/reveal_controller.js +15 -0
- data/app/jobs/deep_dive_job.rb +64 -0
- data/app/jobs/start_run_job.rb +1 -1
- data/app/models/board.rb +101 -12
- data/app/models/card.rb +4 -1
- data/app/models/column.rb +102 -6
- data/app/services/agent/runner.rb +10 -2
- data/app/services/card_transition.rb +1 -1
- data/app/services/rules.rb +36 -11
- data/app/services/run_sweeper.rb +1 -0
- data/app/views/boards/show.html.erb +24 -1
- data/app/views/cards/_card.html.erb +4 -4
- data/app/views/cards/_detail.html.erb +4 -4
- data/app/views/columns/_column.html.erb +11 -0
- data/app/views/columns/edit.html.erb +37 -12
- data/cardinal.md +9 -0
- data/config/routes.rb +4 -1
- data/db/migrate/20260704120000_add_repo_brief_to_boards.rb +8 -0
- data/db/seeds.rb +1 -7
- data/lib/cardinal/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea33d976c3ebc53402438e5dae5e6b480842034e1a77a1c58142d62ad1f347e2
|
|
4
|
+
data.tar.gz: 7571417a375ab611c9345a6b2d7ab6cea33233e668d90414edae44ea2d116048
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db9ec21facb032331fd8686f772e1c1313e0e84388f675eb723e92847c0c7638325d2af5ff8d392f2536c292d0929a79f64e53f040ef1206bb74097477c1f9e1
|
|
7
|
+
data.tar.gz: c290a471053fc66929db5fe9b3219bb49dc85f2b953b4a33beab0878e261d7ed4e815dc5b1b57ca9f8346f2ba9a7e73f7607a30f1af08e09a6665311dfdd6811
|
|
@@ -52,6 +52,7 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
52
52
|
}
|
|
53
53
|
.topbar h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
|
54
54
|
.topbar h1 .sep { color: var(--accent); margin: 0 4px; }
|
|
55
|
+
.topbar-left { display: flex; align-items: center; gap: 12px; }
|
|
55
56
|
.topbar-right { display: flex; align-items: center; gap: 14px; }
|
|
56
57
|
|
|
57
58
|
.theme-toggle {
|
|
@@ -60,6 +61,29 @@ a { color: var(--blue); text-decoration: none; }
|
|
|
60
61
|
}
|
|
61
62
|
.theme-toggle:hover { color: var(--text); border-color: var(--text-dim); }
|
|
62
63
|
|
|
64
|
+
/* Repo deep dive button (card #12). Inline color/border come from the board's
|
|
65
|
+
staleness gradient (grey → red over 10 commits); .stale-critical flashes to
|
|
66
|
+
nag a refresh once the brief is too far behind HEAD to trust. */
|
|
67
|
+
.topbar-right form.button_to { display: inline-flex; margin: 0; }
|
|
68
|
+
.deep-dive {
|
|
69
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
70
|
+
background: transparent; border: 1px solid var(--border); border-radius: 6px;
|
|
71
|
+
color: var(--text-dim); font-weight: 600; padding: 5px 10px; line-height: 1;
|
|
72
|
+
cursor: pointer; white-space: nowrap;
|
|
73
|
+
}
|
|
74
|
+
.deep-dive:hover:not(:disabled) { filter: brightness(1.25); }
|
|
75
|
+
.deep-dive:disabled { cursor: default; opacity: .8; }
|
|
76
|
+
.deep-dive.stale-critical { animation: stale-flash 1.1s ease-in-out infinite; }
|
|
77
|
+
@keyframes stale-flash {
|
|
78
|
+
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(212, 51, 51, .55); }
|
|
79
|
+
50% { opacity: .7; box-shadow: 0 0 0 3px rgba(212, 51, 51, 0); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.pull-form { display: inline; }
|
|
83
|
+
#repo-pull-status { font-size: 0.85rem; }
|
|
84
|
+
#repo-pull-status .pull-ok { color: var(--green); }
|
|
85
|
+
#repo-pull-status .pull-err { color: var(--red); }
|
|
86
|
+
|
|
63
87
|
.attention summary {
|
|
64
88
|
cursor: pointer;
|
|
65
89
|
color: var(--amber);
|
|
@@ -154,6 +178,10 @@ button, input[type="submit"] {
|
|
|
154
178
|
body.dragging .drop-hint { display: block; }
|
|
155
179
|
.ticker { font-size: 11px; color: var(--text-dim); margin: 0 4px 8px; }
|
|
156
180
|
|
|
181
|
+
.column-footer { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-dim); }
|
|
182
|
+
.footer-row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 4px; }
|
|
183
|
+
.footer-value { font-variant-numeric: tabular-nums; color: var(--text); }
|
|
184
|
+
|
|
157
185
|
.cards { min-height: 40px; display: flex; flex-direction: column; gap: 8px; flex: 1; overflow-y: auto; }
|
|
158
186
|
.cards-clickable { cursor: pointer; }
|
|
159
187
|
.agent-chip { color: var(--blue); }
|
|
@@ -509,6 +537,22 @@ body.dragging .drop-hint { display: block; }
|
|
|
509
537
|
[data-theme="light"] .card-footer { background: #e2e5ea; border-top-color: rgba(0, 0, 0, .1); }
|
|
510
538
|
|
|
511
539
|
/* Accept-policy visibility while dragging + rejected-drop flash */
|
|
512
|
-
.column.drop-blocked { opacity: .
|
|
540
|
+
.column.drop-blocked { opacity: .35; filter: grayscale(.5) brightness(.7); transition: opacity .15s, filter .15s; }
|
|
513
541
|
.column.drop-blocked .drop-hint { color: var(--red); }
|
|
514
542
|
.column.drop-blocked .drop-hint::before { content: "✗ won't accept from here — "; }
|
|
543
|
+
|
|
544
|
+
.hidden { display: none !important; }
|
|
545
|
+
|
|
546
|
+
/* The reveal wrappers must be invisible to the form's flex layout — their
|
|
547
|
+
children should behave as direct .card-edit flex items (full width). */
|
|
548
|
+
.card-edit [data-controller="reveal"],
|
|
549
|
+
.card-edit [data-reveal-target="panel"] { display: contents; }
|
|
550
|
+
.card-edit textarea, .card-edit input[type="text"] { width: 100%; }
|
|
551
|
+
|
|
552
|
+
.default-rule.active-rule { border-left-color: var(--green); }
|
|
553
|
+
|
|
554
|
+
.archetype-locked {
|
|
555
|
+
font-size: 13px; color: var(--text-dim); margin: 0;
|
|
556
|
+
padding: 6px 10px; background: var(--surface-2);
|
|
557
|
+
border: 1px solid var(--border); border-radius: 6px;
|
|
558
|
+
}
|
|
@@ -2,4 +2,54 @@ class BoardsController < ApplicationController
|
|
|
2
2
|
def show
|
|
3
3
|
@board = Board.includes(columns: :cards).first!
|
|
4
4
|
end
|
|
5
|
+
|
|
6
|
+
# Kick off the repo deep dive (card #12). Non-blocking: flip the board into
|
|
7
|
+
# its "Working" state, morph the topbar so the button reflects it, and let
|
|
8
|
+
# DeepDiveJob do the read-only exploration in the background. Ignored if a
|
|
9
|
+
# dive is already running.
|
|
10
|
+
def deep_dive
|
|
11
|
+
board = Board.first!
|
|
12
|
+
unless board.brief_working?
|
|
13
|
+
board.update!(brief_status: "working")
|
|
14
|
+
board.broadcast_refresh_to board
|
|
15
|
+
DeepDiveJob.perform_later(board)
|
|
16
|
+
end
|
|
17
|
+
redirect_to root_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Cards reaching Done merge PRs on GitHub, so the checkout Cardinal runs
|
|
21
|
+
# against falls behind. The topbar Pull button fast-forwards it. --ff-only
|
|
22
|
+
# on purpose: never invent merge commits or rebase local work — if the tree
|
|
23
|
+
# has diverged, say so and let the human sort it out in a real terminal.
|
|
24
|
+
def pull
|
|
25
|
+
board = Board.first!
|
|
26
|
+
message, ok = pull_repo(board)
|
|
27
|
+
render turbo_stream: turbo_stream.update(
|
|
28
|
+
"repo-pull-status",
|
|
29
|
+
helpers.tag.span(message, class: ok ? "pull-ok" : "pull-err")
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def pull_repo(board)
|
|
36
|
+
repo = board.local_path.presence
|
|
37
|
+
return ["No local repo path on this board", false] unless repo && Dir.exist?(File.join(repo, ".git"))
|
|
38
|
+
|
|
39
|
+
before, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
|
|
40
|
+
out, status = Open3.capture2e("git", "-C", repo, "pull", "--ff-only")
|
|
41
|
+
unless status.success?
|
|
42
|
+
# Surface git's own reason (diverged, offline, auth) — the last
|
|
43
|
+
# non-blank line is usually the one that matters.
|
|
44
|
+
return [out.lines.map(&:strip).reject(&:blank?).last.to_s.truncate(120), false]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
after, = Open3.capture2e("git", "-C", repo, "rev-parse", "HEAD")
|
|
48
|
+
if before.strip == after.strip
|
|
49
|
+
["Already up to date", true]
|
|
50
|
+
else
|
|
51
|
+
count, = Open3.capture2e("git", "-C", repo, "rev-list", "--count", "#{before.strip}..#{after.strip}")
|
|
52
|
+
["Pulled #{helpers.pluralize(count.strip.to_i, "new commit")}", true]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
5
55
|
end
|
|
@@ -25,8 +25,8 @@ class ColumnsController < ApplicationController
|
|
|
25
25
|
attrs = params.require(:column).permit(
|
|
26
26
|
:name, :archetype, :instructions, :model, :effort,
|
|
27
27
|
:concurrency_limit, :max_turns, :timeout_minutes, :plan_approval,
|
|
28
|
-
:on_entry_text, :on_entry_json, :color, :custom_color, :arrivals,
|
|
29
|
-
accepts_from: []
|
|
28
|
+
:on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
|
|
29
|
+
:footer_text, accepts_from: []
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
policy = @column.policy.dup
|
|
@@ -37,41 +37,84 @@ class ColumnsController < ApplicationController
|
|
|
37
37
|
end
|
|
38
38
|
policy["plan_approval"] = attrs[:plan_approval] == "1"
|
|
39
39
|
policy["arrivals"] = attrs[:arrivals].presence_in(%w[top bottom])
|
|
40
|
-
|
|
41
|
-
#
|
|
40
|
+
policy["ai"] = (attrs[:ai] == "1") if attrs.key?(:ai) # inbox forms omit it — never AI anyway
|
|
41
|
+
# Accept policy (card #15): store allowed source column ids as strings.
|
|
42
|
+
# EXPLICIT ONLY — an empty list means the column accepts from nowhere.
|
|
42
43
|
policy["accepts_from"] = attrs[:accepts_from].to_a.map(&:to_s).reject(&:blank?).presence
|
|
43
44
|
|
|
45
|
+
# Archetype is a TEMPLATE: switching it re-stamps rules + instructions
|
|
46
|
+
# from the new archetype (the submitted fields belong to the old one).
|
|
47
|
+
new_archetype = @column.inbox? ? "inbox" : (attrs[:archetype].presence_in(Column::ARCHETYPES - %w[inbox]) || @column.archetype)
|
|
48
|
+
@archetype_changed = new_archetype != @column.archetype
|
|
49
|
+
if @archetype_changed
|
|
50
|
+
template = Column::ARCHETYPE_TEMPLATES.fetch(new_archetype, {})
|
|
51
|
+
%w[on_entry on_entry_text instructions].each { |k| policy[k] = template[k] }
|
|
52
|
+
end
|
|
53
|
+
|
|
44
54
|
# Rules: plain English is the source of truth (compiled on change); the
|
|
45
55
|
# advanced JSON editor applies only when the English box is empty.
|
|
46
|
-
if attrs[:on_entry_text].present?
|
|
56
|
+
if !@archetype_changed && attrs[:on_entry_text].present?
|
|
47
57
|
if attrs[:on_entry_text].strip != policy["on_entry_text"].to_s.strip
|
|
48
|
-
|
|
58
|
+
begin
|
|
49
59
|
policy["on_entry"] = Rules::Compiler.compile(attrs[:on_entry_text])
|
|
50
60
|
rescue Rules::Compiler::Error => e
|
|
51
|
-
|
|
52
|
-
return render :edit, status: :unprocessable_entity
|
|
61
|
+
return column_error(e.message)
|
|
53
62
|
end
|
|
54
63
|
end
|
|
55
64
|
policy["on_entry_text"] = attrs[:on_entry_text].strip
|
|
56
|
-
elsif attrs[:on_entry_json].present?
|
|
65
|
+
elsif !@archetype_changed && attrs[:on_entry_json].present?
|
|
57
66
|
begin
|
|
58
67
|
policy["on_entry"] = JSON.parse(attrs[:on_entry_json])
|
|
59
68
|
policy.delete("on_entry_text")
|
|
60
69
|
rescue JSON::ParserError => e
|
|
61
|
-
|
|
62
|
-
return render :edit, status: :unprocessable_entity
|
|
70
|
+
return column_error("on_entry is not valid JSON: #{e.message.truncate(120)}")
|
|
63
71
|
end
|
|
64
|
-
|
|
72
|
+
elsif !@archetype_changed
|
|
65
73
|
policy.delete("on_entry")
|
|
66
74
|
policy.delete("on_entry_text")
|
|
67
75
|
end
|
|
68
76
|
|
|
77
|
+
# Footer (card #18): one row per line as "Label | compute". A blank compute
|
|
78
|
+
# is a static label; a compute must be one Column knows how to aggregate.
|
|
79
|
+
begin
|
|
80
|
+
policy["footer"] = parse_footer(attrs[:footer_text])
|
|
81
|
+
rescue ArgumentError => e
|
|
82
|
+
return column_error(e.message)
|
|
83
|
+
end
|
|
84
|
+
|
|
69
85
|
@column.update!(
|
|
70
86
|
name: attrs[:name].presence || @column.name,
|
|
71
|
-
archetype:
|
|
87
|
+
archetype: new_archetype,
|
|
72
88
|
policy: policy.compact
|
|
73
89
|
)
|
|
74
|
-
|
|
90
|
+
|
|
91
|
+
if params[:autosave]
|
|
92
|
+
# Silent save: patch the board's column section + clear any prior error.
|
|
93
|
+
# No modal replace — it would steal focus mid-edit.
|
|
94
|
+
streams = [
|
|
95
|
+
turbo_stream.replace(helpers.dom_id(@column), partial: "columns/column", locals: { column: @column.reload }),
|
|
96
|
+
turbo_stream.update("column-form-errors", "")
|
|
97
|
+
]
|
|
98
|
+
# A re-stamped archetype must re-render the modal (its fields changed
|
|
99
|
+
# server-side); focus loss is fine — the user just picked from a select.
|
|
100
|
+
streams << turbo_stream.replace("modal", template: "columns/edit", formats: [:html]) if @archetype_changed
|
|
101
|
+
render turbo_stream: streams
|
|
102
|
+
else
|
|
103
|
+
redirect_to root_path
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Autosave-friendly error: surface in the modal without re-rendering the form.
|
|
108
|
+
def column_error(message)
|
|
109
|
+
if params[:autosave]
|
|
110
|
+
render turbo_stream: turbo_stream.update(
|
|
111
|
+
"column-form-errors",
|
|
112
|
+
helpers.tag.p("#{message} — this field was NOT saved.", class: "form-error")
|
|
113
|
+
), status: :unprocessable_entity
|
|
114
|
+
else
|
|
115
|
+
@json_error = message
|
|
116
|
+
render :edit, status: :unprocessable_entity
|
|
117
|
+
end
|
|
75
118
|
end
|
|
76
119
|
|
|
77
120
|
def destroy
|
|
@@ -91,5 +134,21 @@ class ColumnsController < ApplicationController
|
|
|
91
134
|
|
|
92
135
|
private
|
|
93
136
|
|
|
137
|
+
# "Label | compute" lines → [{"label" =>, "compute" =>}]. Returns nil when
|
|
138
|
+
# empty so the key is dropped by policy.compact. Raises ArgumentError on an
|
|
139
|
+
# unknown compute key, mirroring the on_entry validation path.
|
|
140
|
+
def parse_footer(text)
|
|
141
|
+
rows = text.to_s.lines.filter_map do |line|
|
|
142
|
+
next if line.strip.blank?
|
|
143
|
+
label, compute = line.split("|", 2).map(&:strip)
|
|
144
|
+
compute = compute.to_s
|
|
145
|
+
if compute.present? && !Column::FOOTER_COMPUTES.include?(compute)
|
|
146
|
+
raise ArgumentError, "Footer compute \"#{compute}\" is not one of: #{Column::FOOTER_COMPUTES.join(', ')}"
|
|
147
|
+
end
|
|
148
|
+
{ "label" => label.to_s, "compute" => compute.presence }.compact
|
|
149
|
+
end
|
|
150
|
+
rows.presence
|
|
151
|
+
end
|
|
152
|
+
|
|
94
153
|
def set_column = @column = Column.find(params[:id])
|
|
95
154
|
end
|
|
@@ -18,7 +18,7 @@ class MessagesController < ApplicationController
|
|
|
18
18
|
card.log!("status_change", actor: "user", text: "Changes requested — drag the card back to a work column when ready")
|
|
19
19
|
else
|
|
20
20
|
card.log!("user_message", actor: "user", text: text)
|
|
21
|
-
AssistantReplyJob.perform_later(card) if card.column.planning?
|
|
21
|
+
AssistantReplyJob.perform_later(card) if card.column.planning? && card.column.ai?
|
|
22
22
|
end
|
|
23
23
|
redirect_to card_path(card)
|
|
24
24
|
end
|
|
@@ -26,6 +26,14 @@ module ApplicationHelper
|
|
|
26
26
|
MARKDOWN.render(text.to_s).html_safe
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Render a column's stored footer config (array of {label, compute} hashes)
|
|
30
|
+
# back into the "Label | compute" line format the gear textarea edits.
|
|
31
|
+
def footer_config_text(column)
|
|
32
|
+
Array(column.footer).map do |row|
|
|
33
|
+
[row["label"], row["compute"].presence].compact.join(" | ")
|
|
34
|
+
end.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
29
37
|
def info_tip(text)
|
|
30
38
|
tag.span("i", class: "info",
|
|
31
39
|
data: { controller: "tooltip", tooltip_text_value: text,
|
|
@@ -5,24 +5,39 @@ export default class extends Controller {
|
|
|
5
5
|
static targets = ["status", "form"]
|
|
6
6
|
|
|
7
7
|
connect() {
|
|
8
|
-
this.onEnd = () => {
|
|
8
|
+
this.onEnd = (event) => {
|
|
9
9
|
if (!this.hasStatusTarget) return
|
|
10
|
-
this.statusTarget.textContent = "Saved ✓"
|
|
10
|
+
this.statusTarget.textContent = event.detail?.success === false ? "✗ not saved" : "Saved ✓"
|
|
11
11
|
clearTimeout(this.fade)
|
|
12
12
|
this.fade = setTimeout(() => (this.statusTarget.textContent = ""), 1500)
|
|
13
13
|
}
|
|
14
14
|
this.element.addEventListener("turbo:submit-end", this.onEnd)
|
|
15
|
+
|
|
16
|
+
// A closing modal must not eat a pending debounce — flush immediately.
|
|
17
|
+
this.onModalClose = () => this.flush()
|
|
18
|
+
document.addEventListener("cardinal:modal-closing", this.onModalClose)
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
disconnect() {
|
|
18
22
|
clearTimeout(this.timer)
|
|
19
23
|
clearTimeout(this.fade)
|
|
20
24
|
this.element.removeEventListener("turbo:submit-end", this.onEnd)
|
|
25
|
+
document.removeEventListener("cardinal:modal-closing", this.onModalClose)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
flush() {
|
|
29
|
+
if (!this.timer) return
|
|
30
|
+
clearTimeout(this.timer)
|
|
31
|
+
this.timer = null
|
|
32
|
+
this.formTarget.requestSubmit()
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
save() {
|
|
24
36
|
if (this.hasStatusTarget) this.statusTarget.textContent = "…"
|
|
25
37
|
clearTimeout(this.timer)
|
|
26
|
-
this.timer = setTimeout(() =>
|
|
38
|
+
this.timer = setTimeout(() => {
|
|
39
|
+
this.timer = null
|
|
40
|
+
this.formTarget.requestSubmit()
|
|
41
|
+
}, 800)
|
|
27
42
|
}
|
|
28
43
|
}
|
|
@@ -37,9 +37,9 @@ export default class extends Controller {
|
|
|
37
37
|
markBlockedColumns() {
|
|
38
38
|
const sourceId = this.element.dataset.columnId
|
|
39
39
|
document.querySelectorAll(".column").forEach(section => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!
|
|
40
|
+
if (section.dataset.colId === sourceId) return // reorder within is always fine
|
|
41
|
+
const allowed = (section.dataset.accepts || "").split(",").filter(Boolean)
|
|
42
|
+
if (!allowed.includes(sourceId)) section.classList.add("drop-blocked")
|
|
43
43
|
})
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -29,6 +29,8 @@ export default class extends Controller {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
close() {
|
|
32
|
+
// Let autosave forms flush any pending debounce before the frame clears.
|
|
33
|
+
document.dispatchEvent(new CustomEvent("cardinal:modal-closing"))
|
|
32
34
|
const frame = this.element.closest("turbo-frame")
|
|
33
35
|
frame.removeAttribute("src")
|
|
34
36
|
frame.innerHTML = ""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Show/hide dependent settings when a toggle changes (e.g. the column AI
|
|
4
|
+
// checkbox reveals model/effort/budget settings). Autosave still fires via
|
|
5
|
+
// the change event bubbling to the autosave controller.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static targets = ["toggle", "panel"]
|
|
8
|
+
|
|
9
|
+
connect() { this.sync() }
|
|
10
|
+
|
|
11
|
+
sync() {
|
|
12
|
+
const on = this.toggleTarget.checked
|
|
13
|
+
this.panelTargets.forEach(p => p.classList.toggle("hidden", !on))
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Opt-in repo deep dive (card #12): a one-shot, read-only agent (the same
|
|
2
|
+
# cheap ClaudeCli tier the planning assistant uses — Read/Glob/Grep, no
|
|
3
|
+
# workspace) that maps the board's repo into a compact "repo brief". The brief
|
|
4
|
+
# is written to .cardinal/repo-brief.md and injected into every worker prompt,
|
|
5
|
+
# converting the per-run exploration tax into a one-time cost.
|
|
6
|
+
class DeepDiveJob < ApplicationJob
|
|
7
|
+
queue_as :default
|
|
8
|
+
|
|
9
|
+
# Enough turns to walk the tree and read a handful of key files, not enough
|
|
10
|
+
# to wander. ClaudeCli wraps up from context if it hits the cap.
|
|
11
|
+
MAX_TURNS = 30
|
|
12
|
+
FALLBACK_MODEL = "claude-haiku-4-5-20251001".freeze
|
|
13
|
+
|
|
14
|
+
PROMPT = <<~PROMPT.freeze
|
|
15
|
+
Map this repository as a concise "repo brief" for other AI agents who will
|
|
16
|
+
work in it. The whole point is to save tokens later, so be dense and skip
|
|
17
|
+
the obvious — every line must earn its place.
|
|
18
|
+
|
|
19
|
+
Explore with your read-only tools, then output ONLY flat markdown with these
|
|
20
|
+
sections (drop any that genuinely don't apply):
|
|
21
|
+
|
|
22
|
+
## Overview
|
|
23
|
+
One short paragraph: what this project is and its shape.
|
|
24
|
+
## Directory Structure
|
|
25
|
+
The top-level directories and what each is for (one line each).
|
|
26
|
+
## Key Directories
|
|
27
|
+
The few places most work actually happens, and what lives there.
|
|
28
|
+
## Build & Test
|
|
29
|
+
The exact commands to install, build, run, and test.
|
|
30
|
+
## Key Conventions
|
|
31
|
+
Naming, patterns, and idioms an agent must follow to match the codebase.
|
|
32
|
+
## Tech Stack
|
|
33
|
+
Languages, frameworks, and notable libraries with their role.
|
|
34
|
+
## Gotchas
|
|
35
|
+
Non-obvious traps, footguns, and constraints worth knowing before editing.
|
|
36
|
+
|
|
37
|
+
Do not include a preamble, closing remarks, or anything outside these sections.
|
|
38
|
+
PROMPT
|
|
39
|
+
|
|
40
|
+
def perform(board)
|
|
41
|
+
repo = board.local_path.presence
|
|
42
|
+
return clear_working(board) unless ClaudeCli.available? && repo
|
|
43
|
+
|
|
44
|
+
sha = board.head_sha
|
|
45
|
+
model = board.columns.find_by(archetype: "planning")&.model.presence || FALLBACK_MODEL
|
|
46
|
+
|
|
47
|
+
brief = ClaudeCli.prompt(PROMPT, model:, tools: "Read,Glob,Grep", cwd: repo, max_turns: MAX_TURNS)
|
|
48
|
+
|
|
49
|
+
File.write(board.brief_path, brief.to_s)
|
|
50
|
+
board.update!(brief_sha: sha, brief_generated_at: Time.current,
|
|
51
|
+
brief_model: model, brief_status: nil)
|
|
52
|
+
board.broadcast_refresh_to board
|
|
53
|
+
rescue StandardError
|
|
54
|
+
# A failed dive must not leave the button stuck on "Working" forever.
|
|
55
|
+
clear_working(board)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def clear_working(board)
|
|
61
|
+
board.update!(brief_status: nil)
|
|
62
|
+
board.broadcast_refresh_to board
|
|
63
|
+
end
|
|
64
|
+
end
|
data/app/jobs/start_run_job.rb
CHANGED
|
@@ -3,7 +3,7 @@ class StartRunJob < ApplicationJob
|
|
|
3
3
|
|
|
4
4
|
def perform(card_id)
|
|
5
5
|
card = Card.find(card_id)
|
|
6
|
-
return unless card.queued? && card.column.execution?
|
|
6
|
+
return unless card.queued? && card.column.execution? && card.column.ai?
|
|
7
7
|
return if card.column.at_wip_limit? # stays queued; kicked when a slot frees
|
|
8
8
|
|
|
9
9
|
session = card.agent_sessions.create!(status: "provisioning", model: card.column.model)
|
data/app/models/board.rb
CHANGED
|
@@ -1,19 +1,53 @@
|
|
|
1
1
|
class Board < ApplicationRecord
|
|
2
|
+
# Captured from the author's live board (2026-07-05) — the battle-tested
|
|
3
|
+
# layout. accepts_from is stored as NAMES here and resolved to column ids
|
|
4
|
+
# by install_default_columns! (ids don't exist until creation).
|
|
2
5
|
DEFAULT_COLUMNS = [
|
|
3
|
-
{ name: "Tasks", archetype: "inbox",
|
|
4
|
-
|
|
6
|
+
{ name: "Tasks", archetype: "inbox",
|
|
7
|
+
policy: { "plan_approval" => false,
|
|
8
|
+
"accepts_from_names" => ["Planning", "Review", "QA", "Done"] } },
|
|
9
|
+
{ name: "Planning", archetype: "planning",
|
|
10
|
+
policy: { "ai" => true, "model" => "claude-haiku-4-5-20251001", "plan_approval" => false,
|
|
11
|
+
"on_entry" => [{ "action" => "assistant_greeting" }],
|
|
12
|
+
"on_entry_text" => "The planning assistant reads the card and opens the discussion.",
|
|
13
|
+
"accepts_from_names" => ["Tasks", "In Progress", "Review", "QA"] } },
|
|
5
14
|
{ name: "In Progress", archetype: "execution",
|
|
6
|
-
policy: { "model" => "claude-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
policy: { "ai" => true, "model" => "claude-opus-4-8", "effort" => "high",
|
|
16
|
+
"concurrency_limit" => 3, "plan_approval" => true,
|
|
17
|
+
"budget_per_run_cents" => 200, "timeout_minutes" => 90, "max_turns" => 80,
|
|
18
|
+
"tools" => %w[read edit run_commands git_commit_push],
|
|
19
|
+
"on_entry" => [{ "action" => "start_agent_run" }],
|
|
20
|
+
"accepts_from_names" => ["Planning", "Review", "QA"],
|
|
21
|
+
"instructions" => "Follow repo conventions. Write tests when the repo has a suite." } },
|
|
22
|
+
{ name: "Review", archetype: "review",
|
|
23
|
+
policy: { "ai" => true, "plan_approval" => false,
|
|
24
|
+
"accepts_from_names" => ["In Progress", "QA"],
|
|
25
|
+
"on_entry" => [{ "action" => "mark_pr_ready" }],
|
|
12
26
|
"on_entry_text" => "Take the PR out of draft — mark it ready for review on GitHub." } },
|
|
27
|
+
{ name: "QA", archetype: "review",
|
|
28
|
+
policy: { "ai" => true, "plan_approval" => false,
|
|
29
|
+
"accepts_from_names" => ["Review"],
|
|
30
|
+
"on_entry" => [{ "action" => "mark_pr_ready" }] } },
|
|
13
31
|
{ name: "Done", archetype: "terminal",
|
|
14
|
-
policy: { "
|
|
32
|
+
policy: { "ai" => false, "plan_approval" => false, "arrivals" => "top",
|
|
33
|
+
"accepts_from_names" => ["Review", "QA", "Planning"],
|
|
34
|
+
"on_entry" => [{ "action" => "merge_pr" }] } }
|
|
15
35
|
].freeze
|
|
16
36
|
|
|
37
|
+
# Create the default columns, then resolve accepts_from_names -> ids.
|
|
38
|
+
def install_default_columns!
|
|
39
|
+
DEFAULT_COLUMNS.each_with_index do |attrs, index|
|
|
40
|
+
columns.create!(name: attrs[:name], archetype: attrs[:archetype], position: index,
|
|
41
|
+
policy: attrs[:policy].except("accepts_from_names"))
|
|
42
|
+
end
|
|
43
|
+
DEFAULT_COLUMNS.each do |attrs|
|
|
44
|
+
names = attrs[:policy]["accepts_from_names"] or next
|
|
45
|
+
col = columns.find_by!(name: attrs[:name])
|
|
46
|
+
ids = columns.where(name: names).pluck(:id).map(&:to_s)
|
|
47
|
+
col.update!(policy: col.policy.merge("accepts_from" => ids))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
17
51
|
has_many :columns, -> { order(:position) }, dependent: :destroy
|
|
18
52
|
has_many :cards, dependent: :destroy
|
|
19
53
|
|
|
@@ -34,9 +68,7 @@ class Board < ApplicationRecord
|
|
|
34
68
|
default_branch: branch_ok.success? && branch.strip.present? ? branch.strip : "main",
|
|
35
69
|
local_path: repo_path
|
|
36
70
|
)
|
|
37
|
-
|
|
38
|
-
board.columns.create!(position: index, **attrs)
|
|
39
|
-
end
|
|
71
|
+
board.install_default_columns!
|
|
40
72
|
board
|
|
41
73
|
end
|
|
42
74
|
|
|
@@ -51,6 +83,63 @@ class Board < ApplicationRecord
|
|
|
51
83
|
cards.pluck(:tags).flatten.compact.uniq.sort
|
|
52
84
|
end
|
|
53
85
|
|
|
86
|
+
# --- Repo brief (card #12) ---------------------------------------------
|
|
87
|
+
# A one-time deep dive that maps the repo, stored as flat markdown in
|
|
88
|
+
# .cardinal/ (never the host repo) and injected into worker prompts to
|
|
89
|
+
# spare each run the exploration tax. Metadata (which SHA/model/when) lives
|
|
90
|
+
# on the board so staleness can be judged against the current HEAD.
|
|
91
|
+
#
|
|
92
|
+
# Storage is a file + metadata, not one text column, so a structure
|
|
93
|
+
# provider (the Graphify child card) can slot a richer representation in
|
|
94
|
+
# underneath later without a migration.
|
|
95
|
+
BRIEF_STALE_AT = 10 # commits behind → the "refresh me" red/flashing state
|
|
96
|
+
|
|
97
|
+
def brief_path = Rails.root.join(".cardinal", "repo-brief.md")
|
|
98
|
+
|
|
99
|
+
def repo_brief
|
|
100
|
+
File.read(brief_path) if File.exist?(brief_path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def brief?
|
|
104
|
+
brief_sha.present? && File.exist?(brief_path)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def brief_working? = brief_status == "working"
|
|
108
|
+
|
|
109
|
+
# HEAD of the board's repo right now — the yardstick staleness measures against.
|
|
110
|
+
def head_sha
|
|
111
|
+
return nil if local_path.blank?
|
|
112
|
+
out, ok = Open3.capture2e("git", "-C", local_path, "rev-parse", "HEAD")
|
|
113
|
+
ok.success? ? out.strip : nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# How many commits landed since the brief was generated. nil when there's
|
|
117
|
+
# no brief (nothing to be stale against) or the SHA is unknown to the repo.
|
|
118
|
+
def commits_behind_brief
|
|
119
|
+
return @commits_behind_brief if defined?(@commits_behind_brief)
|
|
120
|
+
@commits_behind_brief =
|
|
121
|
+
if brief_sha.blank? || local_path.blank?
|
|
122
|
+
nil
|
|
123
|
+
else
|
|
124
|
+
out, ok = Open3.capture2e("git", "-C", local_path, "rev-list", "--count", "#{brief_sha}..HEAD")
|
|
125
|
+
ok.success? ? out.strip.to_i : nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def brief_stale? = (commits_behind_brief || 0) >= BRIEF_STALE_AT
|
|
130
|
+
|
|
131
|
+
# Grey → red interpolation over 0..BRIEF_STALE_AT commits behind, emitted
|
|
132
|
+
# as a validated hex into the button's inline style (mirrors Column#safe_color).
|
|
133
|
+
# Deep red once the brief is stale enough to over-anchor on.
|
|
134
|
+
def brief_staleness_color
|
|
135
|
+
behind = commits_behind_brief || 0
|
|
136
|
+
grey = [0x8a, 0x8a, 0x8a]
|
|
137
|
+
red = [0xd4, 0x33, 0x33]
|
|
138
|
+
t = [behind.to_f / BRIEF_STALE_AT, 1.0].min
|
|
139
|
+
rgb = grey.zip(red).map { |g, r| (g + (r - g) * t).round }
|
|
140
|
+
format("#%02x%02x%02x", *rgb)
|
|
141
|
+
end
|
|
142
|
+
|
|
54
143
|
# Cards currently waiting on the human, ordered by urgency — feeds the
|
|
55
144
|
# attention inbox in the board header.
|
|
56
145
|
def attention_cards
|
data/app/models/card.rb
CHANGED
|
@@ -29,6 +29,9 @@ class Card < ApplicationRecord
|
|
|
29
29
|
validate :status_legal_for_column
|
|
30
30
|
|
|
31
31
|
before_validation :assign_number_and_position, on: :create
|
|
32
|
+
# Optional git fields left blank on the new-card form arrive as "" — store
|
|
33
|
+
# them as nil so "no PR/branch" is one state, not two ("" renders footers).
|
|
34
|
+
normalizes :branch_name, :pr_url, with: ->(v) { v.strip.presence }
|
|
32
35
|
|
|
33
36
|
after_commit -> { broadcast_refresh_to board }
|
|
34
37
|
|
|
@@ -46,7 +49,7 @@ class Card < ApplicationRecord
|
|
|
46
49
|
# Is the planning assistant expected to post next? True right after entering
|
|
47
50
|
# a planning column (kickoff inspection pending) or after a user message.
|
|
48
51
|
def awaiting_assistant?
|
|
49
|
-
return false unless column.planning?
|
|
52
|
+
return false unless column.planning? && column.ai?
|
|
50
53
|
last = events.where(kind: %w[user_message assistant_message error column_move]).order(:id).last
|
|
51
54
|
last.present? && %w[user_message column_move].include?(last.kind)
|
|
52
55
|
end
|
data/app/models/column.rb
CHANGED
|
@@ -6,29 +6,86 @@ class Column < ApplicationRecord
|
|
|
6
6
|
|
|
7
7
|
enum :archetype, ARCHETYPES.index_by(&:itself)
|
|
8
8
|
|
|
9
|
+
# Archetypes are TEMPLATES, not magic: choosing one stamps concrete,
|
|
10
|
+
# editable values into the policy fields. Nothing falls back to these at
|
|
11
|
+
# runtime — what the gear modal shows is everything there is.
|
|
12
|
+
ARCHETYPE_TEMPLATES = {
|
|
13
|
+
"inbox" => {},
|
|
14
|
+
"planning" => {
|
|
15
|
+
"on_entry" => [{ "action" => "assistant_greeting" }],
|
|
16
|
+
"on_entry_text" => "The planning assistant reads the card and opens the discussion.",
|
|
17
|
+
"instructions" => "Drive toward crisp acceptance criteria. Open with the 2-3 sharpest questions."
|
|
18
|
+
},
|
|
19
|
+
"execution" => {
|
|
20
|
+
"on_entry" => [{ "action" => "start_agent_run" }],
|
|
21
|
+
"on_entry_text" => "Assign a dedicated worker agent to the card and start a run."
|
|
22
|
+
},
|
|
23
|
+
"review" => {},
|
|
24
|
+
"terminal" => {
|
|
25
|
+
"on_entry" => [{ "action" => "merge_pr" }],
|
|
26
|
+
"on_entry_text" => "Merge the card's PR and ship it."
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
before_create :seed_archetype_template
|
|
31
|
+
|
|
32
|
+
def archetype_template = ARCHETYPE_TEMPLATES.fetch(archetype, {})
|
|
33
|
+
|
|
9
34
|
# The policy blob is the column's entire behavior configuration (§1, §14.3).
|
|
10
35
|
store_accessor :policy, :instructions, :model, :effort, :concurrency_limit,
|
|
11
36
|
:plan_approval, :budget_per_run_cents, :timeout_minutes,
|
|
12
37
|
:max_turns, :tools, :on_entry, :on_success, :color, :arrivals,
|
|
13
|
-
:accepts_from
|
|
38
|
+
:accepts_from, :footer
|
|
39
|
+
|
|
40
|
+
# Aggregations a footer row may compute over the column's cards (card #18).
|
|
41
|
+
# A compute key not listed here renders blank, so config that outruns the
|
|
42
|
+
# code degrades gracefully instead of erroring.
|
|
43
|
+
FOOTER_COMPUTES = %w[sum_cost sum_tokens count_cards].freeze
|
|
14
44
|
|
|
15
45
|
# Only ever emit a validated hex color into inline styles.
|
|
16
46
|
def safe_color
|
|
17
47
|
color if color.to_s.match?(/\A#\h{6}\z/)
|
|
18
48
|
end
|
|
19
49
|
|
|
50
|
+
# Does any AI service this column? Explicit per-column switch (default ON
|
|
51
|
+
# for back-compat); the inbox/Tasks intake is never AI, unconditionally.
|
|
52
|
+
# When false the column is inert AI-wise: no assistant, no worker runs,
|
|
53
|
+
# no ai_task rules — cards there are human work.
|
|
54
|
+
def ai?
|
|
55
|
+
return false if inbox?
|
|
56
|
+
policy["ai"] != false
|
|
57
|
+
end
|
|
58
|
+
|
|
20
59
|
# Which columns may move cards INTO this one (§ accept policy, card #15).
|
|
21
|
-
# Stored as an array of column-id strings
|
|
22
|
-
#
|
|
60
|
+
# Stored as an array of column-id strings. EXPLICIT ONLY: an empty list
|
|
61
|
+
# means this column accepts from nowhere — there is no permissive default.
|
|
23
62
|
def accepts?(source_column)
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
Array(accepts_from).map(&:to_s).include?(source_column.id.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Rows rendered under the cards (card #18). Footer config is an array of
|
|
67
|
+
# {"label" => "Total cost:", "compute" => "sum_cost"} hashes; each row pairs
|
|
68
|
+
# static label text with an optional computed aggregate over this column's
|
|
69
|
+
# cards. Returns [] when unconfigured, so existing columns render no footer.
|
|
70
|
+
def footer_rows
|
|
71
|
+
rows = Array(footer).filter_map do |row|
|
|
72
|
+
label = row["label"].to_s
|
|
73
|
+
value = footer_value(row["compute"])
|
|
74
|
+
next if label.blank? && value.blank?
|
|
75
|
+
{ label:, value: }
|
|
76
|
+
end
|
|
77
|
+
# AI columns advertise their active model as a final auto-row (card #32).
|
|
78
|
+
# Guarded on model presence so an AI column without one adds nothing,
|
|
79
|
+
# rather than emitting a "Model:" row with a blank value.
|
|
80
|
+
rows << { label: "Model:", value: model_short } if ai? && model.present?
|
|
81
|
+
rows
|
|
26
82
|
end
|
|
27
83
|
|
|
28
84
|
# Start the next queued card when a run slot frees up. A queued card whose
|
|
29
85
|
# run parked and already has its answer recorded resumes instead of
|
|
30
86
|
# starting fresh.
|
|
31
87
|
def kick_queue
|
|
88
|
+
return unless ai?
|
|
32
89
|
return if at_wip_limit?
|
|
33
90
|
next_card = cards.where(status: "queued").order(:position).first
|
|
34
91
|
return unless next_card
|
|
@@ -56,6 +113,21 @@ class Column < ApplicationRecord
|
|
|
56
113
|
execution? && concurrency_limit.present? && running_count >= concurrency_limit.to_i
|
|
57
114
|
end
|
|
58
115
|
|
|
116
|
+
# Aggregate a single footer row over the runs/cards in this column (card #18).
|
|
117
|
+
# Unknown keys return "" so the row shows just its static label.
|
|
118
|
+
def footer_value(compute)
|
|
119
|
+
case compute.to_s
|
|
120
|
+
when "sum_cost"
|
|
121
|
+
"$%.2f" % column_runs.sum(:cost)
|
|
122
|
+
when "sum_tokens"
|
|
123
|
+
ActiveSupport::NumberHelper.number_to_delimited(column_runs.sum("input_tokens + output_tokens"))
|
|
124
|
+
when "count_cards"
|
|
125
|
+
cards.count.to_s
|
|
126
|
+
else
|
|
127
|
+
""
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
59
131
|
# The built-in role contract for AI servicing this archetype — shown
|
|
60
132
|
# read-only in the gear modal so the Instructions field is understood as
|
|
61
133
|
# ADDING to this, never replacing it. Enforced in code, not editable.
|
|
@@ -70,6 +142,23 @@ class Column < ApplicationRecord
|
|
|
70
142
|
|
|
71
143
|
def built_in_role = BUILT_IN_ROLES[archetype]
|
|
72
144
|
|
|
145
|
+
# What "Use AI" concretely means here — the §5 tier distinction, visible.
|
|
146
|
+
AI_MODES = {
|
|
147
|
+
"planning" => "a shared planning assistant joins each card's conversation",
|
|
148
|
+
"execution" => "a dedicated worker agent is assigned to each card",
|
|
149
|
+
"review" => "allow AI on-entry rules (ai_task) in this column",
|
|
150
|
+
"terminal" => "allow AI on-entry rules (ai_task) in this column"
|
|
151
|
+
}.freeze
|
|
152
|
+
|
|
153
|
+
def ai_mode_description = AI_MODES[archetype]
|
|
154
|
+
|
|
155
|
+
# Stamp template values into any policy field the creator left blank.
|
|
156
|
+
def seed_archetype_template
|
|
157
|
+
archetype_template.each do |key, value|
|
|
158
|
+
policy[key] = value if policy[key].blank?
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
73
162
|
# One-line consequence shown while dragging a card over this column (§14.1).
|
|
74
163
|
def drag_hint
|
|
75
164
|
case archetype
|
|
@@ -77,7 +166,14 @@ class Column < ApplicationRecord
|
|
|
77
166
|
when "planning" then "The board assistant will join the discussion"
|
|
78
167
|
when "execution" then "An agent will be assigned and start work"
|
|
79
168
|
when "review" then "Work stops — ready for your verdict"
|
|
80
|
-
when "terminal" then "
|
|
169
|
+
when "terminal" then "Closes it — PR merged and branch deleted, if there is one"
|
|
81
170
|
end
|
|
82
171
|
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# Every run belonging to a card in this column, for footer aggregation.
|
|
176
|
+
def column_runs
|
|
177
|
+
Run.joins(agent_session: :card).where(cards: { column_id: id })
|
|
178
|
+
end
|
|
83
179
|
end
|
|
@@ -329,7 +329,7 @@ module Agent
|
|
|
329
329
|
## Brief
|
|
330
330
|
#{card.description.presence || "(no description — infer scope from the title and conversation)"}
|
|
331
331
|
|
|
332
|
-
#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
332
|
+
#{repo_brief_section}#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
333
333
|
## Card conversation so far
|
|
334
334
|
#{conversation_excerpt.presence || "(none)"}
|
|
335
335
|
|
|
@@ -346,7 +346,7 @@ module Agent
|
|
|
346
346
|
## Brief
|
|
347
347
|
#{card.description.presence || "(no description — infer scope from the title and conversation)"}
|
|
348
348
|
|
|
349
|
-
#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
349
|
+
#{repo_brief_section}#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
350
350
|
## Card conversation so far
|
|
351
351
|
#{conversation_excerpt.presence || "(none)"}
|
|
352
352
|
|
|
@@ -363,6 +363,14 @@ module Agent
|
|
|
363
363
|
PROMPT
|
|
364
364
|
end
|
|
365
365
|
|
|
366
|
+
# The one-time repo deep dive (card #12), injected ahead of the planning
|
|
367
|
+
# brief so the agent starts oriented instead of re-exploring the tree each
|
|
368
|
+
# run. Empty string (not nil) when there's no brief, so the heredoc stays clean.
|
|
369
|
+
def repo_brief_section
|
|
370
|
+
brief = card.board.repo_brief.presence or return ""
|
|
371
|
+
"## Repo brief\n#{brief.strip}\n\n"
|
|
372
|
+
end
|
|
373
|
+
|
|
366
374
|
# The planning assistant's distilled "Ready for execution" brief, if the
|
|
367
375
|
# conversation produced one — the most load-bearing artifact of planning.
|
|
368
376
|
def planning_brief
|
|
@@ -79,7 +79,7 @@ class CardTransition
|
|
|
79
79
|
case @to.archetype
|
|
80
80
|
when "inbox" then "draft"
|
|
81
81
|
when "planning" then "discussing"
|
|
82
|
-
when "execution" then "queued"
|
|
82
|
+
when "execution" then @to.ai? ? "queued" : "working" # no AI = a human is on it
|
|
83
83
|
when "review" then "in_review"
|
|
84
84
|
when "terminal" then "done"
|
|
85
85
|
end
|
data/app/services/rules.rb
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# Column rules (cardinal.md §17): a column's on_entry policy is a list of rule
|
|
2
|
-
# actions fired when a card lands in it. Archetypes
|
|
3
|
-
# any column can carry any rules
|
|
2
|
+
# actions fired when a card lands in it. Archetypes stamp starting rules at
|
|
3
|
+
# creation (templates, not magic) — any column can carry any rules after that,
|
|
4
|
+
# including one-shot AI maintenance tasks.
|
|
4
5
|
module Rules
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"terminal" => [{ "action" => "merge_pr" }]
|
|
9
|
-
}.freeze
|
|
6
|
+
# NOTE: there is deliberately NO runtime fallback to archetype defaults —
|
|
7
|
+
# archetypes are creation-time templates (Column::ARCHETYPE_TEMPLATES).
|
|
8
|
+
# A column with no on_entry rules does nothing on entry, visibly.
|
|
10
9
|
|
|
11
10
|
# Shown in the gear modal so the archetype's built-in behavior is visible,
|
|
12
11
|
# not implied (the on-entry box being blank doesn't mean nothing happens).
|
|
@@ -15,22 +14,48 @@ module Rules
|
|
|
15
14
|
"planning" => "The planning assistant inspects the card and opens the conversation: it reads the title and description, then asks its sharpest clarifying questions to improve the card before execution. Tune its focus with the Instructions field above.",
|
|
16
15
|
"execution" => "A dedicated worker agent is assigned to the card and a run starts (plan-first if plan approval is on).",
|
|
17
16
|
"review" => "Nothing automatic — the card waits for your verdict.",
|
|
18
|
-
"terminal" => "The card's PR is
|
|
17
|
+
"terminal" => "The card's PR is squash-merged and its branch deleted. A card with no PR is simply closed — planning can send work straight here to terminate it."
|
|
19
18
|
}.freeze
|
|
20
19
|
|
|
21
20
|
def self.fire_entry(card, column)
|
|
22
|
-
each_rule(column.policy["on_entry"]
|
|
21
|
+
each_rule(column.policy["on_entry"]) do |rule|
|
|
23
22
|
apply(rule, card, column)
|
|
24
23
|
end
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
def self.each_rule(configured,
|
|
28
|
-
rules = configured.presence ||
|
|
26
|
+
def self.each_rule(configured, &block)
|
|
27
|
+
rules = configured.presence || []
|
|
29
28
|
rules = [rules] if rules.is_a?(Hash) || rules.is_a?(String)
|
|
30
29
|
rules.map { |r| r.is_a?(String) ? { "action" => r } : r }.each(&block)
|
|
31
30
|
end
|
|
32
31
|
|
|
32
|
+
AI_ACTIONS = %w[assistant_greeting start_agent_run ai_task].freeze
|
|
33
|
+
|
|
34
|
+
# Human names for compiled rule actions — so "currently active" behavior is
|
|
35
|
+
# readable in the gear modal without opening the JSON drawer (no-magic).
|
|
36
|
+
ACTION_DESCRIPTIONS = {
|
|
37
|
+
"assistant_greeting" => "the assistant opens the discussion",
|
|
38
|
+
"start_agent_run" => "assign a worker agent and start a run",
|
|
39
|
+
"ai_task" => "run a one-shot AI task",
|
|
40
|
+
"mark_pr_ready" => "take the PR out of draft",
|
|
41
|
+
"merge_pr" => "merge the PR and ship",
|
|
42
|
+
"set_status" => "set the card's status"
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def self.describe(rules)
|
|
46
|
+
normalized = rules.is_a?(Hash) || rules.is_a?(String) ? [rules] : Array(rules)
|
|
47
|
+
normalized.map { |r| r.is_a?(String) ? { "action" => r } : r }.map do |rule|
|
|
48
|
+
base = ACTION_DESCRIPTIONS[rule["action"]] || rule["action"].to_s
|
|
49
|
+
rule["action"] == "ai_task" && rule["prompt"].present? ? "#{base} (“#{rule["prompt"].truncate(60)}”)" : base
|
|
50
|
+
end.join("; then ")
|
|
51
|
+
end
|
|
52
|
+
|
|
33
53
|
def self.apply(rule, card, column)
|
|
54
|
+
if AI_ACTIONS.include?(rule["action"]) && !column.ai?
|
|
55
|
+
card.log!("status_change", text: "AI is off for #{column.name} — skipped #{rule["action"]}")
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
34
59
|
case rule["action"]
|
|
35
60
|
when "assistant_greeting"
|
|
36
61
|
# Contextual opener: the assistant reads the card and asks targeted
|
data/app/services/run_sweeper.rb
CHANGED
|
@@ -31,6 +31,7 @@ module RunSweeper
|
|
|
31
31
|
# state writes.
|
|
32
32
|
def self.repair_stuck_cards
|
|
33
33
|
Card.where(status: "working").find_each do |card|
|
|
34
|
+
next unless card.column.ai? # non-AI columns: "working" means a human is
|
|
34
35
|
next if card.runs.where(status: %w[queued running needs_input]).any? { |r| r.needs_input? || alive?(r) }
|
|
35
36
|
card.update!(status: "failed")
|
|
36
37
|
card.log!("error", text: "Card was stuck working with no live run; marked failed.")
|
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
<%= turbo_stream_from @board %>
|
|
2
2
|
|
|
3
3
|
<header class="topbar">
|
|
4
|
-
<
|
|
4
|
+
<div class="topbar-left">
|
|
5
|
+
<h1>Cardinal AI <span class="sep">▸</span> <%= @board.name %></h1>
|
|
6
|
+
<%= button_to "⇣ Pull", pull_board_path, form_class: "pull-form",
|
|
7
|
+
form: { title: "git pull --ff-only in #{@board.local_path} — fetch what Done has merged" },
|
|
8
|
+
data: { turbo_submits_with: "⇣ Pulling…" }, class: "theme-toggle" %>
|
|
9
|
+
<span id="repo-pull-status"></span>
|
|
10
|
+
</div>
|
|
5
11
|
<div class="topbar-right">
|
|
12
|
+
<%# Repo deep dive (card #12): one-shot read-only mapping of the repo, stored
|
|
13
|
+
as a brief and injected into worker prompts. The button greys→reds as the
|
|
14
|
+
brief falls behind HEAD; a stale brief over-anchors, so it nags at 10+. %>
|
|
15
|
+
<% behind = @board.commits_behind_brief %>
|
|
16
|
+
<%= button_to deep_dive_board_path,
|
|
17
|
+
class: "deep-dive#{' stale-critical' if @board.brief_stale?}#{' working' if @board.brief_working?}",
|
|
18
|
+
disabled: @board.brief_working?,
|
|
19
|
+
style: @board.brief? ? "color: #{@board.brief_staleness_color}; border-color: #{@board.brief_staleness_color}" : nil,
|
|
20
|
+
title: @board.brief? ? "Repo brief from #{@board.brief_sha&.first(7)} · #{@board.brief_generated_at&.to_date}" : "Map this repo into a brief agents reuse — saves exploration on every run" do %>
|
|
21
|
+
<% if @board.brief_working? %>
|
|
22
|
+
<span class="pulse-dot"></span> Deep dive · working…
|
|
23
|
+
<% elsif behind&.positive? %>
|
|
24
|
+
🔍 Deep dive · <%= behind %> commit<%= "s" if behind != 1 %> behind
|
|
25
|
+
<% else %>
|
|
26
|
+
🔍 Deep dive
|
|
27
|
+
<% end %>
|
|
28
|
+
<% end %>
|
|
6
29
|
<button type="button" class="theme-toggle"
|
|
7
30
|
data-controller="theme" data-action="theme#toggle">☀ Light</button>
|
|
8
31
|
<% if ENV["CARDINAL_AUTH"].present? %>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<% if card.queued? %>
|
|
11
11
|
<% ahead = card.column.cards.where(status: "queued").where("position < ?", card.position).count %>
|
|
12
12
|
<p class="card-progress">⏳ queued<%= ahead.positive? ? " — #{ahead} ahead" : " — next up" %></p>
|
|
13
|
-
<% elsif card.working? %>
|
|
13
|
+
<% elsif card.working? && card.column.ai? %>
|
|
14
14
|
<p class="card-progress working-line"><span class="spinner"></span> <%= card.latest_progress || "agent starting…" %></p>
|
|
15
15
|
<% elsif card.latest_progress && card.running? %>
|
|
16
16
|
<p class="card-progress">▸ <%= card.latest_progress %></p>
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
<% end %>
|
|
24
24
|
<div class="card-meta">
|
|
25
25
|
<% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
|
|
26
|
-
<% if card.running? && card.column.model %>
|
|
26
|
+
<% if card.running? && card.column.ai? && card.column.model %>
|
|
27
27
|
<span class="chip agent-chip">🤖 <%= card.column.model_short %><%= " · #{card.column.effort}" if card.column.effort %></span>
|
|
28
28
|
<% end %>
|
|
29
29
|
<% if card.awaiting_assistant? %>
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
<% end %>
|
|
37
37
|
<% if card.parent_id %><span class="chip">↑ sub</span><% end %>
|
|
38
38
|
<% if card.children.any? %><span class="chip">↳ <%= card.children.where(status: %w[done archived]).count %>/<%= card.children.count %></span><% end %>
|
|
39
|
-
<% if card.branch_name && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
|
|
39
|
+
<% if card.branch_name.present? && card.pr_url.blank? %><span class="chip branch">🌿 <%= card.branch_name.delete_prefix("cardinal/") %></span><% end %>
|
|
40
40
|
</div>
|
|
41
41
|
<% end %>
|
|
42
|
-
<% if card.pr_url %>
|
|
42
|
+
<% if card.pr_url.present? %>
|
|
43
43
|
<a class="card-footer" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">
|
|
44
44
|
<span class="footer-left"><%# future: Asana / Trello / linked-ticket slot %></span>
|
|
45
45
|
<span class="footer-pr">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</span>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<span class="card-number-sub" title="Card number — how branches, PRs, and other cards refer to this card">#<%= @card.number %></span>
|
|
9
9
|
</h1>
|
|
10
10
|
<div class="modal-header-right">
|
|
11
|
-
<% if @card.branch_name %>
|
|
11
|
+
<% if @card.branch_name.present? %>
|
|
12
12
|
<span class="git-line">
|
|
13
13
|
<span class="branch-base"><%= @card.board.default_branch %></span>
|
|
14
14
|
<span class="git-arrow">→</span>
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<button type="button" class="copy-btn" data-clipboard-target="button"
|
|
18
18
|
data-action="clipboard#copy" title="Copy branch name">⧉</button>
|
|
19
19
|
</span>
|
|
20
|
-
<% if @card.pr_url %>
|
|
20
|
+
<% if @card.pr_url.present? %>
|
|
21
21
|
<span class="git-arrow">→</span>
|
|
22
22
|
<%= link_to "##{@card.pr_url[%r{/pull/(\d+)}, 1]}", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-link" %>
|
|
23
23
|
<% if @card.pr_state.present? %><span class="pr-state">(<%= @card.pr_state %>)</span><% end %>
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
|
|
114
114
|
<% if @card.column.review? %>
|
|
115
115
|
<h3>Review</h3>
|
|
116
|
-
<% if @card.pr_url %>
|
|
116
|
+
<% if @card.pr_url.present? %>
|
|
117
117
|
<%= link_to "View Pull Request", @card.pr_url, target: "_blank", rel: "noopener", class: "pr-view-btn" %>
|
|
118
118
|
<% end %>
|
|
119
119
|
<% if @card.in_review? %>
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
</li>
|
|
169
169
|
<% end %>
|
|
170
170
|
</ul>
|
|
171
|
-
<% if @card.pr_url %>
|
|
171
|
+
<% if @card.pr_url.present? %>
|
|
172
172
|
<p>🌿 <%= link_to "View pull request", @card.pr_url, target: "_blank" %><%= " (#{@card.pr_state})" if @card.pr_state.present? %></p>
|
|
173
173
|
<% end %>
|
|
174
174
|
<% else %>
|
|
@@ -22,4 +22,15 @@
|
|
|
22
22
|
<%= render "cards/card", card: card %>
|
|
23
23
|
<% end %>
|
|
24
24
|
</div>
|
|
25
|
+
<% rows = column.footer_rows %>
|
|
26
|
+
<% if rows.any? %>
|
|
27
|
+
<footer class="column-footer">
|
|
28
|
+
<% rows.each do |row| %>
|
|
29
|
+
<div class="footer-row">
|
|
30
|
+
<span class="footer-label"><%= row[:label] %></span>
|
|
31
|
+
<span class="footer-value"><%= row[:value] %></span>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</footer>
|
|
35
|
+
<% end %>
|
|
25
36
|
</section>
|
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
<%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
|
|
2
2
|
<div class="modal-backdrop" data-controller="modal" data-action="click->modal#backdrop">
|
|
3
|
-
<div class="modal modal-sm">
|
|
3
|
+
<div class="modal modal-sm" data-controller="autosave">
|
|
4
4
|
<header class="modal-header">
|
|
5
|
-
<h1>⚙ <%= @column.name %> <span class="chip"><%= @column.archetype %></span
|
|
5
|
+
<h1>⚙ <%= @column.name %> <span class="chip"><%= @column.archetype %></span>
|
|
6
|
+
<span class="autosave-status" data-autosave-target="status"></span></h1>
|
|
6
7
|
<button type="button" class="modal-close" data-action="modal#close" title="Close (Esc)">✕</button>
|
|
7
8
|
</header>
|
|
8
9
|
<div class="modal-body">
|
|
9
|
-
|
|
10
|
+
<div id="column-form-errors"><% if @json_error %><p class="form-error"><%= @json_error %></p><% end %></div>
|
|
10
11
|
|
|
11
|
-
<%= form_with model: @column, class: "card-edit"
|
|
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" %>
|
|
12
15
|
<div class="field-row">
|
|
13
16
|
<div>
|
|
14
17
|
<label>Name <%= info_tip("The column's display name on the board.") %></label>
|
|
15
18
|
<%= f.text_field :name, required: true %>
|
|
16
19
|
</div>
|
|
17
20
|
<div>
|
|
18
|
-
<label>Archetype <%= info_tip("What kind of stage this is.
|
|
19
|
-
|
|
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 %>
|
|
20
27
|
</div>
|
|
21
28
|
<div>
|
|
22
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>
|
|
@@ -34,7 +41,7 @@
|
|
|
34
41
|
<% siblings = @column.board.columns.where.not(id: @column.id).order(:position) %>
|
|
35
42
|
<% if siblings.any? %>
|
|
36
43
|
<% 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
|
|
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>
|
|
38
45
|
<div class="accepts-from">
|
|
39
46
|
<% siblings.each do |sib| %>
|
|
40
47
|
<label class="check-row inline">
|
|
@@ -47,7 +54,18 @@
|
|
|
47
54
|
|
|
48
55
|
<%# The Tasks/inbox column is the board's intake — cards park here untouched,
|
|
49
56
|
so none of the AI-work or on-entry settings below apply to it (card #17). %>
|
|
50
|
-
<%
|
|
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">
|
|
51
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>
|
|
52
70
|
<% if @column.built_in_role %>
|
|
53
71
|
<p class="default-rule">Built-in role: <%= @column.built_in_role %></p>
|
|
@@ -87,13 +105,16 @@
|
|
|
87
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.") %>
|
|
88
106
|
</label>
|
|
89
107
|
|
|
108
|
+
</div>
|
|
109
|
+
|
|
90
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>
|
|
91
|
-
|
|
92
|
-
|
|
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>
|
|
93
114
|
<% end %>
|
|
94
115
|
<%= f.text_area :on_entry_text, rows: 3,
|
|
95
116
|
value: @column.policy["on_entry_text"],
|
|
96
|
-
placeholder: "Describe what should happen
|
|
117
|
+
placeholder: "Describe what should happen when a card arrives — replaces the active behavior shown above. Blank = archetype default." %>
|
|
97
118
|
|
|
98
119
|
<details class="advanced-rules">
|
|
99
120
|
<summary>Advanced: compiled rules (JSON)</summary>
|
|
@@ -102,9 +123,13 @@
|
|
|
102
123
|
value: @column.policy["on_entry"] ? JSON.pretty_generate(@column.policy["on_entry"]) : "",
|
|
103
124
|
placeholder: '[{"action": "ai_task", "prompt": "Suggest tags for: %{title}"}]' %>
|
|
104
125
|
</details>
|
|
126
|
+
</div>
|
|
105
127
|
<% end %>
|
|
106
128
|
|
|
107
|
-
<%=
|
|
129
|
+
<label>Footer <%= info_tip("A summary strip under the cards. One row per line as \"Label | compute\", where compute is sum_cost (total run cost), sum_tokens (total input+output tokens), count_cards, or blank for a static label. Example: \"Total cost: | sum_cost\".") %></label>
|
|
130
|
+
<%= f.text_area :footer_text, rows: 3, class: "mono",
|
|
131
|
+
value: footer_config_text(@column),
|
|
132
|
+
placeholder: "Total cost: | sum_cost" %>
|
|
108
133
|
<% end %>
|
|
109
134
|
|
|
110
135
|
<details class="advanced-rules panel-advanced">
|
data/cardinal.md
CHANGED
|
@@ -591,6 +591,15 @@ admin surface.
|
|
|
591
591
|
(5) review surface = in-card final report + file-level diff summary, deep link to the
|
|
592
592
|
GitHub PR for line-level review. Scaffolding started: Rails 8 + Ruby 3.4 (Fullstaq) +
|
|
593
593
|
Postgres 15 inside the cage container, repo at github.com/palamedes/cardinal.
|
|
594
|
+
- **2026-07-03 (de-magic pass)** — **Archetypes are templates, not magic.** Choosing an
|
|
595
|
+
archetype stamps concrete on-entry rules, rule text, and starter instructions into the
|
|
596
|
+
column's policy at creation (and re-stamps them when the archetype is switched in the
|
|
597
|
+
gear modal); there is no hidden runtime fallback — a blank on-entry box means nothing
|
|
598
|
+
happens, visibly. **Accept rails are explicit-only:** "Accepts moves from" is a
|
|
599
|
+
whitelist and an empty list means the column accepts from nowhere; there is no
|
|
600
|
+
permissive blank default. Default board: Done also accepts from Planning — dragging
|
|
601
|
+
planning→Done means "closed/terminated without work" (merge_pr on a card with no PR
|
|
602
|
+
just finalizes it).
|
|
594
603
|
|
|
595
604
|
---
|
|
596
605
|
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddRepoBriefToBoards < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
add_column :boards, :brief_sha, :string
|
|
4
|
+
add_column :boards, :brief_generated_at, :datetime
|
|
5
|
+
add_column :boards, :brief_model, :string
|
|
6
|
+
add_column :boards, :brief_status, :string
|
|
7
|
+
end
|
|
8
|
+
end
|
data/db/seeds.rb
CHANGED
|
@@ -8,12 +8,6 @@ board = Board.find_or_create_by!(name: "Cardinal") do |b|
|
|
|
8
8
|
b.local_path = Rails.root.to_s
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
board.columns.find_or_create_by!(name: attrs[:name]) do |c|
|
|
13
|
-
c.position = index
|
|
14
|
-
c.archetype = attrs[:archetype]
|
|
15
|
-
c.policy = attrs[:policy]
|
|
16
|
-
end
|
|
17
|
-
end
|
|
11
|
+
board.install_default_columns! if board.columns.none?
|
|
18
12
|
|
|
19
13
|
puts "Seeded board '#{board.name}' with #{board.columns.count} columns."
|
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.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jason Ellis
|
|
@@ -149,6 +149,7 @@ files:
|
|
|
149
149
|
- app/javascript/controllers/composer_controller.js
|
|
150
150
|
- app/javascript/controllers/index.js
|
|
151
151
|
- app/javascript/controllers/modal_controller.js
|
|
152
|
+
- app/javascript/controllers/reveal_controller.js
|
|
152
153
|
- app/javascript/controllers/scroll_controller.js
|
|
153
154
|
- app/javascript/controllers/tags_controller.js
|
|
154
155
|
- app/javascript/controllers/theme_controller.js
|
|
@@ -156,6 +157,7 @@ files:
|
|
|
156
157
|
- app/jobs/ai_task_job.rb
|
|
157
158
|
- app/jobs/application_job.rb
|
|
158
159
|
- app/jobs/assistant_reply_job.rb
|
|
160
|
+
- app/jobs/deep_dive_job.rb
|
|
159
161
|
- app/jobs/mark_pr_ready_job.rb
|
|
160
162
|
- app/jobs/merge_pr_job.rb
|
|
161
163
|
- app/jobs/resume_run_job.rb
|
|
@@ -221,6 +223,7 @@ files:
|
|
|
221
223
|
- db/migrate/20260703000002_add_agent_runner_fields.rb
|
|
222
224
|
- db/migrate/20260704000001_add_parent_to_cards.rb
|
|
223
225
|
- db/migrate/20260704000002_add_assistant_session_to_cards.rb
|
|
226
|
+
- db/migrate/20260704120000_add_repo_brief_to_boards.rb
|
|
224
227
|
- db/seeds.rb
|
|
225
228
|
- docker/agent/Dockerfile
|
|
226
229
|
- exe/cardinal
|