space-architect 5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6846eab011f92f0b54a7bb886d7b97cf8c727beb3fc6d279a2db603062645520
4
- data.tar.gz: 0bc8f0b22f785fd2d3ed77419a1ad3b6058e4b76a6aaee41b7a66793b865702a
3
+ metadata.gz: '09d047544f9e1b3a29c56cc1e982a9624f72d45c2198429fcd7ab4564c31da41'
4
+ data.tar.gz: ea4e880ceda1c31ba11b390448d2380ca6d45d39394aeef2963c80a0e59a0901
5
5
  SHA512:
6
- metadata.gz: 77216fd23779d4ee5ae16f3c141fc9f7670b3efc3c8b7fe31292c31284be0903616505c4199fbe4e5d7b12988d0638e7a31cba600a4625caad2b939255fc9370
7
- data.tar.gz: ca1fb9e9bd1c20c6ef201d60f763cdcb87b18b9c289169ff601be6c858b0558f283986b2b24bb82cf73b4b1001c3ce0a9955bd02675557548fcc2e930aea27d2
6
+ metadata.gz: 01d09d4cd267afe17f189606d3620bdfa284802c4ccd506a2526172864b85c1124b8096aa3a5ed3fe61cfc3f18929d0aeb8f00d7d9c6238dfdf331ce1e2f8048
7
+ data.tar.gz: 313a4cf3c9dc69d5cb82d5d8bb1b95fa5d133de711d9ab5e320145525abddff4aebe8fc89cf58e0552d83147fae79ce697b567dce5a8ac387313f5064f58bf2b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.1.0] - 2026-07-19
9
+
10
+ ### Added
11
+
12
+ - **`architect integrate --into <branch>`** — merge a lane into a named branch
13
+ instead of the slug-derived `project/<slug>` default (#47). Outside-touch-set
14
+ conflict message gains an `--into` hint; inside-touch conflict keeps "spec
15
+ defect".
16
+ - **Conductor commit-mode** — `commit_mode: conductor` in `space.yaml` plus a
17
+ `--commit-mode` CLI flag on `verify`/`integrate`: canonical conductor commits
18
+ are classified as non-builder in the lane mechanical check, with canonical
19
+ message-shape matching (#55).
20
+ - **`architect sync` subcommand** (`--ff-only`, per-repo status) plus a
21
+ `ground` stale-repo WARNING with behind count and `--into` hint. **No
22
+ auto-sync** — the operator runs `sync` (#49).
23
+ - **`architect freeze --force` / `section --force`** — re-freeze an iteration
24
+ whose frozen region changed since the last freeze, or write a frozen section
25
+ after the freeze (pre-dispatch amend paths). Both refuse if any lane has
26
+ `dispatched_at` OR `integrate_sha` — moving `freeze_sha` or rewriting a frozen
27
+ section after a builder has run against the AC breaks the cardinal invariant
28
+ (AC freeze before results exist; judging quotes the freeze commit). The guard
29
+ names the offending lane and the reason.
30
+ - **`architect merge --into <branch>` / `--commit-mode <mode>`** — wires the
31
+ existing `merge_lane!` `into:`/`commit_mode:` kwargs through the `Merge` CLI,
32
+ matching `Integrate`'s surface.
33
+ - **`architect provision --force` / `worktree add --force`** — `worktree_add`
34
+ gains a `force: false` kwarg. When a worktree dir exists but is unknown to git
35
+ (stale, e.g. left by an aborted provision), `force: true` clears it via
36
+ `FileUtils.rm_rf` and re-creates; without `force` it still raises with a
37
+ `--force` hint so a genuine git-tracked dir is never silently destroyed.
38
+ `ensure_lane_materialized` (the auto-recovery path) stays non-force —
39
+ auto-recovery never silently `rm -rf`s; only an explicit operator `--force`.
40
+
41
+ ### Changed
42
+
43
+ - **`dir/**` in-bounds touch glob is now recursive** — matches
44
+ `Dir.glob('**/**')` semantics; single-star `*` stays non-recursive.
45
+ Deep-globbed files inside a lane's declared touch set are in-bounds; outside
46
+ is still a spec defect (#52, #54).
47
+ - **`dispatched_at` recorded in `space.yaml` at dispatch time** (#18).
48
+
49
+ ### Fixed
50
+
51
+ - **Canonical `section`/`freeze` commit message shape** — per-section commits
52
+ and the freeze commit carry the correct canonical prefix and body.
53
+ - **`space run --help` steers to the `--` separator** — a quoted multi-word
54
+ command otherwise arrives as one argv token → opaque in-guest failure; the
55
+ desc/argument/example now show the `--` form, with a subprocess test (#28).
56
+ - **Dropped a load-time "assigned but unused variable" warning** — `build_dir`
57
+ in `worktree_add` was computed but never read.
58
+
8
59
  ## [5.0.0] - 2026-07-06
9
60
 
10
61
  ### Added
@@ -20,13 +20,13 @@ 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
27
  "grounds" => { heading: "## Grounds", message: "grounds", prefix: "grounds", frozen: true },
29
28
  "specification" => { heading: "## Specification", message: "specification", prefix: "spec", frozen: true },
29
+ "acceptance-criteria" => { heading: "## Acceptance Criteria", message: "acceptance criteria", prefix: "ac", frozen: true },
30
30
  "prompt" => { heading: "## Builder Prompt", message: "dispatched", prefix: "prompt", frozen: false },
31
31
  "verdict" => { heading: "## Verdict", message: "verdict", prefix: "verdict", frozen: false }
32
32
  }.freeze
@@ -158,7 +158,8 @@ module Space::Architect
158
158
  # Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits
159
159
  # any pending changes to the iteration file and records HEAD as freeze_sha. If
160
160
  # already frozen, refuses when the frozen region has changed since.
161
- def freeze!(iteration, warnings: nil, message: 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)
162
163
  entry = slice_entry(iteration)
163
164
  rel = entry["file"]
164
165
  path = space.path.join(rel)
@@ -174,11 +175,17 @@ module Space::Architect
174
175
  if entry["freeze_sha"]
175
176
  sha = entry["freeze_sha"]
176
177
  if frozen_region_changed?(sha, rel)
177
- raise Space::Core::Error,
178
- "Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
179
- "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
180
188
  end
181
- return sha
182
189
  end
183
190
 
184
191
  files = [rel]
@@ -208,6 +215,9 @@ module Space::Architect
208
215
  b
209
216
  end
210
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
+
211
221
  sha
212
222
  end
213
223
 
@@ -232,14 +242,15 @@ module Space::Architect
232
242
 
233
243
  # Write one section of the iteration file and commit it with the canonical
234
244
  # per-section message, in one call. Refuses to write a frozen section
235
- # (Grounds/Specification) once the iteration is frozen. Acceptance Criteria is
236
- # NOT writable here (use freeze); Builder Report is not here (use evidence).
237
- def write_section!(iteration, section, body:, append: false, lane: nil, message: 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)
238
249
  spec = SECTIONS[section]
239
250
  unless spec
240
251
  raise Space::Core::Error,
241
252
  "Unknown section '#{section}' — one of: #{SECTIONS.keys.join(', ')}. " \
242
- "(Acceptance Criteria is set by `architect freeze`; Builder Report by `architect evidence`.)"
253
+ "(Builder Report is written by `architect evidence`.)"
243
254
  end
244
255
 
245
256
  entry = slice_entry(iteration)
@@ -248,13 +259,19 @@ module Space::Architect
248
259
  raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
249
260
 
250
261
  if spec[:frozen] && entry["freeze_sha"]
251
- raise Space::Core::Error,
252
- "#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
253
- "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
254
269
  end
255
270
 
256
271
  block = lane ? "### #{lane}\n\n#{body.strip}" : body.strip
257
- 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)
258
275
 
259
276
  nn = format("%02d", entry["ordinal"] || 0)
260
277
  _o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m",
@@ -342,13 +359,13 @@ module Space::Architect
342
359
  # the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
343
360
  # Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
344
361
  # (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
345
- def merge_lane!(iteration, lane, message: nil)
362
+ def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil)
346
363
  entry = slice_entry(iteration)
347
364
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
348
365
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
349
366
  lane_entry = ensure_lane_materialized(iteration, lane)
350
367
 
351
- checks = lane_mechanical_checks(entry, lane_entry)
368
+ checks = lane_mechanical_checks(entry, lane_entry, commit_mode: commit_mode)
352
369
  if checks[:no_builder_commits] == false
353
370
  raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
354
371
  end
@@ -363,7 +380,7 @@ module Space::Architect
363
380
  raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
364
381
  base_sha = lane_entry["base_sha"]
365
382
  lane_branch = "lane/#{id}-#{lane}"
366
- integration_branch = project_integration_branch
383
+ integration_branch = into || project_integration_branch
367
384
 
368
385
  status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
369
386
  raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
@@ -385,9 +402,22 @@ module Space::Architect
385
402
  unless mst.success?
386
403
  conflicts, = git_capture("-C", repo_path.to_s, "diff", "--name-only", "--diff-filter=U")
387
404
  git_capture("-C", repo_path.to_s, "merge", "--abort")
388
- raise Space::Core::Error,
389
- "Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) the lane plan was " \
390
- "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
391
421
  end
392
422
 
393
423
  merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
@@ -414,14 +444,14 @@ module Space::Architect
414
444
  # first conflict (a disjointness defect). Never decides which lanes pass. With no
415
445
  # lanes and teardown: true, tears down every lane recorded for the iteration instead
416
446
  # (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
417
- def integrate!(iteration, lanes: nil, teardown: false, message: nil)
447
+ def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil)
418
448
  lanes = Array(lanes)
419
449
  return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
420
450
  raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
421
451
 
422
452
  merged = []
423
453
  lanes.each do |lane|
424
- merged << merge_lane!(iteration, lane, message: message)
454
+ merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into)
425
455
  rescue Space::Core::Error => e
426
456
  done = merged.map { |m| m[:lane] }.join(", ")
427
457
  raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
@@ -534,10 +564,46 @@ module Space::Architect
534
564
  parts << "=== #{rel} ===\n\n#{iter_path.read}"
535
565
  end
536
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
+
537
591
  parts.join("\n")
538
592
  end
539
593
 
540
- 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)
541
607
  if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
