space-architect 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ require "open3"
6
6
  require "fileutils"
7
7
  require "pathname"
8
8
  require "tempfile"
9
+ require "time"
9
10
 
10
11
  module Space::Architect
11
12
  # Manages an architect-loop project inside a space: one self-contained file per
@@ -24,10 +25,10 @@ module Space::Architect
24
25
  # command (`architect evidence`) because it is transcribed verbatim from scratch.
25
26
  # `frozen: true` sections live above the freeze boundary and are refused once frozen.
26
27
  SECTIONS = {
27
- "grounds" => { heading: "## Grounds", message: "grounds", frozen: true },
28
- "specification" => { heading: "## Specification", message: "specification", frozen: true },
29
- "prompt" => { heading: "## Builder Prompt", message: "dispatched", frozen: false },
30
- "verdict" => { heading: "## Verdict", message: "verdict", frozen: false }
28
+ "grounds" => { heading: "## Grounds", message: "grounds", prefix: "grounds", frozen: true },
29
+ "specification" => { heading: "## Specification", message: "specification", prefix: "spec", frozen: true },
30
+ "prompt" => { heading: "## Builder Prompt", message: "dispatched", prefix: "prompt", frozen: false },
31
+ "verdict" => { heading: "## Verdict", message: "verdict", prefix: "verdict", frozen: false }
31
32
  }.freeze
32
33
 
33
34
  # The fixed top-level section headings. Section boundaries are detected against
@@ -41,7 +42,10 @@ module Space::Architect
41
42
  # Hard per-gate timeout. Generous relative to the full suite (~55s).
42
43
  DEFAULT_GATE_TIMEOUT = 900
43
44
 
44
- # Sentinel written to prompt.md by worktree_add. dispatch refuses to launch on this content.
45
+ # Legacy sentinel: worktree_add used to seed prompt.md with this placeholder
46
+ # (dropped — the blind-overwrite tripped harness read-before-write guards, #48).
47
+ # dispatch still refuses to launch on this content, so stubs in old spaces
48
+ # can't reach a builder.
45
49
  PROMPT_STUB = "<!-- ARCHITECT: write this lane's builder prompt here, then dispatch. -->"
46
50
 
47
51
  # Inlined settings.json template for `architect init`. Registers a SessionStart
@@ -73,7 +77,7 @@ module Space::Architect
73
77
  @space = space
74
78
  end
75
79
 
76
- def init!
80
+ def init!(message: nil)
77
81
  handoff_path = space.path.join("architecture", "ARCHITECT.md")
78
82
  settings_path = space.path.join(".claude", "settings.json")
79
83
  to_add = []
@@ -96,15 +100,15 @@ module Space::Architect
96
100
 
97
101
  if to_add.any?
98
102
  git_run("-C", space.path.to_s, "add", *to_add)
99
- msg = to_add.include?("architecture/ARCHITECT.md") ? "Initialize architect project" : "Add architect settings"
100
- git_run("-C", space.path.to_s, "commit", "-m", msg)
103
+ default = to_add.include?("architecture/ARCHITECT.md") ? "Initialize architect project" : "Add architect settings"
104
+ git_run("-C", space.path.to_s, "commit", "-m", compose_message("init:", default, message))
101
105
  end
102
106
 
103
107
  handoff_path
104
108
  end
105
109
 
106
110
  # Allocate the next ordinal and scaffold architecture/I<NN>-<iteration>.md.
107
- def new_iteration!(name)
111
+ def new_iteration!(name, message: nil)
108
112
  block = space.data["project"] || {}
109
113
  iterations = block["iterations"] || []
110
114
  if iterations.any? { |s| s["name"] == name }
@@ -132,7 +136,8 @@ module Space::Architect
132
136
  end
133
137
 
134
138
  git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
135
- git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: scaffold #{name}")
139
+ git_run("-C", space.path.to_s, "commit", "-m",
140
+ compose_message("I#{nn} scaffold:", "I#{nn}: scaffold #{name}", message))
136
141
 
137
142
  path
138
143
  end
@@ -153,7 +158,7 @@ module Space::Architect
153
158
  # Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits
154
159
  # any pending changes to the iteration file and records HEAD as freeze_sha. If
155
160
  # already frozen, refuses when the frozen region has changed since.
156
- def freeze!(iteration, warnings: nil)
161
+ def freeze!(iteration, warnings: nil, message: nil)
157
162
  entry = slice_entry(iteration)
158
163
  rel = entry["file"]
159
164
  path = space.path.join(rel)
