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