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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +117 -0
- data/lib/space_architect/architect_project.rb +417 -86
- data/lib/space_architect/cli/architect.rb +128 -24
- data/lib/space_architect/harness.rb +14 -2
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +22 -9
- data/skill/architect/dispatch.md +84 -2
- metadata +1 -1
|
@@ -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,
|
|
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}"
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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 —
|
|
879
|
-
#
|
|
880
|
-
#
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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,
|
|
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,
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
#
|
|
954
|
-
#
|
|
955
|
-
#
|
|
956
|
-
#
|
|
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"]
|
|
963
|
-
l["harness"]
|
|
964
|
-
l["model"]
|
|
965
|
-
l["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,
|
|
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,
|
|
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
|
-
|
|
1198
|
-
|
|
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 #{
|
|
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
|
-
#
|
|
1216
|
-
#
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
|
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
|
|
1233
|
-
"
|
|
1234
|
-
"```lanes``` block
|
|
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
|
-
|
|
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
|
-
|
|
1296
|
-
g
|
|
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[
|
|
1453
|
+
not_analyzable << { id: g[:id], reason: inv[:reason] }
|
|
1300
1454
|
next
|
|
1301
1455
|
end
|
|
1302
1456
|
|
|
1303
|
-
finding = scope_finding(inv,
|
|
1457
|
+
finding = scope_finding(inv, g[:dir], touch_globs)
|
|
1304
1458
|
if finding
|
|
1305
|
-
findings << finding.merge(id: g[
|
|
1459
|
+
findings << finding.merge(id: g[:id], repo: repo_name_for(g[:dir]))
|
|
1306
1460
|
else
|
|
1307
|
-
not_analyzable << { id: g[
|
|
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
|