space-architect 1.3.0 → 2.0.0.rc1

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +248 -155
  4. data/exe/architect +1 -1
  5. data/exe/space +2 -2
  6. data/exe/src +13 -0
  7. data/lib/space_architect/architect_mission.rb +84 -53
  8. data/lib/space_architect/cli/architect.rb +92 -132
  9. data/lib/space_architect/cli/research.rb +94 -0
  10. data/lib/space_architect/cli/space.rb +25 -31
  11. data/lib/space_architect/cli/src.rb +20 -14
  12. data/lib/space_architect/cli.rb +22 -22
  13. data/lib/space_architect/dispatcher.rb +5 -1
  14. data/lib/space_architect/harness.rb +123 -16
  15. data/lib/space_architect/research/mux.rb +127 -0
  16. data/lib/space_architect/research/registry.rb +70 -0
  17. data/lib/space_architect/research/renderer.rb +101 -0
  18. data/lib/space_architect/research/run.rb +7 -0
  19. data/lib/space_architect/research/supervisor.rb +108 -0
  20. data/lib/space_architect/research.rb +13 -0
  21. data/lib/space_architect/run_creator.rb +53 -0
  22. data/lib/space_architect/skill_installer.rb +81 -79
  23. data/lib/space_architect.rb +5 -20
  24. data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
  25. data/lib/space_core/cli/base_command.rb +19 -0
  26. data/lib/space_core/cli/config.rb +49 -0
  27. data/lib/space_core/cli/current.rb +16 -0
  28. data/lib/space_core/cli/help.rb +110 -0
  29. data/lib/space_core/cli/helpers.rb +115 -0
  30. data/lib/space_core/cli/init.rb +29 -0
  31. data/lib/space_core/cli/list.rb +24 -0
  32. data/lib/space_core/cli/new.rb +38 -0
  33. data/lib/space_core/cli/path.rb +16 -0
  34. data/lib/space_core/cli/repeatable_options.rb +75 -0
  35. data/lib/space_core/cli/repo.rb +76 -0
  36. data/lib/space_core/cli/shell.rb +125 -0
  37. data/lib/space_core/cli/show.rb +21 -0
  38. data/lib/space_core/cli/status.rb +33 -0
  39. data/lib/space_core/cli/use.rb +17 -0
  40. data/lib/space_core/cli.rb +171 -0
  41. data/lib/{space_architect → space_core}/config.rb +1 -1
  42. data/lib/{space_architect → space_core}/errors.rb +1 -1
  43. data/lib/{space_architect → space_core}/git_client.rb +1 -1
  44. data/lib/{space_architect → space_core}/mise_client.rb +1 -1
  45. data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
  46. data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
  47. data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
  48. data/lib/{space_architect → space_core}/slugger.rb +1 -1
  49. data/lib/{space_architect → space_core}/space.rb +1 -1
  50. data/lib/{space_architect → space_core}/space_store.rb +12 -12
  51. data/lib/{space_architect → space_core}/state.rb +1 -1
  52. data/lib/{space_architect → space_core}/terminal.rb +1 -1
  53. data/lib/space_core/version.rb +7 -0
  54. data/lib/{space_architect → space_core}/warnings.rb +1 -1
  55. data/lib/{space_architect → space_core}/xdg.rb +1 -1
  56. data/lib/space_core.rb +24 -0
  57. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
  58. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
  59. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
  60. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
  61. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
  62. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
  63. data/lib/space_src/cli/shell.rb +122 -0
  64. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
  65. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
  66. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
  67. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
  68. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
  69. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
  70. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
  71. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
  72. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
  73. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
  74. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
  75. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
  76. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
  77. data/lib/space_src/migration.rb +43 -0
  78. data/lib/space_src/nav.rb +98 -0
  79. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
  80. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
  81. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
  82. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
  83. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
  84. data/lib/space_src/shell_integration.rb +321 -0
  85. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
  86. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
  87. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
  88. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
  89. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
  90. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
  91. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
  92. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
  93. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
  94. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
  95. data/lib/space_src.rb +37 -0
  96. data/skill/architect/SKILL.md +2 -2
  97. data/skill/architect/research.md +46 -37
  98. metadata +115 -67
  99. data/lib/space_architect/cli/config.rb +0 -61
  100. data/lib/space_architect/cli/current.rb +0 -22
  101. data/lib/space_architect/cli/helpers.rb +0 -117
  102. data/lib/space_architect/cli/init.rb +0 -35
  103. data/lib/space_architect/cli/list.rb +0 -30
  104. data/lib/space_architect/cli/new.rb +0 -43
  105. data/lib/space_architect/cli/options.rb +0 -12
  106. data/lib/space_architect/cli/path.rb +0 -22
  107. data/lib/space_architect/cli/repo.rb +0 -88
  108. data/lib/space_architect/cli/shell.rb +0 -137
  109. data/lib/space_architect/cli/show.rb +0 -27
  110. data/lib/space_architect/cli/status.rb +0 -39
  111. data/lib/space_architect/cli/use.rb +0 -23
  112. data/lib/space_architect/version.rb +0 -5
  113. data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -44
