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/
SECTIONS =

Sections the architect writes (and the CLI commits) via ‘architect section`. Acceptance Criteria is intentionally absent — it is set by `architect freeze`, the one code path that creates the freeze commit. Builder Report has its own command (`architect evidence`) because it is transcribed verbatim from scratch. `frozen: true` sections live above the freeze boundary and are refused once frozen.

{
  "grounds" => { heading: "## Grounds", message: "grounds", frozen: true },
  "specification" => { heading: "## Specification", message: "specification", frozen: true },
  "prompt" => { heading: "## Builder Prompt", message: "dispatched", frozen: false },
  "verdict" => { heading: "## Verdict", message: "verdict", frozen: false }
}.freeze
KNOWN_HEADINGS =

The fixed top-level section headings. Section boundaries are detected against this set (not any “## ” line), so a verbatim Builder Report containing its own “## ” headings cannot fool the parser.

[
  "## Grounds", "## Specification", "## Acceptance Criteria",
  "## Builder Prompt", "## Builder Report", "## Verdict"
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(space:) ⇒ ArchitectMission

Returns a new instance of ArchitectMission.



40
41
42
# File 'lib/space_architect/architect_mission.rb', line 40

def initialize(space:)
  @space = space
end

Instance Method Details

#acceptance_criteria(iteration, ref: :freeze) ⇒ Object

Read the Acceptance Criteria section text, by default from the freeze commit (so the architect quotes the frozen gates, never a drifted working copy).



236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/space_architect/architect_mission.rb', line 236

def acceptance_criteria(iteration, ref: :freeze)
  entry = slice_entry(iteration)
  rel = entry["file"]
  ref = entry["freeze_sha"] if ref == :freeze
  text =
    if ref
      out, _, st = git_capture("-C", space.path.to_s, "show", "#{ref}:#{rel}")
      raise Error, "could not read #{rel} at #{ref}" unless st.success?
      out
    else
      space.path.join(rel).read
    end
  section_body(text, "## Acceptance Criteria")
end

#brief_new!(force: false) ⇒ Object

Scaffold the durable, section-numbered mission brief at architecture/BRIEF.md and commit it. The brief is the stable cross-iteration address space iterations cite as “BRIEF §N”; it lives outside the per-iteration freeze region.



159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/space_architect/architect_mission.rb', line 159

def brief_new!(force: false)
  brief_path = space.path.join("architecture", "BRIEF.md")
  if brief_path.exist? && !force
    raise Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite"
  end

  FileUtils.mkdir_p(brief_path.dirname)
  brief_path.write(render_brief)
  git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md")
  git_run("-C", space.path.to_s, "commit", "-m", "Add mission brief") if staged_changes?
  brief_path
end

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

Raises:



538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
# File 'lib/space_architect/architect_mission.rb', line 538

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:



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/space_architect/architect_mission.rb', line 113

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



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/space_architect/architect_mission.rb', line 44

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

#integrate!(iteration, lanes:, teardown: false) ⇒ Object

Loop merge_lane! over the architect-supplied passing set, in order. Stops on the first conflict (a disjointness defect). Never decides which lanes pass.

Raises:



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/space_architect/architect_mission.rb', line 316

def integrate!(iteration, lanes:, teardown: false)
  raise Error, "No lanes given to integrate" if lanes.nil? || lanes.empty?

  merged = []
  lanes.each do |lane|
    merged << merge_lane!(iteration, lane)
  rescue Error => e
    done = merged.map { |m| m[:lane] }.join(", ")
    raise Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
  end

  if teardown
    id = iteration_id(slice_entry(iteration))
    merged.each do |m|
      worktree_remove(iteration, m[:lane])
      git_capture("-C", space.path.join("repos", m[:repo]).to_s, "branch", "-d", "lane/#{id}-#{m[:lane]}")
    end
  end
  merged
end

#merge_lane!(iteration, lane, message: nil) ⇒ Object

Integrate ONE architect-judged-passing lane: commit the builder’s working tree on the lane branch, then merge –no-ff into the repo’s lane/<id> integration branch. Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.

Raises:



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/space_architect/architect_mission.rb', line 255

def merge_lane!(iteration, lane, message: 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

  checks = lane_mechanical_checks(entry, lane_entry)
  if checks[:no_builder_commits] == false
    raise Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
  end
  if checks[:in_bounds] == false
    raise Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
  end

  repo = lane_entry["repo"]
  repo_path = space.path.join("repos", repo)
  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?
  base_sha = lane_entry["base_sha"]
  lane_branch = "lane/#{id}-#{lane}"
  integration_branch = "lane/#{id}"

  status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
  raise Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?

  git_run("-C", wt_path.to_s, "add", "-A")
  git_run("-C", wt_path.to_s, "commit", "-m", message || "lane #{lane}: integrate")

  _o, _e, exists = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", "--quiet", integration_branch)
  if exists.success?
    git_run("-C", repo_path.to_s, "checkout", integration_branch)
  else
    git_run("-C", repo_path.to_s, "checkout", "-b", integration_branch, base_sha)
  end

  _mo, merr, mst = git_capture("-C", repo_path.to_s, "merge", "--no-ff", lane_branch, "-m", "Merge #{lane_branch}")
  unless mst.success?
    conflicts, = git_capture("-C", repo_path.to_s, "diff", "--name-only", "--diff-filter=U")
    git_capture("-C", repo_path.to_s, "merge", "--abort")
    raise Error,
      "Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) — the lane plan was " \
      "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
  end

  merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
  diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")

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

  { lane: lane, repo: repo, integration_branch: integration_branch,
    merge_sha: merge_sha.strip, base_sha: base_sha, diffstat: diffstat.strip, gates_run: false }
end

#new_iteration!(name) ⇒ Object

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

Raises:



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/space_architect/architect_mission.rb', line 64

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

#run_gates(iteration, lane: nil) ⇒ Object

Run the iteration’s frozen Acceptance Criteria gate commands and stream raw stdout/stderr + exit codes. A path-resolving RUNNER ONLY — no threshold comparison, no PASS/FAIL. The verdict is the architect reading this output.

Raises:



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
368
# File 'lib/space_architect/architect_mission.rb', line 340

def run_gates(iteration, lane: nil)
  entry = slice_entry(iteration)
  freeze_sha = entry["freeze_sha"]
  raise Error, "Iteration '#{iteration}' is not frozen — freeze before running gates." unless freeze_sha
  rel = entry["file"]

  text, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}")
  raise Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
  commands = acceptance_criteria_commands(text)
  raise Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if commands.empty?

  lanes = entry["lanes"] || []
  dir =
    if lane
      le = lanes.find { |l| l["name"] == lane }
      raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
      space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
    else
      repo = lanes.first&.dig("repo")
      raise Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
      space.path.join("repos", repo)
    end
  raise Error, "directory does not exist: #{dir}" unless dir.exist?

  commands.map do |row|
    out, err, status = Open3.capture3(row[:command], chdir: dir.to_s)
    { ac: row[:ac], command: row[:command], stdout: out, stderr: err, exit_code: status.exitstatus, dir: dir }
  end
end

#statusObject



97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/space_architect/architect_mission.rb', line 97

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

#transcribe_evidence!(iteration, lane: nil) ⇒ Object

Transcribe a lane’s scratch report (build/<id>/report.md) VERBATIM into the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.

Raises:



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/space_architect/architect_mission.rb', line 210

def transcribe_evidence!(iteration, lane: nil)
  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?

  id = iteration_id(entry)
  report = space.path.join("build", lane ? "#{id}-#{lane}" : id, "report.md")
  raise Error, "builder report not found: #{report}" unless report.exist?
  raw = report.read
  raise Error, "builder report is empty: #{report}" if raw.strip.empty?

  block = lane ? "### #{lane}\n\n#{raw.rstrip}" : raw.rstrip
  path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))

  nn = format("%02d", entry["ordinal"] || 0)
  git_run("-C", space.path.to_s, "add", rel)
  git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: evidence") if staged_changes?
  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")

  status_line = raw.lines.reverse_each.find { |l| l.strip.start_with?("STATUS:") }&.strip
  { sha: head.strip, lines: raw.lines.count, status_line: status_line, lane: lane }
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.



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/space_architect/architect_mission.rb', line 426

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:



477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# File 'lib/space_architect/architect_mission.rb', line 477

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:



451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# File 'lib/space_architect/architect_mission.rb', line 451

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



531
532
533
534
535
536
# File 'lib/space_architect/architect_mission.rb', line 531

def verify(iteration)
  entry = slice_entry(iteration)
  (entry["lanes"] || []).map do |lane|
    { lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane) }
  end
end

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

Raises:



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/space_architect/architect_mission.rb', line 370

def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: 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
      lane_entry["touch_set"] = Array(touch) if touch && !Array(touch).empty?
      lanes << lane_entry
      s["lanes"] = lanes
    end
    b
  end

  { worktree: wt_path, base_sha: base_sha }
end

#worktree_listObject



525
526
527
528
529
# File 'lib/space_architect/architect_mission.rb', line 525

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:



500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/space_architect/architect_mission.rb', line 500

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

#write_section!(iteration, section, body:, append: false, lane: nil) ⇒ Object

Write one section of the iteration file and commit it with the canonical per-section message, in one call. Refuses to write a frozen section (Grounds/Specification) once the iteration is frozen. Acceptance Criteria is NOT writable here (use freeze); Builder Report is not here (use evidence).

Raises:



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/space_architect/architect_mission.rb', line 176

def write_section!(iteration, section, body:, append: false, lane: nil)
  spec = SECTIONS[section]
  unless spec
    raise Error,
      "Unknown section '#{section}' — one of: #{SECTIONS.keys.join(', ')}. " \
      "(Acceptance Criteria is set by `architect freeze`; Builder Report by `architect evidence`.)"
  end

  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?

  if spec[:frozen] && entry["freeze_sha"]
    raise Error,
      "#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
      "frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
  end

  block = lane ? "### #{lane}\n\n#{body.strip}" : body.strip
  path.write(replace_section_body(path.read, spec[:heading], block, append: append))

  nn = format("%02d", entry["ordinal"] || 0)
  git_run("-C", space.path.to_s, "add", rel)
  committed = staged_changes?
  git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: #{spec[:message]}") if committed

  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
  diffstat, = committed ? git_capture("-C", space.path.to_s, "show", "--stat", "--format=", "HEAD") : [""]
  { section: section, heading: spec[:heading], sha: head.strip, committed: committed, diffstat: diffstat.strip }
end