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 +4 -4
- data/README.md +14 -1
- data/lib/space_architect/architect_project.rb +186 -28
- data/lib/space_architect/cli/architect.rb +80 -6
- data/lib/space_architect/harness.rb +63 -9
- data/lib/space_architect/templates/iteration.md.erb +15 -1
- data/lib/space_core/cli/help.rb +88 -18
- data/lib/space_core/cli/loop_status.rb +47 -0
- data/lib/space_core/cli/repeatable_options.rb +52 -43
- data/lib/space_core/cli/status.rb +54 -13
- data/lib/space_core/shell_integration.rb +1 -1
- data/lib/space_core/space.rb +51 -1
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/version.rb +1 -1
- data/lib/space_src/cli/clone.rb +0 -1
- data/lib/space_src/cli/config.rb +0 -1
- data/lib/space_src/cli/daemon.rb +0 -1
- data/lib/space_src/cli/org.rb +0 -1
- data/lib/space_src/cli/repo.rb +0 -1
- data/lib/space_src/cli/shell.rb +0 -1
- data/lib/space_src/cli/status.rb +0 -1
- data/lib/space_src/cli/sync.rb +0 -1
- data/lib/space_src/cli.rb +0 -1
- data/skill/architect/SKILL.md +37 -15
- data/skill/architect/dispatch.md +32 -20
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb8f902d475bcc8a29e56740ce0c20328935fe05f9d919d38934b2cffd12e966
|
|
4
|
+
data.tar.gz: e31c5b6f8ad98219c9c4ed22225666900bc7aaf8f43bd929ef32e2d2b5aacdf2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
246
|
-
committed =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
diffstat
|
|
251
|
-
{ section: section, heading: spec[:heading], sha:
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
383
|
-
|
|
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
|