data/exe/space CHANGED
@@ -8,6 +8,6 @@ if File.exist?(source_gemfile)
8
8
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
9
9
  end
10
10
 
11
- require "space_architect"
11
+ require "space_core"
12
12
 
13
- SpaceArchitect::CLI.run(["space", *ARGV], $stdout, $stderr)
13
+ Space::Core::CLI.run(ARGV, $stdout, $stderr)
data/exe/src ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ source_gemfile = File.expand_path("../Gemfile", __dir__)
5
+ if File.exist?(source_gemfile)
6
+ ENV["BUNDLE_GEMFILE"] ||= source_gemfile
7
+ require "bundler/setup"
8
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
9
+ end
10
+
11
+ require "space_src"
12
+
13
+ Space::Src::CLI.run(ARGV, $stdout, $stderr)
@@ -6,7 +6,7 @@ require "open3"
6
6
  require "fileutils"
7
7
  require "pathname"
8
8
 
9
- module SpaceArchitect
9
+ module Space::Architect
10
10
  # Manages an architect-loop mission inside a space: one self-contained file per
11
11
  # iteration at architecture/I<NN>-<iteration>.md (Grounds / Specification / Acceptance Criteria / Builder
12
12
  # Prompt / Builder Report / Verdict), grown one commit per section. The freeze
@@ -44,7 +44,7 @@ module SpaceArchitect
44
44
  def init!
45
45
  handoff_path = space.path.join("architecture", "ARCHITECT.md")
46
46
  if handoff_path.exist?
47
- raise Error, "architecture/ARCHITECT.md already exists — remove it first or edit it directly (idempotent guard)"
47
+ raise Space::Core::Error, "architecture/ARCHITECT.md already exists — remove it first or edit it directly (idempotent guard)"
48
48
  end
49
49
 
50
50
  FileUtils.mkdir_p(handoff_path.dirname)
@@ -54,7 +54,7 @@ module SpaceArchitect
54
54
  b.merge("status" => "active", "current_iteration" => nil, "iterations" => [])
55
55
  end
56
56
 
57
- git_run("-C", space.path.to_s, "add", "architecture/ARCHITECT.md", Space::METADATA_FILE)
57
+ git_run("-C", space.path.to_s, "add", "architecture/ARCHITECT.md", Space::Core::Space::METADATA_FILE)
58
58
  git_run("-C", space.path.to_s, "commit", "-m", "Initialize architect mission")
59
59
 
60
60
  handoff_path
@@ -65,14 +65,14 @@ module SpaceArchitect
65
65
  block = space.data["architect"] || {}
66
66
  iterations = block["iterations"] || []
