cardinal-ai 0.2.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: decfcea83bc4294b6cf14e947fa1586dee954dfaa30250b67df7d1c29377c191
4
- data.tar.gz: f6718005edcdeda0d1608d13d93211c7785af98ca2297ef418d96457724c6d1d
3
+ metadata.gz: 9765ce8a3b88ca8a2d72dcdaa766d67d42b0b1f1304ce44551fb0b31a8b62f84
4
+ data.tar.gz: 6bbd3904403e6e91b83a684d134c15ad402a98ab27f4600d6424752033565b0b
5
5
  SHA512:
6
- metadata.gz: 7c106ef6387b43103682e495071e3dfa1d04701eea84c2192f9c67c132ae623a5fa9614294e7e59606e0fad300cf9147008013906d6d9981799e4e3ea0740c89
7
- data.tar.gz: 1e2b72553d1bd2c6fd678bd7a3bd8c49c5ec6acc902026546d51d7e0fa9fe783803285e4f2933319f52f1193f1850cf3dc516fb6439b402ef97daa78393f96e3
6
+ metadata.gz: b91c51d2d12d2df7d7d1f81b7d8547545ed68cac112f4d844dd4666626ccb34081397dc038fa4620a8b0e76e804b18c64608d96c92be657238958bf4bf3540ff
7
+ data.tar.gz: 1e71c84583f3c6f02b3315ce7fa4abce078117dcb47ceb13a15a1641c3a90651673db22015c63ab8c1ca2e0683c136b50ac278b695e7144d34d7f7d2bf8378fc
@@ -509,6 +509,22 @@ body.dragging .drop-hint { display: block; }
509
509
  [data-theme="light"] .card-footer { background: #e2e5ea; border-top-color: rgba(0, 0, 0, .1); }
510
510
 
511
511
  /* Accept-policy visibility while dragging + rejected-drop flash */
512
- .column.drop-blocked { opacity: .45; }
512
+ .column.drop-blocked { opacity: .35; filter: grayscale(.5) brightness(.7); transition: opacity .15s, filter .15s; }
513
513
  .column.drop-blocked .drop-hint { color: var(--red); }
514
514
  .column.drop-blocked .drop-hint::before { content: "✗ won't accept from here — "; }
515
+
516
+ .hidden { display: none !important; }
517
+
518
+ /* The reveal wrappers must be invisible to the form's flex layout — their
519
+ children should behave as direct .card-edit flex items (full width). */
520
+ .card-edit [data-controller="reveal"],
521
+ .card-edit [data-reveal-target="panel"] { display: contents; }
522
+ .card-edit textarea, .card-edit input[type="text"] { width: 100%; }
523
+
524
+ .default-rule.active-rule { border-left-color: var(--green); }
525
+
526
+ .archetype-locked {
527
+ font-size: 13px; color: var(--text-dim); margin: 0;
528
+ padding: 6px 10px; background: var(--surface-2);
529
+ border: 1px solid var(--border); border-radius: 6px;
530
+ }
@@ -25,7 +25,7 @@ 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,
28
+ :on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
29
29
  accepts_from: []
30
30
  )
31
31
 
@@ -37,41 +37,76 @@ 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
- # Accept policy (card #15): store allowed source column ids as strings;
41
- # blank means accept from any column (backward-compatible default).
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
- begin
58
+ begin
49
59
  policy["on_entry"] = Rules::Compiler.compile(attrs[:on_entry_text])
50
60
  rescue Rules::Compiler::Error => e
51
- @json_error = e.message
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
- @json_error = "on_entry is not valid JSON: #{e.message.truncate(120)}"
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
- else
72
+ elsif !@archetype_changed
65
73
  policy.delete("on_entry")
66
74
  policy.delete("on_entry_text")
67
75
  end
68
76
 
69
77
  @column.update!(
70
78
  name: attrs[:name].presence || @column.name,
71
- archetype: attrs[:archetype].presence_in(Column::ARCHETYPES) || @column.archetype,
79
+ archetype: new_archetype,
72
80
  policy: policy.compact
73
81
  )
