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.
@@ -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
- # Acceptance Criteria is intentionally absent it is set by `architect freeze`,
24
- # the one code path that creates the freeze commit. Builder Report has its own
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
- "prompt" => { heading: "## Builder Prompt", message: "dispatched", frozen: false },
31
- "verdict" => { heading: "## Verdict", message: "verdict", frozen: false }
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
- # Sentinel written to prompt.md by worktree_add. dispatch refuses to launch on this content.
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
- msg = to_add.include?("architecture/ARCHITECT.md") ? "Initialize architect project" : "Add architect settings"
101
- git_run("-C", space.path.to_s, "commit", "-m", msg)
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", "I#{nn}: scaffold #{name}")
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
- def freeze!(iteration, warnings: nil)
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
- raise Space::Core::Error,
174
- "Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
175
- "refusing to re-freeze. Restore them to their frozen state or use a new iteration."
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", "I#{nn}: acceptance criteria (freeze)", "--", *files)
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
- def brief_new!(force: false)
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
- git_run("-C", space.path.to_s, "commit", "-m", "Add project brief") if staged_changes?
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. Acceptance Criteria is
228
- # NOT writable here (use freeze); Builder Report is not here (use evidence).
229
- def write_section!(iteration, section, body:, append: false, lane: nil)
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
- "(Acceptance Criteria is set by `architect freeze`; Builder Report by `architect evidence`.)"
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
- raise Space::Core::Error,
244
- "#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
245
- "frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
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
- path.write(replace_section_body(path.read, spec[:heading], block, append: append))
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", "I#{nn}: #{spec[:message]}", "--", rel)
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", "I#{nn}: verdict", "--", rel, Space::Core::Space::METADATA_FILE)
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", "I#{nn}: evidence", "--", rel)
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", message || "lane #{lane}: integrate")
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
- raise Space::Core::Error,
377
- "Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) the lane plan was " \
378
- "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
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
- def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil)
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 — refuse.
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
- raise Space::Core::Error,
560
- "#{wt_path} exists but is not a registered git worktree of #{repo} — " \
561
- "resolve manually before re-running worktree_add"
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
- log_out, = git_capture("-C", wt_path.to_s, "log", "--format=%H", "#{base_sha}..")
1015
- commit_shas = log_out.strip.split("\n").map(&:strip).reject(&:empty?)
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
- builder_shas = recorded_integrate ? commit_shas.reject { |s| s == recorded_integrate } : commit_shas
1018
- checks[:no_builder_commits] = builder_shas.empty?
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? { |f| touch_set.any? { |g| File.fnmatch(g, f, fnm) } }
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?