67
67
  if iterations.any? { |s| s["name"] == name }
68
- raise Error, "iteration '#{name}' already exists in space.yaml"
68
+ raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
69
69
  end
70
70
 
71
71
  ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
72
72
  nn = format("%02d", ordinal)
73
73
  rel = "architecture/I#{nn}-#{name}.md"
74
74
  path = space.path.join(rel)
75
- raise Error, "#{rel} already exists" if path.exist?
75
+ raise Space::Core::Error, "#{rel} already exists" if path.exist?
76
76
 
77
77
  FileUtils.mkdir_p(path.dirname)
78
78
  path.write(render_iteration(nn, name))
@@ -88,7 +88,7 @@ module SpaceArchitect
88
88
  b
89
89
  end
90
90
 
91
- git_run("-C", space.path.to_s, "add", rel, Space::METADATA_FILE)
91
+ git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
92
92
  git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: scaffold #{name}")
93
93
 
94
94
  path
@@ -114,15 +114,15 @@ module SpaceArchitect
114
114
  entry = slice_entry(iteration)
115
115
  rel = entry["file"]
116
116
  path = space.path.join(rel)
117
- raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
117
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
118
118
  unless path.read.match?(/^## Acceptance Criteria/)
119
- raise Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing"
119
+ raise Space::Core::Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing"
120
120
  end
121
121
 
122
122
  if entry["freeze_sha"]
123
123
  sha = entry["freeze_sha"]
124
124
  if frozen_region_changed?(sha, rel)
125
- raise Error,
125
+ raise Space::Core::Error,
126
126
  "Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
127
127
  "refusing to re-freeze. Restore them to their frozen state or use a new iteration."
128
128
  end
@@ -159,7 +159,7 @@ module SpaceArchitect
159
159
  def brief_new!(force: false)
160
160
  brief_path = space.path.join("architecture", "BRIEF.md")
161
161
  if brief_path.exist? && !force
162
- raise Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite"
162
+ raise Space::Core::Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite"
163
163
  end
164
164
 
165
165
  FileUtils.mkdir_p(brief_path.dirname)
@@ -176,7 +176,7 @@ module SpaceArchitect
176
176
  def write_section!(iteration, section, body:, append: false, lane: nil)
177
177
  spec = SECTIONS[section]
178
178
  unless spec
179
- raise Error,
179
+ raise Space::Core::Error,
180
180
  "Unknown section '#{section}' — one of: #{SECTIONS.keys.join(', ')}. " \
181
181
  "(Acceptance Criteria is set by `architect freeze`; Builder Report by `architect evidence`.)"
182
182
  end
@@ -184,10 +184,10 @@ module SpaceArchitect
184
184
  entry = slice_entry(iteration)
185
185
  rel = entry["file"]
186
186
  path = space.path.join(rel)
187
- raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
187
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
188
188
 
189
189
  if spec[:frozen] && entry["freeze_sha"]
190
- raise Error,
190
+ raise Space::Core::Error,
191
191
  "#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
192
192
  "frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
193
193
  end
@@ -211,13 +211,13 @@ module SpaceArchitect
211
211
  entry = slice_entry(iteration)
212
212
  rel = entry["file"]
213
213
  path = space.path.join(rel)
214
- raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
214
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
215
215
 
216
216
  id = iteration_id(entry)
217
217
  report = space.path.join("build", lane ? "#{id}-#{lane}" : id, "report.md")
218
- raise Error, "builder report not found: #{report}" unless report.exist?
218
+ raise Space::Core::Error, "builder report not found: #{report}" unless report.exist?
219
219
  raw = report.read
220
- raise Error, "builder report is empty: #{report}" if raw.strip.empty?
220
+ raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty?
221
221
 
222
222
  block = lane ? "### #{lane}\n\n#{raw.rstrip}" : raw.rstrip
223
223
  path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
@@ -240,7 +240,7 @@ module SpaceArchitect
240
240
  text =
241
241
  if ref
242
242
  out, _, st = git_capture("-C", space.path.to_s, "show", "#{ref}:#{rel}")
243
- raise Error, "could not read #{rel} at #{ref}" unless st.success?
243
+ raise Space::Core::Error, "could not read #{rel} at #{ref}" unless st.success?
244
244
  out
245
245
  else
246
246
  space.path.join(rel).read
@@ -255,27 +255,27 @@ module SpaceArchitect
255
255
  def merge_lane!(iteration, lane, message: nil)
256
256
  entry = slice_entry(iteration)
257
257
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
258
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
258
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
259
259
 
260
260
  checks = lane_mechanical_checks(entry, lane_entry)
261
261
  if checks[:no_builder_commits] == false
262
- raise Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
262
+ raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
263
263
  end
264
264
  if checks[:in_bounds] == false
265
- raise Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
265
+ raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
266
266
  end
267
267
 
268
268
  repo = lane_entry["repo"]
269
269
  repo_path = space.path.join("repos", repo)
270
270
  id = iteration_id(entry)
271
271
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
272
- raise Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
272
+ raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
273
273
  base_sha = lane_entry["base_sha"]
274
274
  lane_branch = "lane/#{id}-#{lane}"
275
275
  integration_branch = "lane/#{id}"
276
276
 
277
277
  status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
278
- raise Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
278
+ raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
279
279
 
280
280
  git_run("-C", wt_path.to_s, "add", "-A")
281
281
  git_run("-C", wt_path.to_s, "commit", "-m", message || "lane #{lane}: integrate")
@@ -291,7 +291,7 @@ module SpaceArchitect
291
291
  unless mst.success?
292
292
  conflicts, = git_capture("-C", repo_path.to_s, "diff", "--name-only", "--diff-filter=U")
293
293
  git_capture("-C", repo_path.to_s, "merge", "--abort")
294
- raise Error,
294
+ raise Space::Core::Error,
295
295
  "Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) — the lane plan was " \
296
296
  "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
297
297
  end
@@ -314,14 +314,14 @@ module SpaceArchitect
314
314
  # Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
315
315
  # first conflict (a disjointness defect). Never decides which lanes pass.
316
316
  def integrate!(iteration, lanes:, teardown: false)
317
- raise Error, "No lanes given to integrate" if lanes.nil? || lanes.empty?
317
+ raise Space::Core::Error, "No lanes given to integrate" if lanes.nil? || lanes.empty?
318
318
 
319
319
  merged = []
320
320
  lanes.each do |lane|
321
321
  merged << merge_lane!(iteration, lane)
322
- rescue Error => e
322
+ rescue Space::Core::Error => e
323
323
  done = merged.map { |m| m[:lane] }.join(", ")
324
- raise Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
324
+ raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
325
325
  end
326
326
 
327
327
  if teardown
@@ -340,26 +340,26 @@ module SpaceArchitect
340
340
  def run_gates(iteration, lane: nil)
341
341
  entry = slice_entry(iteration)
342
342
  freeze_sha = entry["freeze_sha"]
343
- raise Error, "Iteration '#{iteration}' is not frozen — freeze before running gates." unless freeze_sha
343
+ raise Space::Core::Error, "Iteration '#{iteration}' is not frozen — freeze before running gates." unless freeze_sha
344
344
  rel = entry["file"]
345
345
 
346
346
  text, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}")
