space-architect 6.0.0 → 7.0.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: aa2517580f1eefb1906810b2a2d1e2500814e9042a812c5aa8a6ccc7474f0067
4
- data.tar.gz: 66259d63c107e8fe76dc8036766015170ce2d1a0c4ac4d0e617d2b4fed5811fd
3
+ metadata.gz: 1d521b0afec00484bfba5b35e26e9f0a603807fb75f3569fa61b0a0b5ff9b3ab
4
+ data.tar.gz: d21ecd2d8dd8458b0ec2e89d4cc2ee45a5a70eafc53955b8d2b3c365b55efe3b
5
5
  SHA512:
6
- metadata.gz: d2ca2365a338b78284e80573a137ddb0b79fdd10147d03c59f2b01aad7a8e8482899630545673f72f86d3abab075fb8fb9e76eeabf23ebe019f12c0bce1a6aaa
7
- data.tar.gz: 2e1df0a9c9ea9b9ba162420120872d4671e031a712fb08d9995caa4a6e49fd3d50526ec8cc7cd8fc5acf463bb8b454632c99a6dfa51440182cadc24a27a26e87
6
+ metadata.gz: e27a4154359d6f41370287eb86eeb2881d415dd0ce7e5d6c3c3f241ff338f168a7393456d63faa5e3970e3a72eb927ae4db13d4f407e50e320d90ed54c79dc7e
7
+ data.tar.gz: 024047dcd39a0de008284fc256c3f7fb08ea8585e0cd0cae53f0e990b70f9e47341325cf92668ef632bf76ef2e34e5d082ccd98eba94af52c4f659c251afd9f8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,89 @@ 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
+ ## [7.0.0] - 2026-08-14
9
+
10
+ Three iterations of the Architect Loop run against this repo's own tooling, each
11
+ frozen before dispatch and judged by a separate session against criteria written
12
+ before results existed. Major because three destructive verbs gain a refusal they
13
+ did not have, and `provision`'s per-lane return changes shape — see the
14
+ **BREAKING** items under Changed.
15
+
16
+ Every defect closed here is the same animal: a **confident, wrong artifact**. An
17
+ operation that destroys a completed builder run and exits 0 reporting success. A
18
+ value the tool resolves correctly and then declines to use. A lane denied the tool
19
+ it needed, which does not crash and instead files a well-formatted, dataless
20
+ report. The through-line is that none of them announced itself — which is why the
21
+ fixes are as much about making the resolved value *visible* as about resolving it.
22
+ Suite: 1297 → 1344 runs, 5148 → 5373 assertions, green throughout.
23
+
24
+ ### Added
25
+
26
+ - **`--allowed-tools` / `--append-allowed-tools` on `architect dispatch`, and
27
+ `allowed_tools:` / `append_allowed_tools:` on the frozen lane declaration
28
+ (#89).** `ClaudeCodeHarness::ALLOWED_TOOLS` was a defaulted keyword that
29
+ `Harness.for` never accepted and no caller could pass, so no dispatched lane
30
+ could be granted an MCP tool. Because `claude -p` **denies** an unlisted tool
31
+ rather than prompting, the lane did not fail — it reasoned its way to "I was
32
+ unable to query the datasource" and wrote a plausible empty report. Replace and
33
+ append compose as one rule regardless of which surface supplied them
34
+ (`ClaudeCodeHarness.resolve_tools`); the CLI flag wins over the lane key **on
35
+ the same axis**, so a lane's `allowed_tools:` still supplies the base a
36
+ `--append-allowed-tools` flag appends to. The yaml half lives in the frozen lane
37
+ declaration so a grant is reviewable and judged like every other lane boundary
38
+ rather than buried in a shell invocation, and it survives worktree
39
+ re-materialization by the same mechanism `touch_set` already uses.
40
+ - **The resolved grant is recorded and surfaced.** Stamped onto the lane beside
41
+ `harness`/`model`/`effort`, printed at dispatch with its provenance (which
42
+ surface won), and shown in `architect status`'s lane line when it diverges from
43
+ the default. A denial now traces back to a grant a human can read. An MCP
44
+ *reachability* probe is deliberately not in this release: there is no MCP
45
+ modelling anywhere in the codebase, and a prober is a subsystem, not a rider on
46
+ a plumbing fix.
47
+
48
+ ### Changed
49
+
50
+ - **BREAKING — `architect worktree remove`, `architect integrate --teardown`, and
51
+ `architect provision --base` refuse to discard a lane worktree's uncommitted
52
+ work.** The existing safety checks asked whether work had been *committed*, in a
53
+ system whose hard rule 7 guarantees lane work is **never** committed — so all
54
+ three destroyed completed builder runs and exited 0 reporting success. One
55
+ predicate (`dirty_file_count`, untracked files included) now guards all three
56
+ sites. `--force` overrides and discards, restoring the old behavior exactly. A
57
+ script that tore down dirty lanes will now exit non-zero.
58
+ - **BREAKING — the multi-lane verbs decide their refusal before mutating
59
+ anything.** `integrate --teardown` and `provision --base` pre-flight the whole
60
+ declared lane set (`refuse_teardown_if_dirty!`, `refuse_repoint_if_dirty!`), so a
61
+ dirty lane discovered late no longer leaves earlier lanes already destroyed. The
62
+ refusal names every offending lane at once instead of failing on the first.
63
+ - **BREAKING — `provision`'s per-lane return is now `outcome:`**
64
+ (`:created` / `:unchanged` / `:repointed`) plus `discarded:`, replacing the
65
+ boolean `created:`. The boolean could not express the re-point path that #88's
66
+ fix introduces.
67
+ - **`worktree add`, `worktree list`, and `worktree remove` accept an optional
68
+ trailing `SPACE` argument**, like every other space-scoped verb. `worktree
69
+ remove` previously had no way to name its space and resolved from `$PWD` — a
70
+ destructive verb that could fire against the wrong space entirely.
71
+ - **Long-running lane guidance in `skill/architect/dispatch.md` (#85).** The lane
72
+ template's no-busy-wait clause beat every attempt to hold a 30–70 minute detached
73
+ process open, killing exactly the lanes it was least safe to kill.
74
+
75
+ ### Fixed
76
+
77
+ - **`architect provision --base` was resolved and then discarded on the re-attach
78
+ path (#88).** `worktree_add` computed `base_sha` from `--base`, ignored it when
79
+ the lane branch already existed, and recorded it anyway — so `space.yaml` claimed
80
+ a base the worktree was never pointed at. An operator-supplied `--base` is now
81
+ enforced (re-point, or refuse when that would discard work); an internal
82
+ re-materialization still reattaches at whatever is there, never refusing and never
83
+ moving a branch nobody asked to move.
84
+ - **`architect rehearse` refused every cross-repo iteration (#90).** It resolved one
85
+ repo for the whole iteration and raised when the drafted lanes named more than
86
+ one, even though `execute_gates` could already resolve a run dir per gate. Gates
87
+ carrying their own `cwd` now rehearse in their own declared directory regardless
88
+ of how many repos the iteration spans; the refusal survives only for gates that
89
+ genuinely need a default, and it names them.
90
+
8
91
  ## [6.0.0] - 2026-08-03
9
92
 
10
93
  Twelve iterations of the Architect Loop run against this repo's own tooling, each
@@ -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)
@@ -591,12 +599,12 @@ module Space::Architect
591
599
  if gates.empty?
592
600
  [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
+ [r, dir, results, scope_asymmetry_report(results, text)]
600
608
  end
601
609
 
602
610
  digest = gates_digest(text)
@@ -688,8 +696,15 @@ module Space::Architect
688
696
  repos.map { |r| sync_one_repo(r["name"]) }
689
697
  end
690
698
 
691
- def worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false,
692
- effort: nil, touch: nil, force: false, err: $stderr)
699
+ # base_explicit distinguishes an operator-given --base (enforce it refuse or
700
+ # re-point per #88's three cases) from an auto-materializing call (just attach
701
+ # whatever's there, never refuse, never move a branch nobody asked to move).
702
+ # Defaults from whether base: itself was given, which is exactly right for a
703
+ # direct `worktree_add`/CLI `worktree add --base` call; ensure_lane_materialized
704
+ # always resolves SOME base internally (it has to, to create a lane from
705
+ # scratch), so it overrides this explicitly to false.
706
+ def worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?, harness: nil, model: nil,
707
+ variant: false, effort: nil, touch: nil, force: false, err: $stderr)
693
708
  model, suffix_level = Harness.parse_model_suffix(model)
694
709
  harness, model = resolve_harness_model(harness, model)
695
710
  resolved_level = effort || suffix_level || project_defaults["effort"]
@@ -712,6 +727,7 @@ module Space::Architect
712
727
  base_sha = base_sha.strip
713
728
 
714
729
  branch = "lane/#{id}-#{lane}"
730
+ recorded_base = (entry["lanes"] || []).find { |l| l["name"] == lane }&.dig("base_sha")
715
731
 
716
732
  # Guard: an existing directory that is not a registered worktree is ambiguous.
717
733
  if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
@@ -724,16 +740,8 @@ module Space::Architect
724
740
  end
725
741
  end
726
742
 
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
743
+ outcome, base_sha, discarded = attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base,
744
+ base_explicit: base_explicit, force: force)
737
745
 
738
746
  new_fields = {
739
747
  "name" => lane,
@@ -763,7 +771,7 @@ module Space::Architect
763
771
  b
764
772
  end
765
773
 
766
- { worktree: wt_path, base_sha: base_sha }
774
+ { worktree: wt_path, base_sha: base_sha, outcome: outcome, discarded: discarded }
767
775
  end
768
776
 
769
777
  # Declare a variant set for an iteration: one competing lane per (harness, model) pair,
@@ -842,7 +850,13 @@ module Space::Architect
842
850
  }
843
851
  end
844
852
 
845
- def worktree_remove(iteration, lane)
853
+ # #88 (smaller, same area): a removed worktree does not touch its lane branch —
854
+ # remove-then-provision re-points that surviving branch (or refuses if it has
855
+ # its own commits), it is not a reset. Names it in the return so a human
856
+ # reading `worktree remove`'s output isn't misled into thinking it is.
857
+ # Never deletes the branch itself: that would discard lane work with no
858
+ # confirmation.
859
+ def worktree_remove(iteration, lane, force: false)
846
860
  entry = slice_entry(iteration)
847
861
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
848
862
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
@@ -855,6 +869,16 @@ module Space::Architect
855
869
  space.path.join("build", "#{iteration_id(entry)}-#{lane}", "wt")
856
870
  end
857
871
 
872
+ branch = worktree_branch(repo_path, wt_path) || "lane/#{iteration_id(entry)}-#{lane}"
873
+
874
+ dirty = dirty_file_count(repo_path, wt_path)
875
+ if dirty && !force
876
+ raise Space::Core::Error,
877
+ "Refusing to remove lane '#{lane}' worktree — #{dirty} uncommitted change(s) (untracked files " \
878
+ "included) would be discarded. Integrate the lane first, or re-run with --force to discard them " \
879
+ "and remove it."
880
+ end
881
+
858
882
  git_run("-C", repo_path.to_s, "worktree", "remove", "--force", wt_path.to_s)
859
883
  git_run("-C", repo_path.to_s, "worktree", "prune")
860
884
 
@@ -865,6 +889,8 @@ module Space::Architect
865
889
  end
866
890
  b
867
891
  end
892
+
893
+ { lane: lane, branch: branch, branch_survives: branch_exists?(repo_path, branch), discarded: dirty }
868
894
  end
869
895
 
870
896
  def worktree_list
@@ -875,9 +901,13 @@ module Space::Architect
875
901
 
876
902
  # Materialize the iteration's declared lanes: for each lane (or the one named via
877
903
  # `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.
904
+ # record worktree/base_sha/integration_branch. Idempotent — a bare re-run (no
905
+ # explicit --base) over an already-materialized lane changes nothing. An explicit
906
+ # --base is always enforced, even against an already-materialized lane (#88's
907
+ # "blind spot" — a short-circuit that trusted the recorded state without checking
908
+ # it against what was actually asked for). Refuses until the iteration is frozen,
909
+ # because declarations are not authoritative until then. outcome on each result is
910
+ # :created / :unchanged / :repointed (#88/AC4 — tellable apart by a human reader).
881
911
  def provision(iteration, base: nil, lane: nil, force: false)
882
912
  entry = slice_entry(iteration)
883
913
  raise Space::Core::Error,
@@ -888,17 +918,15 @@ module Space::Architect
888
918
  lanes = lanes.select { |l| l["name"] == lane } if lane
889
919
  raise Space::Core::Error, "No lane '#{lane}' declared for iteration '#{iteration}'" if lane && lanes.empty?
890
920
 
921
+ base_explicit = !base.nil?
922
+ refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force
923
+
891
924
  lanes.map do |l|
892
925
  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
926
+ result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
927
+ base_explicit: base_explicit, force: force, **recorded_lane_fields(l))
928
+ { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome],
929
+ discarded: result[:discarded] }
902
930
  end