@@ -164,6 +169,7 @@ module Space::Architect
164
169
  end
165
170
 
166
171
  lint_gates!(text, warnings: warnings)
172
+ lint_lanes!(text)
167
173
 
168
174
  if entry["freeze_sha"]
169
175
  sha = entry["freeze_sha"]
@@ -177,21 +183,27 @@ module Space::Architect
177
183
 
178
184
  files = [rel]
179
185
  files << "architecture/ARCHITECT.md" if space.path.join("architecture", "ARCHITECT.md").exist?
180
- git_run("-C", space.path.to_s, "add", *files)
181
- if staged_changes?
182
- nn = format("%02d", entry["ordinal"] || 0)
183
- git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: acceptance criteria (freeze)")
184
- end
186
+ nn = format("%02d", entry["ordinal"] || 0)
187
+ git_capture("-C", space.path.to_s, "commit", "-m",
188
+ compose_message("I#{nn} freeze:", "I#{nn}: acceptance criteria (freeze)", message), "--", *files)
185
189
 
186
190
  sha, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
187
191
  sha = sha.strip
188
192
 
193
+ declared = parse_lanes(text)
189
194
  update_architect_block do |b|
190
195
  b["current_iteration"] = iteration
191
196
  (b["iterations"] || []).each do |s|
192
197
  next unless s["name"] == iteration
193
198
  s["freeze_sha"] = sha
194
199
  s["verdict"] ||= "pending"
200
+ lanes = s["lanes"] || []
201
+ declared.each do |d|
202
+ fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
203
+ existing = lanes.find { |l| l["name"] == d["name"] }
204
+ existing ? existing.merge!(fields) : lanes << fields
205
+ end
206
+ s["lanes"] = lanes
195
207
  end
196
208
  b
197
209
  end
@@ -201,17 +213,20 @@ module Space::Architect
201
213
 
202
214
  # Scaffold the durable, section-numbered project brief at architecture/BRIEF.md
203
215
  # and commit it. The brief is the stable cross-iteration address space iterations
204
- # cite as "BRIEF §N"; it lives outside the per-iteration freeze region.
205
- def brief_new!(force: false)
216
+ # cite as "BRIEF §N"; it lives outside the per-iteration freeze region. With
217
+ # content, writes the authored brief instead of the placeholder template.
218
+ def brief_new!(force: false, content: nil, message: nil)
206
219
  brief_path = space.path.join("architecture", "BRIEF.md")
207
220
  if brief_path.exist? && !force
208
221
  raise Space::Core::Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite"
209
222
  end
210
223
 
211
224
  FileUtils.mkdir_p(brief_path.dirname)
212
- brief_path.write(render_brief)
225
+ brief_path.write(content || render_brief)
213
226
  git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md")
214
- git_run("-C", space.path.to_s, "commit", "-m", "Add project brief") if staged_changes?
227
+ if staged_changes?
228
+ git_run("-C", space.path.to_s, "commit", "-m", compose_message("brief:", "Add project brief", message))
229
+ end
215
230
  brief_path
216
231
  end
217
232
 
@@ -219,7 +234,7 @@ module Space::Architect
219
234
  # per-section message, in one call. Refuses to write a frozen section
220
235
  # (Grounds/Specification) once the iteration is frozen. Acceptance Criteria is
221
236
  # NOT writable here (use freeze); Builder Report is not here (use evidence).
222
- def write_section!(iteration, section, body:, append: false, lane: nil)
237
+ def write_section!(iteration, section, body:, append: false, lane: nil, message: nil)
223
238
  spec = SECTIONS[section]
224
239
  unless spec
225
240
  raise Space::Core::Error,
@@ -242,18 +257,19 @@ module Space::Architect
242
257
  path.write(replace_section_body(path.read, spec[:heading], block, append: append))
243
258
 
244
259
  nn = format("%02d", entry["ordinal"] || 0)
245
- git_run("-C", space.path.to_s, "add", rel)
246
- committed = staged_changes?
247
- git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: #{spec[:message]}") if committed
248
-
249
- head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
250
- diffstat, = committed ? git_capture("-C", space.path.to_s, "show", "--stat", "--format=", "HEAD") : [""]
251
- { section: section, heading: spec[:heading], sha: head.strip, committed: committed, diffstat: diffstat.strip }
260
+ _o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m",
261
+ compose_message("I#{nn} #{spec[:prefix]}:", "I#{nn}: #{spec[:message]}", message), "--", rel)
262
+ committed = cst.success?
263
+ show_out, = git_capture("-C", space.path.to_s, "show", "--stat", "--format=%H", "HEAD")
264
+ show_lines = show_out.to_s.lines
265
+ sha = show_lines.first&.strip || ""
266
+ diffstat = committed ? show_lines.drop(1).join.strip : ""
267
+ { section: section, heading: spec[:heading], sha: sha, committed: committed, diffstat: diffstat }
252
268
  end
