cardinal-ai 0.0.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +50 -29
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/application.css +10 -0
  6. data/app/assets/stylesheets/cardinal.css +530 -0
  7. data/app/controllers/application_controller.rb +7 -0
  8. data/app/controllers/boards_controller.rb +5 -0
  9. data/app/controllers/cards_controller.rb +129 -0
  10. data/app/controllers/columns_controller.rb +130 -0
  11. data/app/controllers/messages_controller.rb +25 -0
  12. data/app/controllers/runs_controller.rb +58 -0
  13. data/app/helpers/application_helper.rb +35 -0
  14. data/app/javascript/application.js +2 -0
  15. data/app/javascript/controllers/application.js +7 -0
  16. data/app/javascript/controllers/autosave_controller.js +43 -0
  17. data/app/javascript/controllers/board_column_controller.js +96 -0
  18. data/app/javascript/controllers/clipboard_controller.js +18 -0
  19. data/app/javascript/controllers/composer_controller.js +10 -0
  20. data/app/javascript/controllers/index.js +3 -0
  21. data/app/javascript/controllers/modal_controller.js +45 -0
  22. data/app/javascript/controllers/reveal_controller.js +15 -0
  23. data/app/javascript/controllers/scroll_controller.js +44 -0
  24. data/app/javascript/controllers/tags_controller.js +49 -0
  25. data/app/javascript/controllers/theme_controller.js +43 -0
  26. data/app/javascript/controllers/tooltip_controller.js +37 -0
  27. data/app/jobs/ai_task_job.rb +26 -0
  28. data/app/jobs/application_job.rb +7 -0
  29. data/app/jobs/assistant_reply_job.rb +132 -0
  30. data/app/jobs/mark_pr_ready_job.rb +18 -0
  31. data/app/jobs/merge_pr_job.rb +27 -0
  32. data/app/jobs/resume_run_job.rb +30 -0
  33. data/app/jobs/start_run_job.rb +13 -0
  34. data/app/mailers/application_mailer.rb +4 -0
  35. data/app/models/agent_session.rb +8 -0
  36. data/app/models/application_record.rb +3 -0
  37. data/app/models/artifact.rb +8 -0
  38. data/app/models/board.rb +92 -0
  39. data/app/models/card.rb +83 -0
  40. data/app/models/column.rb +134 -0
  41. data/app/models/event.rb +44 -0
  42. data/app/models/run.rb +28 -0
  43. data/app/services/agent/runner.rb +379 -0
  44. data/app/services/agent/workspace.rb +138 -0
  45. data/app/services/card_transition.rb +97 -0
  46. data/app/services/claude_cli.rb +89 -0
  47. data/app/services/rules/compiler.rb +55 -0
  48. data/app/services/rules.rb +92 -0
  49. data/app/services/run_sweeper.rb +53 -0
  50. data/app/views/boards/show.html.erb +79 -0
  51. data/app/views/cards/_card.html.erb +48 -0
  52. data/app/views/cards/_detail.html.erb +190 -0
  53. data/app/views/cards/_tag_picker.html.erb +12 -0
  54. data/app/views/cards/new.html.erb +35 -0
  55. data/app/views/cards/show.html.erb +3 -0
  56. data/app/views/columns/_column.html.erb +25 -0
  57. data/app/views/columns/edit.html.erb +146 -0
  58. data/app/views/events/_event.html.erb +29 -0
  59. data/app/views/layouts/application.html.erb +46 -0
  60. data/app/views/layouts/mailer.html.erb +13 -0
  61. data/app/views/layouts/mailer.text.erb +1 -0
  62. data/app/views/pwa/manifest.json.erb +22 -0
  63. data/app/views/pwa/service-worker.js +26 -0
  64. data/bin/rails +4 -0
  65. data/bin/rake +4 -0
  66. data/cardinal.md +695 -0
  67. data/config/application.rb +60 -0
  68. data/config/boot.rb +13 -0
  69. data/config/bundler-audit.yml +5 -0
  70. data/config/cable.yml +13 -0
  71. data/config/ci.rb +20 -0
  72. data/config/credentials.yml.enc +1 -0
  73. data/config/database.yml +31 -0
  74. data/config/environment.rb +5 -0
  75. data/config/environments/development.rb +78 -0
  76. data/config/environments/production.rb +89 -0
  77. data/config/environments/test.rb +53 -0
  78. data/config/importmap.rb +6 -0
  79. data/config/initializers/assets.rb +7 -0
  80. data/config/initializers/cardinal_bootstrap.rb +12 -0
  81. data/config/initializers/cardinal_instance.rb +20 -0
  82. data/config/initializers/content_security_policy.rb +29 -0
  83. data/config/initializers/filter_parameter_logging.rb +8 -0
  84. data/config/initializers/inflections.rb +16 -0
  85. data/config/initializers/run_sweeper.rb +17 -0
  86. data/config/locales/en.yml +31 -0
  87. data/config/puma.rb +42 -0
  88. data/config/routes.rb +22 -0
  89. data/config/storage.yml +27 -0
  90. data/config.ru +6 -0
  91. data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
  92. data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
  93. data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
  94. data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
  95. data/db/seeds.rb +13 -0
  96. data/docker/agent/Dockerfile +16 -0
  97. data/exe/cardinal +111 -0
  98. data/lib/cardinal/version.rb +1 -1
  99. data/public/400.html +135 -0
  100. data/public/404.html +135 -0
  101. data/public/406-unsupported-browser.html +135 -0
  102. data/public/422.html +135 -0
  103. data/public/500.html +135 -0
  104. data/public/icon.png +0 -0
  105. data/public/icon.svg +3 -0
  106. data/public/robots.txt +1 -0
  107. data/vendor/javascript/sortablejs.js +3378 -0
  108. metadata +236 -9