903
931
  end
904
932
 
@@ -911,7 +939,8 @@ module Space::Architect
911
939
  end
912
940
 
913
941
  def dispatch(iteration, lane, model: nil, max_turns: 200,
914
- claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, force: false, quiet: false,
942
+ claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
943
+ allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false,
915
944
  detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
916
945
  push_client: nil, timeout: nil, prompt: nil, now: Time.now)
917
946
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
@@ -928,6 +957,9 @@ module Space::Architect
928
957
 
929
958
  resolved_harness, resolved_model, resolved_effort =
930
959
  resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
960
+ resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
961
+ resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
962
+ err.puts(tools_provenance) if replace_tools || append_tools
931
963
 
932
964
  raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
933
965
  if push_host && resolved_harness != "claude-code"
@@ -947,22 +979,29 @@ module Space::Architect
947
979
 
948
980
  bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
949
981
  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.
982
+ config_dir: build_dir, effort: resolved_effort,
983
+ allowed_tools: resolved_allowed_tools, force: force, err: err)
984
+
985
+ # Stamp launch time and the resolved harness/model/effort/allowed_tools onto the
986
+ # lane entry: after every preflight validation has passed (a dispatch that raises
987
+ # above records nothing) and before the blocking run or a detached dispatch
988
+ # returns. A re-dispatch overwrites the prior values, so `architect status`
989
+ # always reads what actually ran on the last dispatch. #89/AC8: the resolved
990
+ # tool grant is recorded the same way, so a denial traces back to a grant a
991
+ # human can read — replace_tools/append_tools are the pre-composition components
992
+ # (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later
993
+ # bare re-dispatch re-resolves from the same components instead of compounding.
957
994
  update_architect_block do |b|
958
995
  (b["iterations"] || []).each do |s|
959
996
  next unless s["name"] == iteration
960
997
  (s["lanes"] || []).each do |l|
961
998
  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
999
+ l["dispatched_at"] = now.iso8601
1000
+ l["harness"] = resolved_harness
1001
+ l["model"] = resolved_model
1002
+ l["effort"] = resolved_effort if resolved_effort
1003
+ l["allowed_tools"] = replace_tools if replace_tools
1004
+ l["append_allowed_tools"] = append_tools if append_tools
966
1005
  end
967
1006
  end
968
1007
  b
@@ -1011,7 +1050,8 @@ module Space::Architect
1011
1050
  # transcript lives server-side. jobs_client: is the injectable seam (mirrors
1012
1051
  # dispatch's run_creator:).
1013
1052
  def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
1014
- max_turns: 200, effort: nil, force: false, quiet: false,
1053
+ max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil,
1054
+ force: false, quiet: false,
1015
1055
  job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now)
