space-architect 6.0.0 → 7.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.
@@ -241,6 +241,13 @@ module Space::Architect
241
241
  lanes = s["lanes"] || []
242
242
  declared.each do |d|
243
243
  fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
244
+ # #89/AC3-AC4: the frozen lane's own tool grant, reviewable like every other
245
+ # boundary. Declared-only, like touch_set — worktree_add never touches these
246
+ # keys, so a re-materialize preserves them without threading them through
247
+ # recorded_lane_fields (unlike harness/model, which worktree_add resolves and
248
+ # overwrites on every call).
249
+ fields["allowed_tools"] = d["allowed_tools"] if d["allowed_tools"]
250
+ fields["append_allowed_tools"] = d["append_allowed_tools"] if d["append_allowed_tools"]
244
251
  existing = lanes.find { |l| l["name"] == d["name"] }
245
252
  existing ? existing.merge!(fields) : lanes << fields
246
253
  end
@@ -510,9 +517,10 @@ module Space::Architect
510
517
  # here, by pathspec, so which lanes merged survives independently of whether a
511
518
  # Verdict follows (I13/A3). Always attempted, success or raise, so a conflict that
512
519
  # stops the loop midway doesn't lose the lanes already merged.
513
- def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
520
+ def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil,
521
+ accept_bounds_reason: nil, force: false)
514
522
  lanes = Array(lanes)
515
- return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
523
+ return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || [], force: force) if lanes.empty? && teardown
516
524
  raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
517
525
 
518
526
  merged = []
@@ -524,7 +532,7 @@ module Space::Architect
524
532
  raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
525
533
  end
526
534
 
527
- teardown_lanes!(iteration, merged) if teardown
535
+ teardown_lanes!(iteration, merged, force: force) if teardown
528
536
  merged
529
537
  ensure
530
538
  commit_metadata_mutation!(iteration, message: message)
@@ -587,16 +595,17 @@ module Space::Architect
587
595
  text = path.read
588
596
 
589
597
  gates = parse_gates(text)
590
- repo, base_dir, gate_results, scope_report =
598
+ repo, base_dir, gate_results, scope_report, transcript =
591
599
  if gates.empty?
592
- [nil, nil, [], nil]
600
+ [nil, nil, [], nil, nil]
593
601
  else
594
- r = resolve_rehearsal_repo(iteration, text)
595
- dir = space.path.join("repos", r)
596
- raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
602
+ r = resolve_rehearsal_repo(iteration, text, gates)
603
+ dir = r ? space.path.join("repos", r) : nil
604
+ raise Space::Core::Error, "directory does not exist: #{dir}" if dir && !dir.exist?
597
605
  results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil)
598
606
  .map { |g| g.merge(rehearsal: classify_rehearsal(g)) }
599
- [r, dir, results, scope_asymmetry_report(gates, text, dir)]
607
+ scope = scope_asymmetry_report(results, text)
608
+ [r, dir, results, scope, write_rehearsal_transcript(entry, r, dir, results, scope, now)]
600
609
  end
601
610
 
602
611
  digest = gates_digest(text)
@@ -609,7 +618,8 @@ module Space::Architect
609
618
  end
610
619
 
611
620
  { iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results,
612
- empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report }
621
+ empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report,
622
+ transcript: transcript }
613
623
  end
614
624
 
615
625
  # Emit grounding reads for the architect's SessionStart hook.
@@ -688,8 +698,15 @@ module Space::Architect
688
698
  repos.map { |r| sync_one_repo(r["name"]) }
689
699
  end
690
700
 
691
- def worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false,
692
- effort: nil, touch: nil, force: false, err: $stderr)
701
+ # base_explicit distinguishes an operator-given --base (enforce it refuse or
702
+ # re-point per #88's three cases) from an auto-materializing call (just attach
703
+ # whatever's there, never refuse, never move a branch nobody asked to move).
704
+ # Defaults from whether base: itself was given, which is exactly right for a
705
+ # direct `worktree_add`/CLI `worktree add --base` call; ensure_lane_materialized
706
+ # always resolves SOME base internally (it has to, to create a lane from
707
+ # scratch), so it overrides this explicitly to false.
708
+ def worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?, harness: nil, model: nil,
709
+ variant: false, effort: nil, touch: nil, force: false, err: $stderr)
693
710
  model, suffix_level = Harness.parse_model_suffix(model)
694
711
  harness, model = resolve_harness_model(harness, model)
695
712
  resolved_level = effort || suffix_level || project_defaults["effort"]
@@ -712,6 +729,7 @@ module Space::Architect
712
729
  base_sha = base_sha.strip
713
730
 
714
731
  branch = "lane/#{id}-#{lane}"
732
+ recorded_base = (entry["lanes"] || []).find { |l| l["name"] == lane }&.dig("base_sha")
715
733
 
716
734
  # Guard: an existing directory that is not a registered worktree is ambiguous.
717
735
  if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
@@ -724,16 +742,8 @@ module Space::Architect
724
742
  end
725
743
  end
726
744
 
