space-architect 4.0.0 → 5.1.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 +137 -0
- data/README.md +11 -7
- data/lib/space_architect/architect_project.rb +236 -70
- data/lib/space_architect/cli/architect.rb +128 -60
- data/lib/space_core/cli/run.rb +3 -2
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +26 -5
- data/skill/architect/dispatch.md +26 -16
- metadata +15 -1
|
@@ -20,15 +20,15 @@ module Space::Architect
|
|
|
20
20
|
FROZEN_BOUNDARY = /^## Builder Prompt/
|
|
21
21
|
|
|
22
22
|
# Sections the architect writes (and the CLI commits) via `architect section`.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# command (`architect evidence`) because it is transcribed verbatim from scratch.
|
|
23
|
+
# Builder Report has its own command (`architect evidence`) because it is
|
|
24
|
+
# transcribed verbatim from scratch.
|
|
26
25
|
# `frozen: true` sections live above the freeze boundary and are refused once frozen.
|
|
27
26
|
SECTIONS = {
|
|
28
|
-
"grounds" => { heading: "## Grounds", message: "grounds", frozen: true },
|
|
29
|
-
"specification" => { heading: "## Specification", message: "specification", frozen: true },
|
|
30
|
-
"
|
|
31
|
-
"
|
|
27
|
+
"grounds" => { heading: "## Grounds", message: "grounds", prefix: "grounds", frozen: true },
|
|
28
|
+
"specification" => { heading: "## Specification", message: "specification", prefix: "spec", frozen: true },
|
|
29
|
+
"acceptance-criteria" => { heading: "## Acceptance Criteria", message: "acceptance criteria", prefix: "ac", frozen: true },
|
|
30
|
+
"prompt" => { heading: "## Builder Prompt", message: "dispatched", prefix: "prompt", frozen: false },
|
|
31
|
+
"verdict" => { heading: "## Verdict", message: "verdict", prefix: "verdict", frozen: false }
|
|
32
32
|
}.freeze
|
|
33
33
|
|
|
34
34
|
# The fixed top-level section headings. Section boundaries are detected against
|
|
@@ -42,7 +42,10 @@ module Space::Architect
|
|
|
42
42
|
# Hard per-gate timeout. Generous relative to the full suite (~55s).
|
|
43
43
|
DEFAULT_GATE_TIMEOUT = 900
|
|
44
44
|
|
|
45
|
-
#
|
|
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.
|
|
46
49
|
PROMPT_STUB = "<!-- ARCHITECT: write this lane's builder prompt here, then dispatch. -->"
|
|
47
50
|
|
|
48
51
|
# Inlined settings.json template for `architect init`. Registers a SessionStart
|
|
@@ -74,7 +77,7 @@ module Space::Architect
|
|
|
74
77
|
@space = space
|
|
75
78
|
end
|
|
76
79
|
|
|
77
|
-
def init!
|
|
80
|
+
def init!(message: nil)
|
|
78
81
|
handoff_path = space.path.join("architecture", "ARCHITECT.md")
|
|
79
82
|
settings_path = space.path.join(".claude", "settings.json")
|
|
80
83
|
to_add = []
|
|
@@ -97,15 +100,15 @@ module Space::Architect
|
|
|
97
100
|
|
|
98
101
|
if to_add.any?
|
|
99
102
|
git_run("-C", space.path.to_s, "add", *to_add)
|
|
100
|
-
|
|
101
|
-
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))
|
|
102
105
|
end
|
|
103
106
|
|
|
104
107
|
handoff_path
|
|
105
108
|
end
|
|
106
109
|
|
|
107
110
|
# Allocate the next ordinal and scaffold architecture/I<NN>-<iteration>.md.
|
|
108
|
-
def new_iteration!(name)
|
|
111
|
+
def new_iteration!(name, message: nil)
|
|
109
112
|
block = space.data["project"] || {}
|
|
110
113
|
iterations = block["iterations"] || []
|
|
111
114
|
if iterations.any? { |s| s["name"] == name }
|
|
@@ -133,7 +136,8 @@ module Space::Architect
|
|
|
133
136
|
end
|
|
134
137
|
|
|
135
138
|
git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
|
|
136
|
-
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))
|
|
137
141
|
|
|
138
142
|
path
|
|
139
143
|
end
|
|
@@ -154,7 +158,8 @@ module Space::Architect
|
|
|
154
158
|
# Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits
|
|
155
159
|
# any pending changes to the iteration file and records HEAD as freeze_sha. If
|
|
156
160
|
# already frozen, refuses when the frozen region has changed since.
|
|
157
|
-
|
|
161
|
+
# With force: true, re-freezes a changed frozen region if no lane is dispatched yet.
|
|
162
|
+
def freeze!(iteration, warnings: nil, message: nil, force: false)
|
|
158
163
|
entry = slice_entry(iteration)
|
|
159
164
|
rel = entry["file"]
|
|
160
165
|
path = space.path.join(rel)
|
|
@@ -170,17 +175,24 @@ module Space::Architect
|
|
|
170
175
|
if entry["freeze_sha"]
|
|
171
176
|
sha = entry["freeze_sha"]
|
|
172
177
|
if frozen_region_changed?(sha, rel)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
if force
|
|
179
|
+
dispatched_guard!(entry)
|
|
180
|
+
# fall through to commit path to re-freeze with new sha
|
|
181
|
+
else
|
|
182
|
+
raise Space::Core::Error,
|
|
183
|
+
"Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
|
|
184
|
+
"refusing to re-freeze. Restore them to their frozen state or use a new iteration."
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
return sha
|
|
176
188
|
end
|
|
177
|
-
return sha
|
|
178
189
|
end
|
|
179
190
|
|
|
180
191
|
files = [rel]
|
|
181
192
|
files << "architecture/ARCHITECT.md" if space.path.join("architecture", "ARCHITECT.md").exist?
|
|
182
193
|
nn = format("%02d", entry["ordinal"] || 0)
|
|
183
|
-
git_capture("-C", space.path.to_s, "commit", "-m",
|
|
194
|
+
git_capture("-C", space.path.to_s, "commit", "-m",
|
|
195
|
+
compose_message("I#{nn} freeze:", "I#{nn}: acceptance criteria (freeze)", message), "--", *files)
|
|
184
196
|
|
|
185
197
|
sha, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
|
|
186
198
|
sha = sha.strip
|
|
@@ -203,35 +215,42 @@ module Space::Architect
|
|
|
203
215
|
b
|
|
204
216
|
end
|
|
205
217
|
|
|
218
|
+
git_run("-C", space.path.to_s, "commit", "-m",
|
|
219
|
+
compose_message("I#{nn} freeze:", "I#{nn}: record freeze sha", message), "--", Space::Core::Space::METADATA_FILE)
|
|
220
|
+
|
|
206
221
|
sha
|
|
207
222
|
end
|
|
208
223
|
|
|
209
224
|
# Scaffold the durable, section-numbered project brief at architecture/BRIEF.md
|
|
210
225
|
# and commit it. The brief is the stable cross-iteration address space iterations
|
|
211
|
-
# cite as "BRIEF §N"; it lives outside the per-iteration freeze region.
|
|
212
|
-
|
|
226
|
+
# cite as "BRIEF §N"; it lives outside the per-iteration freeze region. With
|
|
227
|
+
# content, writes the authored brief instead of the placeholder template.
|
|
228
|
+
def brief_new!(force: false, content: nil, message: nil)
|
|
213
229
|
brief_path = space.path.join("architecture", "BRIEF.md")
|
|
214
230
|
if brief_path.exist? && !force
|
|
215
231
|
raise Space::Core::Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite"
|
|
216
232
|
end
|
|
217
233
|
|
|
218
234
|
FileUtils.mkdir_p(brief_path.dirname)
|
|
219
|
-
brief_path.write(render_brief)
|
|
235
|
+
brief_path.write(content || render_brief)
|
|
220
236
|
git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md")
|
|
221
|
-
|
|
237
|
+
if staged_changes?
|
|
238
|
+
git_run("-C", space.path.to_s, "commit", "-m", compose_message("brief:", "Add project brief", message))
|
|
239
|
+
end
|
|
222
240
|
brief_path
|
|
223
241
|
end
|
|
224
242
|
|
|
225
243
|
# Write one section of the iteration file and commit it with the canonical
|
|
226
244
|
# per-section message, in one call. Refuses to write a frozen section
|
|
227
|
-
# (Grounds/Specification) once the iteration is frozen.
|
|
228
|
-
#
|
|
229
|
-
|
|
245
|
+
# (Grounds/Specification/Acceptance Criteria) once the iteration is frozen.
|
|
246
|
+
# With force: true, writes a frozen section if no lane is dispatched yet.
|
|
247
|
+
# Builder Report is not here (use evidence).
|
|
248
|
+
def write_section!(iteration, section, body:, append: false, lane: nil, message: nil, force: false)
|
|
230
249
|
spec = SECTIONS[section]
|
|
231
250
|
unless spec
|
|
232
251
|
raise Space::Core::Error,
|
|
233
252
|
"Unknown section '#{section}' — one of: #{SECTIONS.keys.join(', ')}. " \
|
|
234
|
-
"(
|
|
253
|
+
"(Builder Report is written by `architect evidence`.)"
|
|
235
254
|
end
|
|
236
255
|
|
|
237
256
|
entry = slice_entry(iteration)
|
|
@@ -240,16 +259,23 @@ module Space::Architect
|
|
|
240
259
|
raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
|
|
241
260
|
|
|
242
261
|
if spec[:frozen] && entry["freeze_sha"]
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
262
|
+
if force
|
|
263
|
+
dispatched_guard!(entry)
|
|
264
|
+
else
|
|
265
|
+
raise Space::Core::Error,
|
|
266
|
+
"#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
|
|
267
|
+
"frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
|
|
268
|
+
end
|
|
246
269
|
end
|
|
247
270
|
|
|
248
271
|
block = lane ? "### #{lane}\n\n#{body.strip}" : body.strip
|
|
249
|
-
|
|
272
|
+
new_text = replace_section_body(path.read, spec[:heading], block, append: append)
|
|
273
|
+
lint_gates!(new_text) if section == "acceptance-criteria"
|
|
274
|
+
path.write(new_text)
|
|
250
275
|
|
|
251
276
|
nn = format("%02d", entry["ordinal"] || 0)
|
|
252
|
-
_o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m",
|
|
277
|
+
_o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m",
|
|
278
|
+
compose_message("I#{nn} #{spec[:prefix]}:", "I#{nn}: #{spec[:message]}", message), "--", rel)
|
|
253
279
|
committed = cst.success?
|
|
254
280
|
show_out, = git_capture("-C", space.path.to_s, "show", "--stat", "--format=%H", "HEAD")
|
|
255
281
|
show_lines = show_out.to_s.lines
|
|
@@ -260,7 +286,7 @@ module Space::Architect
|
|
|
260
286
|
|
|
261
287
|
# Write the ## Verdict prose AND record the decision to space.yaml in one commit.
|
|
262
288
|
# decision must be "continue" or "kill".
|
|
263
|
-
def record_verdict!(iteration, decision:, body:)
|
|
289
|
+
def record_verdict!(iteration, decision:, body:, message: nil)
|
|
264
290
|
unless %w[continue kill].include?(decision)
|
|
265
291
|
raise Space::Core::Error,
|
|
266
292
|
"Invalid verdict decision '#{decision}' — must be one of: continue, kill"
|
|
@@ -279,7 +305,8 @@ module Space::Architect
|
|
|
279
305
|
end
|
|
280
306
|
|
|
281
307
|
nn = format("%02d", entry["ordinal"] || 0)
|
|
282
|
-
git_run("-C", space.path.to_s, "commit", "-m",
|
|
308
|
+
git_run("-C", space.path.to_s, "commit", "-m",
|
|
309
|
+
compose_message("I#{nn} verdict:", "I#{nn}: verdict", message), "--", rel, Space::Core::Space::METADATA_FILE)
|
|
283
310
|
|
|
284
311
|
head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
|
|
285
312
|
{ decision: decision, sha: head.strip }
|
|
@@ -287,7 +314,7 @@ module Space::Architect
|
|
|
287
314
|
|
|
288
315
|
# Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
|
|
289
316
|
# the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
|
|
290
|
-
def transcribe_evidence!(iteration, lane: nil)
|
|
317
|
+
def transcribe_evidence!(iteration, lane: nil, message: nil)
|
|
291
318
|
entry = slice_entry(iteration)
|
|
292
319
|
rel = entry["file"]
|
|
293
320
|
path = space.path.join(rel)
|
|
@@ -303,7 +330,8 @@ module Space::Architect
|
|
|
303
330
|
path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
|
|
304
331
|
|
|
305
332
|
nn = format("%02d", entry["ordinal"] || 0)
|
|
306
|
-
git_capture("-C", space.path.to_s, "commit", "-m",
|
|
333
|
+
git_capture("-C", space.path.to_s, "commit", "-m",
|
|
334
|
+
compose_message("I#{nn} evidence:", "I#{nn}: evidence", message), "--", rel)
|
|
307
335
|
head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
|
|
308
336
|
|
|
309
337
|
status_line = raw.lines.reverse_each.find { |l| l.strip.start_with?("STATUS:") }&.strip
|
|
@@ -331,13 +359,13 @@ module Space::Architect
|
|
|
331
359
|
# the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
|
|
332
360
|
# Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
|
|
333
361
|
# (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
|
|
334
|
-
def merge_lane!(iteration, lane, message: nil)
|
|
362
|
+
def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil)
|
|
335
363
|
entry = slice_entry(iteration)
|
|
336
364
|
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
337
365
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
338
366
|
lane_entry = ensure_lane_materialized(iteration, lane)
|
|
339
367
|
|
|
340
|
-
checks = lane_mechanical_checks(entry, lane_entry)
|
|
368
|
+
checks = lane_mechanical_checks(entry, lane_entry, commit_mode: commit_mode)
|
|
341
369
|
if checks[:no_builder_commits] == false
|
|
342
370
|
raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
|
|
343
371
|
end
|
|
@@ -352,13 +380,14 @@ module Space::Architect
|
|
|
352
380
|
raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
|
|
353
381
|
base_sha = lane_entry["base_sha"]
|
|
354
382
|
lane_branch = "lane/#{id}-#{lane}"
|
|
355
|
-
integration_branch = project_integration_branch
|
|
383
|
+
integration_branch = into || project_integration_branch
|
|
356
384
|
|
|
357
385
|
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
|
|
358
386
|
raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
|
|
359
387
|
|
|
360
388
|
git_run("-C", wt_path.to_s, "add", "-A")
|
|
361
|
-
git_run("-C", wt_path.to_s, "commit", "-m",
|
|
389
|
+
git_run("-C", wt_path.to_s, "commit", "-m",
|
|
390
|
+
compose_message("lane #{lane}:", "lane #{lane}: integrate", message))
|
|
362
391
|
integrate_sha_raw, = git_capture("-C", wt_path.to_s, "rev-parse", "HEAD")
|
|
363
392
|
integrate_sha = integrate_sha_raw.strip
|
|
364
393
|
|
|
@@ -373,9 +402,22 @@ module Space::Architect
|
|
|
373
402
|
unless mst.success?
|
|
374
403
|
conflicts, = git_capture("-C", repo_path.to_s, "diff", "--name-only", "--diff-filter=U")
|
|
375
404
|
git_capture("-C", repo_path.to_s, "merge", "--abort")
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
405
|
+
conflict_files = conflicts.split
|
|
406
|
+
lane_touch_set = lane_entry["touch_set"] || []
|
|
407
|
+
fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
408
|
+
outside = conflict_files.reject do |f|
|
|
409
|
+
lane_touch_set.any? { |g| File.fnmatch(g, f, fnm) || (g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm)) }
|
|
410
|
+
end
|
|
411
|
+
if !lane_touch_set.empty? && outside.empty?
|
|
412
|
+
raise Space::Core::Error,
|
|
413
|
+
"Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — the lane plan was " \
|
|
414
|
+
"not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
|
|
415
|
+
else
|
|
416
|
+
raise Space::Core::Error,
|
|
417
|
+
"Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — conflicting files " \
|
|
418
|
+
"are outside the lane's touch set; this looks like a branch mismatch: the lane is being merged " \
|
|
419
|
+
"into '#{integration_branch}'. Use --into <branch> to target the correct branch. #{merr.strip}"
|
|
420
|
+
end
|
|
379
421
|
end
|
|
380
422
|
|
|
381
423
|
merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
|
|
@@ -402,14 +444,14 @@ module Space::Architect
|
|
|
402
444
|
# first conflict (a disjointness defect). Never decides which lanes pass. With no
|
|
403
445
|
# lanes and teardown: true, tears down every lane recorded for the iteration instead
|
|
404
446
|
# (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
|
|
405
|
-
def integrate!(iteration, lanes: nil, teardown: false)
|
|
447
|
+
def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil)
|
|
406
448
|
lanes = Array(lanes)
|
|
407
449
|
return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
|
|
408
450
|
raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
|
|
409
451
|
|
|
410
452
|
merged = []
|
|
411
453
|
lanes.each do |lane|
|
|
412
|
-
merged << merge_lane!(iteration, lane)
|
|
454
|
+
merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into)
|
|
413
455
|
rescue Space::Core::Error => e
|
|
414
456
|
done = merged.map { |m| m[:lane] }.join(", ")
|
|
415
457
|
raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
|
|
@@ -522,10 +564,46 @@ module Space::Architect
|
|
|
522
564
|
parts << "=== #{rel} ===\n\n#{iter_path.read}"
|
|
523
565
|
end
|
|
524
566
|
|
|
567
|
+
space.repos.each do |repo|
|
|
568
|
+
name = repo["name"]
|
|
569
|
+
repo_path = space.path.join("repos", name).to_s
|
|
570
|
+
next unless Dir.exist?(repo_path)
|
|
571
|
+
|
|
572
|
+
branch_out, _, branch_st = git_capture("-C", repo_path, "symbolic-ref", "--short", "HEAD")
|
|
573
|
+
next unless branch_st.success?
|
|
574
|
+
branch = branch_out.strip
|
|
575
|
+
|
|
576
|
+
git_capture("-C", repo_path, "fetch", "origin")
|
|
577
|
+
|
|
578
|
+
count_out, _, count_st = git_capture("-C", repo_path, "rev-list", "--left-right", "--count",
|
|
579
|
+
"#{branch}...origin/#{branch}")
|
|
580
|
+
next unless count_st.success?
|
|
581
|
+
|
|
582
|
+
behind = count_out.strip.split[1].to_i
|
|
583
|
+
if behind > 0
|
|
584
|
+
parts << "WARNING: repos/#{name} local #{branch} is #{behind} commits behind " \
|
|
585
|
+
"origin/#{branch} — run `architect sync #{name}`"
|
|
586
|
+
end
|
|
587
|
+
rescue
|
|
588
|
+
# tolerate fetch or comparison failures silently
|
|
589
|
+
end
|
|
590
|
+
|
|
525
591
|
parts.join("\n")
|
|
526
592
|
end
|
|
527
593
|
|
|
528
|
-
|
|
594
|
+
# Sync tracked repo clones with their remotes (fast-forward only).
|
|
595
|
+
# Returns an array of result hashes: { repo:, status:, message: }.
|
|
596
|
+
# With no repo_name, syncs every tracked repo; with a name, syncs only that one.
|
|
597
|
+
def sync_repos(repo_name: nil)
|
|
598
|
+
repos = space.repos
|
|
599
|
+
if repo_name
|
|
600
|
+
repos = repos.select { |r| r["name"] == repo_name }
|
|
601
|
+
raise Space::Core::Error, "repo '#{repo_name}' not tracked in this space" if repos.empty?
|
|
602
|
+
end
|
|
603
|
+
repos.map { |r| sync_one_repo(r["name"]) }
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil, force: false)
|
|
529
607
|
if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
|
|
530
608
|
raise Space::Core::Error,
|
|
531
609
|
"Pass --model when using --harness opencode " \
|
|
@@ -544,7 +622,6 @@ module Space::Architect
|
|
|
544
622
|
|
|
545
623
|
id = iteration_id(entry)
|
|
546
624
|
wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
|
|
547
|
-
build_dir = space.path.join("build", "#{id}-#{lane}")
|
|
548
625
|
FileUtils.mkdir_p(wt_path.dirname)
|
|
549
626
|
|
|
550
627
|
base_ref = base || "HEAD"
|
|
@@ -554,11 +631,15 @@ module Space::Architect
|
|
|
554
631
|
|
|
555
632
|
branch = "lane/#{id}-#{lane}"
|
|
556
633
|
|
|
557
|
-
# Guard: an existing directory that is not a registered worktree is ambiguous
|
|
634
|
+
# Guard: an existing directory that is not a registered worktree is ambiguous.
|
|
558
635
|
if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
636
|
+
if force
|
|
637
|
+
FileUtils.rm_rf(wt_path)
|
|
638
|
+
else
|
|
639
|
+
raise Space::Core::Error,
|
|
640
|
+
"#{wt_path} exists but is not a registered git worktree of #{repo} — " \
|
|
641
|
+
"resolve manually before re-running worktree_add, or re-run with --force to clear and re-create it"
|
|
642
|
+
end
|
|
562
643
|
end
|
|
563
644
|
|
|
564
645
|
# Skip git worktree add when the branch and worktree already exist (idempotent re-run).
|
|
@@ -572,11 +653,6 @@ module Space::Architect
|
|
|
572
653
|
end
|
|
573
654
|
end
|
|
574
655
|
|
|
575
|
-
# Seed prompt.md with a placeholder stub so the architect has a place to write the prompt.
|
|
576
|
-
# Never overwrite an existing file (real prompt or stub from a prior run).
|
|
577
|
-
prompt_path = build_dir.join("prompt.md")
|
|
578
|
-
prompt_path.write("#{PROMPT_STUB}\n") unless prompt_path.exist?
|
|
579
|
-
|
|
580
656
|
new_fields = {
|
|
581
657
|
"name" => lane,
|
|
582
658
|
"repo" => repo,
|
|
@@ -720,7 +796,7 @@ module Space::Architect
|
|
|
720
796
|
# record worktree/base_sha/integration_branch. Idempotent — an already-materialized
|
|
721
797
|
# lane is skipped, not re-created. Refuses until the iteration is frozen, because
|
|
722
798
|
# declarations are not authoritative until then.
|
|
723
|
-
def provision(iteration, base: nil, lane: nil)
|
|
799
|
+
def provision(iteration, base: nil, lane: nil, force: false)
|
|
724
800
|
entry = slice_entry(iteration)
|
|
725
801
|
raise Space::Core::Error,
|
|
726
802
|
"Iteration '#{iteration}' is not frozen — freeze before provisioning (declarations are not authoritative until frozen)." \
|
|
@@ -738,24 +814,24 @@ module Space::Architect
|
|
|
738
814
|
{ lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
|
|
739
815
|
else
|
|
740
816
|
result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
|
|
741
|
-
**recorded_lane_fields(l))
|
|
817
|
+
force: force, **recorded_lane_fields(l))
|
|
742
818
|
{ lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
|
|
743
819
|
end
|
|
744
820
|
end
|
|
745
821
|
end
|
|
746
822
|
|
|
747
|
-
def verify(iteration)
|
|
823
|
+
def verify(iteration, commit_mode: nil)
|
|
748
824
|
entry = slice_entry(iteration)
|
|
749
825
|
(entry["lanes"] || []).map do |lane|
|
|
750
826
|
ensure_lane_materialized(iteration, lane["name"])
|
|
751
|
-
{ lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane) }
|
|
827
|
+
{ lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane, commit_mode: commit_mode) }
|
|
752
828
|
end
|
|
753
829
|
end
|
|
754
830
|
|
|
755
831
|
def dispatch(iteration, lane, model: nil, max_turns: 200,
|
|
756
832
|
claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
|
|
757
833
|
push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
|
|
758
|
-
push_client: nil, timeout: nil, now: Time.now)
|
|
834
|
+
push_client: nil, timeout: nil, prompt: nil, now: Time.now)
|
|
759
835
|
raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
|
|
760
836
|
raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
|
|
761
837
|
raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
|
|
@@ -781,6 +857,15 @@ module Space::Architect
|
|
|
781
857
|
prompt_path = build_dir.join("prompt.md")
|
|
782
858
|
run_log_path = build_dir.join("run.jsonl")
|
|
783
859
|
report_path = build_dir.join("report.md")
|
|
860
|
+
|
|
861
|
+
# --prompt: the caller authors the lane prompt anywhere (a fresh scratch file)
|
|
862
|
+
# and the CLI owns the canonical copy — byte-for-byte, like variant_add.
|
|
863
|
+
if prompt
|
|
864
|
+
src = Pathname.new(prompt)
|
|
865
|
+
raise Space::Core::Error, "prompt file not found: #{src}" unless src.exist?
|
|
866
|
+
File.open(prompt_path, "wb") { |f| f.write(File.binread(src)) }
|
|
867
|
+
end
|
|
868
|
+
|
|
784
869
|
raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
|
|
785
870
|
|
|
786
871
|
prompt_content = prompt_path.read.strip
|
|
@@ -811,7 +896,9 @@ module Space::Architect
|
|
|
811
896
|
run_log_path: run_log_path,
|
|
812
897
|
chdir: wt_path
|
|
813
898
|
)
|
|
814
|
-
{ pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path }
|
|
899
|
+
result = { pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path }
|
|
900
|
+
result[:prompt_copied] = prompt_path if prompt
|
|
901
|
+
result
|
|
815
902
|
else
|
|
816
903
|
created_run_id = nil
|
|
817
904
|
if push_host
|
|
@@ -830,6 +917,7 @@ module Space::Architect
|
|
|
830
917
|
exit_code = harness_obj.run(**run_kwargs)
|
|
831
918
|
|
|
832
919
|
result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
|
|
920
|
+
result[:prompt_copied] = prompt_path if prompt
|
|
833
921
|
result[:timed_out] = true if exit_code == Harness::ClaudeCodeHarness::TIMEOUT_EXIT_CODE
|
|
834
922
|
result[:created_run_id] = created_run_id if created_run_id
|
|
835
923
|
result[:push_url] = push_url if push_url
|
|
@@ -841,6 +929,20 @@ module Space::Architect
|
|
|
841
929
|
|
|
842
930
|
attr_reader :space
|
|
843
931
|
|
|
932
|
+
# Compose a commit message. Without a custom message, the canonical default
|
|
933
|
+
# (unchanged). With one, a short canonical prefix keeps the loop's commit
|
|
934
|
+
# taxonomy grep-able while the author's first line owns the subject; any
|
|
935
|
+
# remaining lines become the commit body — the space's git log is the loop's
|
|
936
|
+
# durable memory, so callers are encouraged to write detailed bodies.
|
|
937
|
+
def compose_message(prefix, default, message)
|
|
938
|
+
return default if message.nil? || message.strip.empty?
|
|
939
|
+
|
|
940
|
+
subject, _, body = message.strip.partition("\n")
|
|
941
|
+
composed = "#{prefix} #{subject.strip}"
|
|
942
|
+
body = body.strip
|
|
943
|
+
body.empty? ? composed : "#{composed}\n\n#{body}"
|
|
944
|
+
end
|
|
945
|
+
|
|
844
946
|
# Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
|
|
845
947
|
# either merge_lane! results (symbol keys) or recorded lane entries (string
|
|
846
948
|
# keys) — both carry a lane name and a repo.
|
|
@@ -997,7 +1099,7 @@ module Space::Architect
|
|
|
997
1099
|
|
|
998
1100
|
# The four per-lane post-flight checks, shared by `verify` (reports) and
|
|
999
1101
|
# `merge_lane!` (refuses on failure) so the two can never drift.
|
|
1000
|
-
def lane_mechanical_checks(entry, lane)
|
|
1102
|
+
def lane_mechanical_checks(entry, lane, commit_mode: nil)
|
|
1001
1103
|
freeze_sha = entry["freeze_sha"]
|
|
1002
1104
|
rel = entry["file"]
|
|
1003
1105
|
lane_name = lane["name"]
|
|
@@ -1010,12 +1112,21 @@ module Space::Architect
|
|
|
1010
1112
|
# (a) frozen sections of the iteration file untouched since freeze
|
|
1011
1113
|
checks[:frozen_untouched] = (!frozen_region_changed?(freeze_sha, rel) if freeze_sha && rel)
|
|
1012
1114
|
|
|
1013
|
-
# (b) no builder commits in the worktree (the architect's integrate commit is excluded
|
|
1014
|
-
|
|
1015
|
-
|
|
1115
|
+
# (b) no builder commits in the worktree (the architect's integrate commit is excluded;
|
|
1116
|
+
# in conductor mode, canonical conductor commits are also excluded)
|
|
1117
|
+
effective_commit_mode = commit_mode || space.data.dig("project", "commit_mode") || "strict"
|
|
1118
|
+
log_out, = git_capture("-C", wt_path.to_s, "log", "--format=%H%x09%s", "#{base_sha}..")
|
|
1119
|
+
commit_entries = log_out.strip.split("\n").filter_map do |line|
|
|
1120
|
+
sha, subject = line.strip.split("\t", 2)
|
|
1121
|
+
{ sha: sha, subject: subject.to_s } unless sha.nil? || sha.empty?
|
|
1122
|
+
end
|
|
1016
1123
|
recorded_integrate = lane["integrate_sha"]&.strip
|
|
1017
|
-
|
|
1018
|
-
|
|
1124
|
+
canonical_conductor = "#{iteration_id(entry)}-#{lane_name}: builder output"
|
|
1125
|
+
builder_commits = commit_entries.reject do |c|
|
|
1126
|
+
c[:sha] == recorded_integrate ||
|
|
1127
|
+
(effective_commit_mode == "conductor" && c[:subject] == canonical_conductor)
|
|
1128
|
+
end
|
|
1129
|
+
checks[:no_builder_commits] = builder_commits.empty?
|
|
1019
1130
|
|
|
1020
1131
|
# (c) builder's scratch report exists and is non-empty
|
|
1021
1132
|
report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
|
|
@@ -1026,7 +1137,7 @@ module Space::Architect
|
|
|
1026
1137
|
:no_touch_set
|
|
1027
1138
|
else
|
|
1028
1139
|
# -z: NUL-delimited; renames emit new_path NUL old_path — include both
|
|
1029
|
-
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain", "-z")
|
|
1140
|
+
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain", "-z", "-uall")
|
|
1030
1141
|
changed = []
|
|
1031
1142
|
entries = status_out.split("\0")
|
|
1032
1143
|
i = 0
|
|
@@ -1043,7 +1154,12 @@ module Space::Architect
|
|
|
1043
1154
|
changed << orig if orig && !orig.empty?
|
|
1044
1155
|
end
|
|
1045
1156
|
fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
1046
|
-
changed.all?
|
|
1157
|
+
changed.all? do |f|
|
|
1158
|
+
touch_set.any? do |g|
|
|
1159
|
+
File.fnmatch(g, f, fnm) ||
|
|
1160
|
+
(g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm))
|
|
1161
|
+
end
|
|
1162
|
+
end
|
|
1047
1163
|
end
|
|
1048
1164
|
|
|
1049
1165
|
checks
|
|
@@ -1157,6 +1273,17 @@ module Space::Architect
|
|
|
1157
1273
|
raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
|
|
1158
1274
|
end
|
|
1159
1275
|
|
|
1276
|
+
# Raises if any lane in the entry has been dispatched (dispatched_at or integrate_sha set).
|
|
1277
|
+
# Used by freeze! and write_section! --force to prevent rewriting frozen content after a
|
|
1278
|
+
# builder has run (moving freeze_sha post-dispatch breaks the AC cardinal invariant).
|
|
1279
|
+
def dispatched_guard!(entry)
|
|
1280
|
+
lane = (entry["lanes"] || []).find { |l| l["dispatched_at"] || l["integrate_sha"] }
|
|
1281
|
+
return unless lane
|
|
1282
|
+
raise Space::Core::Error,
|
|
1283
|
+
"Lane '#{lane["name"]}' is already dispatched — cannot re-freeze or write frozen sections " \
|
|
1284
|
+
"after dispatch (a builder has run against the frozen AC; rewriting it breaks the cardinal invariant)."
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1160
1287
|
def staged_changes?
|
|
1161
1288
|
_o, _e, st = git_capture("-C", space.path.to_s, "diff", "--cached", "--quiet")
|
|
1162
1289
|
!st.success? # --quiet exits non-zero when there are staged differences
|
|
@@ -1202,6 +1329,45 @@ module Space::Architect
|
|
|
1202
1329
|
Open3.capture3("git", *args)
|
|
1203
1330
|
end
|
|
1204
1331
|
|
|
1332
|
+
def sync_one_repo(name)
|
|
1333
|
+
repo_path = space.path.join("repos", name).to_s
|
|
1334
|
+
|
|
1335
|
+
dirty_out, _, _ = git_capture("-C", repo_path, "status", "--porcelain")
|
|
1336
|
+
return { repo: name, status: :dirty, message: "#{name}: dirty working tree — skipping" } if dirty_out.strip.length > 0
|
|
1337
|
+
|
|
1338
|
+
branch_out, _, branch_st = git_capture("-C", repo_path, "symbolic-ref", "--short", "HEAD")
|
|
1339
|
+
unless branch_st.success?
|
|
1340
|
+
return { repo: name, status: :error, message: "#{name}: detached HEAD — skipping" }
|
|
1341
|
+
end
|
|
1342
|
+
branch = branch_out.strip
|
|
1343
|
+
|
|
1344
|
+
_, fetch_err, fetch_st = git_capture("-C", repo_path, "fetch", "origin")
|
|
1345
|
+
unless fetch_st.success?
|
|
1346
|
+
return { repo: name, status: :error, message: "#{name}: fetch failed — #{fetch_err.strip}" }
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
count_out, _, count_st = git_capture("-C", repo_path, "rev-list", "--left-right", "--count",
|
|
1350
|
+
"#{branch}...origin/#{branch}")
|
|
1351
|
+
unless count_st.success?
|
|
1352
|
+
return { repo: name, status: :error, message: "#{name}: could not compare with origin/#{branch}" }
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
ahead, behind = count_out.strip.split.map(&:to_i)
|
|
1356
|
+
return { repo: name, status: :up_to_date, message: "#{name}: up to date" } if behind == 0
|
|
1357
|
+
|
|
1358
|
+
if ahead > 0
|
|
1359
|
+
return { repo: name, status: :diverged,
|
|
1360
|
+
message: "#{name}: behind #{behind}, diverged #{ahead} — not fast-forwardable, resolve manually" }
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
_, ff_err, ff_st = git_capture("-C", repo_path, "merge", "--ff-only", "origin/#{branch}")
|
|
1364
|
+
if ff_st.success?
|
|
1365
|
+
{ repo: name, status: :fast_forwarded, message: "#{name}: fast-forwarded #{behind} commits" }
|
|
1366
|
+
else
|
|
1367
|
+
{ repo: name, status: :ff_failed, message: "#{name}: merge --ff-only failed — #{ff_err.strip}" }
|
|
1368
|
+
end
|
|
1369
|
+
end
|
|
1370
|
+
|
|
1205
1371
|
def branch_exists?(repo_path, branch)
|
|
1206
1372
|
_, _, st = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", branch)
|
|
1207
1373
|
st.success?
|