Class: Space::Architect::ArchitectProject
- Inherits:
-
Object
- Object
- Space::Architect::ArchitectProject
- 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
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: truesections 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
-
#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).
-
#brief_new!(force: false, content: nil, message: nil) ⇒ Object
Scaffold the durable, section-numbered project brief at architecture/BRIEF.md and commit it.
- #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
-
#freeze!(iteration, warnings: nil, message: nil, force: false) ⇒ Object
Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section.
-
#ground(session_cwd: nil) ⇒ Object
Emit grounding reads for the architect's SessionStart hook.
- #init!(message: nil) ⇒ Object
-
#initialize(space:) ⇒ ArchitectProject
constructor
A new instance of ArchitectProject.
-
#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.
-
#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. -
#new_iteration!(name, message: nil) ⇒ Object
Allocate the next ordinal and scaffold architecture/I
- .md. -
#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. -
#record_verdict!(iteration, decision:, body:, message: nil) ⇒ Object
Write the ## Verdict prose AND record the decision to space.yaml in one commit.
-
#run_gates(iteration, lane: nil) ⇒ Object
Run the iteration's frozen Acceptance Criteria gate commands.
- #status ⇒ Object
-
#sync_repos(repo_name: nil) ⇒ Object
Sync tracked repo clones with their remotes (fast-forward only).
-
#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. -
#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.
-
#variant_compare(iteration) ⇒ Object
Read-only side-by-side view of an iteration's variant set, reading ONLY the durable records in space.yaml.
-
#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).
- #verify(iteration, commit_mode: nil) ⇒ Object
- #worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false, effort: nil, touch: nil, force: false, err: $stderr) ⇒ Object
- #worktree_list ⇒ Object
- #worktree_remove(iteration, lane) ⇒ Object
-
#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.
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", ("brief:", "Add project brief", )) 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
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.
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", ("I#{nn} freeze:", "I#{nn}: acceptance criteria (freeze)", ), "--", *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", ("I#{nn} freeze:", "I#{nn}: record freeze sha", ), "--", 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 (
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.(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", ("init:", default, )) 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).
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: , 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.}" 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/
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", ("lane #{lane}:", "lane #{lane}: integrate", )) 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
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", ("I#{nn} scaffold:", "I#{nn}: scaffold #{name}", )) 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/
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".
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", ("I#{nn} verdict:", "I#{nn}: verdict", ), "--", 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.
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 |
#status ⇒ Object
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/
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", ("I#{nn} evidence:", "I#{nn}: evidence", ), "--", 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.
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.
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
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_list ⇒ Object
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
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).
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", ("I#{nn} #{spec[:prefix]}:", "I#{nn}: #{spec[: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 |