Class: Space::Architect::ArchitectProject

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

Overview

Manages an architect-loop project inside a space: one self-contained file per iteration at architecture/I-.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. 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", prefix: "grounds", frozen: true },
  "specification" => { heading: "## Specification", message: "specification", prefix: "spec", frozen: true },
  "acceptance-criteria" => { heading: "## Acceptance Criteria", message: "acceptance criteria", prefix: "ac", frozen: true },
  "prompt" => { heading: "## Builder Prompt", message: "dispatched", prefix: "prompt", frozen: false },
  "verdict" => { heading: "## Verdict", message: "verdict", prefix: "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
DEFAULT_GATE_TIMEOUT =

Hard per-gate timeout. Generous relative to the full suite (~55s).

900
AC1_PLACEHOLDER =

The scaffold's untouched placeholder AC1 line — templates/iteration.md.erb's Acceptance Criteria section (named, not line-numbered: a pinned line number here has already drifted twice). Pinned by contract with the authoring lane, which is forbidden from changing it, precisely so freeze!'s #73 hard-refuse (below) can key on it.

"**AC1.** ..."
BROKEN_STDERR_PATTERN =

Rehearsal's BROKEN heuristic (I09/AC5): a command-not-found exit code or a shell parse failure, distinct from a clean non-zero (RED — the gate discriminates). Advisory, not authoritative — see #classify_rehearsal.

/\bsyntax error\b|unexpected end of file|unexpected eof/i
BARE_REPO_PREFIX =

I09/AC9(b): a gate cmd that carries a literal 'repos//' prefix with no cwd — legal, occasionally correct, but usually a leftover space-root-relative path since cmd already resolves against the repo tree.

%r{(?<![\w./-])repos/[^/\s'"]+/}
PROMPT_STUB =

Legacy sentinel: worktree_add used to seed prompt.md with this placeholder (dropped — the blind-overwrite tripped harness read-before-write guards, #48). dispatch still refuses to launch on this content, so stubs in old spaces can't reach a builder.

"<!-- ARCHITECT: write this lane's builder prompt here, then dispatch. -->"
SETTINGS_JSON_TEMPLATE =

Inlined settings.json template for architect init. Registers a SessionStart hook on the three explicit session-start events (startup/clear/resume) so every space gets auto-regrounding. compact is intentionally omitted — reground on explicit session events, not every compaction cycle.

<<~JSON
  {
    "hooks": {
      "SessionStart": [
        {
          "matcher": "startup",
          "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
        },
        {
          "matcher": "clear",
          "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
        },
        {
          "matcher": "resume",
          "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
        }
      ]
    }
  }
JSON

Instance Method Summary collapse

Constructor Details

#initialize(space:) ⇒ ArchitectProject

Returns a new instance of ArchitectProject.



95
96
97
# File 'lib/space_architect/architect_project.rb', line 95

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).



387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/space_architect/architect_project.rb', line 387

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 Space::Core::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, content: nil, message: nil) ⇒ Object

Scaffold the durable, section-numbered project 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. With content, writes the authored brief instead of the placeholder template.



262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/space_architect/architect_project.rb', line 262

def brief_new!(force: false, content: nil, message: nil)
  brief_path = space.path.join("architecture", "BRIEF.md")
  if brief_path.exist? && !force
    raise Space::Core::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(content || render_brief)
  git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md")
  if staged_changes?
    git_run("-C", space.path.to_s, "commit", "-m", compose_message("brief:", "Add project brief", message))
  end
  brief_path
end

#dispatch(iteration, lane, model: nil, max_turns: 200, claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, force: false, quiet: false, detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil, push_client: nil, timeout: nil, prompt: nil, now: Time.now) ⇒ Object

Raises:



913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
# File 'lib/space_architect/architect_project.rb', line 913

def dispatch(iteration, lane, model: nil, max_turns: 200,
             claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, force: false, quiet: false,
             detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
             push_client: nil, timeout: nil, prompt: nil, now: Time.now)
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
  raise Space::Core::Error, "--push-host requires --push-token"           if push_host && !push_token
  raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
    if detach && (push_url || push_host)

  err = quiet ? File.open(File::NULL, "w") : $stderr

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

  resolved_harness, resolved_model, resolved_effort =
    resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)

  raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
    if push_host && resolved_harness != "claude-code"

  id = iteration_id(entry)
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
  raise Space::Core::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")

  # --prompt: the caller authors the lane prompt anywhere (a fresh scratch file)
  # and the CLI owns the canonical copy — byte-for-byte, like variant_add.
  copy_and_validate_prompt!(prompt, prompt_path)

  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, force: force, err: err)

  # Stamp launch time and the resolved harness/model/effort onto the lane entry:
  # after every preflight validation has passed (a dispatch that raises above
  # records nothing) and before the blocking run or a detached dispatch returns.
  # A re-dispatch overwrites the prior values, so `architect status` always reads
  # what actually ran on the last dispatch.
  update_architect_block do |b|
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      (s["lanes"] || []).each do |l|
        next unless l["name"] == lane
        l["dispatched_at"] = now.iso8601
        l["harness"]       = resolved_harness
        l["model"]         = resolved_model
        l["effort"]        = resolved_effort if resolved_effort
      end
    end
    b
  end

  if detach
    pid = harness_obj.run_detached(
      prompt_path:  prompt_path,
      run_log_path: run_log_path,
      chdir:        wt_path
    )
    result = { pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path }
    result[:prompt_copied] = prompt_path if prompt
    result
  else
    created_run_id = nil
    if push_host
      creator        = run_creator || RunCreator.new(push_host, push_token)
      created_run_id = creator.create
      push_url       = "#{push_host.chomp('/')}/runs/#{created_run_id}/ingest"
    end

    run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path }
    run_kwargs[:timeout] = timeout if timeout
    if resolved_harness == "claude-code"
      run_kwargs[:push_url]    = push_url    if push_url
      run_kwargs[:push_token]  = push_token  if push_token
      run_kwargs[:push_client] = push_client if push_client
      run_kwargs[:err]         = err         if quiet
    end
    exit_code = harness_obj.run(**run_kwargs)

    result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
    result[:prompt_copied]  = prompt_path    if prompt
    result[:timed_out]      = true           if exit_code == Harness::ClaudeCodeHarness::TIMEOUT_EXIT_CODE
    result[:created_run_id] = created_run_id if created_run_id
    result[:push_url]       = push_url       if push_url
    result
  end