1016
1056
  err = quiet ? File.open(File::NULL, "w") : $stderr
1017
1057
 
@@ -1026,6 +1066,12 @@ module Space::Architect
1026
1066
  unless resolved_harness == "claude-code"
1027
1067
  raise Space::Core::Error, "--job-model is required with --as-job" unless job_model
1028
1068
 
1069
+ # #89: same replace/append resolution as the local dispatch path — the sandboxed
1070
+ # executor still runs `claude -p` with harness_args server-side.
1071
+ resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
1072
+ resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
1073
+ err.puts(tools_provenance) if replace_tools || append_tools
1074
+
1029
1075
  id = iteration_id(entry)
1030
1076
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
1031
1077
  raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
@@ -1036,7 +1082,8 @@ module Space::Architect
1036
1082
 
1037
1083
  repo_path = space.path.join("repos", lane_entry["repo"])
1038
1084
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
1039
- effort: resolved_effort, force: force, err: err)
1085
+ effort: resolved_effort, allowed_tools: resolved_allowed_tools,
1086
+ force: force, err: err)
1040
1087
 
1041
1088
  spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir,
1042
1089
  repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url,
@@ -1101,16 +1148,43 @@ module Space::Architect
1101
1148
  # Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
1102
1149
  # either merge_lane! results (symbol keys) or recorded lane entries (string
1103
1150
  # keys) — both carry a lane name and a repo.
1104
- def teardown_lanes!(iteration, lane_entries)
1151
+ def teardown_lanes!(iteration, lane_entries, force: false)
1105
1152
  id = iteration_id(slice_entry(iteration))
1153
+ refuse_teardown_if_dirty!(iteration, lane_entries, id) unless force
1154
+
1106
1155
  lane_entries.map do |l|
1107
1156
  lane = l[:lane] || l["name"]
1108
1157
  repo = l[:repo] || l["repo"]
1109
- worktree_remove(iteration, lane)
1158
+ removal = worktree_remove(iteration, lane, force: true)
1110
1159
  lane_branch = "lane/#{id}-#{lane}"
1111
1160
  git_capture("-C", space.path.join("repos", repo).to_s, "branch", "-d", lane_branch)
1112
- { lane: lane, repo: repo, lane_branch: lane_branch }
1161
+ { lane: lane, repo: repo, lane_branch: lane_branch, discarded: removal[:discarded] }
1162
+ end
1163
+ end
1164
+
1165
+ # Every lane a teardown-only call would touch is checked for
1166
+ # uncommitted work BEFORE any lane is removed — a dirty lane discovered late
1167
+ # in the recorded set can never leave an earlier lane already torn down.
1168
+ # Lanes reached via a merge-then-teardown call are always clean here
1169
+ # (merge_lane! commits the worktree first), so this never fires on the
1170
+ # normal integrate-then-teardown rhythm.
1171
+ def refuse_teardown_if_dirty!(iteration, lane_entries, id)
1172
+ entry = slice_entry(iteration)
1173
+ dirty = lane_entries.filter_map do |l|
1174
+ lane = l[:lane] || l["name"]
1175
+ repo = l[:repo] || l["repo"]
1176
+ recorded = (entry["lanes"] || []).find { |le| le["name"] == lane }
1177
+ wt_path = space.path.join(recorded&.dig("worktree") || "build/#{id}-#{lane}/wt")
1178
+ count = dirty_file_count(space.path.join("repos", repo), wt_path)
1179
+ count ? [lane, count] : nil
1113
1180
  end
1181
+ return if dirty.empty?
1182
+
1183
+ names = dirty.map { |n, c| "#{n} (#{c})" }.join(", ")
1184
+ raise Space::Core::Error,
1185
+ "Refusing to tear down '#{iteration}' — lane(s) #{names} hold uncommitted work (untracked files " \
1186
+ "included) that would be discarded (worktree removed, branch deleted). Integrate them first, or " \
1187
+ "re-run with --force to discard it."
1114
1188
  end
1115
1189
 
1116
1190
  # Resolve the in-flight iteration file for ground output.
@@ -1212,10 +1286,18 @@ module Space::Architect
1212
1286
  end
1213
1287
  end
1214
1288
 
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)
1289
+ # I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
1290
+ # `cwd` of their own only those gates need one, and only they are ambiguous
1291
+ # when the drafted ```lanes``` block names more than one repo. A gate with its
1292
+ # own `cwd` rehearses in its own declared directory regardless of how many
1293
+ # repos are declared, so an iteration whose gates all declare `cwd` never
1294
+ # needs a default and never reaches this method's refusals. Never resolves
1295
+ # from space.yaml (no lanes are recorded there pre-freeze).
1296
+ def resolve_rehearsal_repo(iteration, text, gates)
1297
+ needing_default = gates.reject { |g| g.transform_keys(&:to_s)["cwd"] }
1298
+ return nil if needing_default.empty?
1299
+
1300
+ ids = needing_default.map { |g| g.transform_keys(&:to_s)["id"] }
1219
1301
  declared = parse_lanes(text).filter_map { |l| l["repo"] }.uniq
1220
1302
  return declared.first if declared.size == 1
1221
1303
 
@@ -1225,13 +1307,13 @@ module Space::Architect
1225
1307
  raise Space::Core::Error,
1226
1308
  "Cannot resolve a repo to rehearse '#{iteration}' against — no lane declares a repo and the space " \
1227
1309
  "tracks #{tracked.size} repos (#{tracked.join(', ')}). Declare a ```lanes``` block in the " \
1228
- "Specification, naming the repo to rehearse against."
1310
+ "Specification naming the repo, or give gate(s) #{ids.join(', ')} an explicit `cwd`."
1229
1311
  end
1230
1312
 
1231
1313
  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."
1314
+ "Cannot default a repo for gate(s) #{ids.join(', ')} — the drafted lanes block names multiple repos " \
1315
+ "(#{declared.join(', ')}) and #{ids.size == 1 ? "it declares" : "they declare"} no `cwd`. Give " \
1316
+ "#{ids.size == 1 ? "it" : "them"} an explicit `cwd`, or narrow the ```lanes``` block to one repo."
1235
1317
  end
1236
1318
 
1237
1319
  # I09/AC5: RED (clean non-zero — discriminates) vs GREEN (passes on base) vs
@@ -1287,24 +1369,28 @@ module Space::Architect
1287
1369
  # union; parse_lanes returns [] with no ```lanes``` block, which still
1288
1370
  # yields the outside-every-lane half of the report. Reports only: never
1289
1371
  # touches rehearse's exit code, RED/GREEN/BROKEN, or the rehearsal stamp.
1290
- def scope_asymmetry_report(gates, text, base_dir)
1372
+ #
1373
+ # #90/AC7: takes the already-EXECUTED gate results, not a single base_dir —
1374
+ # each gate's own resolved `dir` (execute_gates already computed it, per gate)
1375
+ # is what the re-run replays against, so a cross-repo iteration is covered
1376
+ # gate-by-gate in its own repo, and each finding names the repo it came from.
1377
+ def scope_asymmetry_report(gate_results, text)
1291
1378
  touch_globs = parse_lanes(text).flat_map { |l| l["touch"] || [] }
1292
1379
  findings = []
1293
1380
  not_analyzable = []
1294
1381
 
1295
- gates.each do |gate|
1296
- g = gate.transform_keys(&:to_s)
1297
- grep_invocations(g["cmd"].to_s).each do |inv|
1382
+ gate_results.each do |g|
1383
+ grep_invocations(g[:cmd].to_s).each do |inv|
1298
1384
  if inv[:status] != :recognized
1299
- not_analyzable << { id: g["id"], reason: inv[:reason] }
1385
+ not_analyzable << { id: g[:id], reason: inv[:reason] }
1300
1386
  next
1301
1387
  end
1302
1388
 