253
269
 
254
270
  # Write the ## Verdict prose AND record the decision to space.yaml in one commit.
255
271
  # decision must be "continue" or "kill".
256
- def record_verdict!(iteration, decision:, body:)
272
+ def record_verdict!(iteration, decision:, body:, message: nil)
257
273
  unless %w[continue kill].include?(decision)
258
274
  raise Space::Core::Error,
259
275
  "Invalid verdict decision '#{decision}' — must be one of: continue, kill"
@@ -272,8 +288,8 @@ module Space::Architect
272
288
  end
273
289
 
274
290
  nn = format("%02d", entry["ordinal"] || 0)
275
- git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
276
- git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: verdict")
291
+ git_run("-C", space.path.to_s, "commit", "-m",
292
+ compose_message("I#{nn} verdict:", "I#{nn}: verdict", message), "--", rel, Space::Core::Space::METADATA_FILE)
277
293
 
278
294
  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
279
295
  { decision: decision, sha: head.strip }
@@ -281,7 +297,7 @@ module Space::Architect
281
297
 
282
298
  # Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
283
299
  # the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
284
- def transcribe_evidence!(iteration, lane: nil)
300
+ def transcribe_evidence!(iteration, lane: nil, message: nil)
285
301
  entry = slice_entry(iteration)
286
302
  rel = entry["file"]
287
303
  path = space.path.join(rel)
@@ -297,8 +313,8 @@ module Space::Architect
297
313
  path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
298
314
 
299
315
  nn = format("%02d", entry["ordinal"] || 0)
300
- git_run("-C", space.path.to_s, "add", rel)
301
- git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: evidence") if staged_changes?
316
+ git_capture("-C", space.path.to_s, "commit", "-m",
317
+ compose_message("I#{nn} evidence:", "I#{nn}: evidence", message), "--", rel)
302
318
  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
303
319
 
304
320
  status_line = raw.lines.reverse_each.find { |l| l.strip.start_with?("STATUS:") }&.strip
@@ -330,6 +346,7 @@ module Space::Architect
330
346
  entry = slice_entry(iteration)
331
347
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
332
348
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
349
+ lane_entry = ensure_lane_materialized(iteration, lane)
333
350
 
334
351
  checks = lane_mechanical_checks(entry, lane_entry)
335
352
  if checks[:no_builder_commits] == false
@@ -352,7 +369,8 @@ module Space::Architect
352
369
  raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
353
370
 
354
371
  git_run("-C", wt_path.to_s, "add", "-A")
355
- git_run("-C", wt_path.to_s, "commit", "-m", message || "lane #{lane}: integrate")
372
+ git_run("-C", wt_path.to_s, "commit", "-m",
373
+ compose_message("lane #{lane}:", "lane #{lane}: integrate", message))
356
374
  integrate_sha_raw, = git_capture("-C", wt_path.to_s, "rev-parse", "HEAD")
357
375
  integrate_sha = integrate_sha_raw.strip
358
376
 
@@ -393,25 +411,23 @@ module Space::Architect
393
411
  end
394
412
 
395
413
  # Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
396
- # first conflict (a disjointness defect). Never decides which lanes pass.
397
- def integrate!(iteration, lanes:, teardown: false)
398
- raise Space::Core::Error, "No lanes given to integrate" if lanes.nil? || lanes.empty?
414
+ # first conflict (a disjointness defect). Never decides which lanes pass. With no
415
+ # lanes and teardown: true, tears down every lane recorded for the iteration instead
416
+ # (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
417
+ def integrate!(iteration, lanes: nil, teardown: false, message: nil)
418
+ lanes = Array(lanes)
419
+ return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
420
+ raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
399
421
 
400
422
  merged = []
401
423
  lanes.each do |lane|
402
- merged << merge_lane!(iteration, lane)
424
+ merged << merge_lane!(iteration, lane, message: message)
403
425
  rescue Space::Core::Error => e
404
426
  done = merged.map { |m| m[:lane] }.join(", ")
405
427
  raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
406
428
  end
407
429
 