end

#dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil, max_turns: 200, effort: nil, force: false, quiet: false, job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now) ⇒ Object

Submit the lane's builder run as a job to the space-server's queue instead of running it locally: the sandboxed executor mounts the lane worktree + the repo checkout at their identical host absolute paths (so the worktree's gitdir pointer resolves) and runs the harness itself — no local run.jsonl, the transcript lives server-side. jobs_client: is the injectable seam (mirrors dispatch's run_creator:).

Raises:



1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
# File 'lib/space_architect/architect_project.rb', line 1013

def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
                    max_turns: 200, effort: nil, force: false, quiet: false,
                    job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now)
  err = quiet ? File.open(File::NULL, "w") : $stderr

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

  resolved_harness, resolved_model, resolved_effort =
    resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
  raise Space::Core::Error, "--as-job only supports the claude-code harness (lane '#{lane}' resolves to '#{resolved_harness}')" \
    unless resolved_harness == "claude-code"
  raise Space::Core::Error, "--job-model is required with --as-job" unless job_model

  id = iteration_id(entry)
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
  raise Space::Core::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")
  copy_and_validate_prompt!(prompt, prompt_path)

  repo_path   = space.path.join("repos", lane_entry["repo"])
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
                                              effort: resolved_effort, force: force, err: err)

  spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir,
    repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url,
    job_model: job_model, api_key_ref: api_key_ref, harness_args: harness_obj.builder_args)

  client = jobs_client || JobsClient.new(host, token)
  job_id = client.create(spec)

  # Dispatch bookkeeping, mirroring the local path's dispatched_at stamp: a
  # re-dispatch overwrites the prior job_id, so `architect status` always
  # reflects the last dispatch regardless of mode.
  update_architect_block do |b|
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      (s["lanes"] || []).each do |l|
        next unless l["name"] == lane
        l["dispatched_at"] = now.iso8601
        l["job_id"]        = job_id
      end
    end
    b
  end

  result = { job_id: job_id, spec: spec }
  result[:prompt_copied] = prompt_path if prompt
  result
end

#freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil) ⇒ 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. With force: true, re-freezes a changed frozen region if no lane is dispatched yet.

Raises:



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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/space_architect/architect_project.rb', line 182

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

  lint_gates!(text, warnings: warnings)
  lint_lanes!(text)

  if untouched_ac_placeholder?(text)
    raise Space::Core::Error,
      "#{rel}'s Acceptance Criteria still carries the scaffold placeholder '#{AC1_PLACEHOLDER}' with no " \
      "active gate — write the real Acceptance Criteria (a hand-authored prose-only AC without the " \
      "placeholder still freezes) before freezing."
  end

  if skip_rehearse_reason
    raise Space::Core::Error, "--skip-rehearse requires a non-empty REASON" if skip_rehearse_reason.to_s.strip.empty?
  else
    ensure_rehearsed!(iteration, entry, text)
  end

  if entry["freeze_sha"]
    sha = entry["freeze_sha"]
    if frozen_region_changed?(sha, rel)
      if force
        dispatched_guard!(entry)
        # fall through to commit path to re-freeze with new sha
      else
        raise Space::Core::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
    else
      return sha
    end
  end

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

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

  declared = parse_lanes(text)
  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"
      s["rehearsal_skip_reason"] = skip_rehearse_reason.strip if skip_rehearse_reason
      lanes = s["lanes"] || []
      declared.each do |d|
        fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
        existing = lanes.find { |l| l["name"] == d["name"] }
        existing ? existing.merge!(fields) : lanes << fields
      end
      s["lanes"] = lanes
    end
    b
  end

  git_run("-C", space.path.to_s, "commit", "-m",
    compose_message("I#{nn} freeze:", "I#{nn}: record freeze sha", message), "--", Space::Core::Space::METADATA_FILE)

  sha
end

#ground(session_cwd: nil) ⇒ Object

Emit grounding reads for the architect's SessionStart hook.

Prints to stdout (via the caller), in order:

1. architecture/ARCHITECT.md — always, if present
2. architecture/BRIEF.md    — if present
3. In-flight iteration file — resolved as:
   a) space.data["project"]["current_iteration"] entry's file, if it exists on disk
   b) highest-ordinal architecture/I<NN>-*.md otherwise
   c) nothing if neither