542
608
  raise Space::Core::Error,
543
609
  "Pass --model when using --harness opencode " \
@@ -556,7 +622,6 @@ module Space::Architect
556
622
 
557
623
  id = iteration_id(entry)
558
624
  wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
559
- build_dir = space.path.join("build", "#{id}-#{lane}")
560
625
  FileUtils.mkdir_p(wt_path.dirname)
561
626
 
562
627
  base_ref = base || "HEAD"
@@ -566,11 +631,15 @@ module Space::Architect
566
631
 
567
632
  branch = "lane/#{id}-#{lane}"
568
633
 
569
- # 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.
570
635
  if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
571
- raise Space::Core::Error,
572
- "#{wt_path} exists but is not a registered git worktree of #{repo} — " \
573
- "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
574
643
  end
575
644
 
576
645
  # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
@@ -727,7 +796,7 @@ module Space::Architect
727
796
  # record worktree/base_sha/integration_branch. Idempotent — an already-materialized
728
797
  # lane is skipped, not re-created. Refuses until the iteration is frozen, because
729
798
  # declarations are not authoritative until then.
730
- def provision(iteration, base: nil, lane: nil)
799
+ def provision(iteration, base: nil, lane: nil, force: false)
731
800
  entry = slice_entry(iteration)
732
801
  raise Space::Core::Error,
