space-architect 2.0.2 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7251e017d780c7fc4accd3d4ee367d2b5063bb4d0c1692c0c69bc81a228c9fa7
4
- data.tar.gz: ad25da0cf6025716953685d7483e86898368170967a29465625335337f8427a3
3
+ metadata.gz: bb8f902d475bcc8a29e56740ce0c20328935fe05f9d919d38934b2cffd12e966
4
+ data.tar.gz: e31c5b6f8ad98219c9c4ed22225666900bc7aaf8f43bd929ef32e2d2b5aacdf2
5
5
  SHA512:
6
- metadata.gz: baf43e94bcd35987bb133f3958709bfc4083851732ed2d47ef72b25b92e042cfb72c2594f1067189e9581c554140cd2d6145dc7d607f9137931250522ba445dc
7
- data.tar.gz: 0ecd90d05ea7dc4c3832fea90f7af76650b1c8279ced9e7e48242b825aaeef8e7291d6dc9b4dacd6bfac00ad647ecca887a2f27abb3823181287885ccede7210
6
+ metadata.gz: 5cbb47cbacf2231ed09eb74bfb11b80a1a6076bfde1427fe76a19b3581f3b3a8e38db4a6269307e339a022902723d73b1d40dd4b001acd6ffaab624871249d70
7
+ data.tar.gz: 171913d786d1e166a279f72b184ec7987785d561ec795bfe161db9341502210f6d97166c3eb8b7a097b59adba7e435a575c78ca66488ca482312b4bc7cca7249
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] - 2026-07-01
9
+
10
+ ### Added
11
+
12
+ - **`architect bug-report`** — zero-friction bug-filing command. Gathers
13
+ diagnostics (gem version, Ruby version/platform, and when run inside a space:
14
+ space id, title, and iteration list with verdicts), writes a prefilled GitHub
15
+ issue-body template to `<space>/build/bug-report/architect-bug-report-YYYYMMDD-HHMMSS.md`
16
+ (or `./architect-bug-report-YYYYMMDD-HHMMSS.md` outside a space — timestamped so
17
+ back-to-back runs in the same session never overwrite each other), and prints —
18
+ never executes — the `gh issue create -R jetpks/space-architect` invocation to run.
19
+
20
+ ### Removed
21
+
22
+ - **BREAKING — `architect land` removed** (added in 2.0.0) — landing is the architect
23
+ skill's procedure: the architect writes the PR body and presents the push +
24
+ PR command. The command as shipped never produced a runnable block (#25) and
25
+ authored content the CLI has no context for (#24).
26
+
27
+ ### Fixed
28
+
29
+ - **`architect bug-report` — `~`-contracted paths in printed commands.**
30
+ The `gh issue create` invocation printed to stdout renders the
31
+ `--body-file` path as `~/…` instead of the user's expanded `$HOME`.
32
+ `Space::Core::Paths.contract` is the single home-contraction helper; `Terminal#path`
33
+ delegates to it.
34
+ - **Command wrapping for narrow terminals.** The `gh issue create` command is
35
+ now rendered with trailing ` \` continuations broken at `--flag` boundaries,
36
+ continuation lines indented two spaces. The wrapped output is valid shell
37
+ (`bash -n` clean). `Space::Core::Commands.wrap` is the single wrapping helper.
38
+
8
39
  ## [2.0.2] - 2026-07-01
9
40
 
10
41
  ### Added
data/README.md CHANGED
@@ -102,7 +102,9 @@ space show 20260531-name-of-space
102
102
  space path 20260531-name-of-space
103
103
  space current # based on $PWD
104
104
  space show # based on $PWD
105
- space status done # based on $PWD
105
+ space status # report: metadata + loop status (based on $PWD)
106
+ space status 20260531-name-of-space # report another space
107
+ space status done # set: active | paused | done | archived
106
108
  space status 20260531-name-of-space done
107
109
  space config set default_provider github.com
108
110
  space config set default_organization example-org
@@ -115,6 +117,12 @@ space use 20260531-name-of-space # records recent state, prints the
115
117
  space ls --color=always # auto | always | never (--colors also accepted)
116
118
  ```
117
119
 
120
+ `space status` is **report-or-set**: with no status keyword (bare, or with just
121
+ a space id) it *reports* the space — its metadata plus a compact loop-status
122
+ block (project status, current iteration, derived state) when the space runs an
123
+ Architect project, quietly omitted otherwise; pass a status keyword (`active`,
124
+ `paused`, `done`, `archived`) to *set* it instead. 🔁
125
+
118
126
  Repos are passed with a repeatable `-r` flag (`-r org/repo -r org/lib`); the
119
127
  comma form (`-r a,b`) works too. Space ids are date-prefixed
120
128
  (`20260531-name-of-space`) so they sort naturally, and duplicate names on the
@@ -302,12 +310,17 @@ architect evidence <it> --lane <lane> # transcribe the builder's report ve
302
310
  architect gate <iteration> # run the frozen gate commands, stream raw output
303
311
  architect merge <it> <lane> # integrate ONE judged-passing lane (--no-ff)
304
312
  architect integrate <it> --lanes a,b # integrate a set of passing lanes, in order
305
- architect land # end-of-project PR command (no push, no gh)
313
+ # landing (PR body + push/PR command) is the architect's end-of-project procedure (see skill)
306
314
  architect status # project state (read-only)
307
315
  architect variant add|compare|promote … # competing (harness, model) lanes over one frozen spec
308
316
  architect research dispatch|status|wait … # parallel read-only research lanes (see below)
309
317
  ```
310
318
 
319
+ `architect help` (or bare `architect`, `architect --help`) lists these grouped by
320
+ loop phase — **Spec · Build · Judge · Land · Project · Groups**, in canonical
321
+ order — and, inside an architect space, ends with a compact loop-status block
322
+ (project status + current iteration) so you can see where you are in the loop. 🧭
323
+
311
324
  A typical session:
312
325
 
313
326
  ```sh
@@ -321,7 +334,7 @@ architect evidence my-feature --lane lane-a # transcribe raw evidence
321
334
  architect gate my-feature # run the frozen gates yourself
322
335
  # … read the diff against the spec, then write the Verdict …
323
336
  architect integrate my-feature --lanes lane-a # merge passing lanes → project/<slug>
324
- architect land # print gh pr create at project end
337
+ # landing (PR body + push/PR command) is the architect's end-of-project procedure (see skill)
325
338
  ```
326
339
 
327
340
  ### Streaming builder output 📡
@@ -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
@@ -164,6 +165,7 @@ module Space::Architect
164
165
  end
165
166
 
166
167
  lint_gates!(text, warnings: warnings)
168
+ lint_lanes!(text)
167
169
 
168
170
  if entry["freeze_sha"]
169
171
  sha = entry["freeze_sha"]
@@ -177,21 +179,26 @@ module Space::Architect
177
179
 
178
180
  files = [rel]
179
181
  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
182
+ nn = format("%02d", entry["ordinal"] || 0)
183
+ git_capture("-C", space.path.to_s, "commit", "-m", "I#{nn}: acceptance criteria (freeze)", "--", *files)
185
184
 
186
185
  sha, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
187
186
  sha = sha.strip
188
187
 
188
+ declared = parse_lanes(text)
189
189
  update_architect_block do |b|
190
190
  b["current_iteration"] = iteration
191
191
  (b["iterations"] || []).each do |s|
192
192
  next unless s["name"] == iteration
193
193
  s["freeze_sha"] = sha
194
194
  s["verdict"] ||= "pending"
195
+ lanes = s["lanes"] || []
196
+ declared.each do |d|
197
+ fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
198
+ existing = lanes.find { |l| l["name"] == d["name"] }
199
+ existing ? existing.merge!(fields) : lanes << fields
200
+ end
201
+ s["lanes"] = lanes
195
202
  end
196
203
  b
197
204
  end
@@ -242,13 +249,13 @@ module Space::Architect
242
249
  path.write(replace_section_body(path.read, spec[:heading], block, append: append))
243
250
 
244
251
  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 }
252
+ _o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m", "I#{nn}: #{spec[:message]}", "--", rel)
253
+ committed = cst.success?
254
+ show_out, = git_capture("-C", space.path.to_s, "show", "--stat", "--format=%H", "HEAD")
255
+ show_lines = show_out.to_s.lines
256
+ sha = show_lines.first&.strip || ""
257
+ diffstat = committed ? show_lines.drop(1).join.strip : ""
258
+ { section: section, heading: spec[:heading], sha: sha, committed: committed, diffstat: diffstat }
252
259
  end
253
260
 
254
261
  # Write the ## Verdict prose AND record the decision to space.yaml in one commit.
@@ -272,8 +279,7 @@ module Space::Architect
272
279
  end
273
280
 
274
281
  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")
282
+ git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: verdict", "--", rel, Space::Core::Space::METADATA_FILE)
277
283
 
278
284
  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
279
285
  { decision: decision, sha: head.strip }
@@ -297,8 +303,7 @@ module Space::Architect
297
303
  path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
298
304
 
299
305
  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?
306
+ git_capture("-C", space.path.to_s, "commit", "-m", "I#{nn}: evidence", "--", rel)
302
307
  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
303
308
 
304
309
  status_line = raw.lines.reverse_each.find { |l| l.strip.start_with?("STATUS:") }&.strip
@@ -330,6 +335,7 @@ module Space::Architect
330
335
  entry = slice_entry(iteration)
331
336
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
332
337
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
338
+ lane_entry = ensure_lane_materialized(iteration, lane)
333
339
 
334
340
  checks = lane_mechanical_checks(entry, lane_entry)
335
341
  if checks[:no_builder_commits] == false
@@ -393,9 +399,13 @@ module Space::Architect
393
399
  end
394
400
 
395
401
  # 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?
402
+ # first conflict (a disjointness defect). Never decides which lanes pass. With no
403
+ # lanes and teardown: true, tears down every lane recorded for the iteration instead
404
+ # (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
405
+ def integrate!(iteration, lanes: nil, teardown: false)
406
+ lanes = Array(lanes)
407
+ return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
408
+ raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
399
409
 
400
410
  merged = []
401
411
  lanes.each do |lane|
@@ -405,53 +415,10 @@ module Space::Architect
405
415
  raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
406
416
  end
407
417
 
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
418
+ teardown_lanes!(iteration, merged) if teardown
415
419
  merged
416
420
  end
417
421
 
418
- # Generate the end-of-project PR command(s) for each integrated repo.
419
- # Writes a PR body to build/land/<repo>-pr-body.md and returns, per repo:
420
- # { repo:, integration_branch:, body_file:, command:, context: }.
421
- # Raises Space::Core::Error if nothing has been integrated yet.
422
- # Side-effect-free: no git write, no push, no gh.
423
- def land
424
- b = space.data["project"] || {}
425
- integration_branch = project_integration_branch
426
-
427
- integrated_lanes = (b["iterations"] || []).flat_map do |s|
428
- (s["lanes"] || []).filter_map { |l| { iteration: s, lane: l } if l["integration_branch"] }
429
- end
430
-
431
- raise Space::Core::Error, "nothing integrated yet — integrate a lane before landing" if integrated_lanes.empty?
432
-
433
- repos = integrated_lanes.map { |e| e[:lane]["repo"] }.uniq
434
-
435
- repos.map do |repo|
436
- body_dir = space.path.join("build", "land")
437
- FileUtils.mkdir_p(body_dir)
438
- body_path = body_dir.join("#{repo}-pr-body.md")
439
-
440
- iterations = b["iterations"] || []
441
- body = +"# #{space.title}\n\nMerges `#{integration_branch}` → `main`.\n\n## Iterations\n\n"
442
- iterations.each do |s|
443
- nn = format("%02d", s["ordinal"])
444
- verdict = s["verdict"] || "—"
445
- body << "- I#{nn} #{s["name"]} — #{verdict}\n"
446
- end
447
- body_path.write(body)
448
-
449
- cmd = %(gh pr create --base main --head #{integration_branch} --title "#{space.title}" --body-file #{body_path})
450
- context = "# Run from repos/#{repo} on branch #{integration_branch} (gh pushes it)"
451
- { repo: repo, integration_branch: integration_branch, body_file: body_path.to_s, command: cmd, context: context }
452
- end
453
- end
454
-
455
422
  # Run the iteration's frozen Acceptance Criteria gate commands. Each gate is
456
423
  # executed in the resolved cwd (per-gate `cwd` overrides the base dir), under
457
424
  # a hard timeout, and evaluated against its `expect` block. Returns an array
@@ -475,6 +442,7 @@ module Space::Architect
475
442
  if lane
476
443
  le = lanes.find { |l| l["name"] == lane }
477
444
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
445
+ le = ensure_lane_materialized(iteration, lane)
478
446
  repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil
479
447
  space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
480
448
  else
@@ -595,7 +563,13 @@ module Space::Architect
595
563
 
596
564
  # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
597
565
  unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
598
- git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
566
+ if branch_exists?(repo_path, branch)
567
+ # The worktree was removed but its lane branch survived — re-attach the branch
568
+ # (carrying its own tip) instead of `-b`, which would fail "branch already exists".
569
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
570
+ else
571
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
572
+ end
599
573
  end
600
574
 
601
575
  # Seed prompt.md with a placeholder stub so the architect has a place to write the prompt.
@@ -741,9 +715,39 @@ module Space::Architect
741
715
  wt_base.children.select(&:directory?).map { |p| p.basename.to_s }.sort
742
716
  end
743
717
 
718
+ # Materialize the iteration's declared lanes: for each lane (or the one named via
719
+ # `lane:`), create its worktree + lane/<id>-<lane> branch from the resolved base and
720
+ # record worktree/base_sha/integration_branch. Idempotent — an already-materialized
721
+ # lane is skipped, not re-created. Refuses until the iteration is frozen, because
722
+ # declarations are not authoritative until then.
723
+ def provision(iteration, base: nil, lane: nil)
724
+ entry = slice_entry(iteration)
725
+ raise Space::Core::Error,
726
+ "Iteration '#{iteration}' is not frozen — freeze before provisioning (declarations are not authoritative until frozen)." \
727
+ unless entry["freeze_sha"]
728
+
729
+ lanes = entry["lanes"] || []
730
+ lanes = lanes.select { |l| l["name"] == lane } if lane
731
+ raise Space::Core::Error, "No lane '#{lane}' declared for iteration '#{iteration}'" if lane && lanes.empty?
732
+
733
+ lanes.map do |l|
734
+ name = l["name"]
735
+ repo_path = space.path.join("repos", l["repo"])
736
+ wt_path = space.path.join(l["worktree"] || "build/#{iteration_id(entry)}-#{name}/wt")
737
+ if wt_path.exist? && worktree_registered?(repo_path, wt_path)
738
+ { lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
739
+ else
740
+ result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
741
+ **recorded_lane_fields(l))
742
+ { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
743
+ end
744
+ end
745
+ end
746
+
744
747
  def verify(iteration)
745
748
  entry = slice_entry(iteration)
746
749
  (entry["lanes"] || []).map do |lane|
750
+ ensure_lane_materialized(iteration, lane["name"])
747
751
  { lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane) }
748
752
  end
749
753
  end
@@ -751,7 +755,7 @@ module Space::Architect
751
755
  def dispatch(iteration, lane, model: nil, max_turns: 200,
752
756
  claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
753
757
  push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
754
- push_client: nil, timeout: nil)
758
+ push_client: nil, timeout: nil, now: Time.now)
755
759
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
756
760
  raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
757
761
  raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
@@ -760,6 +764,7 @@ module Space::Architect
760
764
  entry = slice_entry(iteration)
761
765
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
762
766
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
767
+ lane_entry = ensure_lane_materialized(iteration, lane)
763
768
 
764
769
  resolved_harness = harness || lane_entry["harness"] || "claude-code"
765
770
  resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
@@ -786,6 +791,20 @@ module Space::Architect
786
791
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
787
792
  bin: bin, config_dir: build_dir, effort: resolved_effort)
788
793
 
794
+ # Stamp launch time onto the lane entry: after every preflight validation has passed
795
+ # (a dispatch that raises above records nothing) and before the blocking run or a
796
+ # detached dispatch returns. A re-dispatch overwrites the prior value.
797
+ update_architect_block do |b|
798
+ (b["iterations"] || []).each do |s|
799
+ next unless s["name"] == iteration
800
+ (s["lanes"] || []).each do |l|
801
+ next unless l["name"] == lane
802
+ l["dispatched_at"] = now.iso8601
803
+ end
804
+ end
805
+ b
806
+ end
807
+
789
808
  if detach
790
809
  pid = harness_obj.run_detached(
791
810
  prompt_path: prompt_path,
@@ -822,6 +841,21 @@ module Space::Architect
822
841
 
823
842
  attr_reader :space
824
843
 
844
+ # Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
845
+ # either merge_lane! results (symbol keys) or recorded lane entries (string
846
+ # keys) — both carry a lane name and a repo.
847
+ def teardown_lanes!(iteration, lane_entries)
848
+ id = iteration_id(slice_entry(iteration))
849
+ lane_entries.map do |l|
850
+ lane = l[:lane] || l["name"]
851
+ repo = l[:repo] || l["repo"]
852
+ worktree_remove(iteration, lane)
853
+ lane_branch = "lane/#{id}-#{lane}"
854
+ git_capture("-C", space.path.join("repos", repo).to_s, "branch", "-d", lane_branch)
855
+ { lane: lane, repo: repo, lane_branch: lane_branch }
856
+ end
857
+ end
858
+
825
859
  # Resolve the in-flight iteration file for ground output.
826
860
  # Rule: (a) current_iteration from project block → entry's file if it exists on disk,
827
861
  # (b) else highest-ordinal architecture/I<NN>-*.md,
@@ -892,6 +926,55 @@ module Space::Architect
892
926
  "project/#{slug}"
893
927
  end
894
928
 
929
+ # Materialize a declared lane on demand: when its worktree is absent, create it
930
+ # identically to `provision` (same base resolution, same worktree_add primitive)
931
+ # so no dispatch/integrate/gate path dead-ends on a missing worktree. Returns the
932
+ # (possibly refreshed) lane entry; a no-op when already materialized or undeclared.
933
+ def ensure_lane_materialized(iteration, lane)
934
+ entry = slice_entry(iteration)
935
+ lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
936
+ return lane_entry unless lane_entry && lane_entry["repo"]
937
+
938
+ repo_path = space.path.join("repos", lane_entry["repo"])
939
+ return lane_entry unless repo_path.exist?
940
+ wt_path = space.path.join(lane_entry["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
941
+ return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
942
+
943
+ worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
944
+ **recorded_lane_fields(lane_entry))
945
+ (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
946
+ end
947
+
948
+ # The lane's declared harness/model/variant/effort, as worktree_add kwargs — so a
949
+ # re-materialize preserves them instead of merging back to defaults. touch_set is
950
+ # deliberately omitted: worktree_add leaves it untouched, so it already survives.
951
+ def recorded_lane_fields(lane_entry)
952
+ {
953
+ harness: lane_entry["harness"] || "claude-code",
954
+ model: lane_entry["model"],
955
+ variant: lane_entry["variant"] || false,
956
+ effort: lane_entry["effort"]
957
+ }
958
+ end
959
+
960
+ # Resolve the base ref a lane's worktree branches from: an explicit override wins;
961
+ # otherwise the project/<slug> integration branch when it exists, else the repo's
962
+ # default branch.
963
+ def resolve_lane_base(repo, override)
964
+ return override if override
965
+ repo_path = space.path.join("repos", repo)
966
+ integration = project_integration_branch
967
+ return integration if branch_exists?(repo_path, integration)
968
+ repo_default_branch(repo_path)
969
+ end
970
+
971
+ def repo_default_branch(repo_path)
972
+ out, _, st = git_capture("-C", repo_path.to_s, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
973
+ return out.strip.sub(%r{\Aorigin/}, "") if st.success? && !out.strip.empty?
974
+ out, _, st = git_capture("-C", repo_path.to_s, "symbolic-ref", "--short", "HEAD")
975
+ st.success? && !out.strip.empty? ? out.strip : "HEAD"
976
+ end
977
+
895
978
  def slice_entry(iteration)
896
979
  block = space.data["project"] || {}
897
980
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
@@ -1018,6 +1101,44 @@ module Space::Architect
1018
1101
  parsed.is_a?(Array) ? parsed : []
1019
1102
  end
1020
1103
 
1104
+ # Extract and parse the fenced ```lanes block from the Specification section.
1105
+ # Returns an array of lane declaration hashes (string-keyed). Returns [] when the
1106
+ # block is absent, empty, or contains only YAML comments (back-compat).
1107
+ def parse_lanes(text)
1108
+ body = section_body(text, "## Specification")
1109
+ return [] unless body
1110
+ match = body.match(/^```lanes\n(.*?)^```/m)
1111
+ return [] unless match
1112
+ parsed = YAML.safe_load(match[1], aliases: false)
1113
+ parsed.is_a?(Array) ? parsed : []
1114
+ end
1115
+
1116
+ # Lint the lanes block in the given iteration file text. Raises Space::Core::Error
1117
+ # with aggregated messages on failure. An absent/empty block is allowed (back-compat).
1118
+ def lint_lanes!(text)
1119
+ lanes = begin
1120
+ parse_lanes(text)
1121
+ rescue Psych::SyntaxError => e
1122
+ raise Space::Core::Error, "ill-formed lanes block: #{e.message}"
1123
+ end
1124
+ return if lanes.empty?
1125
+
1126
+ errors = lanes.each_with_index.flat_map do |l, i|
1127
+ unless l.is_a?(Hash)
1128
+ next ["lane #{i}: expected a mapping with name/repo/touch"]
1129
+ end
1130
+ touch = l["touch"]
1131
+ [
1132
+ ("lane #{i}: missing 'name'" if l["name"].to_s.strip.empty?),
1133
+ ("lane #{i}: missing 'repo'" if l["repo"].to_s.strip.empty?),
1134
+ ("lane #{i} (#{l["name"]}): 'touch' must be a non-empty array of globs" \
1135
+ unless touch.is_a?(Array) && touch.any? && touch.all? { |g| g.is_a?(String) && !g.strip.empty? })
1136
+ ].compact
1137
+ end
1138
+ return if errors.empty?
1139
+ raise Space::Core::Error, "ill-formed lanes block:\n#{errors.join("\n")}"
1140
+ end
1141
+
1021
1142
  # Lint the gates block in the given iteration file text. Raises Space::Core::Error
1022
1143
  # with aggregated messages on failure. Absent/empty gates appends a warning to
1023
1144
  # the optional warnings array but does not fail.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Space
7
+ module Architect
8
+ module BugReport
9
+ REPO = "jetpks/space-architect"
10
+
11
+ class << self
12
+ def generate(space: nil, env: ENV, cwd: Dir.pwd, now: Time.now)
13
+ body_path = resolve_body_path(space, cwd, now)
14
+ FileUtils.mkdir_p(body_path.dirname)
15
+ body = build_body(space)
16
+ body_path.write(body)
17
+ contracted = Space::Core::Paths.contract(body_path, env: env)
18
+ command = Space::Core::Commands.wrap(
19
+ %(gh issue create -R #{REPO} --title "<one-line summary>" --body-file #{contracted})
20
+ )
21
+ { body_path: body_path, command: command, body: body }
22
+ end
23
+
24
+ private
25
+
26
+ def resolve_body_path(space, cwd, now)
27
+ filename = "architect-bug-report-#{now.strftime('%Y%m%d-%H%M%S')}.md"
28
+ if space
29
+ space.path.join("build", "bug-report", filename)
30
+ else
31
+ Pathname.new(cwd).join(filename)
32
+ end
33
+ end
34
+
35
+ def build_body(space)
36
+ body = +template_header
37
+ body << diagnostics_section
38
+ body << space_section(space) if space
39
+ body
40
+ end
41
+
42
+ def template_header
43
+ <<~MD
44
+ <!-- Title: <one-line summary> -->
45
+
46
+ **Kind:** <!-- process / tooling / both -->
47
+
48
+ ## Summary
49
+
50
+ <!-- One sentence describing the bug. -->
51
+
52
+ ## What happened
53
+
54
+ <!-- Describe what you observed. -->
55
+
56
+ ## What was expected
57
+
58
+ <!-- Describe what you expected to happen. -->
59
+
60
+ ## Repro steps
61
+
62
+ <!-- Numbered steps to reproduce. -->
63
+
64
+ MD
65
+ end
66
+
67
+ def diagnostics_section
68
+ <<~MD
69
+ ## Diagnostics
70
+
71
+ - space-architect: #{Space::Core::VERSION}
72
+ - ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})
73
+ MD
74
+ end
75
+
76
+ def space_section(space)
77
+ iterations = Array(space.data.dig("project", "iterations"))
78
+ iter_lines = iterations.map do |s|
79
+ nn = format("%02d", s["ordinal"])
80
+ verdict = s["verdict"] || "—"
81
+ "- I#{nn} #{s["name"]} — #{verdict}"
82
+ end.join("\n")
83
+
84
+ +"\n## Space context\n\n" \
85
+ "- Space id: #{space.id}\n" \
86
+ "- Space title: #{space.title}\n" \
87
+ "\n### Iterations\n\n" \
88
+ "#{iter_lines.empty? ? "(none)" : iter_lines}\n"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end