WORKTREE GUARD (load-bearing, §1): when session_cwd is inside a builder worktree (/build//wt/**), returns "" and the caller emits nothing. Builders never receive architect grounding.

session_cwd defaults to Dir.pwd; callers may inject a path for testing or to pass the value received from the hook's stdin JSON "...".



631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# File 'lib/space_architect/architect_project.rb', line 631

def ground(session_cwd: nil)
  cwd = File.expand_path(session_cwd || Dir.pwd)
  build_root = space.path.join("build").to_s
  if cwd.start_with?("#{build_root}/") && cwd.match?(%r{/build/[^/]+/wt(/|\z)})
    return ""
  end

  parts = []

  architect_path = space.path.join("architecture", "ARCHITECT.md")
  parts << "=== architecture/ARCHITECT.md ===\n\n#{architect_path.read}" if architect_path.exist?

  brief_path = space.path.join("architecture", "BRIEF.md")
  parts << "=== architecture/BRIEF.md ===\n\n#{brief_path.read}" if brief_path.exist?

  iter_path = resolve_inflight_iteration
  if iter_path
    rel = iter_path.relative_path_from(space.path).to_s
    parts << "=== #{rel} ===\n\n#{iter_path.read}"
  end

  space.repos.each do |repo|
    name      = repo["name"]
    repo_path = space.path.join("repos", name).to_s
    next unless Dir.exist?(repo_path)

    branch_out, _, branch_st = git_capture("-C", repo_path, "symbolic-ref", "--short", "HEAD")
    next unless branch_st.success?
    branch = branch_out.strip

    git_capture("-C", repo_path, "fetch", "origin")

    count_out, _, count_st = git_capture("-C", repo_path, "rev-list", "--left-right", "--count",
      "#{branch}...origin/#{branch}")
    next unless count_st.success?

    behind = count_out.strip.split[1].to_i
    if behind > 0
      parts << "WARNING: repos/#{name} local #{branch} is #{behind} commits behind " \
        "origin/#{branch} — run `architect sync #{name}`"
    end
  rescue
    # tolerate fetch or comparison failures silently
  end

  parts.join("\n")
end

#init!(message: nil) ⇒ Object



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
# File 'lib/space_architect/architect_project.rb', line 99

def init!(message: nil)
  handoff_path = space.path.join("architecture", "ARCHITECT.md")
  settings_path = space.path.join(".claude", "settings.json")
  to_add = []

  unless handoff_path.exist?
    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
    to_add << "architecture/ARCHITECT.md"
    to_add << Space::Core::Space::METADATA_FILE
  end

  unless settings_path.exist?
    FileUtils.mkdir_p(settings_path.dirname)
    settings_path.write(SETTINGS_JSON_TEMPLATE)
    to_add << ".claude/settings.json"
  end

  if to_add.any?
    git_run("-C", space.path.to_s, "add", *to_add)
    default = to_add.include?("architecture/ARCHITECT.md") ? "Initialize architect project" : "Add architect settings"
    git_run("-C", space.path.to_s, "commit", "-m", compose_message("init:", default, message))
  end

  handoff_path
end

#integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil) ⇒ 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. With no lanes and teardown: true, tears down every lane recorded for the iteration instead (the second, teardown-only call in the loop's integrate-then-teardown rhythm).

merge_lane!/teardown_lanes! only save integration_branch/integrate_sha/worktree to space.yaml on disk (update_architect_block never commits) — one commit per call, here, by pathspec, so which lanes merged survives independently of whether a Verdict follows (I13/A3). Always attempted, success or raise, so a conflict that stops the loop midway doesn't lose the lanes already merged.



513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
# File 'lib/space_architect/architect_project.rb', line 513

def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
  lanes = Array(lanes)
  return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
  raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?

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

  teardown_lanes!(iteration, merged) if teardown
  merged
ensure
  (iteration, message: message)
end

#merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: 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/ 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.

accept_bounds_reason overrides ONLY the in-bounds check — never no_builder_commits, which stays an unconditional refusal (a builder commit is tampering, not an authoring defect the architect can rule on). Modeled on freeze!'s --skip-rehearse: a non-empty REASON is required whenever passed, recorded in space.yaml beside the lane, and returned for the caller to echo — the override can never be silent. Recorded only for THIS lane, and only when its in-bounds check actually failed — integrate! passes the same reason to every lane in the set, but a lane that was already in bounds gets neither the record nor the echo.

Raises:



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# File 'lib/space_architect/architect_project.rb', line 415

def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
  if accept_bounds_reason
    raise Space::Core::Error, "--accept-bounds requires a non-empty REASON" if accept_bounds_reason.to_s.strip.empty?
  end

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

  checks = lane_mechanical_checks(entry, lane_entry, commit_mode: commit_mode)
  if checks[:no_builder_commits] == false
    raise Space::Core::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 && !accept_bounds_reason
    raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset " \
      "and re-dispatch, or `architect integrate #{iteration} --lanes #{lane} --accept-bounds REASON` to override when " \
      "the touch-set declaration itself is the defect."
  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 Space::Core::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 = into || project_integration_branch

  status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
  raise Space::Core::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",
    compose_message("lane #{lane}:", "lane #{lane}: integrate", message))
  integrate_sha_raw, = git_capture("-C", wt_path.to_s, "rev-parse", "HEAD")
  integrate_sha = integrate_sha_raw.strip

  _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")
    conflict_files = conflicts.split
    lane_touch_set = lane_entry["touch_set"] || []
    outside = conflict_files.reject { |f| in_touch_set?(f, lane_touch_set) }
    if !lane_touch_set.empty? && outside.empty?
      raise Space::Core::Error,
        "Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — the lane plan was " \
        "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
    else
      raise Space::Core::Error,
        "Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — conflicting files " \
        "are outside the lane's touch set; this looks like a branch mismatch: the lane is being merged " \
        "into '#{integration_branch}'. Use --into <branch> to target the correct branch. #{merr.strip}"
    end
  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")

  bounds_override_reason = accept_bounds_reason.strip if accept_bounds_reason && checks[:in_bounds] == false

  update_architect_block do |b|
    b["integration_branch"] = integration_branch
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      (s["lanes"] || []).each do |l|
        next unless l["name"] == lane
        l["integration_branch"] = integration_branch
        l["integrate_sha"] = integrate_sha
        l["bounds_override_reason"] = bounds_override_reason if bounds_override_reason
      end
    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,
    bounds_override_reason: bounds_override_reason }
