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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +16 -3
- data/lib/space_architect/architect_project.rb +186 -65
- data/lib/space_architect/bug_report.rb +93 -0
- data/lib/space_architect/cli/architect.rb +104 -28
- data/lib/space_architect/harness.rb +63 -9
- data/lib/space_architect/templates/iteration.md.erb +15 -1
- data/lib/space_architect.rb +1 -0
- 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/commands.rb +20 -0
- data/lib/space_core/paths.rb +23 -0
- 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/terminal.rb +1 -18
- data/lib/space_core/version.rb +1 -1
- data/lib/space_core.rb +2 -0
- 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 +47 -19
- data/skill/architect/dispatch.md +37 -23
- metadata +5 -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/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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|