cardinal-ai 0.0.1 → 0.2.3

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 (107) 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 +514 -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 +95 -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 +28 -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 +43 -0
  22. data/app/javascript/controllers/scroll_controller.js +44 -0
  23. data/app/javascript/controllers/tags_controller.js +49 -0
  24. data/app/javascript/controllers/theme_controller.js +43 -0
  25. data/app/javascript/controllers/tooltip_controller.js +37 -0
  26. data/app/jobs/ai_task_job.rb +26 -0
  27. data/app/jobs/application_job.rb +7 -0
  28. data/app/jobs/assistant_reply_job.rb +132 -0
  29. data/app/jobs/mark_pr_ready_job.rb +18 -0
  30. data/app/jobs/merge_pr_job.rb +27 -0
  31. data/app/jobs/resume_run_job.rb +30 -0
  32. data/app/jobs/start_run_job.rb +13 -0
  33. data/app/mailers/application_mailer.rb +4 -0
  34. data/app/models/agent_session.rb +8 -0
  35. data/app/models/application_record.rb +3 -0
  36. data/app/models/artifact.rb +8 -0
  37. data/app/models/board.rb +60 -0
  38. data/app/models/card.rb +83 -0
  39. data/app/models/column.rb +83 -0
  40. data/app/models/event.rb +44 -0
  41. data/app/models/run.rb +28 -0
  42. data/app/services/agent/runner.rb +379 -0
  43. data/app/services/agent/workspace.rb +138 -0
  44. data/app/services/card_transition.rb +97 -0
  45. data/app/services/claude_cli.rb +89 -0
  46. data/app/services/rules/compiler.rb +55 -0
  47. data/app/services/rules.rb +67 -0
  48. data/app/services/run_sweeper.rb +52 -0
  49. data/app/views/boards/show.html.erb +79 -0
  50. data/app/views/cards/_card.html.erb +48 -0
  51. data/app/views/cards/_detail.html.erb +190 -0
  52. data/app/views/cards/_tag_picker.html.erb +12 -0
  53. data/app/views/cards/new.html.erb +35 -0
  54. data/app/views/cards/show.html.erb +3 -0
  55. data/app/views/columns/_column.html.erb +25 -0
  56. data/app/views/columns/edit.html.erb +126 -0
  57. data/app/views/events/_event.html.erb +29 -0
  58. data/app/views/layouts/application.html.erb +46 -0
  59. data/app/views/layouts/mailer.html.erb +13 -0
  60. data/app/views/layouts/mailer.text.erb +1 -0
  61. data/app/views/pwa/manifest.json.erb +22 -0
  62. data/app/views/pwa/service-worker.js +26 -0
  63. data/bin/rails +4 -0
  64. data/bin/rake +4 -0
  65. data/cardinal.md +686 -0
  66. data/config/application.rb +60 -0
  67. data/config/boot.rb +13 -0
  68. data/config/bundler-audit.yml +5 -0
  69. data/config/cable.yml +13 -0
  70. data/config/ci.rb +20 -0
  71. data/config/credentials.yml.enc +1 -0
  72. data/config/database.yml +31 -0
  73. data/config/environment.rb +5 -0
  74. data/config/environments/development.rb +78 -0
  75. data/config/environments/production.rb +89 -0
  76. data/config/environments/test.rb +53 -0
  77. data/config/importmap.rb +6 -0
  78. data/config/initializers/assets.rb +7 -0
  79. data/config/initializers/cardinal_bootstrap.rb +12 -0
  80. data/config/initializers/cardinal_instance.rb +20 -0
  81. data/config/initializers/content_security_policy.rb +29 -0
  82. data/config/initializers/filter_parameter_logging.rb +8 -0
  83. data/config/initializers/inflections.rb +16 -0
  84. data/config/initializers/run_sweeper.rb +17 -0
  85. data/config/locales/en.yml +31 -0
  86. data/config/puma.rb +42 -0
  87. data/config/routes.rb +22 -0
  88. data/config/storage.yml +27 -0
  89. data/config.ru +6 -0
  90. data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
  91. data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
  92. data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
  93. data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
  94. data/db/seeds.rb +19 -0
  95. data/docker/agent/Dockerfile +16 -0
  96. data/exe/cardinal +111 -0
  97. data/lib/cardinal/version.rb +1 -1
  98. data/public/400.html +135 -0
  99. data/public/404.html +135 -0
  100. data/public/406-unsupported-browser.html +135 -0
  101. data/public/422.html +135 -0
  102. data/public/500.html +135 -0
  103. data/public/icon.png +0 -0
  104. data/public/icon.svg +3 -0
  105. data/public/robots.txt +1 -0
  106. data/vendor/javascript/sortablejs.js +3378 -0
  107. metadata +235 -9