733
802
  "Iteration '#{iteration}' is not frozen — freeze before provisioning (declarations are not authoritative until frozen)." \
@@ -745,17 +814,17 @@ module Space::Architect
745
814
  { lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
746
815
  else
747
816
  result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
748
- **recorded_lane_fields(l))
817
+ force: force, **recorded_lane_fields(l))
749
818
  { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
750
819
  end
751
820
  end
752
821
  end
753
822
 
754
- def verify(iteration)
823
+ def verify(iteration, commit_mode: nil)
755
824
  entry = slice_entry(iteration)
756
825
  (entry["lanes"] || []).map do |lane|
757
826
  ensure_lane_materialized(iteration, lane["name"])
758
- { 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) }
759
828
  end
760
829
  end
761
830
 
@@ -1030,7 +1099,7 @@ module Space::Architect
1030
1099
 
1031
1100
  # The four per-lane post-flight checks, shared by `verify` (reports) and
1032
1101
  # `merge_lane!` (refuses on failure) so the two can never drift.
1033
- def lane_mechanical_checks(entry, lane)
1102
+ def lane_mechanical_checks(entry, lane, commit_mode: nil)
1034
1103
  freeze_sha = entry["freeze_sha"]
1035
1104
  rel = entry["file"]
1036
1105
  lane_name = lane["name"]
@@ -1043,12 +1112,21 @@ module Space::Architect
1043
1112
  # (a) frozen sections of the iteration file untouched since freeze
1044
1113
  checks[:frozen_untouched] = (!frozen_region_changed?(freeze_sha, rel) if freeze_sha && rel)
1045
1114
 