end

#new_iteration!(name, message: nil) ⇒ Object

Allocate the next ordinal and scaffold architecture/I-.md.

Raises:



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
155
156
157
158
159
160
161
162
# File 'lib/space_architect/architect_project.rb', line 130

def new_iteration!(name, message: nil)
  block = space.data["project"] || {}
  iterations = block["iterations"] || []
  if iterations.any? { |s| s["name"] == name }
    raise Space::Core::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 Space::Core::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::Core::Space::METADATA_FILE)
  git_run("-C", space.path.to_s, "commit", "-m",
    compose_message("I#{nn} scaffold:", "I#{nn}: scaffold #{name}", message))

  path
end

#provision(iteration, base: nil, lane: nil, force: false) ⇒ Object

Materialize the iteration's declared lanes: for each lane (or the one named via lane:), create its worktree + lane/- branch from the resolved base and record worktree/base_sha/integration_branch. Idempotent — an already-materialized lane is skipped, not re-created. Refuses until the iteration is frozen, because declarations are not authoritative until then.

Raises:



881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
# File 'lib/space_architect/architect_project.rb', line 881

def provision(iteration, base: nil, lane: nil, force: false)
  entry = slice_entry(iteration)
  raise Space::Core::Error,
    "Iteration '#{iteration}' is not frozen — freeze before provisioning (declarations are not authoritative until frozen)." \
    unless entry["freeze_sha"]

  lanes = entry["lanes"] || []
  lanes = lanes.select { |l| l["name"] == lane } if lane
  raise Space::Core::Error, "No lane '#{lane}' declared for iteration '#{iteration}'" if lane && lanes.empty?

  lanes.map do |l|
    name = l["name"]
    repo_path = space.path.join("repos", l["repo"])
    wt_path = space.path.join(l["worktree"] || "build/#{iteration_id(entry)}-#{name}/wt")
    if wt_path.exist? && worktree_registered?(repo_path, wt_path)
      { lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
    else
      result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
                            force: force, **recorded_lane_fields(l))
      { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
    end
  end
end

#record_verdict!(iteration, decision:, body:, message: nil) ⇒ Object

Write the ## Verdict prose AND record the decision to space.yaml in one commit. decision must be "continue" or "kill".

Raises:



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/space_architect/architect_project.rb', line 323

def record_verdict!(iteration, decision:, body:, message: nil)
  unless %w[continue kill].include?(decision)
    raise Space::Core::Error,
      "Invalid verdict decision '#{decision}' — must be one of: continue, kill"
  end

  entry = slice_entry(iteration)
  rel = entry["file"]
  path = space.path.join(rel)
  raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?

  path.write(replace_section_body(path.read, SECTIONS["verdict"][:heading], body.strip, append: false))

  update_architect_block do |b|
    (b["iterations"] || []).each { |s| s["verdict"] = decision if s["name"] == iteration }
    b
  end

  nn = format("%02d", entry["ordinal"] || 0)
  git_run("-C", space.path.to_s, "commit", "-m",
    compose_message("I#{nn} verdict:", "I#{nn}: verdict", message), "--", rel, Space::Core::Space::METADATA_FILE)

  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
  { decision: decision, sha: head.strip }
end

#rehearse(iteration, now: Time.now) ⇒ Object

Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the freeze, while they can still be fixed — through the identical execution path #run_gates uses at judge time (#execute_gates). space.yaml records no lanes until freeze! writes them (#freeze!, :~209), so the run directory is resolved from the DRAFTED lanes block instead of the recorded one; rehearsal always runs in the repo checkout (repos/), never a lane worktree, because lane worktrees are not provisioned until after the freeze. Classifies each result RED/GREEN/BROKEN (I09/AC5) and stamps the iteration as rehearsed, keyed to the gates block's content (I09/AC7) — the stamp records that the architect looked, never that gates passed.

Raises:



582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/space_architect/architect_project.rb', line 582

def rehearse(iteration, now: Time.now)
  entry = slice_entry(iteration)
  rel = entry["file"]
  path = space.path.join(rel)
  raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
  text = path.read

  gates = parse_gates(text)
  repo, base_dir, gate_results, scope_report =
    if gates.empty?
      [nil, nil, [], nil]
    else
      r   = resolve_rehearsal_repo(iteration, text)
      dir = space.path.join("repos", r)
      raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
      results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil)
                .map { |g| g.merge(rehearsal: classify_rehearsal(g)) }
      [r, dir, results, scope_asymmetry_report(gates, text, dir)]
    end

  digest = gates_digest(text)
  update_architect_block do |b|
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      s["rehearsal"] = { "gates_digest" => digest, "at" => now.iso8601 }
    end
    b
  end

  { iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results,
    empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report }
