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. Acceptance Criteria is intentionally absent — it is set by architect freeze, the one code path that creates the freeze commit. Builder Report has its own command (architect evidence) because it is transcribed verbatim from scratch. frozen: true sections live above the freeze boundary and are refused once frozen.

{
  "grounds" => { heading: "## Grounds", message: "grounds", prefix: "grounds", frozen: true },
  "specification" => { heading: "## Specification", message: "specification", prefix: "spec", 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).



326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/space_architect/architect_project.rb', line 326

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.



218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/space_architect/architect_project.rb', line 218

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



762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
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
# File 'lib/space_architect/architect_project.rb', line 762

def dispatch(iteration, lane, model: nil, max_turns: 200,
             claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, 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)

  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 = harness || lane_entry["harness"] || "claude-code"
  resolved_model   = model   || lane_entry["model"]   || Harness::CLAUDE_DEFAULT_MODEL
  resolved_effort  = effort  || lane_entry["effort"]

  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)

  # Stamp launch time 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 value.
  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
      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
    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) ⇒ Object

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

Raises:



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

def freeze!(iteration, warnings: 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?
  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)
      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
    return sha
  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

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



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

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

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



417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
# File 'lib/space_architect/architect_project.rb', line 417

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



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
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
# File 'lib/space_architect/architect_project.rb', line 345

def merge_lane!(iteration, lane, message: 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)
  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 = 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")
    raise Space::Core::Error,
      "Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) — the lane plan was " \
      "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
  end

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

  update_architect_block do |b|
    b["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) ⇒ 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:



730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
# File 'lib/space_architect/architect_project.rb', line 730

def provision(iteration, base: nil, lane: nil)
  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),
                            **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:



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/space_architect/architect_project.rb', line 272

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:



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

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

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



300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/space_architect/architect_project.rb', line 300

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.



620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
# File 'lib/space_architect/architect_project.rb', line 620

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:



671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
# File 'lib/space_architect/architect_project.rb', line 671

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:



645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
# File 'lib/space_architect/architect_project.rb', line 645

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



754
755
756
757
758
759
760
# File 'lib/space_architect/architect_project.rb', line 754

def verify(iteration)
  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) }
  end
end

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

Raises:



540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
# File 'lib/space_architect/architect_project.rb', line 540

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

  entry = slice_entry(iteration)
  repo_path = space.path.join("repos", repo)
  raise 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")
  build_dir = space.path.join("build", "#{id}-#{lane}")
  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 — refuse.
  if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
    raise Space::Core::Error,
      "#{wt_path} exists but is not a registered git worktree of #{repo}" \
      "resolve manually before re-running worktree_add"
  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"]    = effort         if effort
  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



719
720
721
722
723
# File 'lib/space_architect/architect_project.rb', line 719

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:



694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
# File 'lib/space_architect/architect_project.rb', line 694

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

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

Raises:



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
264
265
266
267
268
# File 'lib/space_architect/architect_project.rb', line 237

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

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

  if spec[:frozen] && entry["freeze_sha"]
    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

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

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