347
- raise Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
347
+ raise Space::Core::Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
348
348
  commands = acceptance_criteria_commands(text)
349
- raise Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if commands.empty?
349
+ raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if commands.empty?
350
350
 
351
351
  lanes = entry["lanes"] || []
352
352
  dir =
353
353
  if lane
354
354
  le = lanes.find { |l| l["name"] == lane }
355
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
355
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
356
356
  space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
357
357
  else
358
358
  repo = lanes.first&.dig("repo")
359
- raise Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
359
+ raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
360
360
  space.path.join("repos", repo)
361
361
  end
362
- raise Error, "directory does not exist: #{dir}" unless dir.exist?
362
+ raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
363
363
 
364
364
  commands.map do |row|
365
365
  out, err, status = Open3.capture3(row[:command], chdir: dir.to_s)
@@ -369,20 +369,20 @@ module SpaceArchitect
369
369
 
370
370
  def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil)
371
371
  if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
372
- raise Error,
372
+ raise Space::Core::Error,
373
373
  "Pass --model when using --harness opencode " \
374
374
  "(#{Harness::CLAUDE_DEFAULT_MODEL} is a Claude model ID, not valid for opencode — " \
375
375
  "try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
376
376
  end
377
377
  if effort && harness.to_s != "opencode"
378
- raise Error,
378
+ raise Space::Core::Error,
379
379
  "effort is opencode-only (sets opencode reasoningEffort) — " \
