Class: SpaceArchitect::ArchitectMission

Inherits:
Object
  • Object
show all
Defined in:
lib/space_architect/architect_mission.rb

Overview

Manages an architect-loop mission inside a space: one self-contained file per iteration at architecture/I<NN>-<iteration>.md (Grounds / Specification / Acceptance Criteria / Builder Prompt / Builder Report / Verdict), grown one commit per section. The freeze is the commit that establishes the Acceptance Criteria; the frozen region (everything above “## Builder Prompt”) is read-only afterward.

Constant Summary collapse

FROZEN_BOUNDARY =

The heading that separates the frozen sections (Grounds/Specification/Acceptance Criteria) from the appended-after-freeze sections (Builder Prompt/Report/Verdict).

/^## Builder Prompt/

Instance Method Summary collapse

Constructor Details

#initialize(space:) ⇒ ArchitectMission

Returns a new instance of ArchitectMission.



20
21
22
# File 'lib/space_architect/architect_mission.rb', line 20

def initialize(space:)
  @space = space
end

Instance Method Details

#dispatch(iteration, lane, model: nil, max_turns: 200, claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil) ⇒ Object

Raises:



336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/space_architect/architect_mission.rb', line 336

def dispatch(iteration, lane, model: nil, max_turns: 200,
             claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil)
  entry = slice_entry(iteration)
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
  raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry

  resolved_harness = harness || lane_entry["harness"] || "claude-code"
  resolved_model   = model   || lane_entry["model"]   || Harness::CLAUDE_DEFAULT_MODEL
  resolved_effort  = effort  || lane_entry["effort"]

  id = iteration_id(entry)
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
  raise Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?

  build_dir    = space.path.join("build", "#{id}-#{lane}")
  prompt_path  = build_dir.join("prompt.md")
  run_log_path = build_dir.join("run.jsonl")
  report_path  = build_dir.join("report.md")
  raise Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?

  bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
                                              bin: bin, config_dir: build_dir, effort: resolved_effort)

  exit_code = harness_obj.run(
    prompt_path:  prompt_path,
    run_log_path: run_log_path,
    chdir:        wt_path
  )

  { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
end

#freeze!(iteration) ⇒ Object

Freeze the iteration: the iteration file must carry a “## Acceptance Criteria” section. Commits any pending changes to the iteration file and records HEAD as freeze_sha. If already frozen, refuses when the frozen region has changed since.

Raises:



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/space_architect/architect_mission.rb', line 93

def freeze!(iteration)
  entry = slice_entry(iteration)
  rel = entry["file"]
  path = space.path.join(rel)
  raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
  unless path.read.match?(/^## Acceptance Criteria/)
    raise Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing"
  end

  if entry["freeze_sha"]
    sha = entry["freeze_sha"]
    if frozen_region_changed?(sha, rel)
      raise Error,
        "Frozen sections of #{rel} changed since freeze #{sha[0, 8]}" \
        "refusing to re-freeze. Restore them to their frozen state or use a new iteration."
    end
    return sha
  end

  files = [rel]
  files << "architecture/ARCHITECT.md" if space.path.join("architecture", "ARCHITECT.md").exist?
  git_run("-C", space.path.to_s, "add", *files)
  if staged_changes?
    nn = format("%02d", entry["ordinal"] || 0)
    git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: acceptance criteria (freeze)")
  end

  sha, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
  sha = sha.strip

  update_architect_block do |b|
    b["current_iteration"] = iteration
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      s["freeze_sha"] = sha
      s["verdict"] ||= "pending"
    end
    b
  end

  sha
end

#init!Object



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/space_architect/architect_mission.rb', line 24

def init!
  handoff_path = space.path.join("architecture", "ARCHITECT.md")
  if handoff_path.exist?
    raise Error, "architecture/ARCHITECT.md already exists — remove it first or edit it directly (idempotent guard)"
  end

  FileUtils.mkdir_p(handoff_path.dirname)
  handoff_path.write(render_handoff)

  update_architect_block do |b|
    b.merge("status" => "active", "current_iteration" => nil, "iterations" => [])
  end

  git_run("-C", space.path.to_s, "add", "architecture/ARCHITECT.md", Space::METADATA_FILE)
  git_run("-C", space.path.to_s, "commit", "-m", "Initialize architect mission")

  handoff_path
end

#new_iteration!(name) ⇒ Object

Allocate the next ordinal and scaffold architecture/I<NN>-<iteration>.md.

Raises:



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/space_architect/architect_mission.rb', line 44

def new_iteration!(name)
  block = space.data["architect"] || {}
  iterations = block["iterations"] || []
  if iterations.any? { |s| s["name"] == name }
    raise Error, "iteration '#{name}' already exists in space.yaml"
  end

  ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
  nn = format("%02d", ordinal)
  rel = "architecture/I#{nn}-#{name}.md"
  path = space.path.join(rel)
  raise Error, "#{rel} already exists" if path.exist?

  FileUtils.mkdir_p(path.dirname)
  path.write(render_iteration(nn, name))

  update_architect_block do |b|
    b["current_iteration"] = name
    list = b["iterations"] || []
    list << {
      "name" => name, "ordinal" => ordinal, "file" => rel,
      "freeze_sha" => nil, "verdict" => "pending", "lanes" => []
    }
    b["iterations"] = list
    b
  end

  git_run("-C", space.path.to_s, "add", rel, Space::METADATA_FILE)
  git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: scaffold #{name}")

  path
end

#statusObject



77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/space_architect/architect_mission.rb', line 77

def status
  block = space.data["architect"] || {}
  architecture_dir = space.path.join("architecture")
  iteration_files = if architecture_dir.exist?
    architecture_dir.children
      .select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
      .map { |f| f.basename.to_s }.sort
  else
    []
  end
  { block: block, iteration_files: iteration_files }
end

#variant_add(repo, iteration, pairs, base: nil, prompt: nil) ⇒ Object

Declare a variant set for an iteration: one competing lane per (harness, model) pair, all sharing a byte-identical prompt. Returns descriptors for each created variant.



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/space_architect/architect_mission.rb', line 191

def variant_add(repo, iteration, pairs, base: nil, prompt: nil)
  prompt_bytes = prompt ? File.binread(prompt) : nil
  entry = slice_entry(iteration)
  id = iteration_id(entry)
  existing_count = (entry["lanes"] || []).count { |l| l["name"].match?(/\Av\d+\z/) }

  pairs.each_with_index.map do |(harness, model), i|
    v_name = "v#{format('%02d', existing_count + i + 1)}"
    result = worktree_add(repo, iteration, v_name, base: base,
                          harness: harness, model: model, variant: true)

    if prompt_bytes
      build_dir = space.path.join("build", "#{id}-#{v_name}")
      File.open(build_dir.join("prompt.md"), "wb") { |f| f.write(prompt_bytes) }
    end

    { name: v_name, repo: repo, harness: harness, model: model,
      worktree: result[:worktree], base_sha: result[:base_sha] }
  end
end

#variant_compare(iteration) ⇒ Object

Read-only side-by-side view of an iteration’s variant set, reading ONLY the durable records in space.yaml. Returns a structured hash; the CLI renders it.

Raises:



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/space_architect/architect_mission.rb', line 242

def variant_compare(iteration)
  entry = slice_entry(iteration)
  variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
  raise Error, "Iteration '#{iteration}' has no variant set — nothing to compare" if variant_lanes.empty?

  winner = entry["winner"]
  {
    winner:     winner,
    freeze_sha: entry["freeze_sha"],
    variants: variant_lanes.map do |l|
      {
        name:               l["name"],
        harness:            l["harness"] || "claude-code",
        model:              l["model"],
        effort:             l["effort"],
        base_sha:           l["base_sha"],
        integration_branch: l["integration_branch"],
        status:             winner.nil? ? "pending" : (l["name"] == winner ? "winner" : "discarded")
      }
    end
  }
end

#variant_promote(iteration, winner) ⇒ Object

Promote one variant of an iteration’s variant set as the winner: records the decision durably onto the iteration entry (additive — no existing keys are removed or renamed). Re-promotable: a second call reassigns “winner” and recomputes every variant lane’s “discarded” flag.

Raises:



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/space_architect/architect_mission.rb', line 216

def variant_promote(iteration, winner)
  entry = slice_entry(iteration)
  variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
  raise Error, "Iteration '#{iteration}' has no variant set — nothing to promote" if variant_lanes.empty?

  names = variant_lanes.map { |l| l["name"] }
  raise Error, "Cannot promote '#{winner}' — not a variant lane of iteration '#{iteration}'" unless names.include?(winner)
  discarded_names = names - [winner]

  update_architect_block do |b|
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      s["winner"] = winner
      (s["lanes"] || []).each do |l|
        next unless l["variant"]
        l["discarded"] = (l["name"] != winner)
      end
    end
    b
  end

  { winner: winner, discarded: discarded_names }
end

#verify(iteration) ⇒ Object



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/space_architect/architect_mission.rb', line 296

def verify(iteration)
  entry = slice_entry(iteration)
  freeze_sha = entry["freeze_sha"]
  rel = entry["file"]
  lanes = entry["lanes"] || []

  lanes.map do |lane|
    lane_name = lane["name"]
    base_sha = lane["base_sha"]
    wt_path = space.path.join(lane["worktree"] || "build/#{iteration_id(entry)}-#{lane_name}/wt")
    touch_set = lane["touch_set"] || []

    checks = {}

    # (a) frozen sections of the iteration file untouched since freeze
    checks[:frozen_untouched] = if freeze_sha && rel
      !frozen_region_changed?(freeze_sha, rel)
    end

    # (b) no builder commits in the worktree
    log_out, = git_capture("-C", wt_path.to_s, "log", "#{base_sha}..")
    checks[:no_builder_commits] = log_out.strip.empty?

    # (c) builder's scratch report exists and is non-empty
    report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
    checks[:report_exists] = report.exist? && !report.read.strip.empty?

    # (d) in-bounds: changed paths ⊆ touch_set (nil if no touch_set recorded)
    checks[:in_bounds] = if touch_set.empty?
      nil
    else
      status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
      changed = status_out.lines.map { |l| l[3..].strip }
      changed.all? { |f| touch_set.any? { |g| File.fnmatch(g, f) } }
    end

    { lane: lane_name, repo: lane["repo"], checks: checks }
  end
end

#worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil) ⇒ Object

Raises:



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/space_architect/architect_mission.rb', line 136

def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil)
  if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
    raise Error,
      "Pass --model when using --harness opencode " \
      "(#{Harness::CLAUDE_DEFAULT_MODEL} is a Claude model ID, not valid for opencode — " \
      "try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
  end
  if effort && harness.to_s != "opencode"
    raise Error,
      "effort is opencode-only (sets opencode reasoningEffort) — " \
      "set effort only on opencode lanes (harness: opencode)"
  end

  entry = slice_entry(iteration)
  repo_path = space.path.join("repos", repo)
  raise Error, "repos/#{repo} does not exist" unless repo_path.exist?

  id = iteration_id(entry)
  wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
  FileUtils.mkdir_p(wt_path.dirname)

  base_ref = base || "HEAD"
  base_sha, _, wt_status = git_capture("-C", repo_path.to_s, "rev-parse", base_ref)
  raise Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success?
  base_sha = base_sha.strip

  branch = "lane/#{id}-#{lane}"
  git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)

  update_architect_block do |b|
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      lanes = s["lanes"] || []
      lane_entry = {
        "name" => lane,
        "repo" => repo,
        "base_sha" => base_sha,
        "worktree" => "build/#{id}-#{lane}/wt",
        "integration_branch" => nil,
        "harness" => harness.to_s,
        "model" => model,
        "variant" => variant
      }
      lane_entry["effort"] = effort if effort
      lanes << lane_entry
      s["lanes"] = lanes
    end
    b
  end

  { worktree: wt_path, base_sha: base_sha }
end

#worktree_listObject



290
291
292
293
294
# File 'lib/space_architect/architect_mission.rb', line 290

def worktree_list
  wt_base = space.path.join("build")
  return [] unless wt_base.exist?
  wt_base.children.select(&:directory?).map { |p| p.basename.to_s }.sort
end

#worktree_remove(iteration, lane) ⇒ Object

Raises:



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/space_architect/architect_mission.rb', line 265

def worktree_remove(iteration, lane)
  entry = slice_entry(iteration)
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
  raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry

  repo = lane_entry["repo"]
  repo_path = space.path.join("repos", repo)
  wt_path = if lane_entry["worktree"]
    space.path.join(lane_entry["worktree"])
  else
    space.path.join("build", "#{iteration_id(entry)}-#{lane}", "wt")
  end

  git_run("-C", repo_path.to_s, "worktree", "remove", "--force", wt_path.to_s)
  git_run("-C", repo_path.to_s, "worktree", "prune")

  update_architect_block do |b|
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      (s["lanes"] || []).each { |l| l["worktree"] = nil if l["name"] == lane }
    end
    b
  end
end