727
- # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
728
- unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
729
- if branch_exists?(repo_path, branch)
730
- # The worktree was removed but its lane branch survived — re-attach the branch
731
- # (carrying its own tip) instead of `-b`, which would fail "branch already exists".
732
- git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
733
- else
734
- git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
735
- end
736
- end
745
+ outcome, base_sha, discarded = attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base,
746
+ base_explicit: base_explicit, force: force)
737
747
 
738
748
  new_fields = {
739
749
  "name" => lane,
@@ -763,7 +773,7 @@ module Space::Architect
763
773
  b
764
774
  end
765
775
 
766
- { worktree: wt_path, base_sha: base_sha }
776
+ { worktree: wt_path, base_sha: base_sha, outcome: outcome, discarded: discarded }
767
777
  end
768
778
 
769
779
  # Declare a variant set for an iteration: one competing lane per (harness, model) pair,
@@ -842,7 +852,13 @@ module Space::Architect
842
852
  }
843
853
  end
844
854
 
845
- def worktree_remove(iteration, lane)
855
+ # #88 (smaller, same area): a removed worktree does not touch its lane branch —
856
+ # remove-then-provision re-points that surviving branch (or refuses if it has
857
+ # its own commits), it is not a reset. Names it in the return so a human
858
+ # reading `worktree remove`'s output isn't misled into thinking it is.
859
+ # Never deletes the branch itself: that would discard lane work with no
860
+ # confirmation.
861
+ def worktree_remove(iteration, lane, force: false)
846
862
  entry = slice_entry(iteration)
847
863
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
848
864
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
@@ -855,6 +871,16 @@ module Space::Architect
855
871
  space.path.join("build", "#{iteration_id(entry)}-#{lane}", "wt")
856
872
  end
857
873
 
874
+ branch = worktree_branch(repo_path, wt_path) || "lane/#{iteration_id(entry)}-#{lane}"
875
+
876
+ dirty = dirty_file_count(repo_path, wt_path)
877
+ if dirty && !force
878
+ raise Space::Core::Error,
879
+ "Refusing to remove lane '#{lane}' worktree — #{dirty} uncommitted change(s) (untracked files " \
880
+ "included) would be discarded. Integrate the lane first, or re-run with --force to discard them " \
881
+ "and remove it."
882
+ end
883
+
858
884
  git_run("-C", repo_path.to_s, "worktree", "remove", "--force", wt_path.to_s)
859
885
  git_run("-C", repo_path.to_s, "worktree", "prune")
860
886
 
@@ -865,6 +891,8 @@ module Space::Architect
865
891
  end
866
892
  b
867
893
  end
894
+
895
+ { lane: lane, branch: branch, branch_survives: branch_exists?(repo_path, branch), discarded: dirty }
868
896
  end
869
897
 
870
898
  def worktree_list
@@ -875,9 +903,13 @@ module Space::Architect
875
903
 
876
904
  # Materialize the iteration's declared lanes: for each lane (or the one named via
877
905
  # `lane:`), create its worktree + lane/<id>-<lane> branch from the resolved base and
878
- # record worktree/base_sha/integration_branch. Idempotent — an already-materialized
879
- # lane is skipped, not re-created. Refuses until the iteration is frozen, because
880
- # declarations are not authoritative until then.
906
+ # record worktree/base_sha/integration_branch. Idempotent — a bare re-run (no
907
+ # explicit --base) over an already-materialized lane changes nothing. An explicit
908
+ # --base is always enforced, even against an already-materialized lane (#88's
909
+ # "blind spot" — a short-circuit that trusted the recorded state without checking
910
+ # it against what was actually asked for). Refuses until the iteration is frozen,
911
+ # because declarations are not authoritative until then. outcome on each result is
912
+ # :created / :unchanged / :repointed (#88/AC4 — tellable apart by a human reader).
881
913
  def provision(iteration, base: nil, lane: nil, force: false)
882
914
  entry = slice_entry(iteration)
883
915
  raise Space::Core::Error,
@@ -888,17 +920,15 @@ module Space::Architect
888
920
  lanes = lanes.select { |l| l["name"] == lane } if lane
889
921
  raise Space::Core::Error, "No lane '#{lane}' declared for iteration '#{iteration}'" if lane && lanes.empty?
890
922
 
923
+ base_explicit = !base.nil?
924
+ refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force
925
+
891
926
  lanes.map do |l|
892
927
  name = l["name"]
893
- repo_path = space.path.join("repos", l["repo"])
894
- wt_path = space.path.join(l["worktree"] || "build/#{iteration_id(entry)}-#{name}/wt")
895
- if wt_path.exist? && worktree_registered?(repo_path, wt_path)
896
- { lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
897
- else
898
- result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
899
- force: force, **recorded_lane_fields(l))
900
- { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
901
- end
928
+ result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
929
+ base_explicit: base_explicit, force: force, **recorded_lane_fields(l))
930
+ { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome],
931
+ discarded: result[:discarded] }
902
932
  end
903
933
  end
904
934
 
@@ -911,7 +941,8 @@ module Space::Architect
911
941
  end
912
942
 
913
943
  def dispatch(iteration, lane, model: nil, max_turns: 200,
914
- claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, force: false, quiet: false,
944
+ claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
945
+ allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false,
915
946
  detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
916
947
  push_client: nil, timeout: nil, prompt: nil, now: Time.now)
