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. Acceptance Criteria is intentionally absent — it is set byarchitect 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: 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 }, "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, 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) ⇒ 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) ⇒ Object
Loop merge_lane! over the architect-supplied passing set, in order.
-
#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. -
#new_iteration!(name, message: nil) ⇒ Object
Allocate the next ordinal and scaffold architecture/I
- .md. -
#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. -
#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
-
#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) ⇒ Object
- #worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil) ⇒ Object
- #worktree_list ⇒ Object
- #worktree_remove(iteration, lane) ⇒ Object
-
#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.
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", ("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, 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
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.
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", ("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 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 "...".
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.(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", ("init:", default, )) 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).
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: ) 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) ⇒ 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/
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", ("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") 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
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) ⇒ Object
Materialize the iteration's declared lanes: for each lane (or the one named via
lane:), create its worktree + lane/
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".
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", ("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.
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 |
#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 |
#transcribe_evidence!(iteration, lane: nil, message: nil) ⇒ Object
Transcribe a lane's scratch report (build/
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", ("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.
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.
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.
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
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_list ⇒ Object
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
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).
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", ("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 |