1303
- finding = scope_finding(inv, base_dir, touch_globs)
1389
+ finding = scope_finding(inv, g[:dir], touch_globs)
1304
1390
  if finding
1305
- findings << finding.merge(id: g["id"])
1391
+ findings << finding.merge(id: g[:id], repo: repo_name_for(g[:dir]))
1306
1392
  else
1307
- not_analyzable << { id: g["id"], reason: "whole-repo re-run failed" }
1393
+ not_analyzable << { id: g[:id], reason: "whole-repo re-run failed" }
1308
1394
  end
1309
1395
  end
1310
1396
  end
@@ -1312,6 +1398,15 @@ module Space::Architect
1312
1398
  { findings: findings, not_analyzable: not_analyzable }
1313
1399
  end
1314
1400
 
1401
+ # Best-effort label for a scope-asymmetry finding: the repos/<name> a gate's
1402
+ # resolved dir sits under, or nil for a `cwd` outside repos/ entirely (legal —
1403
+ # #test_run_gates_honors_cwd_outside_repo — just not attributable to one repo).
1404
+ def repo_name_for(dir)
1405
+ repos_root = space.path.join("repos")
1406
+ return nil unless dir.to_s.start_with?("#{repos_root}/")
1407
+ dir.relative_path_from(repos_root).to_s.split("/").first
1408
+ end
1409
+
1315
1410
  # Re-runs a recognized invocation's own pattern across the whole repo and
1316
1411
  # splits what it finds into: the gate's own declared scope (expected —
1317
1412
  # that's what the gate already searches, so it's not reported), elsewhere
@@ -1493,6 +1588,10 @@ module Space::Architect
1493
1588
  # identically to `provision` (same base resolution, same worktree_add primitive)
1494
1589
  # so no dispatch/integrate/gate path dead-ends on a missing worktree. Returns the
1495
1590
  # (possibly refreshed) lane entry; a no-op when already materialized or undeclared.
1591
+ # base_explicit: false — this call never had an operator-given --base to honor, so
1592
+ # a surviving lane branch (e.g. dispatched work, worktree since removed) is always
1593
+ # just reattached at its own tip, never refused or re-pointed (#88 is about an
1594
+ # explicit ask being dropped; this path never has one to drop).
1496
1595
  def ensure_lane_materialized(iteration, lane)
1497
1596
  entry = slice_entry(iteration)
1498
1597
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
@@ -1504,7 +1603,7 @@ module Space::Architect
1504
1603
  return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
1505
1604
 
1506
1605
  worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