917
948
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
@@ -928,6 +959,9 @@ module Space::Architect
928
959
 
929
960
  resolved_harness, resolved_model, resolved_effort =
930
961
  resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
962
+ resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
963
+ resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
964
+ err.puts(tools_provenance) if replace_tools || append_tools
931
965
 
932
966
  raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
933
967
  if push_host && resolved_harness != "claude-code"
@@ -947,22 +981,29 @@ module Space::Architect
947
981
 
948
982
  bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
949
983
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin,
950
- config_dir: build_dir, effort: resolved_effort, force: force, err: err)
951
-
952
- # Stamp launch time and the resolved harness/model/effort onto the lane entry:
953
- # after every preflight validation has passed (a dispatch that raises above
954
- # records nothing) and before the blocking run or a detached dispatch returns.
955
- # A re-dispatch overwrites the prior values, so `architect status` always reads
956
- # what actually ran on the last dispatch.
984
+ config_dir: build_dir, effort: resolved_effort,
985
+ allowed_tools: resolved_allowed_tools, force: force, err: err)
986
+
987
+ # Stamp launch time and the resolved harness/model/effort/allowed_tools onto the
988
+ # lane entry: after every preflight validation has passed (a dispatch that raises
989
+ # above records nothing) and before the blocking run or a detached dispatch
990
+ # returns. A re-dispatch overwrites the prior values, so `architect status`
991
+ # always reads what actually ran on the last dispatch. #89/AC8: the resolved
992
+ # tool grant is recorded the same way, so a denial traces back to a grant a
993
+ # human can read — replace_tools/append_tools are the pre-composition components
994
+ # (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later
995
+ # bare re-dispatch re-resolves from the same components instead of compounding.
957
996
  update_architect_block do |b|
958
997
  (b["iterations"] || []).each do |s|
959
998
  next unless s["name"] == iteration
960
999
  (s["lanes"] || []).each do |l|
961
1000
  next unless l["name"] == lane
962
- l["dispatched_at"] = now.iso8601
963
- l["harness"] = resolved_harness
964
- l["model"] = resolved_model
965
- l["effort"] = resolved_effort if resolved_effort
1001
+ l["dispatched_at"] = now.iso8601
1002
+ l["harness"] = resolved_harness
1003
+ l["model"] = resolved_model
1004
+ l["effort"] = resolved_effort if resolved_effort
1005
+ l["allowed_tools"] = replace_tools if replace_tools
1006
+ l["append_allowed_tools"] = append_tools if append_tools
966
1007
  end
967
1008
  end
968
1009
  b
@@ -1011,7 +1052,8 @@ module Space::Architect
1011
1052
  # transcript lives server-side. jobs_client: is the injectable seam (mirrors
1012
1053
  # dispatch's run_creator:).
1013
1054
  def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
1014
- max_turns: 200, effort: nil, force: false, quiet: false,
1055
+ max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil,
1056
+ force: false, quiet: false,
1015
1057
  job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now)
1016
1058
  err = quiet ? File.open(File::NULL, "w") : $stderr
1017
1059
 
@@ -1026,6 +1068,12 @@ module Space::Architect
1026
1068
  unless resolved_harness == "claude-code"
1027
1069
  raise Space::Core::Error, "--job-model is required with --as-job" unless job_model
1028
1070
 
1071
+ # #89: same replace/append resolution as the local dispatch path — the sandboxed
1072
+ # executor still runs `claude -p` with harness_args server-side.
1073
+ resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
1074
+ resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
1075
+ err.puts(tools_provenance) if replace_tools || append_tools
1076
+
1029
1077
  id = iteration_id(entry)
1030
1078
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
1031
1079
  raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
@@ -1036,7 +1084,8 @@ module Space::Architect
1036
1084
 
1037
1085
  repo_path = space.path.join("repos", lane_entry["repo"])
1038
1086
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
1039
- effort: resolved_effort, force: force, err: err)
1087
+ effort: resolved_effort, allowed_tools: resolved_allowed_tools,
1088
+ force: force, err: err)
1040
1089
 
1041
1090
  spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir,
1042
1091
  repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url,
@@ -1101,18 +1150,45 @@ module Space::Architect
1101
1150
  # Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
1102
1151
  # either merge_lane! results (symbol keys) or recorded lane entries (string
1103
1152
  # keys) — both carry a lane name and a repo.
1104
- def teardown_lanes!(iteration, lane_entries)
1153
+ def teardown_lanes!(iteration, lane_entries, force: false)
1105
1154
  id = iteration_id(slice_entry(iteration))
1155
+ refuse_teardown_if_dirty!(iteration, lane_entries, id) unless force
1156
+
1106
1157
  lane_entries.map do |l|
1107
1158
  lane = l[:lane] || l["name"]
1108
1159
  repo = l[:repo] || l["repo"]
1109
- worktree_remove(iteration, lane)
1160
+ removal = worktree_remove(iteration, lane, force: true)
1110
1161
  lane_branch = "lane/#{id}-#{lane}"
1111
1162
  git_capture("-C", space.path.join("repos", repo).to_s, "branch", "-d", lane_branch)