end

#run_gates(iteration, lane: nil) ⇒ Object

Run the iteration's frozen Acceptance Criteria gate commands. Each gate is executed in the resolved cwd (per-gate cwd overrides the base dir), under a hard timeout, and evaluated against its expect block. Returns an array of result hashes with :status (:pass/:fail) and :reason in addition to the raw :stdout/:stderr/:exit_code. The mechanical verdict belongs here; the AC verdict remains the architect's. WHERE the gate text comes from (the frozen commit) is resolved here; HOW gates execute is #execute_gates, shared byte-for-byte with #rehearse so the two can never run gates through different instruments (I09/AC3).

Raises:



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
570
# File 'lib/space_architect/architect_project.rb', line 542

def run_gates(iteration, lane: nil)
  entry = slice_entry(iteration)
  freeze_sha = entry["freeze_sha"]
  raise Space::Core::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 Space::Core::Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
  gates = parse_gates(text)
  raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if gates.empty?

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

  execute_gates(gates, base_dir: base_dir, lane: lane, repo_root: repo_root)
end

#statusObject



164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/space_architect/architect_project.rb', line 164

def status
  block = space.data["project"] || {}
  architecture_dir = space.path.join("architecture")
  iteration_files = if architecture_dir.exist?
    # paths:exempt - the /\AI\d+-.+\.md\z/ filter structurally cannot match a dotfile-prefixed name, so raw enumeration is already dotfile-safe here
    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

#sync_repos(repo_name: nil) ⇒ Object

Sync tracked repo clones with their remotes (fast-forward only). Returns an array of result hashes: { repo:, status:, message: }. With no repo_name, syncs every tracked repo; with a name, syncs only that one.



682
683
684
685
686
687
688
689
# File 'lib/space_architect/architect_project.rb', line 682

def sync_repos(repo_name: nil)
  repos = space.repos
  if repo_name
    repos = repos.select { |r| r["name"] == repo_name }
    raise Space::Core::Error, "repo '#{repo_name}' not tracked in this space" if repos.empty?
  end
  repos.map { |r| sync_one_repo(r["name"]) }
end

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

Transcribe a lane's scratch report (build/[-]/report.md) VERBATIM into the Builder Report section and commit. Byte-for-byte: no summarization, no judgment. Re-transcribing a lane replaces its existing "### " subsection in place (preserving the order lanes were already transcribed in) instead of appending a duplicate; a lane not yet present still appends.

Raises:



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/space_architect/architect_project.rb', line 354

def transcribe_evidence!(iteration, lane: nil, message: nil)
  entry = slice_entry(iteration)
  rel = entry["file"]
  path = space.path.join(rel)
  raise Space::Core::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 Space::Core::Error, "builder report not found: #{report}" unless report.exist?
  raw = report.read
  raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty?

  text = path.read
  new_body =
    if lane
      lane_names = (entry["lanes"] || []).map { |l| l["name"] }
      replace_lane_report(section_body(text, "## Builder Report").to_s, lane, raw.rstrip, lane_names)
    else
      raw.rstrip
    end
  path.write(replace_section_body(text, "## Builder Report", new_body, append: false))

  nn = format("%02d", entry["ordinal"] || 0)
  git_capture("-C", space.path.to_s, "commit", "-m",
    compose_message("I#{nn} evidence:", "I#{nn}: evidence", message), "--", rel)
  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.



771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
# File 'lib/space_architect/architect_project.rb', line 771

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:



822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
# File 'lib/space_architect/architect_project.rb', line 822

def variant_compare(iteration)
  entry = slice_entry(iteration)
  variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
  raise Space::Core::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:



796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
# File 'lib/space_architect/architect_project.rb', line 796

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

  names = variant_lanes.map { |l| l["name"] }
  raise Space::Core::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, commit_mode: nil) ⇒ Object



