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 +4 -4
- data/CHANGELOG.md +83 -0
- data/lib/space_architect/architect_project.rb +330 -67
- data/lib/space_architect/cli/architect.rb +64 -16
- data/lib/space_architect/harness.rb +14 -2
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +15 -6
- data/skill/architect/dispatch.md +84 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d521b0afec00484bfba5b35e26e9f0a603807fb75f3569fa61b0a0b5ff9b3ab
|
|
4
|
+
data.tar.gz: d21ecd2d8dd8458b0ec2e89d4cc2ee45a5a70eafc53955b8d2b3c365b55efe3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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}"
|
|
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(
|
|
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
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 —
|
|
879
|
-
#
|
|
880
|
-
#
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
#
|
|
954
|
-
#
|
|
955
|
-
#
|
|
956
|
-
#
|
|
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"]
|
|
963
|
-
l["harness"]
|
|
964
|
-
l["model"]
|
|
965
|
-
l["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,
|
|
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,
|
|
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
|
|
1216
|
-
#
|
|
1217
|
-
#
|
|
1218
|
-
|
|
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
|
|
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
|
|
1233
|
-
"
|
|
1234
|
-
"```lanes``` block
|
|
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
|
-
|
|
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
|
-
|
|
1296
|
-
g
|
|
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[
|
|
1385
|
+
not_analyzable << { id: g[:id], reason: inv[:reason] }
|
|
1300
1386
|
next
|
|
1301
1387
|
end
|
|
1302
1388
|
|
|
1303
|
-
finding = scope_finding(inv,
|
|
1389
|
+
finding = scope_finding(inv, g[:dir], touch_globs)
|
|
1304
1390
|
if finding
|
|
1305
|
-
findings << finding.merge(id: g[
|
|
1391
|
+
findings << finding.merge(id: g[:id], repo: repo_name_for(g[:dir]))
|
|
1306
1392
|
else
|
|
1307
|
-
not_analyzable << { id: g[
|
|
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
|
-
|
|
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
|
|
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
|
|
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[:
|
|
540
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/space_core/version.rb
CHANGED
data/skill/architect/SKILL.md
CHANGED
|
@@ -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
|
|
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).
|
|
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`)
|
|
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
|
data/skill/architect/dispatch.md
CHANGED
|
@@ -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.
|
|
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
|