380
380
  "set effort only on opencode lanes (harness: opencode)"
381
381
  end
382
382
 
383
383
  entry = slice_entry(iteration)
384
384
  repo_path = space.path.join("repos", repo)
385
- raise Error, "repos/#{repo} does not exist" unless repo_path.exist?
385
+ raise Space::Core::Error, "repos/#{repo} does not exist" unless repo_path.exist?
386
386
 
387
387
  id = iteration_id(entry)
388
388
  wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
@@ -390,7 +390,7 @@ module SpaceArchitect
390
390
 
391
391
  base_ref = base || "HEAD"
392
392
  base_sha, _, wt_status = git_capture("-C", repo_path.to_s, "rev-parse", base_ref)
393
- raise Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success?
393
+ raise Space::Core::Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success?
394
394
  base_sha = base_sha.strip
395
395
 
396
396
  branch = "lane/#{id}-#{lane}"
@@ -451,10 +451,10 @@ module SpaceArchitect
451
451
  def variant_promote(iteration, winner)
452
452
  entry = slice_entry(iteration)
453
453
  variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
454
- raise Error, "Iteration '#{iteration}' has no variant set — nothing to promote" if variant_lanes.empty?
454
+ raise Space::Core::Error, "Iteration '#{iteration}' has no variant set — nothing to promote" if variant_lanes.empty?
455
455
 
456
456
  names = variant_lanes.map { |l| l["name"] }
457
- raise Error, "Cannot promote '#{winner}' — not a variant lane of iteration '#{iteration}'" unless names.include?(winner)
457
+ raise Space::Core::Error, "Cannot promote '#{winner}' — not a variant lane of iteration '#{iteration}'" unless names.include?(winner)
458
458
  discarded_names = names - [winner]
459
459
 
460
460
  update_architect_block do |b|
@@ -477,7 +477,7 @@ module SpaceArchitect
477
477
  def variant_compare(iteration)
478
478
  entry = slice_entry(iteration)
479
479
  variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
480
- raise Error, "Iteration '#{iteration}' has no variant set — nothing to compare" if variant_lanes.empty?
480
+ raise Space::Core::Error, "Iteration '#{iteration}' has no variant set — nothing to compare" if variant_lanes.empty?
481
481
 
482
482
  winner = entry["winner"]
483
483
  {
@@ -500,7 +500,7 @@ module SpaceArchitect
500
500
  def worktree_remove(iteration, lane)
501
501
  entry = slice_entry(iteration)
502
502
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
503
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
503
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
504
504
 
505
505
  repo = lane_entry["repo"]
506
506
  repo_path = space.path.join("repos", repo)
@@ -536,36 +536,67 @@ module SpaceArchitect
536
536
  end
537
537
 
538
538
  def dispatch(iteration, lane, model: nil, max_turns: 200,
539
- claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil)
539
+ claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
540
+ push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
541
+ push_client: nil)
542
+ raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
543
+ raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
544
+ raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
545
+ if detach && (push_url || push_host)
546
+
540
547
  entry = slice_entry(iteration)