@@ -0,0 +1,138 @@
1
+ module Agent
2
+ # The worker agent's isolated checkout, behind a strategy factory
3
+ # (cardinal.md §13, §17):
4
+ #
5
+ # Local — clone under .cardinal/workspaces/; the agent process runs on
6
+ # the host with chdir into the checkout. Process-level
7
+ # isolation only. The default.
8
+ # Container — same host-side checkout, but the agent runs inside a
9
+ # cage-style Docker container that mounts ONLY the checkout.
10
+ # Opt in with CARDINAL_WORKSPACE=container (experimental —
11
+ # requires a Docker daemon and CARDINAL_AGENT_IMAGE with the
12
+ # claude CLI installed; ANTHROPIC_API_KEY is passed through).
13
+ #
14
+ # Both strategies share git provisioning: the runner owns clone, branch,
15
+ # and push — the agent only ever commits.
16
+ module Workspace
17
+ def self.provision(card) = strategy.provision(card)
18
+ def self.attach(card) = strategy.attach(card)
19
+
20
+ def self.strategy
21
+ ENV["CARDINAL_WORKSPACE"] == "container" ? Container : Local
22
+ end
23
+
24
+ class Local
25
+ ROOT = Rails.root.join(".cardinal", "workspaces")
26
+
27
+ attr_reader :card, :path
28
+
29
+ def self.provision(card) = new(card).tap(&:provision)
30
+
31
+ # Reattach without resetting — used when resuming a parked run whose
32
+ # local commits aren't pushed yet.
33
+ def self.attach(card)
34
+ ws = new(card)
35
+ File.directory?(ws.path.join(".git")) ? ws : ws.tap(&:provision)
36
+ end
37
+
38
+ def initialize(card)
39
+ @card = card
40
+ @path = ROOT.join("card-#{card.number}")
41
+ end
42
+
43
+ def provision
44
+ FileUtils.mkdir_p(ROOT)
45
+ unless File.directory?(path.join(".git"))
46
+ git!(ROOT, "clone", "--quiet", (card.board.local_path.presence || Rails.root).to_s, path.to_s)
47
+ git!(path, "remote", "set-url", "origin", card.board.repo_url) if card.board.repo_url.present?
48
+ end
49
+ salvage_dirty_tree!
50
+ git!(path, "fetch", "--quiet", "origin")
51
+ if git?(path, "rev-parse", "--verify", "origin/#{card.branch_name}")
52
+ git!(path, "checkout", "--quiet", card.branch_name)
53
+ git!(path, "reset", "--quiet", "--hard", "origin/#{card.branch_name}")
54
+ elsif git?(path, "rev-parse", "--verify", card.branch_name)
55
+ # Local-only branch (e.g. WIP salvaged but never pushed): keep it.
56
+ git!(path, "checkout", "--quiet", card.branch_name)
57
+ else
58
+ git!(path, "checkout", "--quiet", "-B", card.branch_name, "origin/#{card.board.default_branch}")
59
+ end
60
+ self
61
+ end
62
+
63
+ # A killed run can leave uncommitted edits that block checkout and would
64
+ # otherwise be silently destroyed. Commit them as WIP on the branch and
65
+ # push (best effort) so the interrupted work survives onto the PR.
66
+ def salvage_dirty_tree!
67
+ return if git_out(path, "status", "--porcelain").strip.empty?
68
+ git!(path, "add", "-A")
69
+ git!(path, "commit", "--quiet", "-m", "WIP: salvage uncommitted work from an interrupted run")
70
+ begin
71
+ push!
72
+ rescue RuntimeError
73
+ nil # offline is fine — the local-branch checkout path keeps the WIP
74
+ end
75
+ end
76
+
77
+ # How the runner should spawn the agent process for this workspace.
78
+ def agent_spawn(cmd) = [cmd, { chdir: path.to_s }]
79
+
80
+ def head = git_out(path, "rev-parse", "HEAD").strip
81
+
82
+ def commits_since(sha)
83
+ git_out(path, "log", "--oneline", "#{sha}..HEAD").lines.map(&:strip)
84
+ end
85
+
86
+ def ahead_of_default?
87
+ git_out(path, "rev-list", "--count", "origin/#{card.board.default_branch}..HEAD").strip.to_i.positive?
88
+ end
89
+
90
+ def push!
91
+ git!(path, "push", "--quiet", "-u", "origin", card.branch_name)
92
+ end
93
+
94
+ private
95
+
96
+ def git!(dir, *args)
97
+ out, status = Open3.capture2e("git", "-C", dir.to_s, *args)
98
+ raise "git #{args.first} failed: #{out.truncate(300)}" unless status.success?
99
+ out
100
+ end
101
+
102
+ def git?(dir, *args)
103
+ _, status = Open3.capture2e("git", "-C", dir.to_s, *args)
104
+ status.success?
105
+ end
106
+
107
+ def git_out(dir, *args) = git!(dir, *args)
108
+ end
109
+
110
+ # EXPERIMENTAL — written against the cage model; needs a host with Docker
111
+ # to exercise. Git stays host-side; only the agent process is jailed.
112
+ class Container < Local
113
+ WORKDIR = "/workspace/repo"
114
+
115
+ def image = ENV.fetch("CARDINAL_AGENT_IMAGE", "cardinal-agent:latest")
116
+ def container_name = "cardinal-card-#{card.number}"
117
+
118
+ def agent_spawn(cmd)
119
+ docker = ["docker", "run", "--rm", "-i",
120
+ "--name", container_name,
121
+ "--label", "cardinal=agent",
122
+ "-v", "#{path}:#{WORKDIR}",
123
+ "-w", WORKDIR]
124
+ # Value-embedded because the runner nils the key in the client env
125
+ # (visible in ps on the host — acceptable for the experimental tier).
126
+ # Instance OAuth token (cardinal up account link) or raw API key —
127
+ # whichever this instance runs on.
128
+ docker += ["-e", "ANTHROPIC_API_KEY=#{ENV["ANTHROPIC_API_KEY"]}"] if ENV["ANTHROPIC_API_KEY"].present?
129
+ docker += ["-e", "CLAUDE_CODE_OAUTH_TOKEN=#{ENV["CLAUDE_CODE_OAUTH_TOKEN"]}"] if ENV["CLAUDE_CODE_OAUTH_TOKEN"].present?
130
+ [docker + [image] + cmd, {}]
131
+ end
132
+
133
+ def teardown
134
+ Open3.capture2e("docker", "rm", "-f", container_name)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,97 @@
1
+ # The only code path that moves a card between columns. Validates legality,
2
+ # runs the old column's leave policy and the new column's enter policy, and
3
+ # emits the transition events (§3, §11). Controllers and future automations
4
+ # all call this — never Card#update(column:) directly.
5
+ class CardTransition
6
+ Result = Data.define(:success?, :card, :error)
7
+
8
+ def initialize(card, to_column:, position: nil, actor: "user")
9
+ @card = card
10
+ @from = card.column
11
+ @to = to_column
12
+ @position = position
13
+ @actor = actor
14
+ end
15
+
16
+ def call
17
+ return reposition! if @from == @to
18
+ return failure("Column belongs to a different board") if @to.board_id != @card.board_id
19
+ if @card.working? && @from.execution?
20
+ # An agent process is live — no silent kills (§3). Cancel it first.
21
+ return failure("##{@card.number} has an active run — cancel it before moving the card")
22
+ end
23
+ # Accept policy (card #15): the destination decides which columns may feed
24
+ # it, forcing cards through a defined workflow rather than any-to-any drops.
25
+ return rejected! unless @to.accepts?(@from)
26
+
27
+ Card.transaction do
28
+ leave_policy!
29
+ place_in_column!
30
+ enter_policy!
31
+ end
32
+ Result.new(success?: true, card: @card, error: nil)
33
+ rescue ActiveRecord::RecordInvalid => e
34
+ failure(e.message)
35
+ end
36
+
37
+ private
38
+
39
+ # Same-column drag = prioritization (§8): top of the column runs first, so
40
+ # reordering queued cards IS the priority UI. No policies fire, no events.
41
+ def reposition!
42
+ ids = @to.cards.where.not(id: @card.id).order(:position).pluck(:id)
43
+ ids.insert([@position || ids.size, ids.size].min, @card.id)
44
+ Card.transaction do
45
+ ids.each_with_index { |id, index| Card.where(id: id).update_all(position: index) }
46
+ @card.touch # update_all skips callbacks; touch broadcasts to other windows
47
+ end
48
+ Result.new(success?: true, card: @card.reload, error: nil)
49
+ end
50
+
51
+ def leave_policy!
52
+ return unless @from.execution?
53
+ # Dequeue / abandon parked runs — nothing live is killed (working cards
54
+ # were already blocked above).
55
+ @card.runs.where(status: %w[queued needs_input]).each do |run|
56
+ run.update!(status: "cancelled", finished_at: Time.current)
57
+ end
58
+ end
59
+
60
+ def place_in_column!
61
+ # Column arrivals policy: force where newcomers land, regardless of the
62
+ # drop position. (Reordering within the column stays free-form.)
63
+ case @to.arrivals
64
+ when "top" then @position = 0
65
+ when "bottom" then @position = nil
66
+ end
67
+ @position ||= (@to.cards.maximum(:position) || -1) + 1
68
+ @to.cards.where("position >= ?", @position).update_all("position = position + 1")
69
+ @card.update!(column: @to, position: @position, status: entry_status)
70
+ @card.log!("column_move", actor: @actor,
71
+ from: @from.name, to: @to.name, text: "Moved from #{@from.name} to #{@to.name}")
72
+ end
73
+
74
+ def enter_policy!
75
+ Rules.fire_entry(@card, @to)
76
+ end
77
+
78
+ def entry_status
79
+ case @to.archetype
80
+ when "inbox" then "draft"
81
+ when "planning" then "discussing"
82
+ when "execution" then @to.ai? ? "queued" : "working" # no AI = a human is on it
83
+ when "review" then "in_review"
84
+ when "terminal" then "done"
85
+ end
86
+ end
87
+
88
+ # A drop the destination's accept policy forbids: nothing moves, but the
89
+ # attempt is logged to the card's timeline so the bounce isn't silent.
90
+ def rejected!
91
+ @card.log!("move_rejected", actor: @actor, from: @from.name, to: @to.name,
92
+ text: "Blocked: #{@from.name} can't move directly to #{@to.name}")
93
+ failure("#{@from.name} cannot move directly to #{@to.name}")
94
+ end
95
+
96
+ def failure(message) = Result.new(success?: false, card: @card, error: message)
97
+ end
@@ -0,0 +1,89 @@
1
+ # The single auth path for all of Cardinal's AI (§17): every tier — planning
2
+ # assistant, maintenance agents, rules compiler, worker agents — goes through
3
+ # the claude CLI, so wherever Claude Code is logged in (or an API key is
4
+ # exported), Cardinal works. No separate key provisioning.
5
+ #
6
+ # This module covers the one-shot tiers; worker agents have their own
7
+ # streaming path in Agent::Runner.
8
+ module ClaudeCli
9
+ class Error < StandardError
10
+ # Human message in #message; raw technical payload in #detail (shown only
11
+ # behind a disclosure in the timeline).
12
+ attr_reader :detail
13
+
14
+ def initialize(message, detail: nil)
15
+ super(message)
16
+ @detail = detail
17
+ end
18
+ end
19
+
20
+ # Nested-session guards + creds the model never needs. A blank
21
+ # ANTHROPIC_API_KEY is removed too (it would shadow CLI session auth).
22
+ STRIP_ENV = %w[CLAUDECODE CLAUDE_CODE_ENTRYPOINT GH_TOKEN GITHUB_TOKEN].freeze
23
+
24
+ WRAP_UP = "You have hit your exploration limit. Using only what you have already " \
25
+ "learned, give your best complete reply now. Do not use any tools.".freeze
26
+
27
+ def self.available?
28
+ return @available if defined?(@available)
29
+ @available = system("which claude > /dev/null 2>&1")
30
+ end
31
+
32
+ # tools: comma-separated read-only tool list (e.g. "Read,Glob,Grep") with
33
+ # cwd pointing at the repo. Default remains tool-less single-turn.
34
+ # resume: continue an existing claude session (context carries over).
35
+ # with_session: return [text, session_id] instead of just text, so callers
36
+ # can keep a continuing conversation (the planning assistant does).
37
+ def self.prompt(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1,
38
+ resume: nil, with_session: false)
39
+ raise Error.new("claude CLI not found on PATH") unless available?
40
+
41
+ json = invoke(text, system:, model:, tools:, cwd:, max_turns:, resume:)
42
+ if success?(json)
43
+ return with_session ? [json["result"].to_s, json["session_id"]] : json["result"].to_s
44
+ end
45
+
46
+ # Ran out of turns mid-exploration: resume the same session tool-less and
47
+ # force an answer from the context it already gathered.
48
+ if json["subtype"] == "error_max_turns" && json["session_id"].present?
49
+ wrapped = invoke(WRAP_UP, model:, cwd:, tools: "", max_turns: 2, resume: json["session_id"])
50
+ if success?(wrapped)
51
+ return with_session ? [wrapped["result"].to_s, wrapped["session_id"] || json["session_id"]] : wrapped["result"].to_s
52
+ end
53
+ raise Error.new("ran out of working turns and couldn't wrap up — try again, or simplify the ask",
54
+ detail: wrapped.to_json)
55
+ end
56
+
57
+ raise Error.new(friendly_failure(json), detail: json.to_json)
58
+ end
59
+
60
+ def self.success?(json)
61
+ json["subtype"] == "success" && !json["is_error"]
62
+ end
63
+
64
+ def self.friendly_failure(json)
65
+ case json["subtype"]
66
+ when "error_max_turns" then "ran out of working turns before finishing"
67
+ when "error_during_execution" then "hit an internal error while working"
68
+ else "failed (#{json["subtype"].presence || "unknown error"})"
69
+ end
70
+ end
71
+
72
+ def self.invoke(text, system: nil, model: nil, tools: nil, cwd: nil, max_turns: 1, resume: nil)
73
+ cmd = ["claude", "-p", text, "--output-format", "json",
74
+ "--max-turns", max_turns.to_s, "--tools", tools.presence || ""]
75
+ cmd += ["--append-system-prompt", system] if system.present?
76
+ cmd += ["--model", model] if model.present?
77
+ cmd += ["--resume", resume] if resume.present?
78
+
79
+ env = STRIP_ENV.index_with { nil }
80
+ env["ANTHROPIC_API_KEY"] = nil if ENV["ANTHROPIC_API_KEY"].blank?
81
+
82
+ spawn_opts = cwd.present? && Dir.exist?(cwd) ? { chdir: cwd } : {}
83
+ out, err, status = Open3.capture3(env, *cmd, **spawn_opts)
84
+ JSON.parse(out)
85
+ rescue JSON::ParserError
86
+ raise Error.new("claude produced no readable result (exit #{status&.exitstatus || "?"})",
87
+ detail: [err, out].compact_blank.join("\n---\n").truncate(1500))
88
+ end
89
+ end
@@ -0,0 +1,55 @@
1
+ module Rules
2
+ # Turns a plain-English description of a column's on-entry behavior into the
3
+ # rule actions the dispatcher executes (§17). English is the source of
4
+ # truth; the compiled JSON is stored alongside it and shown read-only.
5
+ module Compiler
6
+ Error = Class.new(StandardError)
7
+
8
+ VOCABULARY = <<~DOC.freeze
9
+ Available actions:
10
+ - {"action": "assistant_greeting"} — the planning assistant posts an opening message
11
+ - {"action": "start_agent_run"} — assign a dedicated worker agent to the card and start a run
12
+ - {"action": "ai_task", "prompt": "...", "model": "optional-model-id"} — a one-shot AI maintenance
13
+ task; the prompt may use %{title}, %{description}, %{conversation}; its output is posted to the
14
+ card timeline
15
+ - {"action": "mark_pr_ready"} — take the card's PR out of draft (ready for review on GitHub)
16
+ - {"action": "merge_pr"} — mark the card's PR ready, squash-merge it, delete the branch
17
+ - {"action": "set_status", "status": "..."} — force a card status
18
+ DOC
19
+
20
+ def self.compile(text)
21
+ raise Error, "Rules compiler needs the claude CLI — use the advanced JSON editor instead." unless ClaudeCli.available?
22
+
23
+ raw = ClaudeCli.prompt(
24
+ text,
25
+ model: AssistantReplyJob::FALLBACK_MODEL,
26
+ system: <<~SYS
27
+ You compile plain-English descriptions of Kanban column automation into JSON rule
28
+ arrays for the Cardinal board engine.
29
+
30
+ #{VOCABULARY}
31
+ Respond with ONLY the JSON array — no prose, no code fences. If the description
32
+ asks for something outside the vocabulary, approximate it with an ai_task whose
33
+ prompt captures the intent.
34
+ SYS
35
+ ).strip
36
+ raw = raw.sub(/\A```(?:json)?\s*/, "").sub(/```\z/, "").strip
37
+ rules = JSON.parse(raw)
38
+ validate!(rules)
39
+ rules
40
+ rescue JSON::ParserError
41
+ raise Error, "Compiler returned invalid JSON — try rephrasing, or use the advanced editor."
42
+ rescue ClaudeCli::Error => e
43
+ raise Error, "Compiler call failed: #{e.message.truncate(120)}"
44
+ end
45
+
46
+ def self.validate!(rules)
47
+ raise Error, "Expected a JSON array of rules" unless rules.is_a?(Array)
48
+ known = %w[assistant_greeting start_agent_run ai_task mark_pr_ready merge_pr set_status]
49
+ rules.each do |rule|
50
+ raise Error, "Each rule must be an object with an \"action\"" unless rule.is_a?(Hash) && rule["action"].present?
51
+ raise Error, "Unknown action #{rule["action"].inspect}" unless known.include?(rule["action"])
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,92 @@
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 stamp starting rules at
3
+ # creation (templates, not magic) — any column can carry any rules after that,
4
+ # including one-shot AI maintenance tasks.
5
+ module Rules
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.
9
+
10
+ # Shown in the gear modal so the archetype's built-in behavior is visible,
11
+ # not implied (the on-entry box being blank doesn't mean nothing happens).
12
+ DEFAULT_DESCRIPTIONS = {
13
+ "inbox" => "Nothing — cards park here untouched.",
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.",
15
+ "execution" => "A dedicated worker agent is assigned to the card and a run starts (plan-first if plan approval is on).",
16
+ "review" => "Nothing automatic — the card waits for your verdict.",
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."
18
+ }.freeze
19
+
20
+ def self.fire_entry(card, column)
21
+ each_rule(column.policy["on_entry"]) do |rule|
22
+ apply(rule, card, column)
23
+ end
24
+ end
25
+
26
+ def self.each_rule(configured, &block)
27
+ rules = configured.presence || []
28
+ rules = [rules] if rules.is_a?(Hash) || rules.is_a?(String)
29
+ rules.map { |r| r.is_a?(String) ? { "action" => r } : r }.each(&block)
30
+ end
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
+
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
+
59
+ case rule["action"]
60
+ when "assistant_greeting"
61
+ # Contextual opener: the assistant reads the card and asks targeted
62
+ # questions (AssistantReplyJob falls back to a canned line without a key).
63
+ AssistantReplyJob.perform_later(card, kickoff: true)
64
+ when "start_agent_run"
65
+ card.update!(branch_name: card.branch_name.presence || card.default_branch_name)
66
+ card.log!("status_change", text: "Queued for execution on #{card.branch_name}")
67
+ StartRunJob.perform_later(card.id)
68
+ when "ai_task"
69
+ # One-shot maintenance agent: a bounded Messages API call whose prompt
70
+ # comes from the rule config. No workspace, no session, no tools.
71
+ AiTaskJob.perform_later(card.id, rule["prompt"].to_s, rule["model"])
72
+ when "mark_pr_ready"
73
+ if card.pr_url.present?
74
+ card.log!("status_change", text: "Taking the PR out of draft…")
75
+ MarkPrReadyJob.perform_later(card.id)
76
+ else
77
+ card.log!("status_change", text: "No PR to mark ready")
78
+ end
79
+ when "merge_pr"
80
+ if card.pr_url.present?
81
+ card.log!("status_change", text: "Shipping: merging #{card.pr_url}")
82
+ MergePrJob.perform_later(card.id)
83
+ else
84
+ card.log!("status_change", text: "Card finalized (no PR to merge)")
85
+ end
86
+ when "set_status"
87
+ card.update!(status: rule["status"]) if Card::STATUSES.include?(rule["status"])
88
+ else
89
+ card.log!("error", text: "Unknown column rule: #{rule["action"].inspect}")
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,53 @@
1
+ # Reliability layer (cardinal.md §11): no run may stay "running" without a
2
+ # live process behind it. The server boots a sweeper thread (see
3
+ # config/initializers/run_sweeper.rb) that fails silent runs and unsticks
4
+ # their cards, then re-kicks execution queues.
5
+ module RunSweeper
6
+ HEARTBEAT_GRACE = 3.minutes
7
+
8
+ def self.sweep
9
+ fail_dead_runs
10
+ repair_stuck_cards
11
+ kick_queues
12
+ end
13
+
14
+ def self.fail_dead_runs
15
+ Run.where(status: %w[queued running]).find_each do |run|
16
+ next if alive?(run)
17
+ next if run.heartbeat_at && run.heartbeat_at > HEARTBEAT_GRACE.ago
18
+ next if run.heartbeat_at.nil? && run.created_at > HEARTBEAT_GRACE.ago
19
+
20
+ run.update!(status: "failed", finished_at: Time.current,
21
+ result_summary: "Runner died without finishing (swept)")
22
+ card = run.card
23
+ if card.working? || card.queued?
24
+ card.update!(status: "failed")
25
+ card.log!("error", run: run, text: "Run ##{run.id} lost its runner process and was marked failed. Retry by dragging the card out and back into the column.")
26
+ end
27
+ end
28
+ end
29
+
30
+ # Cards left "working" with no live or recorded run — e.g. a crash between
31
+ # state writes.
32
+ def self.repair_stuck_cards
33
+ Card.where(status: "working").find_each do |card|
34
+ next unless card.column.ai? # non-AI columns: "working" means a human is
35
+ next if card.runs.where(status: %w[queued running needs_input]).any? { |r| r.needs_input? || alive?(r) }
36
+ card.update!(status: "failed")
37
+ card.log!("error", text: "Card was stuck working with no live run; marked failed.")
38
+ end
39
+ end
40
+
41
+ def self.kick_queues
42
+ Column.where(archetype: "execution").find_each(&:kick_queue)
43
+ end
44
+
45
+ def self.alive?(run)
46
+ pid = run.agent_session&.config&.dig("pid")
47
+ return false if pid.blank?
48
+ Process.kill(0, Integer(pid))
49
+ true
50
+ rescue Errno::ESRCH, Errno::EPERM, ArgumentError
51
+ false
52
+ end
53
+ end
@@ -0,0 +1,79 @@
1
+ <%= turbo_stream_from @board %>
2
+
3
+ <header class="topbar">
4
+ <h1>Cardinal AI <span class="sep">▸</span> <%= @board.name %></h1>
5
+ <div class="topbar-right">
6
+ <button type="button" class="theme-toggle"
7
+ data-controller="theme" data-action="theme#toggle">☀ Light</button>
8
+ <% if ENV["CARDINAL_AUTH"].present? %>
9
+ <span class="auth-chip" title="<%= ENV["CARDINAL_AUTH"] == "dedicated" ? "This board runs as its own linked Claude account (.cardinal/claude). Switch with: cardinal login" : "This board inherits the machine's claude login (CARDINAL_INHERIT_AUTH=1)" %>">
10
+ 🔐 <%= ENV["CARDINAL_AUTH"] == "dedicated" ? "board account" : "machine account" %>
11
+ </span>
12
+ <% end %>
13
+ <% attention = @board.attention_cards %>
14
+ <% working = @board.cards.where(status: "working").order(:updated_at) %>
15
+ <% queued = @board.cards.where(status: "queued").order(:position) %>
16
+ <% if attention.any? || working.any? || queued.any? %>
17
+ <details class="attention">
18
+ <summary>
19
+ <% if attention.any? %><span class="attn-part">⚠ <%= attention.size %> need you</span><% end %>
20
+ <% if working.any? %><span class="attn-part working-part"><span class="pulse-dot"></span> <%= working.size %> working</span><% end %>
21
+ <% if queued.any? %><span class="attn-part">⏳ <%= queued.size %> queued</span><% end %>
22
+ </summary>
23
+ <div class="attention-list">
24
+ <% if attention.any? %>
25
+ <p class="attn-header">Needs you</p>
26
+ <ul>
27
+ <% attention.each do |card| %>
28
+ <li><%= link_to "##{card.number} #{card.title} — #{card.status.humanize.downcase}",
29
+ card_path(card), data: { turbo_frame: "modal", turbo_action: "advance" } %></li>
30
+ <% end %>
31
+ </ul>
32
+ <% end %>
33
+ <% if working.any? %>
34
+ <p class="attn-header">Working</p>
35
+ <ul>
36
+ <% working.each do |card| %>
37
+ <li class="attn-working">
38
+ <span class="pulse-dot"></span>
39
+ <%= link_to card_path(card), data: { turbo_frame: "modal", turbo_action: "advance" } do %>
40
+ #<%= card.number %> <%= card.title %><span class="hint"> — <%= card.latest_progress&.truncate(60) || "starting…" %></span>
41
+ <% end %>
42
+ </li>
43
+ <% end %>
44
+ </ul>
45
+ <% end %>
46
+ <% if queued.any? %>
47
+ <p class="attn-header">Queued</p>
48
+ <ul>
49
+ <% queued.each_with_index do |card, index| %>
50
+ <li><%= link_to "##{card.number} #{card.title} — #{index.zero? ? "next up" : "#{index} ahead"}",
51
+ card_path(card), data: { turbo_frame: "modal", turbo_action: "advance" } %></li>
52
+ <% end %>
53
+ </ul>
54
+ <% end %>
55
+ </div>
56
+ </details>
57
+ <% end %>
58
+ <details class="new-column">
59
+ <summary>+ Column</summary>
60
+ <%= form_with url: columns_path, class: "new-column-form" do |f| %>
61
+ <%= f.text_field "column[name]", placeholder: "Column name", required: true %>
62
+ <%# Inbox is the board's single intake — it can't be created a second time (card #17). %>
63
+ <%= f.select "column[archetype]",
64
+ (Column::ARCHETYPES - %w[inbox]).map { |a| [a.capitalize, a] }, selected: "planning" %>
65
+ <%= f.submit "Add" %>
66
+ <% end %>
67
+ </details>
68
+ </div>
69
+ </header>
70
+
71
+ <main class="board">
72
+ <% @board.columns.each do |column| %>
73
+ <%= render "columns/column", column: column %>
74
+ <% end %>
75
+ </main>
76
+
77
+ <%= turbo_frame_tag "modal", data: { turbo_permanent: true } do %>
78
+ <%= render "cards/detail" if @card %>
79
+ <% end %>
@@ -0,0 +1,48 @@
1
+ <article class="card status-<%= card.status %>" id="<%= dom_id(card) %>" data-card-id="<%= card.number %>">
2
+ <%= link_to card_path(card), class: "card-link", data: { turbo_frame: "modal", turbo_action: "advance" } do %>
3
+ <div class="card-title">
4
+ <%= card.title %>
5
+ <span class="status-glyph"><%= { "working" => "⚡", "needs_input" => "❓", "failed" => "✖",
6
+ "work_complete" => "✅", "done" => "✓", "queued" => "⏳",
7
+ "discussing" => "💬", "in_review" => "👁", "approved" => "👍",
8
+ "changes_requested" => "🔁" }[card.status] %></span>
9
+ </div>
10
+ <% if card.queued? %>
11
+ <% ahead = card.column.cards.where(status: "queued").where("position < ?", card.position).count %>
12
+ <p class="card-progress">⏳ queued<%= ahead.positive? ? " — #{ahead} ahead" : " — next up" %></p>
13
+ <% elsif card.working? && card.column.ai? %>
14
+ <p class="card-progress working-line"><span class="spinner"></span> <%= card.latest_progress || "agent starting…" %></p>
15
+ <% elsif card.latest_progress && card.running? %>
16
+ <p class="card-progress">▸ <%= card.latest_progress %></p>
17
+ <% elsif card.approved? %>
18
+ <p class="card-progress approved-text">👍 approved — drag to Done to ship</p>
19
+ <% elsif card.changes_requested? %>
20
+ <p class="card-progress attention-text">🔁 changes requested</p>
21
+ <% elsif card.needs_attention? %>
22
+ <p class="card-progress attention-text"><%= card.status.humanize %></p>
23
+ <% end %>
24
+ <div class="card-meta">
25
+ <% card.tags.each do |tag| %><span class="tag"><%= tag %></span><% end %>
26
+ <% if card.running? && card.column.ai? && card.column.model %>
27
+ <span class="chip agent-chip">🤖 <%= card.column.model_short %><%= " · #{card.column.effort}" if card.column.effort %></span>
28
+ <% end %>
29
+ <% if card.awaiting_assistant? %>
30
+ <span class="chip agent-chip thinking-chip">🪶 <span class="typing-dots mini"><span></span><span></span><span></span></span></span>
31
+ <% elsif card.discussing? && card.events.conversation.last&.actor == "assistant" %>
32
+ <span class="chip agent-chip">🪶 replied</span>
33
+ <% end %>
34
+ <% if (card.running? || card.needs_attention?) && (last_run = card.runs.order(:id).last) && (last_run.cost.to_f.positive? || last_run.output_tokens.positive?) %>
35
+ <span class="chip">$<%= last_run.cost.round(2) %><%= " · #{(last_run.output_tokens / 1000.0).round(1)}k out" if card.working? %></span>
36
+ <% end %>
37
+ <% if card.parent_id %><span class="chip">↑ sub</span><% end %>
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 %>
40
+ </div>
41
+ <% end %>
42
+ <% if card.pr_url %>
43
+ <a class="card-footer" href="<%= card.pr_url %>" target="_blank" rel="noopener" title="Open the pull request on GitHub">
44
+ <span class="footer-left"><%# future: Asana / Trello / linked-ticket slot %></span>
45
+ <span class="footer-pr">GitHub #<%= card.pr_url[%r{/pull/(\d+)}, 1] %> ↗</span>
46
+ </a>
47
+ <% end %>
48
+ </article>