1507
- **recorded_lane_fields(lane_entry))
1606
+ base_explicit: false, **recorded_lane_fields(lane_entry))
1508
1607
  (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
1509
1608
  end
1510
1609
 
@@ -1570,6 +1669,35 @@ module Space::Architect
1570
1669
  [resolved_harness, resolved_model, resolved_effort]
1571
1670
  end
1572
1671
 
1672
+ # #89/AC1-AC5: resolve the claude-code --allowedTools grant for a dispatch (local or
1673
+ # --as-job), shared so both paths resolve precedence identically. The CLI flag beats
1674
+ # the lane's recorded allowed_tools:/append_allowed_tools: (frozen-declaration or a
1675
+ # prior dispatch's stamp) — resolved independently for replace vs. append, so a flag
1676
+ # on one axis and a lane value on the other both apply (Objective A). Meaningless for
1677
+ # opencode/pi (no equivalent grant mechanism) — still resolved/recorded for visibility,
1678
+ # but Harness.for only wires it into the claude-code branch's argv.
1679
+ def resolve_allowed_tools(lane_entry, allowed_tools:, append_allowed_tools:)
1680
+ replace, replace_from =
1681
+ if allowed_tools
1682
+ [allowed_tools, "--allowed-tools flag"]
1683
+ elsif lane_entry["allowed_tools"]
1684
+ [lane_entry["allowed_tools"], "lane's allowed_tools:"]
1685
+ else
1686
+ [nil, "default"]
1687
+ end
1688
+
1689
+ append, append_from =
1690
+ if append_allowed_tools
1691
+ [append_allowed_tools, "--append-allowed-tools flag"]
1692
+ elsif lane_entry["append_allowed_tools"]
1693
+ [lane_entry["append_allowed_tools"], "lane's append_allowed_tools:"]
1694
+ end
1695
+
1696
+ final = Harness::ClaudeCodeHarness.resolve_tools(replace: replace, append: append)
1697
+ provenance = "allowed-tools: #{replace_from}#{append_from ? " + #{append_from}" : ""} → #{final}"
1698
+ [final, replace, append, provenance]
1699
+ end
1700
+
1573
1701
  # Compose a dispatch --as-job spec per the space-server's job contract: prompt +
1574
1702
  # workspace.dir (the lane worktree) + environment (deps/network/mounts/env or
1575
1703
  # secrets) + harness (claude/backend/args) + provenance. mounts are src:dst with
@@ -2010,10 +2138,145 @@ module Space::Architect
2010
2138
  st.success?
2011
2139
  end
2012
2140
 
2141
+ # Uncommitted work in a lane worktree — untracked files
2142
+ # included. Hard rule 7 forbids builder commits, so this is the only
2143
+ # signal an in-flight or completed-but-unintegrated lane's output ever
2144
+ # leaves behind; "no commits on the branch" is the EXPECTED state, not
2145
+ # evidence the worktree is empty. nil when the worktree doesn't exist,
2146
+ # isn't a registered git worktree, or is clean; otherwise the number of
2147
+ # changed/untracked entries `git status --porcelain` reports (a count is
2148
+ # enough — callers never dump the whole status).
2149
+ def dirty_file_count(repo_path, wt_path)
2150
+ return nil unless wt_path.exist? && worktree_registered?(repo_path, wt_path)
2151
+ out, _, st = git_capture("-C", wt_path.to_s, "status", "--porcelain")
2152
+ return nil unless st.success?
2153
+ count = out.each_line.count { |l| !l.strip.empty? }
2154
+ count.zero? ? nil : count
2155
+ end
2156
+
2157
+ # Would re-pointing `branch`'s worktree to base_sha actually run `git
2158
+ # reset --hard` over uncommitted work? nil when there's nothing to lose
2159
+ # (branch absent, worktree unregistered, already at base_sha, or clean);
2160
+ # otherwise the #dirty_file_count that would be discarded. Read-only —
2161
+ # used by provision's multi-lane preflight (#refuse_repoint_if_dirty!),
2162
+ # never by the mutating path itself (attach_lane_branch! already has
2163
+ # tip_sha in scope and calls #dirty_file_count directly).
2164
+ def repoint_dirty_count(repo_path, wt_path, branch, base_sha)
2165
+ return nil unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
2166
+ tip_out, _, tip_st = git_capture("-C", repo_path.to_s, "rev-parse", branch)
2167
+ tip_sha = tip_st.success? ? tip_out.strip : nil
2168
+ return nil if tip_sha == base_sha
2169
+ dirty_file_count(repo_path, wt_path)
2170
+ end
2171
+
2172
+ # Multi-lane atomicity for provision's re-point path — every
2173
+ # declared lane is checked for uncommitted work BEFORE any lane's
2174
+ # worktree_add runs, so a dirty lane discovered late in the set can never
2175
+ # leave an earlier lane already re-pointed.
2176
+ def refuse_repoint_if_dirty!(iteration, lanes, base, id)
2177
+ dirty = lanes.filter_map do |l|
2178
+ repo_path = space.path.join("repos", l["repo"])
2179
+ wt_path = space.path.join(l["worktree"] || "build/#{id}-#{l["name"]}/wt")
2180
+ branch = "lane/#{id}-#{l["name"]}"
2181
+ resolved, _, st = git_capture("-C", repo_path.to_s, "rev-parse", resolve_lane_base(l["repo"], base))
2182
+ next nil unless st.success?
2183
+ count = repoint_dirty_count(repo_path, wt_path, branch, resolved.strip)
2184
+ count ? [l["name"], count] : nil
2185
+ end
2186
+ return if dirty.empty?
2187
+
2188
+ names = dirty.map { |n, c| "#{n} (#{c})" }.join(", ")
2189
+ raise Space::Core::Error,
2190
+ "Refusing to provision '#{iteration}' — re-pointing to the requested base would discard uncommitted " \
2191
+ "work (untracked files included) in: #{names}. Dispatch or integrate the lane(s) first, or re-run " \
2192
+ "with --force to discard it and re-point."
2193
+ end
2194
+
2013
2195
  def worktree_registered?(repo_path, wt_path)
2014
2196
  out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
2015
2197
  real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
2016
2198
  out.lines.any? { |l| l.start_with?("worktree ") && l.chomp.delete_prefix("worktree ") == real }
2017
2199
  end
2200
+
2201
+ # The branch checked out at wt_path, per `git worktree list --porcelain`
2202
+ # (read before removal, since the entry disappears once the worktree is
2203
+ # gone) — nil if wt_path isn't a registered worktree of repo_path at all.
2204
+ def worktree_branch(repo_path, wt_path)
2205
+ out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
2206
+ real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
2207
+ entry = out.split("\n\n").find { |e| e.lines.any? { |l| l.chomp == "worktree #{real}" } }
2208
+ entry&.lines&.find { |l| l.start_with?("branch ") }&.chomp&.delete_prefix("branch refs/heads/")
2209
+ end
2210
+
2211
+ # #88: attach `branch` at wt_path, choosing among the three lane-branch states
2212
+ # the issue distinguishes — absent (create), present with no commits beyond the
2213
+ # base it was recorded from (safe to re-point — the fast-follow case), or
2214
+ # present with commits of its own (refuse unless force). Returns [outcome,
2215
+ # base_sha], where base_sha is the commit the worktree actually ends up on —
2216
+ # never the freshly-resolved one when nothing about the tree moved (the
2217
+ # provenance half of the bug).
2218
+ #
2219
+ # base_explicit: false short-circuits to "just attach whatever's there" —
2220
+ # never refuses, never re-points, and preserves the previously recorded
2221
+ # base_sha instead of overwriting it with a base nobody asked for (see
2222
+ # ensure_lane_materialized).
2223
+ def attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base, base_explicit:, force:)
2224
+ unless branch_exists?(repo_path, branch)
2225
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
2226
+ return [:created, base_sha, nil]
2227
+ end
2228
+
2229
+ registered = worktree_registered?(repo_path, wt_path)
2230
+ tip_out, _, tip_st = git_capture("-C", repo_path.to_s, "rev-parse", branch)
2231
+ tip_sha = tip_st.success? ? tip_out.strip : nil
2232
+
2233
+ unless base_explicit
2234
+ return [:unchanged, recorded_base || base_sha, nil] if registered
2235
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
2236
+ return [:created, recorded_base || tip_sha, nil]
2237
+ end
2238
+
2239
+ if tip_sha == base_sha
2240
+ return [:unchanged, base_sha, nil] if registered
2241
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
2242
+ return [:created, base_sha, nil]
2243
+ end
2244
+
2245
+ # Hard rule 7 forbids builder commits, so an in-flight lane's
2246
+ # entire output is uncommitted working-tree state — carries_own_commits?
2247
+ # below can't see it (no commits to count). Checked first: it's the more
2248
+ # common loss (partial — reset --hard spares untracked files) and the one
2249
+ # this iteration exists to close.
2250
+ dirty = dirty_file_count(repo_path, wt_path)
2251
+ if dirty && !force
2252
+ raise Space::Core::Error,
2253
+ "Lane branch '#{branch}' worktree has #{dirty} uncommitted change(s) (untracked files included) — " \
2254
+ "re-pointing to '#{base_ref}' (#{base_sha[0, 8]}) would run `git reset --hard` and discard them. " \
2255
+ "Dispatch or integrate it first, or re-run with --force to discard them and re-point."
2256
+ end
2257
+
2258
+ if !force && carries_own_commits?(repo_path, recorded_base, tip_sha)
2259
+ raise Space::Core::Error,
2260
+ "Lane branch '#{branch}' carries commits of its own — it is at #{tip_sha ? tip_sha[0, 8] : "?"}, but " \
2261
+ "base '#{base_ref}' (#{base_sha[0, 8]}) was requested. Re-pointing would discard its commits — " \
2262
+ "dispatch or integrate it first, or re-run with --force to discard them and re-point onto the new base."
2263
+ end
2264
+
2265
+ if registered
2266
+ git_run("-C", wt_path.to_s, "reset", "--hard", base_sha)
2267
+ else
2268
+ git_run("-C", repo_path.to_s, "worktree", "add", "-B", branch, wt_path.to_s, base_sha)
2269
+ end
2270
+ [:repointed, base_sha, dirty]
2271
+ end
2272
+
2273
+ # #88/AC2-AC3: does the branch carry commits beyond the base it was recorded
2274
+ # from? Unrecorded provenance can't be proven safe, so it counts as "carries
2275
+ # its own" — conservative by design, not merely by omission.
2276
+ def carries_own_commits?(repo_path, recorded_base, tip_sha)
2277
+ return true if recorded_base.nil? || tip_sha.nil?
2278
+ out, _, st = git_capture("-C", repo_path.to_s, "rev-list", "--count", "#{recorded_base}..#{tip_sha}")
2279
+ !(st.success? && out.strip.to_i.zero?)
2280
+ end
2018
2281
  end
2019
2282
  end
@@ -181,7 +181,15 @@ module Space::Architect
181
181
  h = l["harness"] || "claude-code"
182
182
  m = l["model"] || Harness.default_model_for(h)
183
183
  eff = l["effort"] ? "·#{l['effort']}" : ""
184
- "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
184
+ # #89/AC8: the resolved tool grant, visible next to the other resolved
185
+ # values — only when it diverges from the harness default, to keep the
186
+ # common case (no lane touches it) uncluttered. Meaningless outside
187
+ # claude-code (no equivalent grant mechanism), so shown only there.
188
+ tools = if h == "claude-code"
189
+ resolved = Harness::ClaudeCodeHarness.resolve_tools(replace: l["allowed_tools"], append: l["append_allowed_tools"])
190
+ resolved == Harness::ClaudeCodeHarness::ALLOWED_TOOLS ? "" : "·tools:#{resolved}"
191
+ end
192
+ "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff}#{tools})"
185
193
  end.join(", ")
186
194
  lanes = lane_list.any? { |l| l["variant"] } ? "variant: #{lanes_str}" : lanes_str
187
195
  lanes = "#{lanes} → winner: #{s['winner']}" if s["winner"]
@@ -259,7 +267,7 @@ module Space::Architect
259
267
  reason = result[:placeholder] ? "the scaffold placeholder '#{ArchitectProject::AC1_PLACEHOLDER}' with no active gate" : "no gates drafted yet"
260
268
  terminal.say "EMPTY — #{reason}. Nothing to rehearse; the pre-freeze look is still stamped."
261
269
  else
262
- terminal.say "Rehearsing #{result[:iteration]} against #{terminal.path(result[:base_dir])} (repo: #{result[:repo]})"
270
+ terminal.say rehearsal_header(result)
263
271
  result[:gates].each { |g| render_gate(g) }
264
272
  render_scope_asymmetry(result[:scope_asymmetry])
265
273
  end
@@ -267,6 +275,17 @@ module Space::Architect
267
275
  terminal.say "Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
268
276
  end
269
277
 