408
- if teardown
409
- id = iteration_id(slice_entry(iteration))
410
- merged.each do |m|
411
- worktree_remove(iteration, m[:lane])
412
- git_capture("-C", space.path.join("repos", m[:repo]).to_s, "branch", "-d", "lane/#{id}-#{m[:lane]}")
413
- end
414
- end
430
+ teardown_lanes!(iteration, merged) if teardown
415
431
  merged
416
432
  end
417
433
 
@@ -438,6 +454,7 @@ module Space::Architect
438
454
  if lane
439
455
  le = lanes.find { |l| l["name"] == lane }
440
456
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
457
+ le = ensure_lane_materialized(iteration, lane)
441
458
  repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil
442
459
  space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
443
460
  else
@@ -558,14 +575,15 @@ module Space::Architect
558
575
 
559
576
  # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
560
577
  unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
561
- git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
578
+ if branch_exists?(repo_path, branch)
579
+ # The worktree was removed but its lane branch survived — re-attach the branch
580
+ # (carrying its own tip) instead of `-b`, which would fail "branch already exists".
581
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
582
+ else
583
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
584
+ end
562
585
  end
563
586
 
564
- # Seed prompt.md with a placeholder stub so the architect has a place to write the prompt.
565
- # Never overwrite an existing file (real prompt or stub from a prior run).
566
- prompt_path = build_dir.join("prompt.md")
567
- prompt_path.write("#{PROMPT_STUB}\n") unless prompt_path.exist?
568
-
569
587
  new_fields = {
570
588
  "name" => lane,
571
589
  "repo" => repo,
@@ -704,9 +722,39 @@ module Space::Architect
704
722
  wt_base.children.select(&:directory?).map { |p| p.basename.to_s }.sort
705
723
  end
706
724
 
725
+ # Materialize the iteration's declared lanes: for each lane (or the one named via
726
+ # `lane:`), create its worktree + lane/<id>-<lane> branch from the resolved base and
727
+ # record worktree/base_sha/integration_branch. Idempotent — an already-materialized
728
+ # lane is skipped, not re-created. Refuses until the iteration is frozen, because
729
+ # declarations are not authoritative until then.
730
+ def provision(iteration, base: nil, lane: nil)
731
+ entry = slice_entry(iteration)
732
+ raise Space::Core::Error,
733
+ "Iteration '#{iteration}' is not frozen — freeze before provisioning (declarations are not authoritative until frozen)." \
734
+ unless entry["freeze_sha"]
735
+
736
+ lanes = entry["lanes"] || []
737
+ lanes = lanes.select { |l| l["name"] == lane } if lane
738
+ raise Space::Core::Error, "No lane '#{lane}' declared for iteration '#{iteration}'" if lane && lanes.empty?
739
+
740
+ lanes.map do |l|
741
+ name = l["name"]
742
+ repo_path = space.path.join("repos", l["repo"])
743
+ wt_path = space.path.join(l["worktree"] || "build/#{iteration_id(entry)}-#{name}/wt")
744
+ if wt_path.exist? && worktree_registered?(repo_path, wt_path)
745
+ { lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
746
+ else
747
+ result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
748
+ **recorded_lane_fields(l))
749
+ { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
750
+ end
751
+ end
752
+ end
753
+
707
754
  def verify(iteration)
708
755
  entry = slice_entry(iteration)
709
756
  (entry["lanes"] || []).map do |lane|
757
+ ensure_lane_materialized(iteration, lane["name"])
710
758
  { lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane) }
711
759
  end
712
760
  end
@@ -714,7 +762,7 @@ module Space::Architect
714
762
  def dispatch(iteration, lane, model: nil, max_turns: 200,
715
763
  claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
716
764
  push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
717
- push_client: nil, timeout: nil)
765
+ push_client: nil, timeout: nil, prompt: nil, now: Time.now)
718
766
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
719
767
  raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
720
768
  raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
@@ -723,6 +771,7 @@ module Space::Architect
723
771
  entry = slice_entry(iteration)
724
772
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
725
773
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
774
+ lane_entry = ensure_lane_materialized(iteration, lane)
726
775
 
727
776
  resolved_harness = harness || lane_entry["harness"] || "claude-code"
728
777
  resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
@@ -739,6 +788,15 @@ module Space::Architect
739
788
  prompt_path = build_dir.join("prompt.md")
740
789
  run_log_path = build_dir.join("run.jsonl")
741
790
  report_path = build_dir.join("report.md")