data/app/models/run.rb ADDED
@@ -0,0 +1,28 @@
1
+ class Run < ApplicationRecord
2
+ STATUSES = %w[queued running needs_input succeeded failed cancelled].freeze
3
+
4
+ belongs_to :agent_session
5
+ has_one :card, through: :agent_session
6
+ has_many :artifacts, dependent: :destroy
7
+ has_many :events, dependent: :nullify
8
+
9
+ enum :status, STATUSES.index_by(&:itself)
10
+
11
+ # A budget/timeout outcome, whether the segment parked (needs_input) or was
12
+ # recorded as a failure. The parked message ("…turn budget mid-work…") lives
13
+ # on the last question event; the failure message (failure_reason) lives on
14
+ # result_summary. Either signals "try again with a fresh budget," not a bug.
15
+ EXHAUSTION = /turn budget|max-turns budget|timed out|timeout/i
16
+
17
+ def finished? = %w[succeeded failed cancelled].include?(status)
18
+
19
+ def exhausted?
20
+ text = needs_input? ? events.where(kind: "question").order(:id).last&.text : result_summary
21
+ text.to_s.match?(EXHAUSTION)
22
+ end
23
+
24
+ # A run the user can relaunch from the work panel: an execution-column run
25
+ # that parked or failed on its budget/timeout. Restart resumes the surviving
26
+ # session (fresh budget) or starts a clean run when no session remains.
27
+ def restartable? = card.column.execution? && (needs_input? || failed?) && exhausted?
28
+ end
@@ -0,0 +1,379 @@
1
+ module Agent
2
+ # Drives one Run through its phases (cardinal.md §4, §11, §17):
3
+ #
4
+ # start → plan phase (read-only, --permission-mode plan) when the column
5
+ # requires approval, else straight to execute
6
+ # park → plan_proposed or QUESTION: → run + card go needs_input
7
+ # resume → same claude session (--resume) with the user's answer,
8
+ # approval, or plan feedback
9
+ # finish → push branch, ensure draft PR, final report, work_complete
10
+ #
11
+ # The subprocess is the Claude Agent runtime (`claude -p`, stream-json).
12
+ # Heartbeats are written while streaming; RunSweeper reaps silent runs.
13
+ class Runner
14
+ STRIP_ENV = %w[ANTHROPIC_API_KEY CLAUDECODE CLAUDE_CODE_ENTRYPOINT GH_TOKEN GITHUB_TOKEN].freeze
15
+ HEARTBEAT_EVERY = 10 # seconds
16
+ PLAN_TURNS = 20
17
+ DEFAULT_EXECUTE_TURNS = 80 # turn caps are runaway guards, not work limits
18
+
19
+ EXECUTE_RULES = <<~RULES.freeze
20
+ ## Rules
21
+ - You have the FULL toolset now: shell (bash, git), file editing, everything. Run
22
+ commands yourself — never ask who should run them.
23
+ - Work only inside this repository checkout (you are already on the card's branch).
24
+ - If the branch conflicts with origin's default branch, merge it into the card
25
+ branch yourself and resolve the conflicts as part of the work.
26
+ - Commit your work as you go with clear messages. Do NOT push — the runner pushes for you.
27
+ - Stay strictly within the card's scope. Prefer the smallest reasonable interpretation and note assumptions.
28
+ - If you are blocked on a decision only the user can make, output a single line starting with
29
+ "QUESTION:" followed by the question, then stop immediately. Do not guess on genuinely ambiguous choices.
30
+ - Finish with a concise report: what you did, what to check, any open questions.
31
+ RULES
32
+
33
+ def self.start(run) = new(run).start
34
+ def self.resume(run, message, approve: false) = new(run).resume(message, approve: approve)
35
+
36
+ attr_reader :run, :card, :column
37
+
38
+ def initialize(run)
39
+ @run = run
40
+ @card = run.card
41
+ @column = card.column
42
+ end
43
+
44
+ def start
45
+ begin_segment!(first: true)
46
+ if plan_gated?
47
+ run.update!(phase: "plan")
48
+ stream_agent(prompt: plan_prompt, mode: "plan")
49
+ else
50
+ stream_agent(prompt: briefing_prompt, mode: "execute")
51
+ end
52
+ rescue => e
53
+ record_failure(e)
54
+ ensure
55
+ column.kick_queue if column.execution?
56
+ end
57
+
58
+ def resume(message, approve: false)
59
+ begin_segment!
60
+ if run.phase == "plan" && approve
61
+ run.update!(phase: "execute")
62
+ stream_agent(prompt: "Your plan is approved — execute it now.\n\n#{EXECUTE_RULES}",
63
+ mode: "execute", resuming: true)
64
+ elsif run.phase == "plan"
65
+ stream_agent(prompt: "Feedback on your plan:\n\n#{message}\n\nRevise the plan accordingly, present it, and stop again for approval. Stay in read-only mode.",
66
+ mode: "plan", resuming: true)
67
+ else
68
+ stream_agent(prompt: "Answer from the user:\n\n#{message}\n\nContinue the work. The same rules apply (commit, don't push, QUESTION: if blocked again, final report when done).",
69
+ mode: "execute", resuming: true)
70
+ end
71
+ rescue => e
72
+ record_failure(e)
73
+ ensure
74
+ column.kick_queue if column.execution?
75
+ end
76
+
77
+ private
78
+
79
+ def plan_gated?
80
+ ActiveModel::Type::Boolean.new.cast(column.plan_approval)
81
+ end
82
+
83
+ def begin_segment!(first: false)
84
+ run.update!(status: "running", started_at: run.started_at || Time.current, heartbeat_at: Time.current)
85
+ card.update!(status: "working")
86
+ if first
87
+ card.log!("run_started", run: run, text: "Run ##{run.id} started")
88
+ else
89
+ card.log!("progress", actor: "agent", run: run, text: "Run resumed")
90
+ end
91
+ end
92
+
93
+ def stream_agent(prompt:, mode:, resuming: false)
94
+ workspace = resuming ? Workspace.attach(card) : Workspace.provision(card)
95
+ remember_base_sha(workspace) if mode == "execute"
96
+
97
+ cmd = ["claude", "-p", prompt, "--output-format", "stream-json", "--verbose",
98
+ "--permission-mode", "bypassPermissions"]
99
+ case mode
100
+ when "plan"
101
+ # Read-only exploration for the plan phase. (--permission-mode plan
102
+ # hangs headless: ExitPlanMode waits for an approval that never comes.)
103
+ cmd += ["--max-turns", PLAN_TURNS.to_s, "--tools", "Read,Glob,Grep"]
104
+ when "plan_wrap"
105
+ # Turn-capped plan: force the plan out of the context already gathered.
106
+ cmd += ["--max-turns", "3", "--tools", ""]
107
+ else
108
+ cmd += ["--max-turns", (column.max_turns.presence || DEFAULT_EXECUTE_TURNS).to_s]
109
+ end
110
+ cmd += ["--model", column.model] if column.model.present?
111
+ cmd += ["--effort", column.effort] if column.effort.present?
112
+ cmd += ["--resume", run.external_session_id] if resuming && run.external_session_id.present?
113
+
114
+ result = {}
115
+ @base_in, @base_out = run.input_tokens, run.output_tokens
116
+ @seg_in = @seg_out = 0
117
+ env = STRIP_ENV.index_with { nil }
118
+ spawn_cmd, spawn_opts = workspace.agent_spawn(cmd)
119
+ Open3.popen3(env, *spawn_cmd, **spawn_opts) do |stdin, stdout, stderr, wait|
120
+ stdin.close
121
+ run.agent_session.update!(status: "ready", config: run.agent_session.config.merge("pid" => wait.pid))
122
+ timeout_min = (column.timeout_minutes.presence || 30).to_i
123
+ watchdog = Thread.new do
124
+ sleep timeout_min * 60
125
+ @timed_out = true
126
+ Process.kill("TERM", wait.pid) rescue nil
127
+ end
128
+ err_lines = []
129
+ drain = Thread.new { stderr.each_line { |l| err_lines << l.strip; err_lines.shift while err_lines.size > 4 } }
130
+ last_beat = Time.current
131
+ stdout.each_line do |line|
132
+ if Time.current - last_beat > HEARTBEAT_EVERY
133
+ # Heartbeat + live token tally: tokens survive even if this
134
+ # segment is killed before its result event.
135
+ run.update_columns(heartbeat_at: Time.current,
136
+ input_tokens: @base_in + @seg_in,
137
+ output_tokens: @base_out + @seg_out)
138
+ last_beat = Time.current
139
+ end
140
+ begin
141
+ handle_stream_event(JSON.parse(line), result)
142
+ rescue JSON::ParserError
143
+ next
144
+ end
145
+ end
146
+ drain.join(1)
147
+ watchdog.kill
148
+ result[:exit_status] = wait.value
149
+ result[:stderr] = err_lines.join(" | ")
150
+ result[:timed_out] = @timed_out
151
+ result[:timeout_min] = timeout_min
152
+ end
153
+
154
+ mode == "execute" ? conclude_execute(workspace, result) : conclude_plan(result)
155
+ end
156
+
157
+ def handle_stream_event(json, result)
158
+ case json["type"]
159
+ when "system"
160
+ if json["subtype"] == "init"
161
+ run.update_columns(external_session_id: json["session_id"]) if json["session_id"].present?
162
+ card.log!("progress", actor: "agent", run: run, text: "Agent session started (#{json["model"]})")
163
+ end
164
+ when "assistant"
165
+ if (usage = json.dig("message", "usage"))
166
+ @seg_in += usage["input_tokens"].to_i
167
+ @seg_out += usage["output_tokens"].to_i
168
+ end
169
+ Array(json.dig("message", "content")).each do |block|
170
+ case block["type"]
171
+ when "text"
172
+ card.log!("progress", actor: "agent", run: run, text: block["text"].to_s.truncate(400)) if block["text"].present?
173
+ when "tool_use"
174
+ card.log!("tool_call", actor: "agent", run: run,
175
+ text: "#{block["name"]}: #{block["input"].to_json.truncate(160)}")
176
+ end
177
+ end
178
+ when "result"
179
+ result[:success] = json["subtype"] == "success" && !json["is_error"]
180
+ result[:subtype] = json["subtype"]
181
+ result[:report] = json["result"].to_s
182
+ result[:cost] = json["total_cost_usd"]
183
+ result[:turns] = json["num_turns"]
184
+ result[:input_tokens] = json.dig("usage", "input_tokens")
185
+ result[:output_tokens] = json.dig("usage", "output_tokens")
186
+ end
187
+ end
188
+
189
+ def conclude_plan(result)
190
+ accumulate_usage(result)
191
+ unless result[:success] && result[:report].present?
192
+ # Turn-capped mid-exploration: one tool-less wrap-up pass to force the
193
+ # plan out of the context it already gathered.
194
+ if result[:subtype] == "error_max_turns" && run.external_session_id.present? && !@plan_wrap_attempted
195
+ @plan_wrap_attempted = true
196
+ card.log!("progress", actor: "agent", run: run, text: "Hit the exploration budget — wrapping up the plan from what was learned")
197
+ return stream_agent(prompt: "You have hit your exploration limit. Present your best plan-of-attack now, using only what you have already learned. Do not use any tools.",
198
+ mode: "plan_wrap", resuming: true)
199
+ end
200
+ return record_failure(RuntimeError.new("plan phase failed — #{failure_reason(result)}"))
201
+ end
202
+ park!("plan_proposed", result[:report],
203
+ note: "Plan proposed — approve it in the work panel, or reply to redirect.")
204
+ end
205
+
206
+ def conclude_execute(workspace, result)
207
+ accumulate_usage(result)
208
+ unless result[:success]
209
+ # Budget exhaustion isn't failure — park and offer to continue (§8).
210
+ # The session survives; an answer resumes it with a fresh turn budget.
211
+ if result[:subtype] == "error_max_turns" && run.external_session_id.present?
212
+ commits = salvage_commits(workspace)
213
+ return park!("question",
214
+ "I've used this segment's turn budget mid-work#{" — #{commits} commit(s) so far are saved to the branch" if commits.to_i.positive?}. Reply (anything) to continue with a fresh budget, or cancel the run.",
215
+ note: "Agent paused at the turn budget — reply on the card to continue.")
216
+ end
217
+ salvage_commits(workspace)
218
+ return record_failure(RuntimeError.new(failure_reason(result)))
219
+ end
220
+
221
+ report = result[:report].to_s
222
+ if report.lstrip.start_with?("QUESTION:")
223
+ return park!("question", report.lstrip.delete_prefix("QUESTION:").strip,
224
+ note: "Agent is waiting on your answer — reply on the card.")
225
+ end
226
+
227
+ commits = workspace.commits_since(base_sha)
228
+ if commits.any?
229
+ workspace.push!
230
+ ensure_pull_request(workspace)
231
+ run.artifacts.create!(kind: "pull_request", name: "PR for #{card.branch_name}",
232
+ payload: { url: card.pr_url, commits: commits })
233
+ elsif card.pr_url.blank? && workspace.ahead_of_default?
234
+ # No new commits this run, but the branch carries earlier work (e.g. a
235
+ # salvage commit) that still needs a PR.
236
+ workspace.push!
237
+ ensure_pull_request(workspace)
238
+ end
239
+
240
+ run.update!(status: "succeeded", finished_at: Time.current,
241
+ result_summary: report.presence&.truncate(2000))
242
+ card.log!("final_report", actor: "agent", run: run,
243
+ text: [report.presence || "Run finished with no report.",
244
+ commits.any? ? "\n**Commits (#{commits.size}):**\n#{commits.map { |c| "- #{c}" }.join("\n")}" : "\n_No commits were made._"].join("\n"))
245
+ card.update!(status: "work_complete")
246
+ card.log!("run_finished", run: run,
247
+ text: "Run succeeded — #{result[:turns]} turns, $#{run.cost.round(2)} total")
248
+ end
249
+
250
+ def park!(kind, text, note:)
251
+ run.update!(status: "needs_input")
252
+ card.log!(kind, actor: "agent", run: run, text: text)
253
+ card.update!(status: "needs_input")
254
+ card.log!("status_change", run: run, text: note)
255
+ end
256
+
257
+ # Say WHY, not just that it died: timeout vs turn cap vs error vs crash.
258
+ def failure_reason(result)
259
+ return "timed out after #{result[:timeout_min]} minutes and was stopped — raise the column's timeout for bigger tasks, or split the card" if result[:timed_out]
260
+ return "hit this segment's max-turns budget — raise Max turns in the column's gear settings, or split the card" if result[:subtype] == "error_max_turns"
261
+ parts = ["agent did not finish cleanly (exit #{result[:exit_status]&.exitstatus || "?"})"]
262
+ parts << "last output: #{result[:report].truncate(300)}" if result[:report].present?
263
+ parts << "stderr: #{result[:stderr].truncate(300)}" if result[:stderr].present?
264
+ parts.join(" — ")
265
+ end
266
+
267
+ # A failed/timed-out segment may still hold real local commits; push them
268
+ # so the branch (and any PR) keeps the partial progress instead of the
269
+ # next provision's reset wiping it.
270
+ def salvage_commits(workspace)
271
+ commits = workspace.commits_since(base_sha)
272
+ return 0 if commits.empty?
273
+ workspace.push!
274
+ card.log!("progress", run: run, text: "Partial work preserved: #{commits.size} commit(s) pushed to #{card.branch_name}")
275
+ commits.size
276
+ rescue => e
277
+ card.log!("progress", run: run, text: "Could not preserve partial work: #{e.message.truncate(120)}")
278
+ 0
279
+ end
280
+
281
+ def record_failure(error)
282
+ run.update!(status: "failed", finished_at: Time.current,
283
+ result_summary: error.message.truncate(500))
284
+ card.update!(status: "failed")
285
+ card.log!("error", run: run, text: "Run failed: #{error.message.truncate(500)}")
286
+ end
287
+
288
+ # Cost/tokens accumulate across segments of the same run (plan + execute +
289
+ # resumes). Tokens were live-tallied during the stream; the result event's
290
+ # figures are authoritative when present, the live tally is the fallback
291
+ # for killed segments (which never emit a result).
292
+ def accumulate_usage(result)
293
+ base_in = @base_in || run.input_tokens
294
+ base_out = @base_out || run.output_tokens
295
+ run.update!(cost: run.cost + (result[:cost] || 0),
296
+ input_tokens: base_in + (result[:input_tokens] || @seg_in || 0),
297
+ output_tokens: base_out + (result[:output_tokens] || @seg_out || 0))
298
+ @base_in = @base_out = @seg_in = @seg_out = nil
299
+ end
300
+
301
+ def remember_base_sha(workspace)
302
+ return if run.briefing["base_sha"].present?
303
+ run.update!(briefing: run.briefing.merge("base_sha" => workspace.head))
304
+ end
305
+
306
+ def base_sha = run.briefing.fetch("base_sha")
307
+
308
+ def ensure_pull_request(workspace)
309
+ return if card.pr_url.present?
310
+ out, status = Open3.capture2e(
311
+ "gh", "pr", "create", "--draft",
312
+ "--head", card.branch_name,
313
+ "--title", "##{card.number} #{card.title}",
314
+ "--body", "Automated work by Cardinal card ##{card.number}'s agent.\n\n#{card.description}",
315
+ chdir: workspace.path.to_s
316
+ )
317
+ if status.success? && (url = out[%r{https://github\.com/\S+/pull/\d+}])
318
+ card.update!(pr_url: url, pr_state: "draft")
319
+ card.log!("artifact_created", run: run, text: "Draft PR opened: #{url}")
320
+ else
321
+ card.log!("progress", run: run, text: "Branch pushed (PR not created: #{out.truncate(120)})")
322
+ end
323
+ end
324
+
325
+ def briefing_prompt
326
+ <<~PROMPT
327
+ You are the dedicated worker agent for card ##{card.number} of the Cardinal board: "#{card.title}".
328
+
329
+ ## Brief
330
+ #{card.description.presence || "(no description — infer scope from the title and conversation)"}
331
+
332
+ #{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
333
+ ## Card conversation so far
334
+ #{conversation_excerpt.presence || "(none)"}
335
+
336
+ #{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
337
+ #{EXECUTE_RULES}
338
+ PROMPT
339
+ end
340
+
341
+ def plan_prompt
342
+ <<~PROMPT
343
+ You are the dedicated worker agent for card ##{card.number} of the Cardinal board: "#{card.title}".
344
+ You are in READ-ONLY PLAN MODE. Do not modify anything yet.
345
+
346
+ ## Brief
347
+ #{card.description.presence || "(no description — infer scope from the title and conversation)"}
348
+
349
+ #{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
350
+ ## Card conversation so far
351
+ #{conversation_excerpt.presence || "(none)"}
352
+
353
+ #{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
354
+ Explore the repository as needed, then present a short numbered plan-of-attack
355
+ (files you'll touch, approach, how you'll verify) and stop. The user will approve
356
+ or redirect before any changes are made.
357
+
358
+ IMPORTANT: you are read-only ONLY during this planning pass. Once the plan is
359
+ approved you will have the full toolset — shell, git, file editing — in this same
360
+ session. Plan every step as something YOU will do (including git merges and
361
+ running commands); never ask who should run a command or plan around not having
362
+ a shell.
363
+ PROMPT
364
+ end
365
+
366
+ # The planning assistant's distilled "Ready for execution" brief, if the
367
+ # conversation produced one — the most load-bearing artifact of planning.
368
+ def planning_brief
369
+ return @planning_brief if defined?(@planning_brief)
370
+ @planning_brief = card.events.where(kind: "assistant_message")
371
+ .order(:id).filter_map(&:text).reverse
372
+ .find { |t| t.match?(/ready for execution/i) }
373
+ end
374
+
375
+ def conversation_excerpt
376
+ card.events.conversation.filter_map { |e| "#{e.actor}: #{e.text}" if e.text }.last(30).join("\n")
377
+ end
378
+ end
379
+ end
@@ -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 "queued"
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