space-architect 3.0.0 → 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: 135a6282cfe3e3073265b71867e7d228673cbfb9b7253feaaba5ad67a180b7cb
4
- data.tar.gz: 0d0406a99feadadebb24a6167c11837de25bede933e752ef6507524208f4e5a7
3
+ metadata.gz: bb8f902d475bcc8a29e56740ce0c20328935fe05f9d919d38934b2cffd12e966
4
+ data.tar.gz: e31c5b6f8ad98219c9c4ed22225666900bc7aaf8f43bd929ef32e2d2b5aacdf2
5
5
  SHA512:
6
- metadata.gz: c8b161eff4f4b5a788c2e3c9b423bcc31a495915a076afb70445f6b0cdaf1cc2573ee381ce0c3e09ca712a8398b70ca091b015260b6b4e9c24914ece4a0f5539
7
- data.tar.gz: f61cdd897881530ad2e81df5bd846c4e644fb85ac6847c4afd147e643b6045b59c7ecb89fd12b83f9a7b5c2ef12813c5e88e7634449a919aac2e4bc75052a6b0
6
+ metadata.gz: 5cbb47cbacf2231ed09eb74bfb11b80a1a6076bfde1427fe76a19b3581f3b3a8e38db4a6269307e339a022902723d73b1d40dd4b001acd6ffaab624871249d70
7
+ data.tar.gz: 171913d786d1e166a279f72b184ec7987785d561ec795bfe161db9341502210f6d97166c3eb8b7a097b59adba7e435a575c78ca66488ca482312b4bc7cca7249
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
@@ -308,6 +316,11 @@ architect variant add|compare|promote … # competing (harness, model) lanes
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
@@ -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,13 +415,7 @@ 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
 
@@ -438,6 +442,7 @@ module Space::Architect
438
442
  if lane
439
443
  le = lanes.find { |l| l["name"] == lane }
440
444
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
445
+ le = ensure_lane_materialized(iteration, lane)
441
446
  repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil
442
447
  space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
443
448
  else
@@ -558,7 +563,13 @@ module Space::Architect
558
563
 
559
564
  # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
560
565
  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)
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
562
573
  end
563
574
 
564
575
  # Seed prompt.md with a placeholder stub so the architect has a place to write the prompt.
@@ -704,9 +715,39 @@ module Space::Architect
704
715
  wt_base.children.select(&:directory?).map { |p| p.basename.to_s }.sort
705
716
  end
706
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
+
707
747
  def verify(iteration)
708
748
  entry = slice_entry(iteration)
709
749
  (entry["lanes"] || []).map do |lane|
750
+ ensure_lane_materialized(iteration, lane["name"])
710
751
  { lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane) }
711
752
  end
712
753
  end
@@ -714,7 +755,7 @@ module Space::Architect
714
755
  def dispatch(iteration, lane, model: nil, max_turns: 200,
715
756
  claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
716
757
  push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
717
- push_client: nil, timeout: nil)
758
+ push_client: nil, timeout: nil, now: Time.now)
718
759
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
719
760
  raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
720
761
  raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
@@ -723,6 +764,7 @@ module Space::Architect
723
764
  entry = slice_entry(iteration)
724
765
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
725
766
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
767
+ lane_entry = ensure_lane_materialized(iteration, lane)
726
768
 
727
769
  resolved_harness = harness || lane_entry["harness"] || "claude-code"
728
770
  resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
@@ -749,6 +791,20 @@ module Space::Architect
749
791
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
750
792
  bin: bin, config_dir: build_dir, effort: resolved_effort)
751
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
+
752
808
  if detach