791
+
792
+ # --prompt: the caller authors the lane prompt anywhere (a fresh scratch file)
793
+ # and the CLI owns the canonical copy — byte-for-byte, like variant_add.
794
+ if prompt
795
+ src = Pathname.new(prompt)
796
+ raise Space::Core::Error, "prompt file not found: #{src}" unless src.exist?
797
+ File.open(prompt_path, "wb") { |f| f.write(File.binread(src)) }
798
+ end
799
+
742
800
  raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
743
801
 
744
802
  prompt_content = prompt_path.read.strip
@@ -749,13 +807,29 @@ module Space::Architect
749
807
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
750
808
  bin: bin, config_dir: build_dir, effort: resolved_effort)
751
809
 
810
+ # Stamp launch time onto the lane entry: after every preflight validation has passed
811
+ # (a dispatch that raises above records nothing) and before the blocking run or a
812
+ # detached dispatch returns. A re-dispatch overwrites the prior value.
813
+ update_architect_block do |b|
814
+ (b["iterations"] || []).each do |s|
815
+ next unless s["name"] == iteration
816
+ (s["lanes"] || []).each do |l|
817
+ next unless l["name"] == lane
818
+ l["dispatched_at"] = now.iso8601
819
+ end
820
+ end
821
+ b
822
+ end
823
+
752
824
  if detach
753
825
  pid = harness_obj.run_detached(
754
826
  prompt_path: prompt_path,
755
827
  run_log_path: run_log_path,
756
828
  chdir: wt_path
757
829
  )
758
- { pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path }
830
+ result = { pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path }
831
+ result[:prompt_copied] = prompt_path if prompt
832
+ result
759
833
  else
760
834
  created_run_id = nil
761
835
  if push_host
@@ -774,6 +848,7 @@ module Space::Architect
774
848
  exit_code = harness_obj.run(**run_kwargs)
775
849
 
776
850
  result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
851
+ result[:prompt_copied] = prompt_path if prompt
777
852
  result[:timed_out] = true if exit_code == Harness::ClaudeCodeHarness::TIMEOUT_EXIT_CODE
778
853
  result[:created_run_id] = created_run_id if created_run_id
779
854
  result[:push_url] = push_url if push_url
@@ -785,6 +860,35 @@ module Space::Architect
785
860
 
786
861
  attr_reader :space
787
862
 
863
+ # Compose a commit message. Without a custom message, the canonical default
864
+ # (unchanged). With one, a short canonical prefix keeps the loop's commit
865
+ # taxonomy grep-able while the author's first line owns the subject; any
866
+ # remaining lines become the commit body — the space's git log is the loop's
867
+ # durable memory, so callers are encouraged to write detailed bodies.
868
+ def compose_message(prefix, default, message)
869
+ return default if message.nil? || message.strip.empty?
870
+
871
+ subject, _, body = message.strip.partition("\n")
872
+ composed = "#{prefix} #{subject.strip}"
873
+ body = body.strip
874
+ body.empty? ? composed : "#{composed}\n\n#{body}"
875
+ end
876
+
877
+ # Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
878
+ # either merge_lane! results (symbol keys) or recorded lane entries (string
879
+ # keys) — both carry a lane name and a repo.
880
+ def teardown_lanes!(iteration, lane_entries)
881
+ id = iteration_id(slice_entry(iteration))
882
+ lane_entries.map do |l|
883
+ lane = l[:lane] || l["name"]
884
+ repo = l[:repo] || l["repo"]
885
+ worktree_remove(iteration, lane)
886
+ lane_branch = "lane/#{id}-#{lane}"
887
+ git_capture("-C", space.path.join("repos", repo).to_s, "branch", "-d", lane_branch)
888
+ { lane: lane, repo: repo, lane_branch: lane_branch }
889
+ end
890
+ end
891
+
788
892
  # Resolve the in-flight iteration file for ground output.
789
893
  # Rule: (a) current_iteration from project block → entry's file if it exists on disk,
790
894
  # (b) else highest-ordinal architecture/I<NN>-*.md,
@@ -855,6 +959,55 @@ module Space::Architect
855
959
  "project/#{slug}"
856
960
  end
857
961
 
