cardinal-ai 0.0.1 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +50 -29
- data/Rakefile +6 -0
- data/app/assets/stylesheets/application.css +10 -0
- data/app/assets/stylesheets/cardinal.css +530 -0
- data/app/controllers/application_controller.rb +7 -0
- data/app/controllers/boards_controller.rb +5 -0
- data/app/controllers/cards_controller.rb +129 -0
- data/app/controllers/columns_controller.rb +130 -0
- data/app/controllers/messages_controller.rb +25 -0
- data/app/controllers/runs_controller.rb +58 -0
- data/app/helpers/application_helper.rb +35 -0
- data/app/javascript/application.js +2 -0
- data/app/javascript/controllers/application.js +7 -0
- data/app/javascript/controllers/autosave_controller.js +43 -0
- data/app/javascript/controllers/board_column_controller.js +96 -0
- data/app/javascript/controllers/clipboard_controller.js +18 -0
- data/app/javascript/controllers/composer_controller.js +10 -0
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/modal_controller.js +45 -0
- data/app/javascript/controllers/reveal_controller.js +15 -0
- data/app/javascript/controllers/scroll_controller.js +44 -0
- data/app/javascript/controllers/tags_controller.js +49 -0
- data/app/javascript/controllers/theme_controller.js +43 -0
- data/app/javascript/controllers/tooltip_controller.js +37 -0
- data/app/jobs/ai_task_job.rb +26 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/assistant_reply_job.rb +132 -0
- data/app/jobs/mark_pr_ready_job.rb +18 -0
- data/app/jobs/merge_pr_job.rb +27 -0
- data/app/jobs/resume_run_job.rb +30 -0
- data/app/jobs/start_run_job.rb +13 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/agent_session.rb +8 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/artifact.rb +8 -0
- data/app/models/board.rb +92 -0
- data/app/models/card.rb +83 -0
- data/app/models/column.rb +134 -0
- data/app/models/event.rb +44 -0
- data/app/models/run.rb +28 -0
- data/app/services/agent/runner.rb +379 -0
- data/app/services/agent/workspace.rb +138 -0
- data/app/services/card_transition.rb +97 -0
- data/app/services/claude_cli.rb +89 -0
- data/app/services/rules/compiler.rb +55 -0
- data/app/services/rules.rb +92 -0
- data/app/services/run_sweeper.rb +53 -0
- data/app/views/boards/show.html.erb +79 -0
- data/app/views/cards/_card.html.erb +48 -0
- data/app/views/cards/_detail.html.erb +190 -0
- data/app/views/cards/_tag_picker.html.erb +12 -0
- data/app/views/cards/new.html.erb +35 -0
- data/app/views/cards/show.html.erb +3 -0
- data/app/views/columns/_column.html.erb +25 -0
- data/app/views/columns/edit.html.erb +146 -0
- data/app/views/events/_event.html.erb +29 -0
- data/app/views/layouts/application.html.erb +46 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/bin/rails +4 -0
- data/bin/rake +4 -0
- data/cardinal.md +695 -0
- data/config/application.rb +60 -0
- data/config/boot.rb +13 -0
- data/config/bundler-audit.yml +5 -0
- data/config/cable.yml +13 -0
- data/config/ci.rb +20 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +31 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +78 -0
- data/config/environments/production.rb +89 -0
- data/config/environments/test.rb +53 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/cardinal_bootstrap.rb +12 -0
- data/config/initializers/cardinal_instance.rb +20 -0
- data/config/initializers/content_security_policy.rb +29 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/initializers/run_sweeper.rb +17 -0
- data/config/locales/en.yml +31 -0
- data/config/puma.rb +42 -0
- data/config/routes.rb +22 -0
- data/config/storage.yml +27 -0
- data/config.ru +6 -0
- data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
- data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
- data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
- data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
- data/db/seeds.rb +13 -0
- data/docker/agent/Dockerfile +16 -0
- data/exe/cardinal +111 -0
- data/lib/cardinal/version.rb +1 -1
- data/public/400.html +135 -0
- data/public/404.html +135 -0
- data/public/406-unsupported-browser.html +135 -0
- data/public/422.html +135 -0
- data/public/500.html +135 -0
- data/public/icon.png +0 -0
- data/public/icon.svg +3 -0
- data/public/robots.txt +1 -0
- data/vendor/javascript/sortablejs.js +3378 -0
- metadata +236 -9
|
@@ -0,0 +1,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>
|