1112
- { lane: lane, repo: repo, lane_branch: lane_branch }
1163
+ { lane: lane, repo: repo, lane_branch: lane_branch, discarded: removal[:discarded] }
1113
1164
  end
1114
1165
  end
1115
1166
 
1167
+ # Every lane a teardown-only call would touch is checked for
1168
+ # uncommitted work BEFORE any lane is removed — a dirty lane discovered late
1169
+ # in the recorded set can never leave an earlier lane already torn down.
1170
+ # Lanes reached via a merge-then-teardown call are always clean here
1171
+ # (merge_lane! commits the worktree first), so this never fires on the
1172
+ # normal integrate-then-teardown rhythm.
1173
+ def refuse_teardown_if_dirty!(iteration, lane_entries, id)
1174
+ entry = slice_entry(iteration)
1175
+ dirty = lane_entries.filter_map do |l|
1176
+ lane = l[:lane] || l["name"]
1177
+ repo = l[:repo] || l["repo"]
1178
+ recorded = (entry["lanes"] || []).find { |le| le["name"] == lane }
1179
+ wt_path = space.path.join(recorded&.dig("worktree") || "build/#{id}-#{lane}/wt")
1180
+ count = dirty_file_count(space.path.join("repos", repo), wt_path)
1181
+ count ? [lane, count] : nil
1182
+ end
1183
+ return if dirty.empty?
1184
+
1185
+ names = dirty.map { |n, c| "#{n} (#{c})" }.join(", ")
1186
+ raise Space::Core::Error,
1187
+ "Refusing to tear down '#{iteration}' — lane(s) #{names} hold uncommitted work (untracked files " \
1188
+ "included) that would be discarded (worktree removed, branch deleted). Integrate them first, or " \
1189
+ "re-run with --force to discard it."
1190
+ end
1191
+
1116
1192
  # Resolve the in-flight iteration file for ground output.
1117
1193
  # Rule: (a) current_iteration from project block → entry's file if it exists on disk,
1118
1194
  # (b) else highest-ordinal architecture/I<NN>-*.md,
@@ -1178,28 +1254,35 @@ module Space::Architect
1178
1254
  # same shell (capture_with_timeout's Process.spawn), same GateEvaluator,
1179
1255
  # same timeout handling (I09/AC3). repo_root/lane are nil outside a lane
1180
1256
  # context (rehearsal never has one — it always runs in the repo checkout).
1257
+ #
1258
+ # I01: gates sharing an identical (resolved cmd, resolved dir) pair execute
1259
+ # once; the shared capture is fanned back out to every member, each still
1260
+ # evaluated against its own `expect` and classified independently. A
1261
+ # group's effective timeout is the largest any member declares, so no
1262
+ # duplicate is cut short by another's shorter budget.
1181
1263
  def execute_gates(gates, base_dir:, lane:, repo_root:)
1182
- gates.map do |gate|
1264
+ resolved = gates.map do |gate|
1183
1265
  g = gate.transform_keys(&:to_s)
1184
- dir =
1185
- if (cwd = g["cwd"])
1186
- gate_cwd = space.path.join(cwd)
1187
- if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
1188
- base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
1189
- else
1190
- gate_cwd
1191
- end
1192
- else
1193
- base_dir
1194
- end
1266
+ dir = resolve_gate_dir(g, base_dir: base_dir, lane: lane, repo_root: repo_root)
1195
1267
  raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
1268
+ [g, dir]
1269
+ end
1196
1270
 
1197
- effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
1198
- captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
1271
+ groups = resolved.group_by { |g, dir| [g["cmd"], dir.to_s] }
1272
+ captures = groups.transform_values do |members|
1273
+ timeout = members.map { |g, _| g["timeout"] || DEFAULT_GATE_TIMEOUT }.max
1274
+ sample_g, dir = members.first
1275
+ capture_with_timeout(sample_g["cmd"], dir: dir, timeout: timeout).merge(timeout: timeout)
1276
+ end
1277
+
1278
+ resolved.map do |g, dir|
1279
+ key = [g["cmd"], dir.to_s]
1280
+ captured = captures[key]
1281
+ siblings = groups[key].map { |gg, _| gg["id"] } - [g["id"]]
1199
1282
 
1200
1283
  if captured[:timed_out]
1201
1284
  status = :fail
1202
- reason = "timed out after #{effective}s"
1285
+ reason = "timed out after #{captured[:timeout]}s"
1203
1286
  else
1204
1287
  ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
1205
1288
  status = ev.pass? ? :pass : :fail
@@ -1208,14 +1291,81 @@ module Space::Architect
1208
1291
 
1209
1292
  { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
1210
1293
  stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
1211
- dir: dir, status: status, reason: reason, timed_out: captured[:timed_out] }
1294
+ dir: dir, status: status, reason: reason, timed_out: captured[:timed_out],
1295
+ shared_with: siblings }
1212
1296
  end
1213
1297
  end
1214
1298
 