278
+ # #90/AC7: with a single defaultable repo, name it as before; a cross-repo
279
+ # run (or one where every gate declares its own `cwd`) has no one repo to
280
+ # name — each gate's own "dir:" line (below) already says where it ran.
281
+ def rehearsal_header(result)
282
+ if result[:repo]
283
+ "Rehearsing #{result[:iteration]} against #{terminal.path(result[:base_dir])} (repo: #{result[:repo]})"
284
+ else
285
+ "Rehearsing #{result[:iteration]} — #{result[:gates].size} gate(s), each in its own declared cwd"
286
+ end
287
+ end
288
+
270
289
  def render_gate(g)
271
290
  terminal.say ""
272
291
  terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]}: #{g[:cmd]} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
@@ -291,7 +310,7 @@ module Space::Architect
291
310
  terminal.say ""
292
311
  terminal.say "Scope-asymmetry check (grep-family gates only):"
293
312
  report[:findings].each do |f|
294
- terminal.say "── #{f[:id]}: pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
313
+ terminal.say "── #{f[:id]} (#{f[:repo]}): pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
295
314
  if f[:outside_lanes].any?
296
315
  terminal.say " OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
297
316
  f[:outside_lanes].each { |file| terminal.say " #{file}" }
@@ -311,9 +330,10 @@ module Space::Architect
311
330
 
312
331
  by = result[:gates].group_by { |g| g[:rehearsal] }
313
332
  ids = ->(sym) { (by[sym] || []).map { |g| g[:id] }.join(", ") }
333
+ ran_against = result[:repo] ? "against `#{result[:repo]}`" : "each in its own declared `cwd`"
314
334
  [
315
335
  "> **Dry-run at rehearsal time, recorded for transparency.** All #{result[:gates].size} gate command(s) " \
316
- "were executed as written against `#{result[:repo]}` under `/bin/sh` — the shell `architect gate` uses.",
336
+ "were executed as written #{ran_against} under `/bin/sh` — the shell `architect gate` uses.",
317
337
  ">",
318
338
  "> - **RED (#{(by[:red] || []).size} — discriminate):** #{ids.call(:red).empty? ? "(none)" : ids.call(:red)}",
319
339
  "> - **GREEN (#{(by[:green] || []).size} — regression guard or non-discriminating):** #{ids.call(:green).empty? ? "(none)" : ids.call(:green)}",
@@ -420,6 +440,8 @@ module Space::Architect
420
440
  option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else space.yaml project.model, else the per-harness sensible default). Any provider/tier; pin a full id, not a floating alias"
421
441
  option :max_turns, default: "200", desc: "Max turns for the builder"
422
442
  option :harness, default: nil, desc: "Harness override (claude-code, opencode, pi)"
443
+ option :allowed_tools, default: nil, desc: "Comma-separated tool list — replaces the claude-code --allowedTools grant for this dispatch (default: Read,Edit,Write,Grep,Glob,Bash,WebSearch,WebFetch). The lane's frozen allowed_tools: does the same with no flag; this flag wins over that key. Meaningless for opencode/pi"
444
+ option :append_allowed_tools, default: nil, desc: "Comma-separated tool list — appends to the claude-code --allowedTools grant for this dispatch (to the flag/lane replace value, or the default). The lane's frozen append_allowed_tools: does the same with no flag; this flag wins over that key. Meaningless for opencode/pi"
423
445
  option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
424
446
  option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
425
447
  option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
@@ -441,6 +463,7 @@ module Space::Architect
441
463
 
442
464
  def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
443
465
  max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
466
+ allowed_tools: nil, append_allowed_tools: nil,
444
467
  force_effort: nil, force_thinking: nil, force_reasoning: nil, quiet: false, detach: false,
445
468
  timeout: "14400", push_url: nil, push_token: nil, push_host: nil,
446
469
  as_job: false, host: nil, token: nil, backend_url: nil, job_model: nil, api_key_ref: nil, **opts)
@@ -467,6 +490,8 @@ module Space::Architect
467
490
  kwargs[:model] = model if model
468
491
  kwargs[:harness] = harness if harness
469
492
  kwargs[:effort] = forced_level || level if forced_level || level
493
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
494
+ kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
470
495
  kwargs[:force] = true if forced_level
471
496
  kwargs[:quiet] = true if quiet
472
497
  kwargs[:job_model] = job_model if job_model
@@ -482,6 +507,8 @@ module Space::Architect
482
507
  kwargs[:model] = model if model
483
508
  kwargs[:harness] = harness if harness
484
509
  kwargs[:effort] = forced_level || level if forced_level || level
510
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
511
+ kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
485
512
  kwargs[:force] = true if forced_level
486
513
  kwargs[:quiet] = true if quiet
487
514
  kwargs[:timeout] = timeout.to_i unless detach
@@ -536,8 +563,14 @@ module Space::Architect
536
563
  terminal.say "No declared lanes to provision for '#{iteration}'"
537
564
  else
538
565
  results.each do |r|
539
- state = r[:created] ? "created" : "already present"
540
- terminal.say "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
566
+ state = case r[:outcome]
567
+ when :created then "created"
568
+ when :repointed then "re-pointed"
569
+ else "already present"
570
+ end
571
+ line = "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
572
+ line += " — discarded #{r[:discarded]} uncommitted change(s), --force was given" if r[:discarded]
573
+ terminal.say line
541
574
  end
542
575
  end
543
576
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -668,9 +701,10 @@ module Space::Architect
668
701
  option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
669
702
  option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
670
703
  option :accept_bounds, default: nil, desc: "Escape valve: override the in-bounds check for these lanes when the frozen touch-set glob is itself the defect, recording REASON in space.yaml (never overrides the no-builder-commits check)"
704
+ option :force, type: :boolean, default: false, desc: "Teardown-only: discard uncommitted work in a lane worktree instead of refusing (never overrides the mechanical merge checks)"
671
705
  commit_message_options
672
706
 
673
- def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, accept_bounds: nil, **opts)
707
+ def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, accept_bounds: nil, force: false, **opts)
674
708
  setup_terminal(**opts.slice(:color, :colors))
675
709
  handle_errors do
676
710
  lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
@@ -681,13 +715,15 @@ module Space::Architect
681
715
  project = ArchitectProject.new(space: sp)
682
716
  results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
683
717
  message: read_commit_message(message: message, message_from: message_from),
684
- commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds)
718
+ commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds, force: force)
685
719
  if lane_names.empty?
686
720
  if results.empty?
687
721
  terminal.say "Nothing to tear down for #{iteration}"
688
722
  else
689
723
  results.each do |r|
690
- terminal.say "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
724
+ line = "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
725
+ line += " — discarded #{r[:discarded]} uncommitted change(s), --force was given" if r[:discarded]
726
+ terminal.say line
691
727
  end
692
728
  end
693
729
  else
@@ -792,6 +828,7 @@ module Space::Architect
792
828
  argument :repo, required: true, desc: "Repo name (under repos/)"
793
829
  argument :iteration, required: true, desc: "Iteration name"
794
830
  argument :lane, required: true, desc: "Lane name"
831
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
795
832
  option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
796
833
  option :harness, default: nil, desc: "Harness (claude-code, opencode, pi; default: space.yaml project.harness, else claude-code)"
797
834
  option :model, default: nil, desc: "Model; a trailing :<level> suffix (e.g. foo:high) is parsed into --effort (default: space.yaml project.model, else the per-harness sensible default)"
@@ -802,12 +839,12 @@ module Space::Architect
802
839
  option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
803
840
  option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
804
841
 
805
- def call(repo:, iteration:, lane:, base: nil, harness: nil, model: nil,
842
+ def call(repo:, iteration:, lane:, space: nil, base: nil, harness: nil, model: nil,
806
843
  effort: nil, thinking: nil, reasoning: nil, quiet: false, touch: nil, force: false, **opts)
807
844
  setup_terminal(**opts.slice(:color, :colors))
808
845
  handle_errors do
809
846
  level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
810
- render(store.find) do |sp|
847
+ render(store.find(space)) do |sp|
811
848
  project = ArchitectProject.new(space: sp)
812
849
  touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
813
850
  err = quiet ? File.open(File::NULL, "w") : $stderr
@@ -815,6 +852,7 @@ module Space::Architect
815
852
  effort: level, touch: touch_set, force: force, err: err)