753
809
  pid = harness_obj.run_detached(
754
810
  prompt_path: prompt_path,
@@ -785,6 +841,21 @@ module Space::Architect
785
841
 
786
842
  attr_reader :space
787
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
+
788
859
  # Resolve the in-flight iteration file for ground output.
789
860
  # Rule: (a) current_iteration from project block → entry's file if it exists on disk,
790
861
  # (b) else highest-ordinal architecture/I<NN>-*.md,
@@ -855,6 +926,55 @@ module Space::Architect
855
926
  "project/#{slug}"
856
927
  end
857
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
+
858
978
  def slice_entry(iteration)
859
979
  block = space.data["project"] || {}
860
980
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
@@ -981,6 +1101,44 @@ module Space::Architect
981
1101
  parsed.is_a?(Array) ? parsed : []
982
1102
  end
983
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
+
984
1142
  # Lint the gates block in the given iteration file text. Raises Space::Core::Error
985
1143
  # with aggregated messages on failure. Absent/empty gates appends a warning to
986
1144
  # the optional warnings array but does not fail.
@@ -4,9 +4,23 @@ require "json"
4
4
 
5
5
  module Space::Architect
6
6
  module CLI
7
+ # Optional loop-phase DSL on the shared command base, read by
8
+ # Space::Core::CLI::Help to group the help listing. A command may declare
9
+ # `phase <order>, "<Label>"`; groups, and members within a group, sort by
10
+ # <order>. Only architect commands declare one — space commands leave it nil
11
+ # and render as the single ungrouped default listing (unchanged).
12
+ class BaseCommand
13
+ def self.phase(order = nil, label = nil)
14
+ return @phase if order.nil?
15
+
16
+ @phase = [order, label]
17
+ end
18
+ end
19
+
7
20
  module Architect
8
21
  class Init < BaseCommand
9
22
  desc "Scaffold (or top up) the architect project: ARCHITECT.md, space.yaml project block, SessionStart hook"
23
+ phase 50, "Project"
10
24
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
11
25
 
12
26
  def call(space: nil, **opts)
@@ -24,6 +38,7 @@ module Space::Architect
24
38
 
25
39
  class Ground < BaseCommand
26
40
  desc "Print grounding reads (ARCHITECT.md, BRIEF.md, in-flight iteration) to stdout"
41
+ phase 51, "Project"
27
42
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
28
43
 
29
44
  def call(space: nil, **opts)
@@ -56,6 +71,7 @@ module Space::Architect
56
71
 
57
72
  class New < BaseCommand
58
73
  desc "Scaffold the next iteration file (architecture/I<NN>-<iteration>.md)"
74
+ phase 10, "Spec"
59
75
  argument :iteration, required: true, desc: "Iteration name (kebab-case)"
60
76
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
61
77
 
@@ -74,6 +90,7 @@ module Space::Architect
74
90
 
75
91
  class Status < BaseCommand
76
92
  desc "Show architect project state (read-only)"
93
+ phase 52, "Project"
77
94
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
78
95
 
79
96
  def call(space: nil, **opts)
@@ -126,6 +143,7 @@ module Space::Architect
126
143
 
127
144
  class Freeze < BaseCommand
128
145
  desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
146
+ phase 12, "Spec"
129
147
  argument :iteration, required: true, desc: "Iteration name"
130
148
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
131
149
 
@@ -152,6 +170,7 @@ module Space::Architect
152
170
 
153
171
  class Verify < BaseCommand
154
172
  desc "Post-flight mechanical lane checks — frozen-untouched, no builder commits, report exists, in-bounds (reports only, no judgment)"
173
+ phase 30, "Judge"
155
174
  argument :iteration, required: true, desc: "Iteration name"
156
175
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
157
176
 
@@ -199,6 +218,7 @@ module Space::Architect
199
218
 
200
219
  class Dispatch < BaseCommand
201
220
  desc "Dispatch a builder for a lane (streams to build/<id>-<lane>/run.jsonl)"
221
+ phase 21, "Build"
202
222
  argument :iteration, required: true, desc: "Iteration name"
203
223
  argument :lane, required: true, desc: "Lane name"
204
224
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
@@ -251,8 +271,37 @@ module Space::Architect
251
271
  end
252
272
  end
253
273
 
274
+ class Provision < BaseCommand
275
+ desc "Materialize declared lanes (worktree + lane/<id>-<lane> branch) from the frozen lane plan"
276
+ phase 20, "Build"
277
+ argument :iteration, required: true, desc: "Iteration name"
278
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
279
+ option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
280
+ option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
281
+
282
+ def call(iteration:, space: nil, base: nil, lane: nil, **opts)
283
+ setup_terminal(**opts.slice(:color, :colors))
284
+ handle_errors do
285
+ render(store.find(space)) do |sp|
286
+ project = ArchitectProject.new(space: sp)
287
+ results = project.provision(iteration, base: base, lane: lane)
288
+ if results.empty?
289
+ terminal.say "No declared lanes to provision for '#{iteration}'"
290
+ else
291
+ results.each do |r|
292
+ state = r[:created] ? "created" : "already present"
293
+ terminal.say "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
294
+ end
295
+ end
296
+ CLI.record_outcome(Outcome.new(exit_code: 0))
297
+ end
298
+ end
299
+ end
300
+ end
301
+
254
302
  class Section < BaseCommand
255
303
  desc "Write a section of the iteration file and commit it (one call)"
304
+ phase 11, "Spec"
256
305
  argument :iteration, required: true, desc: "Iteration name"
257
306
  argument :section, required: true, desc: "Section: grounds, specification, prompt, verdict"
258
307
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
@@ -292,6 +341,7 @@ module Space::Architect
292
341
 
293
342
  class Verdict < BaseCommand
294
343
  desc "Record the architect's verdict decision (continue or kill) and write ## Verdict prose"
344
+ phase 33, "Judge"
295
345
  argument :iteration, required: true, desc: "Iteration name"
296
346
  argument :decision, required: true, desc: "Decision: continue or kill"
297
347
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
@@ -324,6 +374,7 @@ module Space::Architect
324
374
 
325
375
  class Evidence < BaseCommand
326
376
  desc "Transcribe a lane's scratch report VERBATIM into Builder Report and commit"
377
+ phase 31, "Judge"
327
378
  argument :iteration, required: true, desc: "Iteration name"
328
379
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
329
380
  option :lane, default: nil, desc: "Lane name (per-lane subsection; omit for a single-lane iteration)"
@@ -345,6 +396,7 @@ module Space::Architect
345
396
 
346
397
  class Merge < BaseCommand
347
398
  desc "Integrate ONE judged-passing lane (merges --no-ff; runs no gates, makes no verdict)"
399
+ phase 41, "Land"
348
400
  argument :iteration, required: true, desc: "Iteration name"
349
401
  argument :lane, required: true, desc: "Lane name (architect-judged passing)"
350
402
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
@@ -367,22 +419,36 @@ module Space::Architect
367
419
 
368
420
  class Integrate < BaseCommand
369
421
  desc "Integrate the architect-supplied set of passing lanes, in order (stops on conflict)"
422
+ phase 40, "Land"
370
423
  argument :iteration, required: true, desc: "Iteration name"
371
424
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
372
- option :lanes, required: true, desc: "Comma-separated passing lane names (you decide the set)"
425
+ option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
373
426
  option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
374
427
 
375
- def call(iteration:, space: nil, lanes:, teardown: false, **opts)
428
+ def call(iteration:, space: nil, lanes: nil, teardown: false, **opts)
376
429
  setup_terminal(**opts.slice(:color, :colors))
377
430
  handle_errors do
431
+ lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
432
+ raise Space::Core::Error, "integrate needs --lanes <set>, or --teardown for teardown-only" \
433
+ if lane_names.empty? && !teardown
434
+
378
435
  render(store.find(space)) do |sp|
379
436
  project = ArchitectProject.new(space: sp)
380
- lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
381
437
  results = project.integrate!(iteration, lanes: lane_names, teardown: teardown)
382
- results.each do |r|
383
- terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
438
+ if lane_names.empty?
439
+ if results.empty?
440
+ terminal.say "Nothing to tear down for #{iteration}"
441
+ else
442
+ results.each do |r|
443
+ terminal.say "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
444
+ end
445
+ end
446
+ else
447
+ results.each do |r|
448
+ terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
449
+ end
450
+ terminal.say "Gates NOT run — run `architect gate #{iteration}`; the verdict is the next session's."
384
451
  end
385
- terminal.say "Gates NOT run — run `architect gate #{iteration}`; the verdict is the next session's."
386
452
  CLI.record_outcome(Outcome.new(exit_code: 0))
387
453
  end
388
454
  end
@@ -391,6 +457,7 @@ module Space::Architect
391
457
 
392
458
  class Gate < BaseCommand
393
459
  desc "Run the frozen Acceptance Criteria gate commands and report PASS/FAIL"
460
+ phase 32, "Judge"
394
461
  argument :iteration, required: true, desc: "Iteration name"
395
462
  argument :lane, required: false, desc: "Run in a lane worktree (default: the integration repo)"
396
463
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
@@ -420,6 +487,7 @@ module Space::Architect
420
487
 
421
488
  class BugReport < BaseCommand
422
489
  desc "Generate a prefilled GitHub issue template for filing bugs against space-architect"
490
+ phase 54, "Project"
423
491
 
424
492
  def call(**opts)
425
493
  setup_terminal(**opts.slice(:color, :colors))
@@ -443,6 +511,7 @@ module Space::Architect
443
511
 
444
512
  class InstallSkills < BaseCommand
445
513
  desc "Install bundled skills (architect, architect-research, architect-vocabulary) for a harness"
514
+ phase 53, "Project"
446
515
  option :provider, default: "claude", desc: "Harness: claude, codex, opencode, pi"
447
516
  option :project, type: :boolean, default: false, desc: "Install to CWD instead of global"
448
517
  option :force, type: :boolean, default: false, desc: "Overwrite existing skills that differ"
@@ -642,12 +711,17 @@ module Space::Architect
642
711
  end
643
712
  end
644
713
 
714
+ # Loop-phase declarations above sort the architect help listing; its namespaces
715
+ # (brief/worktree/variant/research) declare no phase and list under this header.
716
+ Space::Core::CLI::Help.trailing_group_label = "Groups"
717
+
645
718
  Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Architect::Init
646
719
  Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
647
720
  Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
648
721
  Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
649
722
  Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
650
723
  Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
724
+ Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
651
725
  Space::Architect::CLI::Registry.register "dispatch", Space::Architect::CLI::Architect::Dispatch
652
726
  Space::Architect::CLI::Registry.register "section", Space::Architect::CLI::Architect::Section
653
727
  Space::Architect::CLI::Registry.register "verdict", Space::Architect::CLI::Architect::Verdict