1215
- # I09/AC4: resolve rehearsal's run repo from the DRAFTED ```lanes``` block
1216
- # one repo, unambiguously declared or inferable, else a message naming what
1217
- # to do. Never from space.yaml (no lanes are recorded there pre-freeze).
1218
- def resolve_rehearsal_repo(iteration, text)
1299
+ # The directory a single gate executes in: its own `cwd` (remapped into the
1300
+ # lane worktree when it falls under repo_root) or the run's base_dir.
1301
+ def resolve_gate_dir(g, base_dir:, lane:, repo_root:)
1302
+ return base_dir unless (cwd = g["cwd"])
1303
+
1304
+ gate_cwd = space.path.join(cwd)
1305
+ if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
1306
+ base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
1307
+ else
1308
+ gate_cwd
1309
+ end
1310
+ end
1311
+
1312
+ # I01: the durable, unbounded counterpart to the CLI's bounded terminal
1313
+ # report — one file per non-EMPTY rehearsal under build/rehearse/, holding
1314
+ # every gate's full command, dir, exit code, verdict, reason, and complete
1315
+ # stdout+stderr, plus the scope-asymmetry findings. The CLI prints this
1316
+ # path so a rerun is never needed just to see output the terminal trimmed.
1317
+ def write_rehearsal_transcript(entry, repo, base_dir, gate_results, scope_report, now)
1318
+ dir = space.path.join("build", "rehearse")
1319
+ FileUtils.mkdir_p(dir)
1320
+ path = dir.join("#{iteration_id(entry)}-#{now.strftime('%Y%m%dT%H%M%S.%6N')}.md")
1321
+
1322
+ lines = ["# Rehearsal transcript — #{entry['name']} — #{now.iso8601}"]
1323
+ lines << (repo ? "Repo: #{repo} (#{base_dir})" : "Each gate ran in its own declared cwd")
1324
+
1325
+ gate_results.each do |g|
1326
+ lines << ""
1327
+ lines << "## #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} — #{g[:rehearsal].to_s.upcase} (exit #{g[:exit_code].inspect})"
1328
+ lines << "cmd: #{g[:cmd]}"
1329
+ lines << "dir: #{g[:dir]}"
1330
+ lines << "reason: #{g[:reason]}" unless g[:reason].to_s.empty?
1331
+ lines << "shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)" if g[:shared_with]&.any?
1332
+ lines << "--- stdout ---"
1333
+ lines << g[:stdout].to_s
1334
+ lines << "--- stderr ---"
1335
+ lines << g[:stderr].to_s
1336
+ end
1337
+
1338
+ if scope_report && (scope_report[:findings].any? || scope_report[:not_analyzable].any?)
1339
+ lines << ""
1340
+ lines << "## Scope-asymmetry check (grep-family gates only)"
1341
+ scope_report[:findings].each do |f|
1342
+ lines << "── #{f[:id]} (#{f[:repo]}): pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
1343
+ if f[:outside_lanes].any?
1344
+ lines << "OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
1345
+ f[:outside_lanes].each { |file| lines << " #{file}" }
1346
+ end
1347
+ lines << "also matches (within a declared lane's touch set): #{f[:within_lanes].join(', ')}" if f[:within_lanes].any?
1348
+ lines << "0 files elsewhere match" if f[:outside_lanes].empty? && f[:within_lanes].empty?
1349
+ end
1350
+ scope_report[:not_analyzable].each { |na| lines << "not analyzed — #{na[:id]}: #{na[:reason]}" }
1351
+ end
1352
+
1353
+ path.write("#{lines.join("\n")}\n")
1354
+ path
1355
+ end
1356
+
1357
+ # I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
1358
+ # `cwd` of their own — only those gates need one, and only they are ambiguous
1359
+ # when the drafted ```lanes``` block names more than one repo. A gate with its
1360
+ # own `cwd` rehearses in its own declared directory regardless of how many
1361
+ # repos are declared, so an iteration whose gates all declare `cwd` never
1362
+ # needs a default and never reaches this method's refusals. Never resolves
1363
+ # from space.yaml (no lanes are recorded there pre-freeze).
1364
+ def resolve_rehearsal_repo(iteration, text, gates)
1365
+ needing_default = gates.reject { |g| g.transform_keys(&:to_s)["cwd"] }
1366
+ return nil if needing_default.empty?
1367
+
1368
+ ids = needing_default.map { |g| g.transform_keys(&:to_s)["id"] }
1219
1369
  declared = parse_lanes(text).filter_map { |l| l["repo"] }.uniq
1220
1370
  return declared.first if declared.size == 1
1221
1371
 
@@ -1225,13 +1375,13 @@ module Space::Architect
1225
1375
  raise Space::Core::Error,
1226
1376
  "Cannot resolve a repo to rehearse '#{iteration}' against — no lane declares a repo and the space " \
1227
1377
  "tracks #{tracked.size} repos (#{tracked.join(', ')}). Declare a ```lanes``` block in the " \
1228
- "Specification, naming the repo to rehearse against."
1378
+ "Specification naming the repo, or give gate(s) #{ids.join(', ')} an explicit `cwd`."
1229
1379
  end
1230
1380
 
1231
1381
  raise Space::Core::Error,
1232
- "Cannot resolve a single repo to rehearse '#{iteration}' against — the drafted lanes block names " \
1233
- "multiple repos (#{declared.join(', ')}). Rehearsal runs once, against one repo checkout; narrow the " \
1234
- "```lanes``` block before rehearsing."
1382
+ "Cannot default a repo for gate(s) #{ids.join(', ')} — the drafted lanes block names multiple repos " \
1383
+ "(#{declared.join(', ')}) and #{ids.size == 1 ? "it declares" : "they declare"} no `cwd`. Give " \
1384
+ "#{ids.size == 1 ? "it" : "them"} an explicit `cwd`, or narrow the ```lanes``` block to one repo."
1235
1385
  end
1236
1386
 
1237
1387
  # I09/AC5: RED (clean non-zero — discriminates) vs GREEN (passes on base) vs
@@ -1287,24 +1437,28 @@ module Space::Architect
1287
1437
  # union; parse_lanes returns [] with no ```lanes``` block, which still
1288
1438
  # yields the outside-every-lane half of the report. Reports only: never
1289
1439
  # touches rehearse's exit code, RED/GREEN/BROKEN, or the rehearsal stamp.
1290
- def scope_asymmetry_report(gates, text, base_dir)
1440
+ #
1441
+ # #90/AC7: takes the already-EXECUTED gate results, not a single base_dir —
1442
+ # each gate's own resolved `dir` (execute_gates already computed it, per gate)
1443
+ # is what the re-run replays against, so a cross-repo iteration is covered
1444
+ # gate-by-gate in its own repo, and each finding names the repo it came from.
1445
+ def scope_asymmetry_report(gate_results, text)
1291
1446
  touch_globs = parse_lanes(text).flat_map { |l| l["touch"] || [] }
1292
1447
  findings = []
1293
1448
  not_analyzable = []
1294
1449
 
1295
- gates.each do |gate|
1296
- g = gate.transform_keys(&:to_s)
1297
- grep_invocations(g["cmd"].to_s).each do |inv|
1450
+ gate_results.each do |g|
1451
+ grep_invocations(g[:cmd].to_s).each do |inv|
1298
1452
  if inv[:status] != :recognized
1299
- not_analyzable << { id: g["id"], reason: inv[:reason] }
1453
+ not_analyzable << { id: g[:id], reason: inv[:reason] }
1300
1454
  next
1301
1455
  end
1302
1456
 
1303
- finding = scope_finding(inv, base_dir, touch_globs)
1457
+ finding = scope_finding(inv, g[:dir], touch_globs)
1304
1458
  if finding
1305
- findings << finding.merge(id: g["id"])
1459
+ findings << finding.merge(id: g[:id], repo: repo_name_for(g[:dir]))
1306
1460
  else
1307
- not_analyzable << { id: g["id"], reason: "whole-repo re-run failed" }
1461
+ not_analyzable << { id: g[:id], reason: "whole-repo re-run failed" }
1308
1462
  end
1309
1463
  end
1310
1464
  end
@@ -1312,6 +1466,15 @@ module Space::Architect
1312
1466
  { findings: findings, not_analyzable: not_analyzable }
1313
1467
  end
1314
1468
 
1469
+ # Best-effort label for a scope-asymmetry finding: the repos/<name> a gate's
1470
+ # resolved dir sits under, or nil for a `cwd` outside repos/ entirely (legal —
1471
+ # #test_run_gates_honors_cwd_outside_repo — just not attributable to one repo).
1472
+ def repo_name_for(dir)
1473
+ repos_root = space.path.join("repos")
1474
+ return nil unless dir.to_s.start_with?("#{repos_root}/")
1475
+ dir.relative_path_from(repos_root).to_s.split("/").first
1476
+ end
1477
+
1315
1478
  # Re-runs a recognized invocation's own pattern across the whole repo and
1316
1479
  # splits what it finds into: the gate's own declared scope (expected —
1317
1480
  # that's what the gate already searches, so it's not reported), elsewhere
@@ -1493,6 +1656,10 @@ module Space::Architect
1493
1656
  # identically to `provision` (same base resolution, same worktree_add primitive)
1494
1657
  # so no dispatch/integrate/gate path dead-ends on a missing worktree. Returns the
1495
1658
  # (possibly refreshed) lane entry; a no-op when already materialized or undeclared.
1659
+ # base_explicit: false — this call never had an operator-given --base to honor, so
1660
+ # a surviving lane branch (e.g. dispatched work, worktree since removed) is always
1661
+ # just reattached at its own tip, never refused or re-pointed (#88 is about an
1662
+ # explicit ask being dropped; this path never has one to drop).
1496
1663
  def ensure_lane_materialized(iteration, lane)
1497
1664
  entry = slice_entry(iteration)
1498
1665
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
@@ -1504,7 +1671,7 @@ module Space::Architect
1504
1671
  return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
1505
1672
 
1506
1673
  worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