816
853
  terminal.say "Worktree: #{terminal.path(result[:worktree])}"
817
854
  terminal.say "Base SHA: #{result[:base_sha]}"
855
+ terminal.say "Discarded #{result[:discarded]} uncommitted change(s), --force was given" if result[:discarded]
818
856
  CLI.record_outcome(Outcome.new(exit_code: 0))
819
857
  end
820
858
  end
@@ -825,14 +863,23 @@ module Space::Architect
825
863
  desc "Remove a lane worktree"
826
864
  argument :iteration, required: true, desc: "Iteration name"
827
865
  argument :lane, required: true, desc: "Lane name"
866
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
867
+ option :force, type: :boolean, default: false, desc: "Discard uncommitted work in the worktree (untracked files included) instead of refusing"
828
868
 
829
- def call(iteration:, lane:, **opts)
869
+ def call(iteration:, lane:, space: nil, force: false, **opts)
830
870
  setup_terminal(**opts.slice(:color, :colors))
831
871
  handle_errors do
832
- render(store.find) do |sp|
872
+ render(store.find(space)) do |sp|
833
873
  project = ArchitectProject.new(space: sp)
834
- project.worktree_remove(iteration, lane)
874
+ result = project.worktree_remove(iteration, lane, force: force)
835
875
  terminal.say "Removed worktree for #{iteration}/#{lane}"
876
+ if result[:discarded]
877
+ terminal.say "Discarded #{result[:discarded]} uncommitted change(s), --force was given"
878
+ end
879
+ if result[:branch_survives]
880
+ terminal.say "Lane branch '#{result[:branch]}' survives — this is not a reset; " \
881
+ "re-provisioning re-points or refuses it, it does not start it over."
882
+ end
836
883
  CLI.record_outcome(Outcome.new(exit_code: 0))
837
884
  end
838
885
  end
@@ -841,11 +888,12 @@ module Space::Architect
841
888
 
842
889
  class List < BaseCommand
843
890
  desc "List active architect worktrees"
891
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
844
892
 
845
- def call(**opts)
893
+ def call(space: nil, **opts)
846
894
  setup_terminal(**opts.slice(:color, :colors))
847
895
  handle_errors do
848
- render(store.find) do |sp|
896
+ render(store.find(space)) do |sp|
849
897
  project = ArchitectProject.new(space: sp)
850
898
  worktrees = project.worktree_list
851
899
  if worktrees.empty?
@@ -62,13 +62,16 @@ module Space::Architect
62
62
  # (default $stderr; thread a null writer to suppress) when it clamps/strips.
63
63
  # With force: true, the literal `effort` value is passed through unmodified.
64
64
  # For opencode: config_dir is required (build/<id>-<lane> dir outside the worktree).
65
- def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil, force: false, err: $stderr)
65
+ def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil, allowed_tools: nil,
66
+ force: false, err: $stderr)
66
67
  translated, inform = translate_thinking(name, effort, force: force)
67
68
  err.puts(inform) if inform
68
69
 
69
70
  case name.to_s
70
71
  when "claude-code"
71
- ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin, effort: translated)
72
+ kwargs = { model: model, max_turns: max_turns, bin: bin, effort: translated }
73
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
74
+ ClaudeCodeHarness.new(**kwargs)
72
75
  when "opencode"
73
76
  raise Space::Core::Error, "config_dir is required for opencode harness" unless config_dir
74
77
  OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
@@ -93,6 +96,15 @@ module Space::Architect
93
96
  ACCEPTED_LEVELS = %w[low medium high xhigh max].freeze
94
97
  CLAMP_MAP = { "minimal" => "low" }.freeze
95
98
 
99
+ # #89: compose the --allowedTools value from an optional replace + append pair —
100
+ # resolved once here regardless of which surface (--allowed-tools/--append-allowed-tools
101
+ # flag, or the lane's frozen allowed_tools:/append_allowed_tools: declaration) supplied
102
+ # them, since replace/append combine identically either way.
103
+ def self.resolve_tools(replace: nil, append: nil)
104
+ base = replace || ALLOWED_TOOLS
105
+ append ? "#{base},#{append}" : base
106
+ end
107
+
96
108
  def self.translate_thinking(level, force: false)
97
109
  return [nil, nil] if level.nil?
98
110
  return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "6.0.0"
5
+ VERSION = "7.0.0"
6
6
  end
7
7
  end
@@ -277,7 +277,9 @@ contract, self-contained:
277
277
  its **target repo + file-touch set, checked for overlap**: name the repo
278
278
  (`repos/<repo>`) and every file each lane may touch. The machine-readable
279
279
  declaration lives in a fenced ` ```lanes ` block in the Specification — one
280
- entry per lane (`name`, `repo`, `touch` globs) the single frozen source of
280
+ entry per lane (`name`, `repo`, `touch` globs; optionally `allowed_tools`/
281
+ `append_allowed_tools` to replace/extend the lane's builder tool grant — see
282
+ `dispatch.md`) — the single frozen source of
281
283
  truth `architect freeze` records into `space.yaml` and `architect provision`
282
284
  materializes. The same boundary is stated twice more — the lane-prompt's
283
285
  may-touch list, and the scope gate that checks the lane's diff at judge
@@ -298,12 +300,15 @@ contract, self-contained:
298
300
  overlap run as one. Each lane gets its own objective, output format, and
299
301
  boundaries. Most
300
302
  iterations are one lane — fan out only when the work is genuinely parallel (a
301
- cross-repo project often is). Two first-class patterns — runnable recipes in `dispatch.md`: **parallel +
303
+ cross-repo project often is). Three first-class patterns — runnable recipes in `dispatch.md`: **parallel +
302
304
  fast-follow** (disjoint lanes integrate first; a fast-follow lane off
303
- `project/<slug>` carries the seam — see `### Parallel + fast-follow`) and
305
+ `project/<slug>` carries the seam — see `### Parallel + fast-follow`),
304
306
  **serial deferred judgment** (iterations run to gates-green with `architect
305
307
  verdict` withheld; one later batch session judges each against its own frozen
306
- AC — see `### Serial deferred judgment`).
308
+ AC — see `### Serial deferred judgment`), and **long-running sweep** (a lane
309
+ launches its own long detached process, ends its session at a sanctioned
310
+ pending status, and a later session in the same worktree audits it once it
311
+ exits — see `### Long-running sweep` in `dispatch.md`).
307
312
  - **Effort call** — thinking budget set per dispatch with `architect dispatch
308
313
  --effort <level>`, translated and clamped to the lane's harness (the
309
314
  escalation keywords `think hard` … `ultrathink` still work in-prompt);
@@ -402,7 +407,8 @@ Per the mechanics in `dispatch.md`. The lane lifecycle is **declare → rehearse
402
407
  there is no dispatch-in-the-checkout path:
403
408
 
404
409
  - **Declare** — at spec time, each lane is one entry in the Specification's
405
- fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs.
410
+ fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs, and optionally
411
+ `allowed_tools`/`append_allowed_tools`.
406
412
  - **Rehearse** — `architect rehearse <iteration>` dry-runs the drafted gates
407
413
  from the working tree (§4's pre-freeze check), resolving its run dir from the
408
414
  drafted ` ```lanes ` block, and stamps `space.yaml`; `freeze` refuses without
@@ -488,7 +494,10 @@ only the per-lane `lane/<iteration>-<lane>` branches and worktrees, never the
488
494
  project branch. Update the iteration index in `architecture/ARCHITECT.md`
489
495
  (recording the `project/<slug>` branch), remove the provisioned worktrees
490
496
  (`architect integrate … --teardown`, or `architect worktree remove <iteration>
491
- <lane>`), and commit the space.
497
+ <lane>`), and commit the space. Both refuse per lane when its worktree still
498
+ holds uncommitted work — untracked files included; `--force` overrides and
499
+ discards it. The `--lanes` integration path itself is unaffected, since
500
+ `integrate` commits each lane's work before teardown.
492
501
 