1046
- # (b) no builder commits in the worktree (the architect's integrate commit is excluded)
1047
- log_out, = git_capture("-C", wt_path.to_s, "log", "--format=%H", "#{base_sha}..")
1048
- 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
1049
1123
  recorded_integrate = lane["integrate_sha"]&.strip
1050
- builder_shas = recorded_integrate ? commit_shas.reject { |s| s == recorded_integrate } : commit_shas
1051
- 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?
1052
1130
 
1053
1131
  # (c) builder's scratch report exists and is non-empty
1054
1132
  report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
@@ -1059,7 +1137,7 @@ module Space::Architect
1059
1137
  :no_touch_set
1060
1138
  else
1061
1139
  # -z: NUL-delimited; renames emit new_path NUL old_path — include both
1062
- 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")
1063
1141
  changed = []
1064
1142
  entries = status_out.split("\0")
1065
1143
  i = 0
@@ -1076,7 +1154,12 @@ module Space::Architect
1076
1154
  changed << orig if orig && !orig.empty?
1077
1155
  end
1078
1156
  fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
1079
- 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
1080
1163
  end
1081
1164
 
1082
1165
  checks
@@ -1190,6 +1273,17 @@ module Space::Architect
1190
1273
  raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
1191
1274
  end
1192
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
+
1193
1287
  def staged_changes?
1194
1288
  _o, _e, st = git_capture("-C", space.path.to_s, "diff", "--cached", "--quiet")
1195
1289
  !st.success? # --quiet exits non-zero when there are staged differences
@@ -1235,6 +1329,45 @@ module Space::Architect
1235
1329
  Open3.capture3("git", *args)
1236
1330
  end
1237
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
+
1238
1371
  def branch_exists?(repo_path, branch)
1239
1372
  _, _, st = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", branch)
1240
1373
  st.success?
@@ -174,20 +174,40 @@ module Space::Architect
174
174
  end
175
175
  end
176
176
 
177
+ class Sync < BaseCommand
178
+ desc "Sync tracked repo clones with their remotes (fast-forward only, no rebase/reset)"
179
+ phase 53, "Project"
180
+ argument :repo, required: false, desc: "Repo name to sync (default: all tracked repos)"
181
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
182
+
183
+ def call(repo: nil, space: nil, **opts)
184
+ setup_terminal(**opts.slice(:color, :colors))
185
+ handle_errors do
186
+ render(store.find(space)) do |sp|
187
+ project = ArchitectProject.new(space: sp)
188
+ results = project.sync_repos(repo_name: repo)
189
+ results.each { |r| terminal.say r[:message] }
190
+ CLI.record_outcome(Outcome.new(exit_code: 0))
191
+ end
192
+ end
193
+ end
194
+ end
195
+
177
196
  class Freeze < BaseCommand
178
197
  desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
179
198
  phase 12, "Spec"
180
199
  argument :iteration, required: true, desc: "Iteration name"
181
200
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
201
+ option :force, type: :boolean, default: false, desc: "Re-freeze even if the frozen region changed (pre-dispatch only)"
182
202
  commit_message_options
183
203
 
184
- def call(iteration:, space: nil, message: nil, message_from: nil, **opts)
204
+ def call(iteration:, space: nil, message: nil, message_from: nil, force: false, **opts)
185
205
  setup_terminal(**opts.slice(:color, :colors))
186
206
  handle_errors do
187
207
  render(store.find(space)) do |sp|
188
208
  project = ArchitectProject.new(space: sp)
189
209
  warnings = []
190
- sha = project.freeze!(iteration, warnings: warnings,
210
+ sha = project.freeze!(iteration, warnings: warnings, force: force,
191
211
  message: read_commit_message(message: message, message_from: message_from))
192
212
  terminal.say "Frozen #{iteration} at #{sha}"
193
213
  warnings.each { |w| terminal.say "Warning: #{w}" }
@@ -206,15 +226,17 @@ module Space::Architect
206
226
  class Verify < BaseCommand
207
227
  desc "Post-flight mechanical lane checks — frozen-untouched, no builder commits, report exists, in-bounds (reports only, no judgment)"
208
228
  phase 30, "Judge"
209
- argument :iteration, required: true, desc: "Iteration name"
210
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
229
+ argument :iteration, required: true, desc: "Iteration name"
230
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
231
+ option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
211
232
 
212
- def call(iteration:, space: nil, **opts)
233
+ def call(iteration:, space: nil, commit_mode: nil, **opts)
213
234
  setup_terminal(**opts.slice(:color, :colors))
214
235
  handle_errors do
215
236
  render(store.find(space)) do |sp|
216
237
  project = ArchitectProject.new(space: sp)
217
- results = project.verify(iteration)
238
+ terminal.say "Effective commit_mode: #{commit_mode}" if commit_mode
239
+ results = project.verify(iteration, commit_mode: commit_mode)
218
240
 
219
241
  if results.empty?
220
242
  terminal.say "No lanes recorded for iteration '#{iteration}'"
@@ -316,13 +338,14 @@ module Space::Architect
316
338
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
317
339
  option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
318
340
  option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
341
+ option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
319
342
 
320
- def call(iteration:, space: nil, base: nil, lane: nil, **opts)
343
+ def call(iteration:, space: nil, base: nil, lane: nil, force: false, **opts)
321
344
  setup_terminal(**opts.slice(:color, :colors))
322
345
  handle_errors do
323
346
  render(store.find(space)) do |sp|
324
347
  project = ArchitectProject.new(space: sp)
325
- results = project.provision(iteration, base: base, lane: lane)
348
+ results = project.provision(iteration, base: base, lane: lane, force: force)
326
349
  if results.empty?
327
350
  terminal.say "No declared lanes to provision for '#{iteration}'"
328
351
  else
@@ -341,23 +364,24 @@ module Space::Architect
341
364
  desc "Write a section of the iteration file and commit it (one call)"
342
365
  phase 11, "Spec"
343
366
  argument :iteration, required: true, desc: "Iteration name"
344
- argument :section, required: true, desc: "Section: grounds, specification, prompt, verdict"
367
+ argument :section, required: true, desc: "Section: grounds, specification, acceptance-criteria, prompt, verdict"
345
368
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
346
369
  option :from, default: nil, desc: "Read the section body from this file"
347
370
  option :body, default: nil, desc: "Inline section body (one-liners)"
348
371
  option :stdin, type: :boolean, default: false, desc: "Read the section body from stdin"
349
372
  option :append, type: :boolean, default: false, desc: "Append a ### <lane> subsection instead of replacing"
350
373
  option :lane, default: nil, desc: "Lane name for an appended ### subsection"
374
+ option :force, type: :boolean, default: false, desc: "Write a frozen section (pre-dispatch only)"
351
375
  commit_message_options
352
376
 
353
377
  def call(iteration:, section:, space: nil, from: nil, body: nil, stdin: false, append: false, lane: nil,
354
- message: nil, message_from: nil, **opts)
378
+ message: nil, message_from: nil, force: false, **opts)
355
379
  setup_terminal(**opts.slice(:color, :colors))
356
380
  handle_errors do
357
381
  content = read_body(from: from, body: body, stdin: stdin, what: "section body")
358
382
  render(store.find(space)) do |sp|
359
383
  project = ArchitectProject.new(space: sp)
360
- res = project.write_section!(iteration, section, body: content, append: append, lane: lane,
384
+ res = project.write_section!(iteration, section, body: content, append: append, lane: lane, force: force,
361
385
  message: read_commit_message(message: message, message_from: message_from))
362
386
  if res[:committed]
363
387
  terminal.say "Committed #{res[:heading]} → #{res[:sha][0, 8]}"
@@ -425,17 +449,19 @@ module Space::Architect
425
449
  class Merge < BaseCommand
426
450
  desc "Integrate ONE judged-passing lane (merges --no-ff; runs no gates, makes no verdict)"
427
451
  phase 41, "Land"
428
- argument :iteration, required: true, desc: "Iteration name"
429
- argument :lane, required: true, desc: "Lane name (architect-judged passing)"
430
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
452
+ argument :iteration, required: true, desc: "Iteration name"
453
+ argument :lane, required: true, desc: "Lane name (architect-judged passing)"
454
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
455
+ option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
456
+ option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
431
457
  commit_message_options
432
458
 
433
- def call(iteration:, lane:, space: nil, message: nil, message_from: nil, **opts)
459
+ def call(iteration:, lane:, space: nil, message: nil, message_from: nil, into: nil, commit_mode: nil, **opts)
434
460
  setup_terminal(**opts.slice(:color, :colors))
435
461
  handle_errors do
436
462
  render(store.find(space)) do |sp|
437
463
  project = ArchitectProject.new(space: sp)
438
- r = project.merge_lane!(iteration, lane,
464
+ r = project.merge_lane!(iteration, lane, into: into, commit_mode: commit_mode,
439
465
  message: read_commit_message(message: message, message_from: message_from))
440
466
  terminal.say "Merged #{lane} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
441
467
  terminal.say r[:diffstat] unless r[:diffstat].empty?
@@ -449,13 +475,15 @@ module Space::Architect
449
475
  class Integrate < BaseCommand
450
476
  desc "Integrate the architect-supplied set of passing lanes, in order (stops on conflict)"
451
477
  phase 40, "Land"
452
- argument :iteration, required: true, desc: "Iteration name"
453
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
454
- option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
455
- option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
478
+ argument :iteration, required: true, desc: "Iteration name"
479
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
480
+ option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
481
+ option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
482
+ option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
483
+ option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
456
484
  commit_message_options
457
485
 
458
- def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, **opts)
486
+ def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, **opts)
459
487
  setup_terminal(**opts.slice(:color, :colors))