541
548
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
542
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
549
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
543
550
 
544
551
  resolved_harness = harness || lane_entry["harness"] || "claude-code"
545
552
  resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
546
553
  resolved_effort = effort || lane_entry["effort"]
547
554
 
555
+ raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
556
+ if push_host && resolved_harness != "claude-code"
557
+
548
558
  id = iteration_id(entry)
549
559
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
550
- raise Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
560
+ raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
551
561
 
552
562
  build_dir = space.path.join("build", "#{id}-#{lane}")
553
563
  prompt_path = build_dir.join("prompt.md")
554
564
  run_log_path = build_dir.join("run.jsonl")
555
565
  report_path = build_dir.join("report.md")
556
- raise Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
566
+ raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
557
567
 
558
568
  bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
559
569
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
560
570
  bin: bin, config_dir: build_dir, effort: resolved_effort)
561
571
 
562
- exit_code = harness_obj.run(
563
- prompt_path: prompt_path,
564
- run_log_path: run_log_path,
565
- chdir: wt_path
566
- )
572
+ if detach
573
+ pid = harness_obj.run_detached(
574
+ prompt_path: prompt_path,
575
+ run_log_path: run_log_path,
576
+ chdir: wt_path
577
+ )
578
+ { pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path }
579
+ else
580
+ created_run_id = nil
581
+ if push_host
582
+ creator = run_creator || RunCreator.new(push_host, push_token)
583
+ created_run_id = creator.create
584
+ push_url = "#{push_host.chomp('/')}/runs/#{created_run_id}/ingest"
585
+ end
567
586
 
568
- { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
587
+ run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path }
588
+ if resolved_harness == "claude-code"
589
+ run_kwargs[:push_url] = push_url if push_url
590
+ run_kwargs[:push_token] = push_token if push_token
591
+ run_kwargs[:push_client] = push_client if push_client
592
+ end
593
+ exit_code = harness_obj.run(**run_kwargs)
594
+
595
+ result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
596
+ result[:created_run_id] = created_run_id if created_run_id
597
+ result[:push_url] = push_url if push_url
598
+ result
599
+ end
569
600
  end
570
601
 
571
602
  private
@@ -579,7 +610,7 @@ module SpaceArchitect
579
610
  def slice_entry(iteration)
580
611
  block = space.data["architect"] || {}
581
612
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
582
- raise Error, "Iteration '#{iteration}' not recorded in space.yaml — run `architect new #{iteration}` first" unless entry
613
+ raise Space::Core::Error, "Iteration '#{iteration}' not recorded in space.yaml — run `architect new #{iteration}` first" unless entry
583
614
  entry
584
615
  end
585
616
 
@@ -637,7 +668,7 @@ module SpaceArchitect
637
668
  def replace_section_body(text, heading, new_block, append:)
638
669
  lines = text.lines
639
670
  start = lines.index { |l| l.chomp == heading }
640
- raise Error, "section heading '#{heading}' not found in iteration file" unless start
671
+ raise Space::Core::Error, "section heading '#{heading}' not found in iteration file" unless start
641
672
 
642
673
  finish = ((start + 1)...lines.length).find { |i| KNOWN_HEADINGS.include?(lines[i].chomp) } || lines.length
643
674
  body = lines[(start + 1)...finish].join
@@ -735,7 +766,7 @@ module SpaceArchitect
735
766
  out, err, status = Open3.capture3("git", *args)
736
767
  return if status.success?
737
768
  output = [out, err].map(&:strip).reject(&:empty?).join(" ")
738
- raise Error, "git #{args.join(' ')} failed: #{output}"
769
+ raise Space::Core::Error, "git #{args.join(' ')} failed: #{output}"
739
770
  end
740
771
 
741
772
  def git_capture(*args)