493
502
  **Run the frozen gates cold** — `architect gate <iteration>` runs the frozen
494
503
  gate commands against the integration tree and streams raw output (a runner, not
@@ -145,7 +145,12 @@ spec defect: kill the conflicting lane and re-spec; don't hand-resolve builder
145
145
  conflicts. It runs **no gates and makes no verdict** — `architect gate` streams
146
146
  the raw gate output for you to judge. `--teardown` deletes only the per-lane
147
147
  `lane/<iteration>-<lane>` branches and worktrees; it never deletes the
148
- `project/<slug>` branch.
148
+ `project/<slug>` branch. Every destructive lane operation — `worktree remove`,
149
+ `integrate --teardown`, and the re-point path of `provision --base` — refuses
150
+ per lane when that lane's worktree still holds uncommitted work, untracked
151
+ files included, because that's what a dispatched lane always holds; `--force`
152
+ overrides and discards it. The `--lanes <passing-set>` integration path itself
153
+ is unaffected — `integrate` commits each lane's work before tearing it down.
149
154
 
150
155
  Under the hood / manual fallback (one lane shown):
151
156
 
@@ -261,6 +266,66 @@ rests on a foundation that a later KILL at N would revert. Accept this coupling
261
266
  only consciously, and always judge oldest-first so a KILL stops you before you
262
267
  compound it.
263
268
 
269
+ ### Long-running sweep (detached, two-phase)
270
+
271
+ Use when a lane's own work *is* a long-running detached process — a 30–70
272
+ minute sweep, migration, or batch run the lane must launch, let run
273
+ unattended, and then audit once it exits. A metered builder session paying
274
+ tokens to sit in a poll loop is the wrong instrument for that wait: split the
275
+ lane across two builder sessions instead, one before the process runs and one
276
+ after.
277
+
278
+ **Recipe:**
279
+
280
+ 1. **Phase A — launch, prove liveness, stop deliberately.** The lane-prompt's
281
+ PHASE 2 tells the builder to launch the process detached (backgrounded and
282
+ redirected to a log file, survives the builder's own process exit), capture
283
+ its pid, prove it's alive with one command result (e.g. `ps -p <pid>` plus a
284
+ growing log tail — not an assertion), pre-structure the report with the
285
+ sections phase B will fill in, and end the session there — not mid-poll —
286
+ at:
287
+ ```
288
+ STATUS: SWEEP_RUNNING (pid <pid>, run dir <path>)
289
+ ```
290
+ `SWEEP_RUNNING` is the fixed token spec authors and architects grep for;
291
+ the pid and run dir ride in the same line because nothing else in the loop
292
+ captures them.
293
+
294
+ 2. **The wait belongs to the architect's harness, not the builder session.**
295
+ Between phase A and B, a process-exit monitor on the architect's side —
296
+ notification-driven, not a poll loop — watches for the pid to exit. A
297
+ builder session spends tokens to sit idle in a wait; the harness doesn't.
298
+ Do not dispatch a phase-B session, or resume one, until the process has
299
+ actually exited.
300
+
301
+ 3. **Phase B — resume and audit.** Once the process has exited, resume the
302
+ same lane worktree with `--continue` — the one sanctioned use of
303
+ `--continue` beyond same-lane follow-ups (see `## Operating guidance`
304
+ below) — and hand it the post-run audit as the new instruction:
305
+ ```bash
306
+ ( cd build/<id>-<lane>/wt && \
307
+ echo "The sweep at <run dir> (pid <pid>) has exited. Audit its output
308
+ against the spec's acceptance criteria, finish the report you
309
+ pre-structured in phase A, and end at STATUS: COMPLETE." \
310
+ | claude -p --continue --model <builder-model> \
311
+ --permission-mode acceptEdits \
312
+ --allowedTools 'Read,Edit,Write,Grep,Glob,Bash,WebSearch,WebFetch' \
313
+ --disallowedTools 'Bash(git commit:*),Bash(git push:*),Bash(git reset:*),Bash(git merge:*),Bash(git rebase:*),Bash(git checkout:*),Bash(git branch:*)' \
314
+ --output-format stream-json --verbose \
315
+ > build/<id>-<lane>/run-b.jsonl 2>&1 )
316
+ ```
317
+ Phase B runs the post-run audits the spec asked for, then finishes the
318
+ report at `STATUS: COMPLETE` (or `COMPLETE_WITH_CONCERNS`/`BLOCKED`, per the
319
+ template) exactly as any other lane would.
320
+
321
+ **Invariant:** phase A never ends mid-poll or at a pending status of its own
322
+ invention — it ends at `SWEEP_RUNNING` with the pid and run dir recorded, or
323
+ it's indistinguishable from a lane that gave up. Spec authors: an in-lane
324
+ "poll until it finishes" / "never end the session while it runs" instruction
325
+ loses to the template's no-busy-wait clause below every time a builder holds
326
+ both — write this two-phase shape into the lane spec instead of a poll
327
+ instruction.
328
+
264
329
  ## Operating guidance
265
330
 
266
331
  - Background each lane as its own harness task and let the **per-lane
@@ -282,6 +347,19 @@ compound it.
282
347
  `ultrathink`) and the `MAX_THINKING_TOKENS` env var still raise depth from
283
348
  inside the block. Default unattended builder work to a high budget; downgrade
284
349
  a routine, tightly-specified lane (record which and why in the spec).
350
+ - **Tool grant.** The default `--allowedTools` list is overridable per dispatch:
351
+ `--allowed-tools <list>` replaces it, `--append-allowed-tools <list>` appends
352
+ to it. The same is settable in the frozen lane declaration's ` ```lanes ` block
353
+ — `allowed_tools:` (replaces) / `append_allowed_tools:` (appends) — so a lane
354
+ that needs an MCP or other non-default tool is granted it reviewably, judged
355
+ like every other lane boundary, rather than buried in a shell invocation. The
356
+ CLI flag wins over the lane's yaml key when both are given, and dispatch
357
+ reports which source resolved. This exists because `claude -p` **denies** a
358
+ tool not on the allow list rather than prompting for it — a lane missing a
359
+ needed grant doesn't error, it silently can't use the tool and files a
360
+ confident, dataless report instead. `--disallowedTools`/`DISALLOWED_TOOLS`
361
+ (the builder-never-commits deny rules above) has no override route, by design
362
+ — hard rule 7 depends on it staying fixed.
285
363
  - **Builders never commit, and the architect verifies it.** Claude Code has no
286
364
  sandbox to make `.git` read-only, so this is enforced by the deny rules at
287
365
  dispatch *and* checked after the run: before integrating a lane, confirm
@@ -400,7 +478,11 @@ backgrounded work is SIGTERMed and its output lost while the run still exits
400
478
  potentially long command an explicit timeout; if a
401
479
  runtime will not start unattended (interactive prompt, server with no timeout),
402
480
  record the exact failure in your report and route around it — never busy-wait
403
- or retry in a loop. When done, write your report to the scratch file given to
481
+ or retry in a loop. The one sanctioned exception is a deliberate wait on a
482
+ detached process this lane itself launched: end that phase at
483
+ `STATUS: SWEEP_RUNNING` (pid + run dir) per `### Long-running sweep` in
484
+ dispatch.md, instead of polling for it to finish — this clause still forbids
485
+ retry loops and sitting on an unattended runtime with no timeout. When done, write your report to the scratch file given to
404
486
  you, build/<id>-<lane>/report.md (an absolute path outside your worktree),
405
487
  with RAW results only — tables, numbers, command output — no interpretation, no
406
488
  "promising". Every status claim must be backed by a command result from this
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: 6.0.0
4
+ version: 7.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs