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



394
395
396
397
398
399
400
401
402
403
404
405
406
407
# File 'lib/space_architect/architect_project.rb', line 394

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.



269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/space_architect/architect_project.rb', line 269

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, allowed_tools: nil, append_allowed_tools: 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:



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
1006
1007
1008
1009
1010
1011
1012
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
# File 'lib/space_architect/architect_project.rb', line 941

def dispatch(iteration, lane, model: nil, max_turns: 200,
             claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
             allowed_tools: nil, append_allowed_tools: 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)
  resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
    resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
  err.puts(tools_provenance) if replace_tools || append_tools

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

  # Stamp launch time and the resolved harness/model/effort/allowed_tools 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. #89/AC8: the resolved
  # tool grant is recorded the same way, so a denial traces back to a grant a
  # human can read — replace_tools/append_tools are the pre-composition components
  # (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later
  # bare re-dispatch re-resolves from the same components instead of compounding.
  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
        l["allowed_tools"]         = replace_tools    if replace_tools
        l["append_allowed_tools"]  = append_tools     if append_tools
      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, allowed_tools: nil, append_allowed_tools: 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:



1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
# File 'lib/space_architect/architect_project.rb', line 1052

def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
                    max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: 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

  # #89: same replace/append resolution as the local dispatch path — the sandboxed
  # executor still runs `claude -p` with harness_args server-side.
  resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
    resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
  err.puts(tools_provenance) if replace_tools || append_tools

  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, allowed_tools: resolved_allowed_tools,
                                              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
257
258
259
260
261
262
263
# 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"]) }
        # #89/AC3-AC4: the frozen lane's own tool grant, reviewable like every other
        # boundary. Declared-only, like touch_set — worktree_add never touches these
        # keys, so a re-materialize preserves them without threading them through
        # recorded_lane_fields (unlike harness/model, which worktree_add resolves and
        # overwrites on every call).
        fields["allowed_tools"]        = d["allowed_tools"]        if d["allowed_tools"]
        fields["append_allowed_tools"] = d["append_allowed_tools"] if d["append_allowed_tools"]
        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 "...".



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
678
679
680
681
682
683
684
685
# File 'lib/space_architect/architect_project.rb', line 639

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, force: 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. 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.



520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# File 'lib/space_architect/architect_project.rb', line 520

def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil,
               accept_bounds_reason: nil, force: false)
  lanes = Array(lanes)
  return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || [], force: force) 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, force: force) 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:



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
502
503
504
505
506
507
508
# File 'lib/space_architect/architect_project.rb', line 422

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 — a bare re-run (no explicit --base) over an already-materialized lane changes nothing. An explicit --base is always enforced, even against an already-materialized lane (#88's "blind spot" — a short-circuit that trusted the recorded state without checking it against what was actually asked for). Refuses until the iteration is frozen, because declarations are not authoritative until then. outcome on each result is :created / :unchanged / :repointed (#88/AC4 — tellable apart by a human reader).

Raises:



911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
# File 'lib/space_architect/architect_project.rb', line 911

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?

  base_explicit = !base.nil?
  refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force

  lanes.map do |l|
    name = l["name"]
    result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
                          base_explicit: base_explicit, force: force, **recorded_lane_fields(l))
    { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome],
      discarded: result[:discarded] }
  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:



330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/space_architect/architect_project.rb', line 330

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:



590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
# File 'lib/space_architect/architect_project.rb', line 590

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, gates)
      dir = r ? space.path.join("repos", r) : nil
      raise Space::Core::Error, "directory does not exist: #{dir}" if dir && !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(results, text)]
    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:



550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
# File 'lib/space_architect/architect_project.rb', line 550

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.



690
691
692
693
694
695
696
697
# File 'lib/space_architect/architect_project.rb', line 690

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:



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'lib/space_architect/architect_project.rb', line 361

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.



779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
# File 'lib/space_architect/architect_project.rb', line 779

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:



830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
# File 'lib/space_architect/architect_project.rb', line 830

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:



804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
# File 'lib/space_architect/architect_project.rb', line 804

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



933
934
935
936
937
938
939
# File 'lib/space_architect/architect_project.rb', line 933

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, base_explicit: !base.nil?,, harness: nil, model: nil, variant: false, effort: nil, touch: nil, force: false, err: $stderr) ⇒ Object

base_explicit distinguishes an operator-given --base (enforce it — refuse or re-point per #88's three cases) from an auto-materializing call (just attach whatever's there, never refuse, never move a branch nobody asked to move). Defaults from whether base: itself was given, which is exactly right for a direct worktree_add/CLI worktree add --base call; ensure_lane_materialized always resolves SOME base internally (it has to, to create a lane from scratch), so it overrides this explicitly to false.

Raises:



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
768
769
770
771
772
773
774
775
# File 'lib/space_architect/architect_project.rb', line 706

def worktree_add(repo, iteration, lane, base: nil, base_explicit: !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}"
  recorded_base = (entry["lanes"] || []).find { |l| l["name"] == lane }&.dig("base_sha")

  # 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

  outcome, base_sha, discarded = attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base,
                                          base_explicit: base_explicit, force: force)

  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, outcome: outcome, discarded: discarded }
end

#worktree_listObject



896
897
898
899
900
# File 'lib/space_architect/architect_project.rb', line 896

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, force: false) ⇒ Object

#88 (smaller, same area): a removed worktree does not touch its lane branch — remove-then-provision re-points that surviving branch (or refuses if it has its own commits), it is not a reset. Names it in the return so a human reading worktree remove's output isn't misled into thinking it is. Never deletes the branch itself: that would discard lane work with no confirmation.

Raises:



859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
# File 'lib/space_architect/architect_project.rb', line 859

def worktree_remove(iteration, lane, force: false)
  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

  branch = worktree_branch(repo_path, wt_path) || "lane/#{iteration_id(entry)}-#{lane}"

  dirty = dirty_file_count(repo_path, wt_path)
  if dirty && !force
    raise Space::Core::Error,
      "Refusing to remove lane '#{lane}' worktree — #{dirty} uncommitted change(s) (untracked files " \
      "included) would be discarded. Integrate the lane first, or re-run with --force to discard them " \
      "and remove it."
  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

  { lane: lane, branch: branch, branch_survives: branch_exists?(repo_path, branch), discarded: dirty }
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:



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
320
321
322
323
324
325
326
# File 'lib/space_architect/architect_project.rb', line 289

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