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- AC1_PLACEHOLDER =
The scaffold's untouched placeholder AC1 line — templates/iteration.md.erb's Acceptance Criteria section (named, not line-numbered: a pinned line number here has already drifted twice). Pinned by contract with the authoring lane, which is forbidden from changing it, precisely so freeze!'s #73 hard-refuse (below) can key on it.
"**AC1.** ..."- BROKEN_STDERR_PATTERN =
Rehearsal's BROKEN heuristic (I09/AC5): a command-not-found exit code or a shell parse failure, distinct from a clean non-zero (RED — the gate discriminates). Advisory, not authoritative — see #classify_rehearsal.
/\bsyntax error\b|unexpected end of file|unexpected eof/i- BARE_REPO_PREFIX =
I09/AC9(b): a gate
cmdthat carries a literal 'repos//' prefix with no cwd— legal, occasionally correct, but usually a leftover space-root-relative path sincecmdalready resolves against the repo tree. %r{(?<![\w./-])repos/[^/\s'"]+/}- PROMPT_STUB =
Legacy sentinel: worktree_add used to seed prompt.md with this placeholder (dropped — the blind-overwrite tripped harness read-before-write guards, #48). dispatch still refuses to launch on this content, so stubs in old spaces can't reach a builder.
"<!-- ARCHITECT: write this lane's builder prompt here, then dispatch. -->"- SETTINGS_JSON_TEMPLATE =
Inlined settings.json template for
architect init. Registers a SessionStart hook on the three explicit session-start events (startup/clear/resume) so every space gets auto-regrounding. compact is intentionally omitted — reground on explicit session events, not every compaction cycle. <<~JSON { "hooks": { "SessionStart": [ { "matcher": "startup", "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}] }, { "matcher": "clear", "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}] }, { "matcher": "resume", "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}] } ] } } JSON
Instance Method Summary collapse
-
#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, allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false, detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil, push_client: nil, timeout: nil, prompt: nil, now: Time.now) ⇒ Object
-
#dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil, max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false, job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now) ⇒ Object
Submit the lane's builder run as a job to the space-server's queue instead of running it locally: the sandboxed executor mounts the lane worktree + the repo checkout at their identical host absolute paths (so the worktree's gitdir pointer resolves) and runs the harness itself — no local run.jsonl, the transcript lives server-side.
-
#freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil) ⇒ Object
Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section.
-
#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, accept_bounds_reason: nil, force: false) ⇒ Object
Loop merge_lane! over the architect-supplied passing set, in order.
-
#merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil) ⇒ Object
Integrate ONE architect-judged-passing lane: commit the builder's working tree on the lane branch, then merge --no-ff into the repo's lane/
integration branch. -
#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.
-
#rehearse(iteration, now: Time.now) ⇒ Object
Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the freeze, while they can still be fixed — through the identical execution path #run_gates uses at judge time (#execute_gates).
-
#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, base_explicit: !base.nil?,, harness: nil, model: nil, variant: false, effort: nil, touch: nil, force: false, err: $stderr) ⇒ Object
base_explicit distinguishes an operator-given --base (enforce it — refuse or re-point per #88's three cases) from an auto-materializing call (just attach whatever's there, never refuse, never move a branch nobody asked to move).
- #worktree_list ⇒ Object
-
#worktree_remove(iteration, lane, force: false) ⇒ Object
#88 (smaller, same area): a removed worktree does not touch its lane branch — remove-then-provision re-points that surviving branch (or refuses if it has its own commits), it is not a reset.
-
#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.
95 96 97 |
# File 'lib/space_architect/architect_project.rb', line 95 def initialize(space:) @space = space end |
Instance Method Details
#acceptance_criteria(iteration, ref: :freeze) ⇒ Object
Read the Acceptance Criteria section text, by default from the freeze commit (so the architect quotes the frozen gates, never a drifted working copy).
394 395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/space_architect/architect_project.rb', line 394 def acceptance_criteria(iteration, ref: :freeze) entry = slice_entry(iteration) rel = entry["file"] ref = entry["freeze_sha"] if ref == :freeze text = if ref out, _, st = git_capture("-C", space.path.to_s, "show", "#{ref}:#{rel}") raise Space::Core::Error, "could not read #{rel} at #{ref}" unless st.success? out else space.path.join(rel).read end section_body(text, "## Acceptance Criteria") end |
#brief_new!(force: false, content: nil, message: nil) ⇒ Object
Scaffold the durable, section-numbered project brief at architecture/BRIEF.md and commit it. The brief is the stable cross-iteration address space iterations cite as "BRIEF §N"; it lives outside the per-iteration freeze region. With content, writes the authored brief instead of the placeholder template.
269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/space_architect/architect_project.rb', line 269 def brief_new!(force: false, content: nil, message: nil) brief_path = space.path.join("architecture", "BRIEF.md") if brief_path.exist? && !force raise Space::Core::Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite" end FileUtils.mkdir_p(brief_path.dirname) brief_path.write(content || render_brief) git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md") if staged_changes? git_run("-C", space.path.to_s, "commit", "-m", ("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, allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false, detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil, push_client: nil, timeout: nil, prompt: nil, now: Time.now) ⇒ Object
941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 |
# File 'lib/space_architect/architect_project.rb', line 941 def dispatch(iteration, lane, model: nil, max_turns: 200, claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false, detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil, push_client: nil, timeout: nil, prompt: nil, now: Time.now) raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \ if detach && (push_url || push_host) err = quiet ? File.open(File::NULL, "w") : $stderr entry = slice_entry(iteration) lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane } raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry lane_entry = ensure_lane_materialized(iteration, lane) resolved_harness, resolved_model, resolved_effort = resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err) resolved_allowed_tools, replace_tools, append_tools, tools_provenance = resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools) err.puts(tools_provenance) if replace_tools || append_tools raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \ if push_host && resolved_harness != "claude-code" id = iteration_id(entry) wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt") raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist? build_dir = space.path.join("build", "#{id}-#{lane}") prompt_path = build_dir.join("prompt.md") run_log_path = build_dir.join("run.jsonl") report_path = build_dir.join("report.md") # --prompt: the caller authors the lane prompt anywhere (a fresh scratch file) # and the CLI owns the canonical copy — byte-for-byte, like variant_add. copy_and_validate_prompt!(prompt, prompt_path) bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin, config_dir: build_dir, effort: resolved_effort, allowed_tools: resolved_allowed_tools, force: force, err: err) # Stamp launch time and the resolved harness/model/effort/allowed_tools onto the # lane entry: after every preflight validation has passed (a dispatch that raises # above records nothing) and before the blocking run or a detached dispatch # returns. A re-dispatch overwrites the prior values, so `architect status` # always reads what actually ran on the last dispatch. #89/AC8: the resolved # tool grant is recorded the same way, so a denial traces back to a grant a # human can read — replace_tools/append_tools are the pre-composition components # (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later # bare re-dispatch re-resolves from the same components instead of compounding. update_architect_block do |b| (b["iterations"] || []).each do |s| next unless s["name"] == iteration (s["lanes"] || []).each do |l| next unless l["name"] == lane l["dispatched_at"] = now.iso8601 l["harness"] = resolved_harness l["model"] = resolved_model l["effort"] = resolved_effort if resolved_effort l["allowed_tools"] = replace_tools if replace_tools l["append_allowed_tools"] = append_tools if append_tools end end b end if detach pid = harness_obj.run_detached( prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path ) result = { pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path } result[:prompt_copied] = prompt_path if prompt result else created_run_id = nil if push_host creator = run_creator || RunCreator.new(push_host, push_token) created_run_id = creator.create push_url = "#{push_host.chomp('/')}/runs/#{created_run_id}/ingest" end run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path } run_kwargs[:timeout] = timeout if timeout if resolved_harness == "claude-code" run_kwargs[:push_url] = push_url if push_url run_kwargs[:push_token] = push_token if push_token run_kwargs[:push_client] = push_client if push_client run_kwargs[:err] = err if quiet end exit_code = harness_obj.run(**run_kwargs) result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path } result[:prompt_copied] = prompt_path if prompt result[:timed_out] = true if exit_code == Harness::ClaudeCodeHarness::TIMEOUT_EXIT_CODE result[:created_run_id] = created_run_id if created_run_id result[:push_url] = push_url if push_url result end end |
#dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil, max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false, job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now) ⇒ Object
Submit the lane's builder run as a job to the space-server's queue instead of running it locally: the sandboxed executor mounts the lane worktree + the repo checkout at their identical host absolute paths (so the worktree's gitdir pointer resolves) and runs the harness itself — no local run.jsonl, the transcript lives server-side. jobs_client: is the injectable seam (mirrors dispatch's run_creator:).
1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 |
# File 'lib/space_architect/architect_project.rb', line 1052 def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil, max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false, job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now) err = quiet ? File.open(File::NULL, "w") : $stderr entry = slice_entry(iteration) lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane } raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry lane_entry = ensure_lane_materialized(iteration, lane) resolved_harness, resolved_model, resolved_effort = resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err) raise Space::Core::Error, "--as-job only supports the claude-code harness (lane '#{lane}' resolves to '#{resolved_harness}')" \ unless resolved_harness == "claude-code" raise Space::Core::Error, "--job-model is required with --as-job" unless job_model # #89: same replace/append resolution as the local dispatch path — the sandboxed # executor still runs `claude -p` with harness_args server-side. resolved_allowed_tools, replace_tools, append_tools, tools_provenance = resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools) err.puts(tools_provenance) if replace_tools || append_tools id = iteration_id(entry) wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt") raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist? build_dir = space.path.join("build", "#{id}-#{lane}") prompt_path = build_dir.join("prompt.md") copy_and_validate_prompt!(prompt, prompt_path) repo_path = space.path.join("repos", lane_entry["repo"]) harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, effort: resolved_effort, allowed_tools: resolved_allowed_tools, force: force, err: err) spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir, repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url, job_model: job_model, api_key_ref: api_key_ref, harness_args: harness_obj.builder_args) client = jobs_client || JobsClient.new(host, token) job_id = client.create(spec) # Dispatch bookkeeping, mirroring the local path's dispatched_at stamp: a # re-dispatch overwrites the prior job_id, so `architect status` always # reflects the last dispatch regardless of mode. update_architect_block do |b| (b["iterations"] || []).each do |s| next unless s["name"] == iteration (s["lanes"] || []).each do |l| next unless l["name"] == lane l["dispatched_at"] = now.iso8601 l["job_id"] = job_id end end b end result = { job_id: job_id, spec: spec } result[:prompt_copied] = prompt_path if prompt result end |
#freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil) ⇒ Object
Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits any pending changes to the iteration file and records HEAD as freeze_sha. If already frozen, refuses when the frozen region has changed since. With force: true, re-freezes a changed frozen region if no lane is dispatched yet.
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/space_architect/architect_project.rb', line 182 def freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil) entry = slice_entry(iteration) rel = entry["file"] path = space.path.join(rel) raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist? text = path.read unless text.match?(/^## Acceptance Criteria/) raise Space::Core::Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing" end lint_gates!(text, warnings: warnings) lint_lanes!(text) if untouched_ac_placeholder?(text) raise Space::Core::Error, "#{rel}'s Acceptance Criteria still carries the scaffold placeholder '#{AC1_PLACEHOLDER}' with no " \ "active gate — write the real Acceptance Criteria (a hand-authored prose-only AC without the " \ "placeholder still freezes) before freezing." end if skip_rehearse_reason raise Space::Core::Error, "--skip-rehearse requires a non-empty REASON" if skip_rehearse_reason.to_s.strip.empty? else ensure_rehearsed!(iteration, entry, text) end if entry["freeze_sha"] sha = entry["freeze_sha"] if frozen_region_changed?(sha, rel) if force dispatched_guard!(entry) # fall through to commit path to re-freeze with new sha else raise Space::Core::Error, "Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \ "refusing to re-freeze. Restore them to their frozen state or use a new iteration." end else return sha end end files = [rel] files << "architecture/ARCHITECT.md" if space.path.join("architecture", "ARCHITECT.md").exist? nn = format("%02d", entry["ordinal"] || 0) git_capture("-C", space.path.to_s, "commit", "-m", ("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" s["rehearsal_skip_reason"] = skip_rehearse_reason.strip if skip_rehearse_reason lanes = s["lanes"] || [] declared.each do |d| fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) } # #89/AC3-AC4: the frozen lane's own tool grant, reviewable like every other # boundary. Declared-only, like touch_set — worktree_add never touches these # keys, so a re-materialize preserves them without threading them through # recorded_lane_fields (unlike harness/model, which worktree_add resolves and # overwrites on every call). fields["allowed_tools"] = d["allowed_tools"] if d["allowed_tools"] fields["append_allowed_tools"] = d["append_allowed_tools"] if d["append_allowed_tools"] existing = lanes.find { |l| l["name"] == d["name"] } existing ? existing.merge!(fields) : lanes << fields end s["lanes"] = lanes end b end git_run("-C", space.path.to_s, "commit", "-m", ("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 "...".
639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 |
# File 'lib/space_architect/architect_project.rb', line 639 def ground(session_cwd: nil) cwd = File.(session_cwd || Dir.pwd) build_root = space.path.join("build").to_s if cwd.start_with?("#{build_root}/") && cwd.match?(%r{/build/[^/]+/wt(/|\z)}) return "" end parts = [] architect_path = space.path.join("architecture", "ARCHITECT.md") parts << "=== architecture/ARCHITECT.md ===\n\n#{architect_path.read}" if architect_path.exist? brief_path = space.path.join("architecture", "BRIEF.md") parts << "=== architecture/BRIEF.md ===\n\n#{brief_path.read}" if brief_path.exist? iter_path = resolve_inflight_iteration if iter_path rel = iter_path.relative_path_from(space.path).to_s parts << "=== #{rel} ===\n\n#{iter_path.read}" end space.repos.each do |repo| name = repo["name"] repo_path = space.path.join("repos", name).to_s next unless Dir.exist?(repo_path) branch_out, _, branch_st = git_capture("-C", repo_path, "symbolic-ref", "--short", "HEAD") next unless branch_st.success? branch = branch_out.strip git_capture("-C", repo_path, "fetch", "origin") count_out, _, count_st = git_capture("-C", repo_path, "rev-list", "--left-right", "--count", "#{branch}...origin/#{branch}") next unless count_st.success? behind = count_out.strip.split[1].to_i if behind > 0 parts << "WARNING: repos/#{name} local #{branch} is #{behind} commits behind " \ "origin/#{branch} — run `architect sync #{name}`" end rescue # tolerate fetch or comparison failures silently end parts.join("\n") end |
#init!(message: nil) ⇒ Object
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/space_architect/architect_project.rb', line 99 def init!(message: nil) handoff_path = space.path.join("architecture", "ARCHITECT.md") settings_path = space.path.join(".claude", "settings.json") to_add = [] unless handoff_path.exist? FileUtils.mkdir_p(handoff_path.dirname) handoff_path.write(render_handoff) update_architect_block do |b| b.merge("status" => "active", "current_iteration" => nil, "iterations" => []) end to_add << "architecture/ARCHITECT.md" to_add << Space::Core::Space::METADATA_FILE end unless settings_path.exist? FileUtils.mkdir_p(settings_path.dirname) settings_path.write(SETTINGS_JSON_TEMPLATE) to_add << ".claude/settings.json" end if to_add.any? git_run("-C", space.path.to_s, "add", *to_add) default = to_add.include?("architecture/ARCHITECT.md") ? "Initialize architect project" : "Add architect settings" git_run("-C", space.path.to_s, "commit", "-m", ("init:", default, )) end handoff_path end |
#integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil, force: false) ⇒ Object
Loop merge_lane! over the architect-supplied passing set, in order. Stops on the first conflict (a disjointness defect). Never decides which lanes pass. With no lanes and teardown: true, tears down every lane recorded for the iteration instead (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
merge_lane!/teardown_lanes! only save integration_branch/integrate_sha/worktree to space.yaml on disk (update_architect_block never commits) — one commit per call, here, by pathspec, so which lanes merged survives independently of whether a Verdict follows (I13/A3). Always attempted, success or raise, so a conflict that stops the loop midway doesn't lose the lanes already merged.
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 |
# File 'lib/space_architect/architect_project.rb', line 520 def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil, force: false) lanes = Array(lanes) return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || [], force: force) if lanes.empty? && teardown raise Space::Core::Error, "No lanes given to integrate" if lanes.empty? merged = [] lanes.each do |lane| merged << merge_lane!(iteration, lane, message: , commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds_reason) rescue Space::Core::Error => e done = merged.map { |m| m[:lane] }.join(", ") raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.}" end teardown_lanes!(iteration, merged, force: force) if teardown merged ensure (iteration, message: ) end |
#merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil) ⇒ Object
Integrate ONE architect-judged-passing lane: commit the builder's working tree on
the lane branch, then merge --no-ff into the repo's lane/
accept_bounds_reason overrides ONLY the in-bounds check — never no_builder_commits, which stays an unconditional refusal (a builder commit is tampering, not an authoring defect the architect can rule on). Modeled on freeze!'s --skip-rehearse: a non-empty REASON is required whenever passed, recorded in space.yaml beside the lane, and returned for the caller to echo — the override can never be silent. Recorded only for THIS lane, and only when its in-bounds check actually failed — integrate! passes the same reason to every lane in the set, but a lane that was already in bounds gets neither the record nor the echo.
422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 |
# File 'lib/space_architect/architect_project.rb', line 422 def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil) if accept_bounds_reason raise Space::Core::Error, "--accept-bounds requires a non-empty REASON" if accept_bounds_reason.to_s.strip.empty? end entry = slice_entry(iteration) lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane } raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry lane_entry = ensure_lane_materialized(iteration, lane) checks = lane_mechanical_checks(entry, lane_entry, commit_mode: commit_mode) if checks[:no_builder_commits] == false raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge." end if checks[:in_bounds] == false && !accept_bounds_reason raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset " \ "and re-dispatch, or `architect integrate #{iteration} --lanes #{lane} --accept-bounds REASON` to override when " \ "the touch-set declaration itself is the defect." end repo = lane_entry["repo"] repo_path = space.path.join("repos", repo) id = iteration_id(entry) wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt") raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist? base_sha = lane_entry["base_sha"] lane_branch = "lane/#{id}-#{lane}" integration_branch = into || project_integration_branch status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain") raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty? git_run("-C", wt_path.to_s, "add", "-A") git_run("-C", wt_path.to_s, "commit", "-m", ("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"] || [] outside = conflict_files.reject { |f| in_touch_set?(f, lane_touch_set) } if !lane_touch_set.empty? && outside.empty? raise Space::Core::Error, "Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — the lane plan was " \ "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}" else raise Space::Core::Error, "Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — conflicting files " \ "are outside the lane's touch set; this looks like a branch mismatch: the lane is being merged " \ "into '#{integration_branch}'. Use --into <branch> to target the correct branch. #{merr.strip}" end end merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD") diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD") bounds_override_reason = accept_bounds_reason.strip if accept_bounds_reason && checks[:in_bounds] == false update_architect_block do |b| b["integration_branch"] = integration_branch (b["iterations"] || []).each do |s| next unless s["name"] == iteration (s["lanes"] || []).each do |l| next unless l["name"] == lane l["integration_branch"] = integration_branch l["integrate_sha"] = integrate_sha l["bounds_override_reason"] = bounds_override_reason if bounds_override_reason end end b end { lane: lane, repo: repo, integration_branch: integration_branch, merge_sha: merge_sha.strip, base_sha: base_sha, diffstat: diffstat.strip, gates_run: false, bounds_override_reason: bounds_override_reason } end |
#new_iteration!(name, message: nil) ⇒ Object
Allocate the next ordinal and scaffold architecture/I
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/space_architect/architect_project.rb', line 130 def new_iteration!(name, message: nil) block = space.data["project"] || {} iterations = block["iterations"] || [] if iterations.any? { |s| s["name"] == name } raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml" end ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1 nn = format("%02d", ordinal) rel = "architecture/I#{nn}-#{name}.md" path = space.path.join(rel) raise Space::Core::Error, "#{rel} already exists" if path.exist? FileUtils.mkdir_p(path.dirname) path.write(render_iteration(nn, name)) update_architect_block do |b| b["current_iteration"] = name list = b["iterations"] || [] list << { "name" => name, "ordinal" => ordinal, "file" => rel, "freeze_sha" => nil, "verdict" => "pending", "lanes" => [] } b["iterations"] = list b end git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE) git_run("-C", space.path.to_s, "commit", "-m", ("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/
911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 |
# File 'lib/space_architect/architect_project.rb', line 911 def provision(iteration, base: nil, lane: nil, force: false) entry = slice_entry(iteration) raise Space::Core::Error, "Iteration '#{iteration}' is not frozen — freeze before provisioning (declarations are not authoritative until frozen)." \ unless entry["freeze_sha"] lanes = entry["lanes"] || [] lanes = lanes.select { |l| l["name"] == lane } if lane raise Space::Core::Error, "No lane '#{lane}' declared for iteration '#{iteration}'" if lane && lanes.empty? base_explicit = !base.nil? refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force lanes.map do |l| name = l["name"] result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base), base_explicit: base_explicit, force: force, **recorded_lane_fields(l)) { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome], discarded: result[:discarded] } end end |
#record_verdict!(iteration, decision:, body:, message: nil) ⇒ Object
Write the ## Verdict prose AND record the decision to space.yaml in one commit. decision must be "continue" or "kill".
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/space_architect/architect_project.rb', line 330 def record_verdict!(iteration, decision:, body:, message: nil) unless %w[continue kill].include?(decision) raise Space::Core::Error, "Invalid verdict decision '#{decision}' — must be one of: continue, kill" end entry = slice_entry(iteration) rel = entry["file"] path = space.path.join(rel) raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist? path.write(replace_section_body(path.read, SECTIONS["verdict"][:heading], body.strip, append: false)) update_architect_block do |b| (b["iterations"] || []).each { |s| s["verdict"] = decision if s["name"] == iteration } b end nn = format("%02d", entry["ordinal"] || 0) git_run("-C", space.path.to_s, "commit", "-m", ("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 |
#rehearse(iteration, now: Time.now) ⇒ Object
Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the
freeze, while they can still be fixed — through the identical execution
path #run_gates uses at judge time (#execute_gates). space.yaml records no
lanes until freeze! writes them (#freeze!, :~209), so the run directory is
resolved from the DRAFTED lanes block instead of the recorded one;
rehearsal always runs in the repo checkout (repos/
590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 |
# File 'lib/space_architect/architect_project.rb', line 590 def rehearse(iteration, now: Time.now) entry = slice_entry(iteration) rel = entry["file"] path = space.path.join(rel) raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist? text = path.read gates = parse_gates(text) repo, base_dir, gate_results, scope_report = if gates.empty? [nil, nil, [], nil] else r = resolve_rehearsal_repo(iteration, text, gates) dir = r ? space.path.join("repos", r) : nil raise Space::Core::Error, "directory does not exist: #{dir}" if dir && !dir.exist? results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil) .map { |g| g.merge(rehearsal: classify_rehearsal(g)) } [r, dir, results, scope_asymmetry_report(results, text)] end digest = gates_digest(text) update_architect_block do |b| (b["iterations"] || []).each do |s| next unless s["name"] == iteration s["rehearsal"] = { "gates_digest" => digest, "at" => now.iso8601 } end b end { iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results, empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report } end |
#run_gates(iteration, lane: nil) ⇒ Object
Run the iteration's frozen Acceptance Criteria gate commands. Each gate is
executed in the resolved cwd (per-gate cwd overrides the base dir), under
a hard timeout, and evaluated against its expect block. Returns an array
of result hashes with :status (:pass/:fail) and :reason in addition to the
raw :stdout/:stderr/:exit_code. The mechanical verdict belongs here; the AC
verdict remains the architect's. WHERE the gate text comes from (the frozen
commit) is resolved here; HOW gates execute is #execute_gates, shared
byte-for-byte with #rehearse so the two can never run gates through
different instruments (I09/AC3).
550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/space_architect/architect_project.rb', line 550 def run_gates(iteration, lane: nil) entry = slice_entry(iteration) freeze_sha = entry["freeze_sha"] raise Space::Core::Error, "Iteration '#{iteration}' is not frozen — freeze before running gates." unless freeze_sha rel = entry["file"] text, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}") raise Space::Core::Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success? gates = parse_gates(text) raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if gates.empty? lanes = entry["lanes"] || [] repo_root = nil base_dir = if lane le = lanes.find { |l| l["name"] == lane } raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le le = ensure_lane_materialized(iteration, lane) repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt") else repo = lanes.first&.dig("repo") raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo space.path.join("repos", repo) end raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist? execute_gates(gates, base_dir: base_dir, lane: lane, repo_root: repo_root) end |
#status ⇒ Object
164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/space_architect/architect_project.rb', line 164 def status block = space.data["project"] || {} architecture_dir = space.path.join("architecture") iteration_files = if architecture_dir.exist? # paths:exempt - the /\AI\d+-.+\.md\z/ filter structurally cannot match a dotfile-prefixed name, so raw enumeration is already dotfile-safe here architecture_dir.children .select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) } .map { |f| f.basename.to_s }.sort else [] end { block: block, iteration_files: iteration_files } end |
#sync_repos(repo_name: nil) ⇒ Object
Sync tracked repo clones with their remotes (fast-forward only). Returns an array of result hashes: { repo:, status:, message: }. With no repo_name, syncs every tracked repo; with a name, syncs only that one.
690 691 692 693 694 695 696 697 |
# File 'lib/space_architect/architect_project.rb', line 690 def sync_repos(repo_name: nil) repos = space.repos if repo_name repos = repos.select { |r| r["name"] == repo_name } raise Space::Core::Error, "repo '#{repo_name}' not tracked in this space" if repos.empty? end repos.map { |r| sync_one_repo(r["name"]) } end |
#transcribe_evidence!(iteration, lane: nil, message: nil) ⇒ Object
Transcribe a lane's scratch report (build/
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 |
# File 'lib/space_architect/architect_project.rb', line 361 def transcribe_evidence!(iteration, lane: nil, message: nil) entry = slice_entry(iteration) rel = entry["file"] path = space.path.join(rel) raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist? id = iteration_id(entry) report = space.path.join("build", lane ? "#{id}-#{lane}" : id, "report.md") raise Space::Core::Error, "builder report not found: #{report}" unless report.exist? raw = report.read raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty? text = path.read new_body = if lane lane_names = (entry["lanes"] || []).map { |l| l["name"] } replace_lane_report(section_body(text, "## Builder Report").to_s, lane, raw.rstrip, lane_names) else raw.rstrip end path.write(replace_section_body(text, "## Builder Report", new_body, append: false)) nn = format("%02d", entry["ordinal"] || 0) git_capture("-C", space.path.to_s, "commit", "-m", ("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.
779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 |
# File 'lib/space_architect/architect_project.rb', line 779 def variant_add(repo, iteration, pairs, base: nil, prompt: nil) prompt_bytes = prompt ? File.binread(prompt) : nil entry = slice_entry(iteration) id = iteration_id(entry) existing_count = (entry["lanes"] || []).count { |l| l["name"].match?(/\Av\d+\z/) } pairs.each_with_index.map do |(harness, model), i| v_name = "v#{format('%02d', existing_count + i + 1)}" result = worktree_add(repo, iteration, v_name, base: base, harness: harness, model: model, variant: true) if prompt_bytes build_dir = space.path.join("build", "#{id}-#{v_name}") File.open(build_dir.join("prompt.md"), "wb") { |f| f.write(prompt_bytes) } end { name: v_name, repo: repo, harness: harness, model: model, worktree: result[:worktree], base_sha: result[:base_sha] } end end |
#variant_compare(iteration) ⇒ Object
Read-only side-by-side view of an iteration's variant set, reading ONLY the durable records in space.yaml. Returns a structured hash; the CLI renders it.
830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 |
# File 'lib/space_architect/architect_project.rb', line 830 def variant_compare(iteration) entry = slice_entry(iteration) variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] } raise Space::Core::Error, "Iteration '#{iteration}' has no variant set — nothing to compare" if variant_lanes.empty? winner = entry["winner"] { winner: winner, freeze_sha: entry["freeze_sha"], variants: variant_lanes.map do |l| { name: l["name"], harness: l["harness"] || "claude-code", model: l["model"], effort: l["effort"], base_sha: l["base_sha"], integration_branch: l["integration_branch"], status: winner.nil? ? "pending" : (l["name"] == winner ? "winner" : "discarded") } end } end |
#variant_promote(iteration, winner) ⇒ Object
Promote one variant of an iteration's variant set as the winner: records the decision durably onto the iteration entry (additive — no existing keys are removed or renamed). Re-promotable: a second call reassigns "winner" and recomputes every variant lane's "discarded" flag.
804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 |
# File 'lib/space_architect/architect_project.rb', line 804 def variant_promote(iteration, winner) entry = slice_entry(iteration) variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] } raise Space::Core::Error, "Iteration '#{iteration}' has no variant set — nothing to promote" if variant_lanes.empty? names = variant_lanes.map { |l| l["name"] } raise Space::Core::Error, "Cannot promote '#{winner}' — not a variant lane of iteration '#{iteration}'" unless names.include?(winner) discarded_names = names - [winner] update_architect_block do |b| (b["iterations"] || []).each do |s| next unless s["name"] == iteration s["winner"] = winner (s["lanes"] || []).each do |l| next unless l["variant"] l["discarded"] = (l["name"] != winner) end end b end { winner: winner, discarded: discarded_names } end |
#verify(iteration, commit_mode: nil) ⇒ Object
933 934 935 936 937 938 939 |
# File 'lib/space_architect/architect_project.rb', line 933 def verify(iteration, commit_mode: nil) entry = slice_entry(iteration) (entry["lanes"] || []).map do |lane| ensure_lane_materialized(iteration, lane["name"]) { lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane, commit_mode: commit_mode) } end end |
#worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?,, harness: nil, model: nil, variant: false, effort: nil, touch: nil, force: false, err: $stderr) ⇒ Object
base_explicit distinguishes an operator-given --base (enforce it — refuse or
re-point per #88's three cases) from an auto-materializing call (just attach
whatever's there, never refuse, never move a branch nobody asked to move).
Defaults from whether base: itself was given, which is exactly right for a
direct worktree_add/CLI worktree add --base call; ensure_lane_materialized
always resolves SOME base internally (it has to, to create a lane from
scratch), so it overrides this explicitly to false.
706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 |
# File 'lib/space_architect/architect_project.rb', line 706 def worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?, harness: nil, model: nil, variant: false, effort: nil, touch: nil, force: false, err: $stderr) model, suffix_level = Harness.parse_model_suffix(model) harness, model = resolve_harness_model(harness, model) resolved_level = effort || suffix_level || project_defaults["effort"] Harness.validate_thinking_level!(resolved_level) if effort.nil? && suffix_level err.puts "thinking: model suffix ':#{suffix_level}' parsed from lane model → level=#{suffix_level} (model stripped)" end entry = slice_entry(iteration) repo_path = space.path.join("repos", repo) raise Space::Core::Error, "repos/#{repo} does not exist" unless repo_path.exist? id = iteration_id(entry) wt_path = space.path.join("build", "#{id}-#{lane}", "wt") FileUtils.mkdir_p(wt_path.dirname) base_ref = base || "HEAD" base_sha, _, wt_status = git_capture("-C", repo_path.to_s, "rev-parse", base_ref) raise Space::Core::Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success? base_sha = base_sha.strip branch = "lane/#{id}-#{lane}" recorded_base = (entry["lanes"] || []).find { |l| l["name"] == lane }&.dig("base_sha") # Guard: an existing directory that is not a registered worktree is ambiguous. if wt_path.exist? && !worktree_registered?(repo_path, wt_path) if force FileUtils.rm_rf(wt_path) else raise Space::Core::Error, "#{wt_path} exists but is not a registered git worktree of #{repo} — " \ "resolve manually before re-running worktree_add, or re-run with --force to clear and re-create it" end end outcome, base_sha, discarded = attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base, base_explicit: base_explicit, force: force) new_fields = { "name" => lane, "repo" => repo, "base_sha" => base_sha, "worktree" => "build/#{id}-#{lane}/wt", "integration_branch" => nil, "harness" => harness.to_s, "model" => model, "variant" => variant } new_fields["effort"] = resolved_level if resolved_level new_fields["touch_set"] = Array(touch) if touch && !Array(touch).empty? update_architect_block do |b| (b["iterations"] || []).each do |s| next unless s["name"] == iteration lanes = s["lanes"] || [] existing = lanes.find { |l| l["name"] == lane } if existing existing.merge!(new_fields) else lanes << new_fields s["lanes"] = lanes end end b end { worktree: wt_path, base_sha: base_sha, outcome: outcome, discarded: discarded } end |
#worktree_list ⇒ Object
896 897 898 899 900 |
# File 'lib/space_architect/architect_project.rb', line 896 def worktree_list wt_base = space.path.join("build") return [] unless wt_base.exist? Space::Core::Paths.layout_children(wt_base).select(&:directory?).map { |p| p.basename.to_s }.sort end |
#worktree_remove(iteration, lane, force: false) ⇒ Object
#88 (smaller, same area): a removed worktree does not touch its lane branch —
remove-then-provision re-points that surviving branch (or refuses if it has
its own commits), it is not a reset. Names it in the return so a human
reading worktree remove's output isn't misled into thinking it is.
Never deletes the branch itself: that would discard lane work with no
confirmation.
859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 |
# File 'lib/space_architect/architect_project.rb', line 859 def worktree_remove(iteration, lane, force: false) entry = slice_entry(iteration) lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane } raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry repo = lane_entry["repo"] repo_path = space.path.join("repos", repo) wt_path = if lane_entry["worktree"] space.path.join(lane_entry["worktree"]) else space.path.join("build", "#{iteration_id(entry)}-#{lane}", "wt") end branch = worktree_branch(repo_path, wt_path) || "lane/#{iteration_id(entry)}-#{lane}" dirty = dirty_file_count(repo_path, wt_path) if dirty && !force raise Space::Core::Error, "Refusing to remove lane '#{lane}' worktree — #{dirty} uncommitted change(s) (untracked files " \ "included) would be discarded. Integrate the lane first, or re-run with --force to discard them " \ "and remove it." end git_run("-C", repo_path.to_s, "worktree", "remove", "--force", wt_path.to_s) git_run("-C", repo_path.to_s, "worktree", "prune") update_architect_block do |b| (b["iterations"] || []).each do |s| next unless s["name"] == iteration (s["lanes"] || []).each { |l| l["worktree"] = nil if l["name"] == lane } end b end { lane: lane, branch: branch, branch_survives: branch_exists?(repo_path, branch), discarded: dirty } end |
#write_section!(iteration, section, body:, append: false, lane: nil, message: nil, force: false) ⇒ Object
Write one section of the iteration file and commit it with the canonical per-section message, in one call. Refuses to write a frozen section (Grounds/Specification/Acceptance Criteria) once the iteration is frozen. With force: true, writes a frozen section if no lane is dispatched yet. Builder Report is not here (use evidence).
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 |
# File 'lib/space_architect/architect_project.rb', line 289 def write_section!(iteration, section, body:, append: false, lane: nil, message: nil, force: false) spec = SECTIONS[section] unless spec raise Space::Core::Error, "Unknown section '#{section}' — one of: #{SECTIONS.keys.join(', ')}. " \ "(Builder Report is written by `architect evidence`.)" end entry = slice_entry(iteration) rel = entry["file"] path = space.path.join(rel) raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist? if spec[:frozen] && entry["freeze_sha"] if force dispatched_guard!(entry) else raise Space::Core::Error, "#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \ "frozen sections are read-only after the freeze commit. Open a new iteration to change the contract." end end block = lane ? "### #{lane}\n\n#{body.strip}" : body.strip new_text = replace_section_body(path.read, spec[:heading], block, append: append) lint_gates!(new_text) if section == "acceptance-criteria" path.write(new_text) nn = format("%02d", entry["ordinal"] || 0) _o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m", ("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 |