74
- redirect_to root_path
82
+
83
+ if params[:autosave]
84
+ # Silent save: patch the board's column section + clear any prior error.
85
+ # No modal replace — it would steal focus mid-edit.
86
+ streams = [
87
+ turbo_stream.replace(helpers.dom_id(@column), partial: "columns/column", locals: { column: @column.reload }),
88
+ turbo_stream.update("column-form-errors", "")
89
+ ]
90
+ # A re-stamped archetype must re-render the modal (its fields changed
91
+ # server-side); focus loss is fine — the user just picked from a select.
92
+ streams << turbo_stream.replace("modal", template: "columns/edit", formats: [:html]) if @archetype_changed
93
+ render turbo_stream: streams
94
+ else
95
+ redirect_to root_path
96
+ end
97
+ end
98
+
99
+ # Autosave-friendly error: surface in the modal without re-rendering the form.
100
+ def column_error(message)
101
+ if params[:autosave]
102
+ render turbo_stream: turbo_stream.update(
103
+ "column-form-errors",
104
+ helpers.tag.p("#{message} — this field was NOT saved.", class: "form-error")
105
+ ), status: :unprocessable_entity
106
+ else
107
+ @json_error = message
108
+ render :edit, status: :unprocessable_entity
109
+ end
75
110
  end
76
111
 
77
112
  def destroy
@@ -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
@@ -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(() => this.formTarget.requestSubmit(), 800)
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
- const accepts = section.dataset.accepts
41
- if (!accepts || section.dataset.colId === sourceId) return
42
- if (!accepts.split(",").includes(sourceId)) section.classList.add("drop-blocked")
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
+ }
@@ -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", policy: {} },
4
- { name: "Planning", archetype: "planning", policy: { "model" => "claude-haiku-4-5-20251001" } },
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-sonnet-4-6", "effort" => "high", "concurrency_limit" => 3,
7
- "plan_approval" => true, "max_turns" => 80, "timeout_minutes" => 30,
8
- "on_entry" => [{ "action" => "start_agent_run" }] } },
9
- { name: "Review", archetype: "review", policy: {} },
10
- { name: "QA", archetype: "review",
11
- policy: { "on_entry" => [{ "action" => "mark_pr_ready" }],
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: { "on_entry" => [{ "action" => "merge_pr" }], "arrivals" => "top" } }
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
- DEFAULT_COLUMNS.each_with_index do |attrs, index|
38
- board.columns.create!(position: index, **attrs)
39
- end
71
+ board.install_default_columns!
40
72
  board
41
73
  end
42
74
 
data/app/models/card.rb CHANGED
@@ -46,7 +46,7 @@ class Card < ApplicationRecord
46
46
  # Is the planning assistant expected to post next? True right after entering
47
47
  # a planning column (kickoff inspection pending) or after a user message.
48
48
  def awaiting_assistant?
49
- return false unless column.planning?
49
+ return false unless column.planning? && column.ai?
50
50
  last = events.where(kind: %w[user_message assistant_message error column_move]).order(:id).last
51
51
  last.present? && %w[user_message column_move].include?(last.kind)
52
52
  end
data/app/models/column.rb CHANGED
@@ -6,6 +6,31 @@ 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,
@@ -17,18 +42,27 @@ class Column < ApplicationRecord
17
42
  color if color.to_s.match?(/\A#\h{6}\z/)
18
43
  end
19
44
 
45
+ # Does any AI service this column? Explicit per-column switch (default ON
46
+ # for back-compat); the inbox/Tasks intake is never AI, unconditionally.
47
+ # When false the column is inert AI-wise: no assistant, no worker runs,
48
+ # no ai_task rules — cards there are human work.
49
+ def ai?
50
+ return false if inbox?
51
+ policy["ai"] != false
52
+ end
53
+
20
54
  # Which columns may move cards INTO this one (§ accept policy, card #15).
21
- # Stored as an array of column-id strings; blank = accept from anywhere, so
22
- # existing boards keep their unrestricted behavior.
55
+ # Stored as an array of column-id strings. EXPLICIT ONLY: an empty list
56
+ # means this column accepts from nowhere — there is no permissive default.
23
57
  def accepts?(source_column)
24
- ids = Array(accepts_from).map(&:to_s).reject(&:blank?)
25
- ids.empty? || ids.include?(source_column.id.to_s)
58
+ Array(accepts_from).map(&:to_s).include?(source_column.id.to_s)
26
59
  end
27
60
 
28
61
  # Start the next queued card when a run slot frees up. A queued card whose
29
62
  # run parked and already has its answer recorded resumes instead of
30
63
  # starting fresh.
31
64
  def kick_queue
65
+ return unless ai?
32
66
  return if at_wip_limit?
33
67
  next_card = cards.where(status: "queued").order(:position).first
34
68
  return unless next_card
@@ -70,6 +104,23 @@ class Column < ApplicationRecord
70
104
 
71
105
  def built_in_role = BUILT_IN_ROLES[archetype]
72
106
 
107
+ # What "Use AI" concretely means here — the §5 tier distinction, visible.
108
+ AI_MODES = {
109
+ "planning" => "a shared planning assistant joins each card's conversation",
110
+ "execution" => "a dedicated worker agent is assigned to each card",
111
+ "review" => "allow AI on-entry rules (ai_task) in this column",
112
+ "terminal" => "allow AI on-entry rules (ai_task) in this column"
113
+ }.freeze
114
+
115
+ def ai_mode_description = AI_MODES[archetype]
116
+
117
+ # Stamp template values into any policy field the creator left blank.
118
+ def seed_archetype_template
119
+ archetype_template.each do |key, value|
120
+ policy[key] = value if policy[key].blank?
121
+ end
122
+ end
123
+
73
124
  # One-line consequence shown while dragging a card over this column (§14.1).
74
125
  def drag_hint
75
126
  case archetype
@@ -77,7 +128,7 @@ class Column < ApplicationRecord
77
128
  when "planning" then "The board assistant will join the discussion"
78
129
  when "execution" then "An agent will be assigned and start work"
79
130
  when "review" then "Work stops — ready for your verdict"
80
- when "terminal" then "Ships it — PR merged, branch deleted"
131
+ when "terminal" then "Closes it — PR merged and branch deleted, if there is one"
81
132
  end
82
133
  end
83
134
  end
@@ -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
@@ -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 only supply defaults
3
- # any column can carry any rules, including one-shot AI maintenance tasks.
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
- DEFAULTS = {
6
- "planning" => [{ "action" => "assistant_greeting" }],
7
- "execution" => [{ "action" => "start_agent_run" }],
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 marked ready, squash-merged, and its branch deleted."
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"], column.archetype) do |rule|
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, archetype, &block)
28
- rules = configured.presence || DEFAULTS[archetype] || []
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
@@ -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.")
@@ -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? %>
@@ -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></h1>
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
- <% if @json_error %><p class="form-error"><%= @json_error %></p><% end %>
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" do |f| %>
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. Inbox: parking lot, no AI. Planning: a shared assistant discusses cards with you. Execution: each card gets its own worker agent that does the work. Review: work stops for your verdict. Terminal: finalizes the card (Done merges the PR).") %></label>
19
- <%= f.select :archetype, Column::ARCHETYPES.map { |a| [a.capitalize, a] } %>
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 set that on the destination columns. Leave all unchecked to accept from anywhere. Example: check only Planning on In Progress so work can't skip triage.") %></label>
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
- <% unless @column.inbox? %>
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
- <% if @column.policy["on_entry_text"].blank? && @column.policy["on_entry"].blank? %>
92
- <p class="default-rule">Default: <%= Rules::DEFAULT_DESCRIPTIONS[@column.archetype] %></p>
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 instead of the default" %>
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,8 @@
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
-
107
- <%= f.submit "Save" %>
108
128
  <% end %>
109
129
 
110
130
  <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/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
- Board::DEFAULT_COLUMNS.each_with_index do |attrs, index|
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."
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.3"
2
+ VERSION = "0.2.4"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardinal-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
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