460
488
  handle_errors do
461
489
  lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
@@ -465,7 +493,8 @@ module Space::Architect
465
493
  render(store.find(space)) do |sp|
466
494
  project = ArchitectProject.new(space: sp)
467
495
  results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
468
- message: read_commit_message(message: message, message_from: message_from))
496
+ message: read_commit_message(message: message, message_from: message_from),
497
+ commit_mode: commit_mode, into: into)
469
498
  if lane_names.empty?
470
499
  if results.empty?
471
500
  terminal.say "Nothing to tear down for #{iteration}"
@@ -574,15 +603,16 @@ module Space::Architect
574
603
  option :model, default: nil, desc: "Model (required for opencode)"
575
604
  option :effort, default: nil, desc: "Reasoning effort (opencode only; sets reasoningEffort in the model config)"
576
605
  option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
606
+ option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
577
607
 
578
- def call(repo:, iteration:, lane:, base: nil, harness: "claude-code", model: nil, effort: nil, touch: nil, **opts)
608
+ def call(repo:, iteration:, lane:, base: nil, harness: "claude-code", model: nil, effort: nil, touch: nil, force: false, **opts)
579
609
  setup_terminal(**opts.slice(:color, :colors))
580
610
  handle_errors do
581
611
  render(store.find) do |sp|
582
612
  project = ArchitectProject.new(space: sp)
583
613
  touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
584
614
  result = project.worktree_add(repo, iteration, lane, base: base,
585
- harness: harness, model: model, effort: effort, touch: touch_set)
615
+ harness: harness, model: model, effort: effort, touch: touch_set, force: force)
586
616
  terminal.say "Worktree: #{terminal.path(result[:worktree])}"
587
617
  terminal.say "Base SHA: #{result[:base_sha]}"
588
618
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -756,6 +786,7 @@ Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Archit
756
786
  Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
757
787
  Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
758
788
  Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
789
+ Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
759
790
  Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
760
791
  Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
761
792
  Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
@@ -5,11 +5,12 @@ require_relative "../oci_runner"
5
5
 
6
6
  module Space::Core::CLI
7
7
  class Run < BaseCommand
8
- desc "Run the packed OCI image for the current space (auth injected at runtime)"
8
+ desc "Run the packed OCI image for the current space (auth injected at runtime). Pass a command and its arguments after `--` so they forward as separate argv tokens"
9
9
 
10
- argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell)"
10
+ argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell). Use `--` to pass a command with arguments, e.g. `space run -- hermes -z \"hello\"`"
11
11
  option :tty, type: :boolean, default: nil, desc: "Force interactive TTY (default: auto-detect)"
12
12
  option :env, type: :array, desc: "Host env var to forward into the container (repeatable; adds to run.env)"
13
+ example "-- hermes -z \"What is 17 plus 4?\" # `--` forwards the command and its args as separate tokens (a quoted multi-word command arrives as one token and fails in-guest)"
13
14
 
14
15
  def call(command: [], tty: nil, env: [], **opts)
15
16
  setup_terminal(**opts.slice(:color, :colors))
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "5.0.0"
5
+ VERSION = "5.1.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -205,6 +205,20 @@ dependencies:
205
205
  - - "~>"
206
206
  - !ruby/object:Gem::Version
207
207
  version: '6.0'
208
+ - !ruby/object:Gem::Dependency
209
+ name: mutant-minitest
210
+ requirement: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - "~>"
213
+ - !ruby/object:Gem::Version
214
+ version: '0.16'
215
+ type: :development
216
+ prerelease: false
217
+ version_requirements: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - "~>"
220
+ - !ruby/object:Gem::Version
221
+ version: '0.16'
208
222
  - !ruby/object:Gem::Dependency
209
223
  name: rake
210
224
  requirement: !ruby/object:Gem::Requirement