1507
- **recorded_lane_fields(lane_entry))
1674
+ base_explicit: false, **recorded_lane_fields(lane_entry))
1508
1675
  (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
1509
1676
  end
1510
1677
 
@@ -1570,6 +1737,35 @@ module Space::Architect
1570
1737
  [resolved_harness, resolved_model, resolved_effort]
1571
1738
  end
1572
1739
 
1740
+ # #89/AC1-AC5: resolve the claude-code --allowedTools grant for a dispatch (local or
1741
+ # --as-job), shared so both paths resolve precedence identically. The CLI flag beats
1742
+ # the lane's recorded allowed_tools:/append_allowed_tools: (frozen-declaration or a
1743
+ # prior dispatch's stamp) — resolved independently for replace vs. append, so a flag
1744
+ # on one axis and a lane value on the other both apply (Objective A). Meaningless for
1745
+ # opencode/pi (no equivalent grant mechanism) — still resolved/recorded for visibility,
1746
+ # but Harness.for only wires it into the claude-code branch's argv.
1747
+ def resolve_allowed_tools(lane_entry, allowed_tools:, append_allowed_tools:)
1748
+ replace, replace_from =
1749
+ if allowed_tools
1750
+ [allowed_tools, "--allowed-tools flag"]
1751
+ elsif lane_entry["allowed_tools"]
1752
+ [lane_entry["allowed_tools"], "lane's allowed_tools:"]
1753
+ else
1754
+ [nil, "default"]
1755
+ end
1756
+
1757
+ append, append_from =
1758
+ if append_allowed_tools
1759
+ [append_allowed_tools, "--append-allowed-tools flag"]
1760
+ elsif lane_entry["append_allowed_tools"]
1761
+ [lane_entry["append_allowed_tools"], "lane's append_allowed_tools:"]
1762
+ end
1763
+
1764
+ final = Harness::ClaudeCodeHarness.resolve_tools(replace: replace, append: append)
1765
+ provenance = "allowed-tools: #{replace_from}#{append_from ? " + #{append_from}" : ""} → #{final}"
1766
+ [final, replace, append, provenance]
1767
+ end
1768
+
1573
1769
  # Compose a dispatch --as-job spec per the space-server's job contract: prompt +
1574
1770
  # workspace.dir (the lane worktree) + environment (deps/network/mounts/env or
1575
1771
  # secrets) + harness (claude/backend/args) + provenance. mounts are src:dst with
@@ -2010,10 +2206,145 @@ module Space::Architect
2010
2206
  st.success?
2011
2207
  end
2012
2208
 
2209
+ # Uncommitted work in a lane worktree — untracked files
2210
+ # included. Hard rule 7 forbids builder commits, so this is the only
2211
+ # signal an in-flight or completed-but-unintegrated lane's output ever
2212
+ # leaves behind; "no commits on the branch" is the EXPECTED state, not
2213
+ # evidence the worktree is empty. nil when the worktree doesn't exist,
2214
+ # isn't a registered git worktree, or is clean; otherwise the number of
2215
+ # changed/untracked entries `git status --porcelain` reports (a count is
2216
+ # enough — callers never dump the whole status).
2217
+ def dirty_file_count(repo_path, wt_path)
2218
+ return nil unless wt_path.exist? && worktree_registered?(repo_path, wt_path)
2219
+ out, _, st = git_capture("-C", wt_path.to_s, "status", "--porcelain")
2220
+ return nil unless st.success?
2221
+ count = out.each_line.count { |l| !l.strip.empty? }
2222
+ count.zero? ? nil : count
2223
+ end
2224
+
2225
+ # Would re-pointing `branch`'s worktree to base_sha actually run `git
2226
+ # reset --hard` over uncommitted work? nil when there's nothing to lose
2227
+ # (branch absent, worktree unregistered, already at base_sha, or clean);
2228
+ # otherwise the #dirty_file_count that would be discarded. Read-only —
2229
+ # used by provision's multi-lane preflight (#refuse_repoint_if_dirty!),
2230
+ # never by the mutating path itself (attach_lane_branch! already has
2231
+ # tip_sha in scope and calls #dirty_file_count directly).
2232
+ def repoint_dirty_count(repo_path, wt_path, branch, base_sha)
2233
+ return nil unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
2234
+ tip_out, _, tip_st = git_capture("-C", repo_path.to_s, "rev-parse", branch)
2235
+ tip_sha = tip_st.success? ? tip_out.strip : nil
2236
+ return nil if tip_sha == base_sha
2237
+ dirty_file_count(repo_path, wt_path)
2238
+ end
2239
+
2240
+ # Multi-lane atomicity for provision's re-point path — every
2241
+ # declared lane is checked for uncommitted work BEFORE any lane's
2242
+ # worktree_add runs, so a dirty lane discovered late in the set can never
2243
+ # leave an earlier lane already re-pointed.
2244
+ def refuse_repoint_if_dirty!(iteration, lanes, base, id)
2245
+ dirty = lanes.filter_map do |l|
2246
+ repo_path = space.path.join("repos", l["repo"])
2247
+ wt_path = space.path.join(l["worktree"] || "build/#{id}-#{l["name"]}/wt")
2248
+ branch = "lane/#{id}-#{l["name"]}"
2249
+ resolved, _, st = git_capture("-C", repo_path.to_s, "rev-parse", resolve_lane_base(l["repo"], base))
2250
+ next nil unless st.success?
2251
+ count = repoint_dirty_count(repo_path, wt_path, branch, resolved.strip)
2252
+ count ? [l["name"], count] : nil
2253
+ end
2254
+ return if dirty.empty?
2255
+
2256
+ names = dirty.map { |n, c| "#{n} (#{c})" }.join(", ")
2257
+ raise Space::Core::Error,
2258
+ "Refusing to provision '#{iteration}' — re-pointing to the requested base would discard uncommitted " \
2259
+ "work (untracked files included) in: #{names}. Dispatch or integrate the lane(s) first, or re-run " \
2260
+ "with --force to discard it and re-point."
2261
+ end
2262
+
2013
2263
  def worktree_registered?(repo_path, wt_path)
2014
2264
  out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
2015
2265
  real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
2016
2266
  out.lines.any? { |l| l.start_with?("worktree ") && l.chomp.delete_prefix("worktree ") == real }
2017
2267
  end
2268
+
2269
+ # The branch checked out at wt_path, per `git worktree list --porcelain`
2270
+ # (read before removal, since the entry disappears once the worktree is
2271
+ # gone) — nil if wt_path isn't a registered worktree of repo_path at all.
2272
+ def worktree_branch(repo_path, wt_path)
2273
+ out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
2274
+ real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
2275
+ entry = out.split("\n\n").find { |e| e.lines.any? { |l| l.chomp == "worktree #{real}" } }
2276
+ entry&.lines&.find { |l| l.start_with?("branch ") }&.chomp&.delete_prefix("branch refs/heads/")
2277
+ end
2278
+
2279
+ # #88: attach `branch` at wt_path, choosing among the three lane-branch states
2280
+ # the issue distinguishes — absent (create), present with no commits beyond the
2281
+ # base it was recorded from (safe to re-point — the fast-follow case), or
2282
+ # present with commits of its own (refuse unless force). Returns [outcome,
2283
+ # base_sha], where base_sha is the commit the worktree actually ends up on —
2284
+ # never the freshly-resolved one when nothing about the tree moved (the
2285
+ # provenance half of the bug).
2286
+ #
2287
+ # base_explicit: false short-circuits to "just attach whatever's there" —
2288
+ # never refuses, never re-points, and preserves the previously recorded
2289
+ # base_sha instead of overwriting it with a base nobody asked for (see
2290
+ # ensure_lane_materialized).
2291
+ def attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base, base_explicit:, force:)
2292
+ unless branch_exists?(repo_path, branch)
2293
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
2294
+ return [:created, base_sha, nil]
2295
+ end
2296
+
2297
+ registered = worktree_registered?(repo_path, wt_path)
2298
+ tip_out, _, tip_st = git_capture("-C", repo_path.to_s, "rev-parse", branch)
2299
+ tip_sha = tip_st.success? ? tip_out.strip : nil
2300
+
2301
+ unless base_explicit
2302
+ return [:unchanged, recorded_base || base_sha, nil] if registered
2303
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
2304
+ return [:created, recorded_base || tip_sha, nil]
2305
+ end
2306
+
2307
+ if tip_sha == base_sha
2308
+ return [:unchanged, base_sha, nil] if registered
2309
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
2310
+ return [:created, base_sha, nil]
2311
+ end
2312
+
2313
+ # Hard rule 7 forbids builder commits, so an in-flight lane's
2314
+ # entire output is uncommitted working-tree state — carries_own_commits?
2315
+ # below can't see it (no commits to count). Checked first: it's the more
2316
+ # common loss (partial — reset --hard spares untracked files) and the one
2317
+ # this iteration exists to close.
2318
+ dirty = dirty_file_count(repo_path, wt_path)
2319
+ if dirty && !force
2320
+ raise Space::Core::Error,
2321
+ "Lane branch '#{branch}' worktree has #{dirty} uncommitted change(s) (untracked files included) — " \
2322
+ "re-pointing to '#{base_ref}' (#{base_sha[0, 8]}) would run `git reset --hard` and discard them. " \
2323
+ "Dispatch or integrate it first, or re-run with --force to discard them and re-point."
2324
+ end
2325
+
2326
+ if !force && carries_own_commits?(repo_path, recorded_base, tip_sha)
2327
+ raise Space::Core::Error,
2328
+ "Lane branch '#{branch}' carries commits of its own — it is at #{tip_sha ? tip_sha[0, 8] : "?"}, but " \
2329
+ "base '#{base_ref}' (#{base_sha[0, 8]}) was requested. Re-pointing would discard its commits — " \
2330
+ "dispatch or integrate it first, or re-run with --force to discard them and re-point onto the new base."
2331
+ end
2332
+
2333
+ if registered
2334
+ git_run("-C", wt_path.to_s, "reset", "--hard", base_sha)
2335
+ else
2336
+ git_run("-C", repo_path.to_s, "worktree", "add", "-B", branch, wt_path.to_s, base_sha)
2337
+ end
2338
+ [:repointed, base_sha, dirty]
2339
+ end
2340
+
2341
+ # #88/AC2-AC3: does the branch carry commits beyond the base it was recorded
2342
+ # from? Unrecorded provenance can't be proven safe, so it counts as "carries
2343
+ # its own" — conservative by design, not merely by omission.
2344
+ def carries_own_commits?(repo_path, recorded_base, tip_sha)
2345
+ return true if recorded_base.nil? || tip_sha.nil?
2346
+ out, _, st = git_capture("-C", repo_path.to_s, "rev-list", "--count", "#{recorded_base}..#{tip_sha}")
2347
+ !(st.success? && out.strip.to_i.zero?)
2348
+ end
2018
2349
  end
2019
2350
  end