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



76
77
78
# File 'lib/space_architect/architect_project.rb', line 76

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



343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/space_architect/architect_project.rb', line 343

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.



228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/space_architect/architect_project.rb', line 228

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:



828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
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
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
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
# File 'lib/space_architect/architect_project.rb', line 828

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)

  model, suffix_level = Harness.parse_model_suffix(model)
  resolved_harness, resolved_model = resolve_harness_model(harness, model,
    stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])

  resolved_effort =
    if effort
      Harness.validate_thinking_level!(effort) unless force
      effort
    elsif suffix_level
      err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
      suffix_level
    else
      lane_entry["effort"]
    end

  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.
  if prompt
    src = Pathname.new(prompt)
    raise Space::Core::Error, "prompt file not found: #{src}" unless src.exist?
    File.open(prompt_path, "wb") { |f| f.write(File.binread(src)) }
  end

  raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?

  prompt_content = prompt_path.read.strip
  raise Space::Core::Error, "Write this lane's prompt to #{prompt_path} before dispatching." \
    if prompt_content.empty? || prompt_content == PROMPT_STUB.strip

  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

#freeze!(iteration, warnings: nil, message: nil, force: false) ⇒ 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:



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
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
# File 'lib/space_architect/architect_project.rb', line 162

def freeze!(iteration, warnings: nil, message: nil, force: false)
  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 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"
      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 "...".



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
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
# File 'lib/space_architect/architect_project.rb', line 546

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



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/space_architect/architect_project.rb', line 80

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

Raises:



447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/space_architect/architect_project.rb', line 447

def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: 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)
  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
end

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

Raises:



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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/space_architect/architect_project.rb', line 362

def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil)
  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
    raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
  end

  repo = lane_entry["repo"]
  repo_path = space.path.join("repos", repo)
  id = iteration_id(entry)
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
  raise 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"] || []
    fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
    outside = conflict_files.reject do |f|
      lane_touch_set.any? { |g| File.fnmatch(g, f, fnm) || (g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm)) }
    end
    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")

  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
      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 }
end

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

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

Raises:



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/space_architect/architect_project.rb', line 111

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:



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 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:



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

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

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

Raises:



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
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/space_architect/architect_project.rb', line 470

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?

  gates.map do |gate|
    g   = gate.transform_keys(&:to_s)
    dir =
      if (cwd = g["cwd"])
        gate_cwd = space.path.join(cwd)
        if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
          base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
        else
          gate_cwd
        end
      else
        base_dir
      end
    raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?

    effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
    captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)

    if captured[:timed_out]
      status = :fail
      reason = "timed out after #{effective}s"
    else
      ev     = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
      status = ev.pass? ? :pass : :fail
      reason = ev.reason
    end

    { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
      stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
      dir: dir, status: status, reason: reason }
  end
end

#statusObject



145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/space_architect/architect_project.rb', line 145

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

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



597
598
599
600
601
602
603
604
# File 'lib/space_architect/architect_project.rb', line 597

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.

Raises:



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

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?

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

  nn = format("%02d", entry["ordinal"] || 0)
  git_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.



686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
# File 'lib/space_architect/architect_project.rb', line 686

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:



737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
# File 'lib/space_architect/architect_project.rb', line 737

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:



711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
# File 'lib/space_architect/architect_project.rb', line 711

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



820
821
822
823
824
825
826
# File 'lib/space_architect/architect_project.rb', line 820

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:



606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
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
678
679
680
681
682
# File 'lib/space_architect/architect_project.rb', line 606

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



785
786
787
788
789
# File 'lib/space_architect/architect_project.rb', line 785

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

#worktree_remove(iteration, lane) ⇒ Object

Raises:



760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
# File 'lib/space_architect/architect_project.rb', line 760

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:



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/space_architect/architect_project.rb', line 248

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