962
+ # Materialize a declared lane on demand: when its worktree is absent, create it
963
+ # identically to `provision` (same base resolution, same worktree_add primitive)
964
+ # so no dispatch/integrate/gate path dead-ends on a missing worktree. Returns the
965
+ # (possibly refreshed) lane entry; a no-op when already materialized or undeclared.
966
+ def ensure_lane_materialized(iteration, lane)
967
+ entry = slice_entry(iteration)
968
+ lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
969
+ return lane_entry unless lane_entry && lane_entry["repo"]
970
+
971
+ repo_path = space.path.join("repos", lane_entry["repo"])
972
+ return lane_entry unless repo_path.exist?
973
+ wt_path = space.path.join(lane_entry["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
974
+ return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
975
+
976
+ worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
977
+ **recorded_lane_fields(lane_entry))
978
+ (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
979
+ end
980
+
981
+ # The lane's declared harness/model/variant/effort, as worktree_add kwargs — so a
982
+ # re-materialize preserves them instead of merging back to defaults. touch_set is
983
+ # deliberately omitted: worktree_add leaves it untouched, so it already survives.
984
+ def recorded_lane_fields(lane_entry)
985
+ {
986
+ harness: lane_entry["harness"] || "claude-code",
987
+ model: lane_entry["model"],
988
+ variant: lane_entry["variant"] || false,
989
+ effort: lane_entry["effort"]
990
+ }
991
+ end
992
+
993
+ # Resolve the base ref a lane's worktree branches from: an explicit override wins;
994
+ # otherwise the project/<slug> integration branch when it exists, else the repo's
995
+ # default branch.
996
+ def resolve_lane_base(repo, override)
997
+ return override if override
998
+ repo_path = space.path.join("repos", repo)
999
+ integration = project_integration_branch
1000
+ return integration if branch_exists?(repo_path, integration)
1001
+ repo_default_branch(repo_path)
1002
+ end
1003
+
1004
+ def repo_default_branch(repo_path)
1005
+ out, _, st = git_capture("-C", repo_path.to_s, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
1006
+ return out.strip.sub(%r{\Aorigin/}, "") if st.success? && !out.strip.empty?
1007
+ out, _, st = git_capture("-C", repo_path.to_s, "symbolic-ref", "--short", "HEAD")
1008
+ st.success? && !out.strip.empty? ? out.strip : "HEAD"
1009
+ end
1010
+
858
1011
  def slice_entry(iteration)
859
1012
  block = space.data["project"] || {}
860
1013
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
@@ -981,6 +1134,44 @@ module Space::Architect
981
1134
  parsed.is_a?(Array) ? parsed : []
982
1135
  end
983
1136
 
1137
+ # Extract and parse the fenced ```lanes block from the Specification section.
1138
+ # Returns an array of lane declaration hashes (string-keyed). Returns [] when the
1139
+ # block is absent, empty, or contains only YAML comments (back-compat).
1140
+ def parse_lanes(text)
1141
+ body = section_body(text, "## Specification")
1142
+ return [] unless body
1143
+ match = body.match(/^```lanes\n(.*?)^```/m)
1144
+ return [] unless match
1145
+ parsed = YAML.safe_load(match[1], aliases: false)
1146
+ parsed.is_a?(Array) ? parsed : []
1147
+ end
1148
+
1149
+ # Lint the lanes block in the given iteration file text. Raises Space::Core::Error
1150
+ # with aggregated messages on failure. An absent/empty block is allowed (back-compat).
1151
+ def lint_lanes!(text)
1152
+ lanes = begin
1153
+ parse_lanes(text)
1154
+ rescue Psych::SyntaxError => e
1155
+ raise Space::Core::Error, "ill-formed lanes block: #{e.message}"
1156
+ end
1157
+ return if lanes.empty?
1158
+
1159
+ errors = lanes.each_with_index.flat_map do |l, i|
1160
+ unless l.is_a?(Hash)
1161
+ next ["lane #{i}: expected a mapping with name/repo/touch"]
1162
+ end
1163
+ touch = l["touch"]
1164
+ [
1165
+ ("lane #{i}: missing 'name'" if l["name"].to_s.strip.empty?),
1166
+ ("lane #{i}: missing 'repo'" if l["repo"].to_s.strip.empty?),
1167
+ ("lane #{i} (#{l["name"]}): 'touch' must be a non-empty array of globs" \
1168
+ unless touch.is_a?(Array) && touch.any? && touch.all? { |g| g.is_a?(String) && !g.strip.empty? })
1169
+ ].compact
1170
+ end
1171
+ return if errors.empty?
1172
+ raise Space::Core::Error, "ill-formed lanes block:\n#{errors.join("\n")}"
1173
+ end
1174
+
984
1175
  # Lint the gates block in the given iteration file text. Raises Space::Core::Error
985
1176
  # with aggregated messages on failure. Absent/empty gates appends a warning to
986
1177
  # the optional warnings array but does not fail.