905
906
907
908
909
910
911
# File 'lib/space_architect/architect_project.rb', line 905

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

#worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false, effort: nil, touch: nil, force: false, err: $stderr) ⇒ Object

Raises:



691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
# File 'lib/space_architect/architect_project.rb', line 691

def worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false,
                 effort: nil, touch: nil, force: false, err: $stderr)
  model, suffix_level = Harness.parse_model_suffix(model)
  harness, model = resolve_harness_model(harness, model)
  resolved_level = effort || suffix_level || project_defaults["effort"]
  Harness.validate_thinking_level!(resolved_level)
  if effort.nil? && suffix_level
    err.puts "thinking: model suffix ':#{suffix_level}' parsed from lane model → level=#{suffix_level} (model stripped)"
  end

  entry = slice_entry(iteration)
  repo_path = space.path.join("repos", repo)
  raise Space::Core::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 Space::Core::Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success?
  base_sha = base_sha.strip

  branch = "lane/#{id}-#{lane}"

  # Guard: an existing directory that is not a registered worktree is ambiguous.
  if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
    if force
      FileUtils.rm_rf(wt_path)
    else
      raise Space::Core::Error,
        "#{wt_path} exists but is not a registered git worktree of #{repo}" \
        "resolve manually before re-running worktree_add, or re-run with --force to clear and re-create it"
    end
  end

  # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
  unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
    if branch_exists?(repo_path, branch)
      # The worktree was removed but its lane branch survived — re-attach the branch
      # (carrying its own tip) instead of `-b`, which would fail "branch already exists".
      git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
    else
      git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
    end
  end

  new_fields = {
    "name" => lane,
    "repo" => repo,
    "base_sha" => base_sha,
    "worktree" => "build/#{id}-#{lane}/wt",
    "integration_branch" => nil,
    "harness" => harness.to_s,
    "model" => model,
    "variant" => variant
  }
  new_fields["effort"]    = resolved_level if resolved_level
  new_fields["touch_set"] = Array(touch)   if touch && !Array(touch).empty?

  update_architect_block do |b|
    (b["iterations"] || []).each do |s|
      next unless s["name"] == iteration
      lanes = s["lanes"] || []
      existing = lanes.find { |l| l["name"] == lane }
      if existing
        existing.merge!(new_fields)
      else
        lanes << new_fields
        s["lanes"] = lanes
      end
    end
    b
  end

  { worktree: wt_path, base_sha: base_sha }
end

#worktree_listObject



870
871
872
873
874
# File 'lib/space_architect/architect_project.rb', line 870

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

#worktree_remove(iteration, lane) ⇒ Object

Raises:



845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
# File 'lib/space_architect/architect_project.rb', line 845

def worktree_remove(iteration, lane)
  entry = slice_entry(iteration)
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
  raise Space::Core::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, message: nil, force: false) ⇒ 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/Acceptance Criteria) once the iteration is frozen. With force: true, writes a frozen section if no lane is dispatched yet. Builder Report is not here (use evidence).

Raises:



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
313
314
315
316
317
318
319
# File 'lib/space_architect/architect_project.rb', line 282

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

  entry = slice_entry(iteration)
  rel = entry["file"]
  path = space.path.join(rel)
  raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?

  if spec[:frozen] && entry["freeze_sha"]
    if force
      dispatched_guard!(entry)
    else
      raise Space::Core::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
  end

  block = lane ? "### #{lane}\n\n#{body.strip}" : body.strip
  new_text = replace_section_body(path.read, spec[:heading], block, append: append)
  lint_gates!(new_text) if section == "acceptance-criteria"
  path.write(new_text)

  nn = format("%02d", entry["ordinal"] || 0)
  _o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m",
    compose_message("I#{nn} #{spec[:prefix]}:", "I#{nn}: #{spec[:message]}", message), "--", rel)
  committed = cst.success?
  show_out, = git_capture("-C", space.path.to_s, "show", "--stat", "--format=%H", "HEAD")
  show_lines = show_out.to_s.lines
  sha = show_lines.first&.strip || ""
  diffstat = committed ? show_lines.drop(1).join.strip : ""
  { section: section, heading: spec[:heading], sha: sha, committed: committed, diffstat: diffstat }
end