space-architect 5.1.0 → 5.2.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 +63 -0
- data/lib/space_architect/architect_project.rb +69 -26
- data/lib/space_architect/cli/architect.rb +61 -14
- data/lib/space_architect/harness.rb +176 -18
- data/lib/space_core/version.rb +1 -1
- 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: 3e8120606794bd415a99f1fffa4f850a141aeb0674a94b809311c19513e2bef3
|
|
4
|
+
data.tar.gz: 45796109310f890c1e20c13cd01c42bc09d88dbd3cb0dd8fb8b3dbb73cad8c0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cd1e6c226adfce0082aeee3aea0b9f7e24dbcbd583ab8ce715c79ed2e9352baf4274922c241f7f25599f42bcee044ba878aa622555e4c813928bbf26038985b
|
|
7
|
+
data.tar.gz: 3ea9beaa3bce9a280893b5d2d1021dfe050285e6584d9ff42d010548eeb955ddede01f286251ddec3a966b679dab650be129a4cbac36a666cdbe3d8966a3ac7b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,69 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.2.0] - 2026-07-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`pi` harness (`PiHarness`)** — `pi -p --mode json --model <m> --session-dir
|
|
13
|
+
<build_dir> --no-approve` as a first-class dispatch harness alongside
|
|
14
|
+
claude-code and opencode. The session dir is redirected into the lane's
|
|
15
|
+
`build/` dir via `--session-dir` so nothing leaks to
|
|
16
|
+
`~/.pi/agent/sessions/`. `when "pi"` factory branch; `dispatch`/
|
|
17
|
+
`worktree_add` recognize `pi`; CLI descs updated to include `pi`. opencode
|
|
18
|
+
and claude-code paths byte-for-byte unchanged (I09).
|
|
19
|
+
- **Unified `--effort` / `--thinking` / `--reasoning` knob** — three aliases
|
|
20
|
+
for one thinking-level setting, normalized to
|
|
21
|
+
`off, minimal, low, medium, high, xhigh, max`. Per-harness
|
|
22
|
+
`translate_thinking`: claude `--effort` (low/medium/high/xhigh/max,
|
|
23
|
+
off→omit, minimal→low); opencode `reasoningEffort` (low/medium/high,
|
|
24
|
+
xhigh/max→high, off/minimal→omit); pi `--thinking` passthrough (pi's
|
|
25
|
+
`thinkingLevelMap` clamps, architect doesn't). `--force-{effort,thinking,reasoning}`
|
|
26
|
+
(dispatch-only) skips the clamp and passes the literal — binary rejection is
|
|
27
|
+
final. `--quiet` threads a null `err:` sink. A trailing `:level` model-suffix
|
|
28
|
+
(e.g. `foo:high`) is parsed and translated across all harnesses
|
|
29
|
+
(explicit > suffix > stored) (I10).
|
|
30
|
+
- **Per-harness sensible model defaults** via `Harness.default_model_for`:
|
|
31
|
+
`claude-code → claude-sonnet-5`, `pi → qwen3-27b-optiq`,
|
|
32
|
+
`opencode → fireworks-ai/accounts/fireworks/models/glm-5p2`. The constant
|
|
33
|
+
`CLAUDE_DEFAULT_MODEL` keeps its name (to avoid a `research/supervisor.rb`
|
|
34
|
+
ripple) but its value is now `claude-sonnet-5` (I12).
|
|
35
|
+
- **`space.yaml` project defaults** — `project.harness` / `project.model` /
|
|
36
|
+
`project.effort` (all optional) may be declared in `space.yaml` and sit
|
|
37
|
+
between the CLI flag and the per-harness default in the resolution chain.
|
|
38
|
+
Pin a project's harness/model/effort once instead of per command; the user
|
|
39
|
+
authors them in `space.yaml` directly (no new CLI setter) (I12).
|
|
40
|
+
- **Dispatch stamp** (fixes the `status`-wrong-harness-model bug): `dispatch`
|
|
41
|
+
now stamps the resolved `harness`/`model`/`effort` onto the lane entry
|
|
42
|
+
alongside `dispatched_at`, so `architect status` reads what actually ran on
|
|
43
|
+
the last dispatch — not the `worktree_add`-time values or the global default
|
|
44
|
+
(I12).
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **Shared `resolve_harness_model` helper** DRY across `worktree_add` and
|
|
49
|
+
`dispatch`. Precedence: explicit CLI flag > lane entry's stored value >
|
|
50
|
+
`space.yaml` project default > per-harness sensible default. A stored model
|
|
51
|
+
is only honored when the stored harness still matches the resolved harness,
|
|
52
|
+
so a `--harness` override drops the old harness's model instead of leaking it
|
|
53
|
+
(I12).
|
|
54
|
+
- **Section-write idempotence** — `replace_section_body` now strips a leading
|
|
55
|
+
line equal to the target `## <Heading>` from the supplied body before
|
|
56
|
+
writing. Makes `architect section <it> <sec>` idempotent: `--from
|
|
57
|
+
<file-with-heading>` and `--from <body-only>` produce byte-identical output;
|
|
58
|
+
running it twice never accumulates a duplicate heading. Narrow — only the
|
|
59
|
+
target heading, so `### fix` sub-headings and wrong-section headings survive
|
|
60
|
+
(I11).
|
|
61
|
+
|
|
62
|
+
### Removed
|
|
63
|
+
|
|
64
|
+
- The `worktree_add` opencode/pi nil-model guards and the factory opencode/pi
|
|
65
|
+
`model == CLAUDE_DEFAULT_MODEL` guards — per-harness defaults cover nil, and
|
|
66
|
+
an explicit `--model claude-sonnet-5 --harness pi` is a valid deliberate
|
|
67
|
+
choice (via openrouter/anthropic routing); the binary rejects an invalid
|
|
68
|
+
model. Stale "Pass --model" tests rewritten to assert default-resolution
|
|
69
|
+
(I12).
|
|
70
|
+
|
|
8
71
|
## [5.1.0] - 2026-07-19
|
|
9
72
|
|
|
10
73
|
### Added
|
|
@@ -603,17 +603,14 @@ module Space::Architect
|
|
|
603
603
|
repos.map { |r| sync_one_repo(r["name"]) }
|
|
604
604
|
end
|
|
605
605
|
|
|
606
|
-
def worktree_add(repo, iteration, lane, base: nil, harness:
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
raise Space::Core::Error,
|
|
615
|
-
"effort is opencode-only (sets opencode reasoningEffort) — " \
|
|
616
|
-
"set effort only on opencode lanes (harness: opencode)"
|
|
606
|
+
def worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false,
|
|
607
|
+
effort: nil, touch: nil, force: false, err: $stderr)
|
|
608
|
+
model, suffix_level = Harness.parse_model_suffix(model)
|
|
609
|
+
harness, model = resolve_harness_model(harness, model)
|
|
610
|
+
resolved_level = effort || suffix_level || project_defaults["effort"]
|
|
611
|
+
Harness.validate_thinking_level!(resolved_level)
|
|
612
|
+
if effort.nil? && suffix_level
|
|
613
|
+
err.puts "thinking: model suffix ':#{suffix_level}' parsed from lane model → level=#{suffix_level} (model stripped)"
|
|
617
614
|
end
|
|
618
615
|
|
|
619
616
|
entry = slice_entry(iteration)
|
|
@@ -663,7 +660,7 @@ module Space::Architect
|
|
|
663
660
|
"model" => model,
|
|
664
661
|
"variant" => variant
|
|
665
662
|
}
|
|
666
|
-
new_fields["effort"] =
|
|
663
|
+
new_fields["effort"] = resolved_level if resolved_level
|
|
667
664
|
new_fields["touch_set"] = Array(touch) if touch && !Array(touch).empty?
|
|
668
665
|
|
|
669
666
|
update_architect_block do |b|
|
|
@@ -829,22 +826,35 @@ module Space::Architect
|
|
|
829
826
|
end
|
|
830
827
|
|
|
831
828
|
def dispatch(iteration, lane, model: nil, max_turns: 200,
|
|
832
|
-
claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
|
|
833
|
-
push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
|
|
829
|
+
claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, force: false, quiet: false,
|
|
830
|
+
detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
|
|
834
831
|
push_client: nil, timeout: nil, prompt: nil, now: Time.now)
|
|
835
832
|
raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
|
|
836
833
|
raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
|
|
837
834
|
raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
|
|
838
835
|
if detach && (push_url || push_host)
|
|
839
836
|
|
|
837
|
+
err = quiet ? File.open(File::NULL, "w") : $stderr
|
|
838
|
+
|
|
840
839
|
entry = slice_entry(iteration)
|
|
841
840
|
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
842
841
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
843
842
|
lane_entry = ensure_lane_materialized(iteration, lane)
|
|
844
843
|
|
|
845
|
-
|
|
846
|
-
resolved_model
|
|
847
|
-
|
|
844
|
+
model, suffix_level = Harness.parse_model_suffix(model)
|
|
845
|
+
resolved_harness, resolved_model = resolve_harness_model(harness, model,
|
|
846
|
+
stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
|
|
847
|
+
|
|
848
|
+
resolved_effort =
|
|
849
|
+
if effort
|
|
850
|
+
Harness.validate_thinking_level!(effort) unless force
|
|
851
|
+
effort
|
|
852
|
+
elsif suffix_level
|
|
853
|
+
err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
|
|
854
|
+
suffix_level
|
|
855
|
+
else
|
|
856
|
+
lane_entry["effort"]
|
|
857
|
+
end
|
|
848
858
|
|
|
849
859
|
raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
|
|
850
860
|
if push_host && resolved_harness != "claude-code"
|
|
@@ -873,18 +883,23 @@ module Space::Architect
|
|
|
873
883
|
if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
|
|
874
884
|
|
|
875
885
|
bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
|
|
876
|
-
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
# Stamp launch time onto the lane entry:
|
|
880
|
-
# (a dispatch that raises above
|
|
881
|
-
#
|
|
886
|
+
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin,
|
|
887
|
+
config_dir: build_dir, effort: resolved_effort, force: force, err: err)
|
|
888
|
+
|
|
889
|
+
# Stamp launch time and the resolved harness/model/effort onto the lane entry:
|
|
890
|
+
# after every preflight validation has passed (a dispatch that raises above
|
|
891
|
+
# records nothing) and before the blocking run or a detached dispatch returns.
|
|
892
|
+
# A re-dispatch overwrites the prior values, so `architect status` always reads
|
|
893
|
+
# what actually ran on the last dispatch.
|
|
882
894
|
update_architect_block do |b|
|
|
883
895
|
(b["iterations"] || []).each do |s|
|
|
884
896
|
next unless s["name"] == iteration
|
|
885
897
|
(s["lanes"] || []).each do |l|
|
|
886
898
|
next unless l["name"] == lane
|
|
887
899
|
l["dispatched_at"] = now.iso8601
|
|
900
|
+
l["harness"] = resolved_harness
|
|
901
|
+
l["model"] = resolved_model
|
|
902
|
+
l["effort"] = resolved_effort if resolved_effort
|
|
888
903
|
end
|
|
889
904
|
end
|
|
890
905
|
b
|
|
@@ -913,6 +928,7 @@ module Space::Architect
|
|
|
913
928
|
run_kwargs[:push_url] = push_url if push_url
|
|
914
929
|
run_kwargs[:push_token] = push_token if push_token
|
|
915
930
|
run_kwargs[:push_client] = push_client if push_client
|
|
931
|
+
run_kwargs[:err] = err if quiet
|
|
916
932
|
end
|
|
917
933
|
exit_code = harness_obj.run(**run_kwargs)
|
|
918
934
|
|
|
@@ -1050,6 +1066,27 @@ module Space::Architect
|
|
|
1050
1066
|
# The lane's declared harness/model/variant/effort, as worktree_add kwargs — so a
|
|
1051
1067
|
# re-materialize preserves them instead of merging back to defaults. touch_set is
|
|
1052
1068
|
# deliberately omitted: worktree_add leaves it untouched, so it already survives.
|
|
1069
|
+
# The space.yaml project block's optional harness/model/effort defaults (empty
|
|
1070
|
+
# hash when the block or the keys are absent).
|
|
1071
|
+
def project_defaults
|
|
1072
|
+
space.data["project"] || {}
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# Resolve harness + model by precedence: explicit value > (dispatch only) the
|
|
1076
|
+
# lane's stored value > space.yaml project.harness/project.model > the
|
|
1077
|
+
# per-harness sensible default (model only, keyed on the resolved harness).
|
|
1078
|
+
# Shared by worktree_add and dispatch so both read the same defaults. A stored
|
|
1079
|
+
# model is only honored when its stored harness still matches the resolved
|
|
1080
|
+
# harness — a dispatch-time harness override drops the old harness's stored
|
|
1081
|
+
# model instead of leaking it into the new harness's run.
|
|
1082
|
+
def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
|
|
1083
|
+
defaults = project_defaults
|
|
1084
|
+
resolved_harness = (harness || stored_harness || defaults["harness"] || "claude-code").to_s
|
|
1085
|
+
stored_model = nil unless stored_harness.nil? || stored_harness.to_s == resolved_harness
|
|
1086
|
+
resolved_model = model || stored_model || defaults["model"] || Harness.default_model_for(resolved_harness)
|
|
1087
|
+
[resolved_harness, resolved_model]
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1053
1090
|
def recorded_lane_fields(lane_entry)
|
|
1054
1091
|
{
|
|
1055
1092
|
harness: lane_entry["harness"] || "claude-code",
|
|
@@ -1167,7 +1204,10 @@ module Space::Architect
|
|
|
1167
1204
|
|
|
1168
1205
|
# Replace (or, with append:, extend) the body of a "## Heading" section, leaving
|
|
1169
1206
|
# every other section byte-untouched. Append replaces a placeholder body (only a
|
|
1170
|
-
# template comment) the first time, then stacks subsections after it.
|
|
1207
|
+
# template comment) the first time, then stacks subsections after it. A supplied
|
|
1208
|
+
# block that redundantly repeats the section's own heading as its first line (the
|
|
1209
|
+
# natural way to author a complete section) has that leading line stripped first,
|
|
1210
|
+
# so writing the same section twice never accumulates a duplicate heading.
|
|
1171
1211
|
def replace_section_body(text, heading, new_block, append:)
|
|
1172
1212
|
lines = text.lines
|
|
1173
1213
|
start = lines.index { |l| l.chomp == heading }
|
|
@@ -1176,11 +1216,14 @@ module Space::Architect
|
|
|
1176
1216
|
finish = ((start + 1)...lines.length).find { |i| KNOWN_HEADINGS.include?(lines[i].chomp) } || lines.length
|
|
1177
1217
|
body = lines[(start + 1)...finish].join
|
|
1178
1218
|
|
|
1219
|
+
first, rest = new_block.strip.split("\n", 2)
|
|
1220
|
+
block = first == heading ? rest.to_s.strip : new_block.strip
|
|
1221
|
+
|
|
1179
1222
|
new_body =
|
|
1180
1223
|
if append && !placeholder_body?(body)
|
|
1181
|
-
"#{body.strip}\n\n#{
|
|
1224
|
+
"#{body.strip}\n\n#{block}"
|
|
1182
1225
|
else
|
|
1183
|
-
|
|
1226
|
+
block
|
|
1184
1227
|
end
|
|
1185
1228
|
|
|
1186
1229
|
prefix = lines[0..start].join.rstrip
|
|
@@ -31,7 +31,13 @@ module Space::Architect
|
|
|
31
31
|
# Authored-content intake shared by section/verdict/brief: a file, an
|
|
32
32
|
# inline flag, or stdin — canonical files are only ever written by the CLI.
|
|
33
33
|
def read_body(from: nil, body: nil, stdin: false, what: "section body")
|
|
34
|
-
|
|
34
|
+
if from
|
|
35
|
+
begin
|
|
36
|
+
return File.read(from)
|
|
37
|
+
rescue Errno::ENOENT
|
|
38
|
+
raise Space::Core::Error, "file for --from not found: #{from}; provide the #{what} via --from <file>, --body <text>, or --stdin"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
35
41
|
return body if body
|
|
36
42
|
return $stdin.read if stdin
|
|
37
43
|
|
|
@@ -41,10 +47,32 @@ module Space::Architect
|
|
|
41
47
|
# Commit-message intake for commit_message_options: --message-from wins
|
|
42
48
|
# over -m/--message; nil means the command's canonical default message.
|
|
43
49
|
def read_commit_message(message: nil, message_from: nil)
|
|
44
|
-
|
|
50
|
+
if message_from
|
|
51
|
+
begin
|
|
52
|
+
return File.read(message_from)
|
|
53
|
+
rescue Errno::ENOENT
|
|
54
|
+
raise Space::Core::Error, "file for --message-from not found: #{message_from}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
45
57
|
|
|
46
58
|
message
|
|
47
59
|
end
|
|
60
|
+
|
|
61
|
+
# --effort/--thinking/--reasoning are three aliases for one thinking-level knob.
|
|
62
|
+
# At most one may be passed per invocation.
|
|
63
|
+
def resolve_thinking_alias(effort:, thinking:, reasoning:)
|
|
64
|
+
given = { effort: effort, thinking: thinking, reasoning: reasoning }.compact
|
|
65
|
+
raise Space::Core::Error, "pass only one of --effort/--thinking/--reasoning" if given.size > 1
|
|
66
|
+
given.values.first
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --force-effort/--force-thinking/--force-reasoning are three aliases for the
|
|
70
|
+
# force escape hatch. At most one may be passed per invocation.
|
|
71
|
+
def resolve_force_thinking_alias(force_effort:, force_thinking:, force_reasoning:)
|
|
72
|
+
given = { force_effort: force_effort, force_thinking: force_thinking, force_reasoning: force_reasoning }.compact
|
|
73
|
+
raise Space::Core::Error, "pass only one of --force-effort/--force-thinking/--force-reasoning" if given.size > 1
|
|
74
|
+
given.values.first
|
|
75
|
+
end
|
|
48
76
|
end
|
|
49
77
|
|
|
50
78
|
module Architect
|
|
@@ -146,7 +174,7 @@ module Space::Architect
|
|
|
146
174
|
lane_list = s["lanes"] || []
|
|
147
175
|
lanes_str = lane_list.map do |l|
|
|
148
176
|
h = l["harness"] || "claude-code"
|
|
149
|
-
m = l["model"] || Harness
|
|
177
|
+
m = l["model"] || Harness.default_model_for(h)
|
|
150
178
|
eff = l["effort"] ? "·#{l['effort']}" : ""
|
|
151
179
|
"#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
|
|
152
180
|
end.join(", ")
|
|
@@ -280,10 +308,16 @@ module Space::Architect
|
|
|
280
308
|
argument :lane, required: true, desc: "Lane name"
|
|
281
309
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
282
310
|
option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
|
|
283
|
-
option :model, default: nil, desc: "Builder model to pin (default: the lane's model, else the
|
|
311
|
+
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"
|
|
284
312
|
option :max_turns, default: "200", desc: "Max turns for the builder"
|
|
285
|
-
option :harness, default: nil, desc: "Harness override (claude-code, opencode)"
|
|
286
|
-
option :effort, default: nil, desc: "
|
|
313
|
+
option :harness, default: nil, desc: "Harness override (claude-code, opencode, pi)"
|
|
314
|
+
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"
|
|
315
|
+
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"
|
|
316
|
+
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"
|
|
317
|
+
option :force_effort, default: nil, desc: "Force the literal level onto the harness flag, skipping architect's clamp — alias for --force-thinking/--force-reasoning (dispatch only; the binary's rejection is final)"
|
|
318
|
+
option :force_thinking, default: nil, desc: "Force the literal level onto the harness flag, skipping architect's clamp — alias for --force-effort/--force-reasoning (dispatch only; the binary's rejection is final)"
|
|
319
|
+
option :force_reasoning, default: nil, desc: "Force the literal level onto the harness flag, skipping architect's clamp — alias for --force-effort/--force-thinking (dispatch only; the binary's rejection is final)"
|
|
320
|
+
option :quiet, type: :boolean, default: false, desc: "Suppress thinking-translation and harness run-time warn lines (liveness/push) on $stderr for this dispatch"
|
|
287
321
|
option :detach, type: :boolean, default: false, desc: "Detach the builder process (returns immediately with PID; poll report for completion)"
|
|
288
322
|
option :timeout, default: "14400", desc: "Wall-clock timeout in seconds (0 disables; default 4h); foreground only"
|
|
289
323
|
option :push_url, default: nil, desc: "HTTP endpoint for streaming push (POST body to this URL)"
|
|
@@ -291,17 +325,24 @@ module Space::Architect
|
|
|
291
325
|
option :push_host, default: nil, desc: "Base URL of the ingest server; the CLI creates a run via POST <host>/runs and streams to /runs/<id>/ingest (requires --push-token)"
|
|
292
326
|
|
|
293
327
|
def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
|
|
294
|
-
max_turns: "200", harness: nil, effort: nil,
|
|
328
|
+
max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
|
|
329
|
+
force_effort: nil, force_thinking: nil, force_reasoning: nil, quiet: false, detach: false,
|
|
295
330
|
timeout: "14400", push_url: nil, push_token: nil, push_host: nil, **opts)
|
|
296
331
|
setup_terminal(**opts.slice(:color, :colors))
|
|
297
332
|
handle_errors do
|
|
333
|
+
level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
|
|
334
|
+
forced_level = resolve_force_thinking_alias(force_effort: force_effort,
|
|
335
|
+
force_thinking: force_thinking, force_reasoning: force_reasoning)
|
|
336
|
+
|
|
298
337
|
render(store.find(space)) do |sp|
|
|
299
338
|
project = ArchitectProject.new(space: sp)
|
|
300
339
|
kwargs = { max_turns: max_turns.to_i, detach: detach }
|
|
301
340
|
kwargs[:prompt] = prompt if prompt
|
|
302
341
|
kwargs[:model] = model if model
|
|
303
342
|
kwargs[:harness] = harness if harness
|
|
304
|
-
kwargs[:effort] =
|
|
343
|
+
kwargs[:effort] = forced_level || level if forced_level || level
|
|
344
|
+
kwargs[:force] = true if forced_level
|
|
345
|
+
kwargs[:quiet] = true if quiet
|
|
305
346
|
kwargs[:timeout] = timeout.to_i unless detach
|
|
306
347
|
kwargs[:push_url] = push_url if push_url
|
|
307
348
|
kwargs[:push_token] = push_token if push_token
|
|
@@ -599,20 +640,26 @@ module Space::Architect
|
|
|
599
640
|
argument :iteration, required: true, desc: "Iteration name"
|
|
600
641
|
argument :lane, required: true, desc: "Lane name"
|
|
601
642
|
option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
|
|
602
|
-
option :harness, default:
|
|
603
|
-
option :model, default: nil, desc: "Model (
|
|
604
|
-
option :effort, default: nil, desc: "
|
|
643
|
+
option :harness, default: nil, desc: "Harness (claude-code, opencode, pi; default: space.yaml project.harness, else claude-code)"
|
|
644
|
+
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)"
|
|
645
|
+
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 at dispatch time"
|
|
646
|
+
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 at dispatch time"
|
|
647
|
+
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 at dispatch time"
|
|
648
|
+
option :quiet, type: :boolean, default: false, desc: "Suppress the thinking-translation inform line on $stderr"
|
|
605
649
|
option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
|
|
606
650
|
option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
|
|
607
651
|
|
|
608
|
-
def call(repo:, iteration:, lane:, base: nil, harness:
|
|
652
|
+
def call(repo:, iteration:, lane:, base: nil, harness: nil, model: nil,
|
|
653
|
+
effort: nil, thinking: nil, reasoning: nil, quiet: false, touch: nil, force: false, **opts)
|
|
609
654
|
setup_terminal(**opts.slice(:color, :colors))
|
|
610
655
|
handle_errors do
|
|
656
|
+
level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
|
|
611
657
|
render(store.find) do |sp|
|
|
612
658
|
project = ArchitectProject.new(space: sp)
|
|
613
659
|
touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
|
|
614
|
-
|
|
615
|
-
|
|
660
|
+
err = quiet ? File.open(File::NULL, "w") : $stderr
|
|
661
|
+
result = project.worktree_add(repo, iteration, lane, base: base, harness: harness, model: model,
|
|
662
|
+
effort: level, touch: touch_set, force: force, err: err)
|
|
616
663
|
terminal.say "Worktree: #{terminal.path(result[:worktree])}"
|
|
617
664
|
terminal.say "Base SHA: #{result[:base_sha]}"
|
|
618
665
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
@@ -10,30 +10,73 @@ require "uri"
|
|
|
10
10
|
|
|
11
11
|
module Space::Architect
|
|
12
12
|
module Harness
|
|
13
|
-
CLAUDE_DEFAULT_MODEL = "claude-sonnet-
|
|
13
|
+
CLAUDE_DEFAULT_MODEL = "claude-sonnet-5"
|
|
14
|
+
|
|
15
|
+
# Per-harness sensible default model, used when neither an explicit --model
|
|
16
|
+
# nor a space.yaml project.model is given.
|
|
17
|
+
DEFAULT_MODELS = {
|
|
18
|
+
"claude-code" => CLAUDE_DEFAULT_MODEL,
|
|
19
|
+
"pi" => "qwen3-27b-optiq",
|
|
20
|
+
"opencode" => "fireworks-ai/accounts/fireworks/models/glm-5p2"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.default_model_for(harness_name)
|
|
24
|
+
DEFAULT_MODELS[harness_name.to_s]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The normalized internal thinking vocabulary — pi's fullest set. Every alias
|
|
28
|
+
# (--effort/--thinking/--reasoning) is normalized to a member of this array;
|
|
29
|
+
# each harness then translates + clamps down to its own flag's vocabulary.
|
|
30
|
+
THINKING_LEVELS = %w[off minimal low medium high xhigh max].freeze
|
|
31
|
+
|
|
32
|
+
def self.validate_thinking_level!(level)
|
|
33
|
+
return if level.nil? || THINKING_LEVELS.include?(level)
|
|
34
|
+
raise Space::Core::Error,
|
|
35
|
+
"unknown thinking level '#{level}' — valid: #{THINKING_LEVELS.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Split a "model:level" id into [model, level] (level nil, model unchanged if no
|
|
39
|
+
# suffix). Only a trailing segment that is a member of THINKING_LEVELS is treated
|
|
40
|
+
# as a suffix — a model id containing a literal colon for another reason is left alone.
|
|
41
|
+
def self.parse_model_suffix(model)
|
|
42
|
+
return [model, nil] unless model
|
|
43
|
+
base, _, suffix = model.rpartition(":")
|
|
44
|
+
return [model, nil] if base.empty? || !THINKING_LEVELS.include?(suffix)
|
|
45
|
+
[base, suffix]
|
|
46
|
+
end
|
|
14
47
|
|
|
15
|
-
#
|
|
48
|
+
# Per-harness translate + clamp: input a normalized level (or nil) and whether the
|
|
49
|
+
# caller forced it (skips clamping — the literal is passed through unmodified).
|
|
50
|
+
# Returns [translated_level_or_nil, inform_line_or_nil].
|
|
51
|
+
def self.translate_thinking(harness, level, force: false)
|
|
52
|
+
case harness.to_s
|
|
53
|
+
when "claude-code" then ClaudeCodeHarness.translate_thinking(level, force: force)
|
|
54
|
+
when "opencode" then OpenCodeHarness.translate_thinking(level, force: force)
|
|
55
|
+
when "pi" then PiHarness.translate_thinking(level, force: force)
|
|
56
|
+
else nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Factory keyed by harness name. Translates + clamps the normalized `effort`
|
|
61
|
+
# level to the harness's own vocabulary, printing one inform line to `err`
|
|
62
|
+
# (default $stderr; thread a null writer to suppress) when it clamps/strips.
|
|
63
|
+
# With force: true, the literal `effort` value is passed through unmodified.
|
|
16
64
|
# For opencode: config_dir is required (build/<id>-<lane> dir outside the worktree).
|
|
17
|
-
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, force: false, err: $stderr)
|
|
66
|
+
translated, inform = translate_thinking(name, effort, force: force)
|
|
67
|
+
err.puts(inform) if inform
|
|
68
|
+
|
|
18
69
|
case name.to_s
|
|
19
70
|
when "claude-code"
|
|
20
|
-
|
|
21
|
-
raise Space::Core::Error,
|
|
22
|
-
"effort is opencode-only (sets opencode reasoningEffort) — " \
|
|
23
|
-
"claude-code effort is set via the prompt"
|
|
24
|
-
end
|
|
25
|
-
ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin)
|
|
71
|
+
ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin, effort: translated)
|
|
26
72
|
when "opencode"
|
|
27
|
-
if model == CLAUDE_DEFAULT_MODEL
|
|
28
|
-
raise Space::Core::Error,
|
|
29
|
-
"Pass --model when using --harness opencode (the claude-sonnet-4-6 default " \
|
|
30
|
-
"is a Claude model ID and will not work with opencode — " \
|
|
31
|
-
"try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
|
|
32
|
-
end
|
|
33
73
|
raise Space::Core::Error, "config_dir is required for opencode harness" unless config_dir
|
|
34
|
-
OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort:
|
|
74
|
+
OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
|
|
75
|
+
when "pi"
|
|
76
|
+
raise Space::Core::Error, "config_dir is required for pi harness" unless config_dir
|
|
77
|
+
PiHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
|
|
35
78
|
else
|
|
36
|
-
raise Space::Core::Error, "Unknown harness '#{name}' — valid values: claude-code, opencode"
|
|
79
|
+
raise Space::Core::Error, "Unknown harness '#{name}' — valid values: claude-code, opencode, pi"
|
|
37
80
|
end
|
|
38
81
|
end
|
|
39
82
|
|
|
@@ -45,11 +88,27 @@ module Space::Architect
|
|
|
45
88
|
"Bash(git branch:*)"
|
|
46
89
|
].join(",")
|
|
47
90
|
|
|
48
|
-
|
|
91
|
+
# claude-code's --effort accepts low/medium/high/xhigh/max; it has no off level
|
|
92
|
+
# (stripped — omit --effort) and no minimal level (clamped to low).
|
|
93
|
+
ACCEPTED_LEVELS = %w[low medium high xhigh max].freeze
|
|
94
|
+
CLAMP_MAP = { "minimal" => "low" }.freeze
|
|
95
|
+
|
|
96
|
+
def self.translate_thinking(level, force: false)
|
|
97
|
+
return [nil, nil] if level.nil?
|
|
98
|
+
return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
|
|
99
|
+
return [level, nil] if ACCEPTED_LEVELS.include?(level)
|
|
100
|
+
return [nil, "thinking: off → claude-code (no --effort; claude-code has no off level)"] if level == "off"
|
|
101
|
+
|
|
102
|
+
clamped = CLAMP_MAP.fetch(level)
|
|
103
|
+
[clamped, "thinking: #{level} → claude-code #{clamped} (clamped; claude-code has no #{level} level)"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def initialize(model:, max_turns:, bin: nil, effort: nil,
|
|
49
107
|
allowed_tools: ALLOWED_TOOLS, disallowed_tools: DISALLOWED_TOOLS)
|
|
50
108
|
@model = model
|
|
51
109
|
@max_turns = max_turns
|
|
52
110
|
@bin = bin || ENV.fetch("ARCHITECT_CLAUDE_BIN", "claude")
|
|
111
|
+
@effort = effort
|
|
53
112
|
@allowed_tools = allowed_tools
|
|
54
113
|
@disallowed_tools = disallowed_tools
|
|
55
114
|
end
|
|
@@ -179,6 +238,7 @@ module Space::Architect
|
|
|
179
238
|
"--max-turns", @max_turns.to_s
|
|
180
239
|
]
|
|
181
240
|
args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
|
|
241
|
+
args += ["--effort", @effort] if @effort
|
|
182
242
|
args
|
|
183
243
|
end
|
|
184
244
|
|
|
@@ -237,6 +297,23 @@ module Space::Architect
|
|
|
237
297
|
end
|
|
238
298
|
|
|
239
299
|
class OpenCodeHarness
|
|
300
|
+
# opencode's reasoningEffort accepts only low/medium/high; off/minimal are
|
|
301
|
+
# stripped (omit reasoningEffort) and xhigh/max are clamped down to high.
|
|
302
|
+
ACCEPTED_LEVELS = %w[low medium high].freeze
|
|
303
|
+
CLAMP_MAP = { "xhigh" => "high", "max" => "high" }.freeze
|
|
304
|
+
|
|
305
|
+
def self.translate_thinking(level, force: false)
|
|
306
|
+
return [nil, nil] if level.nil?
|
|
307
|
+
return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
|
|
308
|
+
return [level, nil] if ACCEPTED_LEVELS.include?(level)
|
|
309
|
+
|
|
310
|
+
if (clamped = CLAMP_MAP[level])
|
|
311
|
+
[clamped, "thinking: #{level} → opencode #{clamped} (clamped; opencode accepts low/medium/high)"]
|
|
312
|
+
else
|
|
313
|
+
[nil, "thinking: #{level} → opencode (omitting reasoningEffort; opencode has no #{level} level)"]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
240
317
|
def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil)
|
|
241
318
|
@model = model
|
|
242
319
|
@max_turns = max_turns
|
|
@@ -346,5 +423,86 @@ module Space::Architect
|
|
|
346
423
|
args
|
|
347
424
|
end
|
|
348
425
|
end
|
|
426
|
+
|
|
427
|
+
# `pi -p --mode json`, session redirected into the lane's build dir via --session-dir
|
|
428
|
+
# (pi otherwise auto-saves under ~/.pi/agent/sessions/, organized by mangled CWD).
|
|
429
|
+
class PiHarness
|
|
430
|
+
TIMEOUT_EXIT_CODE = 124
|
|
431
|
+
|
|
432
|
+
# pi's --thinking accepts the full normalized vocabulary unchanged — pi's own
|
|
433
|
+
# per-model thinkingLevelMap clamps further; architect does not clamp for pi.
|
|
434
|
+
def self.translate_thinking(level, force: false)
|
|
435
|
+
return [nil, nil] if level.nil?
|
|
436
|
+
return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
|
|
437
|
+
[level, nil]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# max_turns is accepted for interface parity with the other harnesses but is
|
|
441
|
+
# intentionally absent from argv — pi has no turn cap; the wall-clock timeout
|
|
442
|
+
# (below) is the only bound on a run.
|
|
443
|
+
def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil)
|
|
444
|
+
@model = model
|
|
445
|
+
@bin = bin || ENV.fetch("ARCHITECT_PI_BIN", "pi")
|
|
446
|
+
@config_dir = Pathname.new(config_dir)
|
|
447
|
+
@effort = effort
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def run(prompt_path:, run_log_path:, chdir:, timeout: nil, **)
|
|
451
|
+
prompt_path = Pathname.new(prompt_path)
|
|
452
|
+
run_log_path = Pathname.new(run_log_path)
|
|
453
|
+
|
|
454
|
+
File.open(prompt_path, "r") do |prompt_io|
|
|
455
|
+
File.open(run_log_path, "w") do |log|
|
|
456
|
+
Sync do
|
|
457
|
+
child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: log, err: log)
|
|
458
|
+
timed_out = false
|
|
459
|
+
timeout_task = nil
|
|
460
|
+
|
|
461
|
+
# Same TERM→grace→KILL escalation as ClaudeCodeHarness — with: Async::Process::Child's
|
|
462
|
+
# #wait_thread ensure goes straight to KILL, so a concurrent fiber does the escalation.
|
|
463
|
+
if timeout && timeout > 0
|
|
464
|
+
timeout_task = Async(transient: true) do
|
|
465
|
+
sleep timeout
|
|
466
|
+
timed_out = true
|
|
467
|
+
Process.kill("TERM", -child.pid) rescue nil
|
|
468
|
+
sleep 0.5
|
|
469
|
+
Process.kill("KILL", -child.pid) rescue nil
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
status = child.wait
|
|
474
|
+
timeout_task&.stop
|
|
475
|
+
|
|
476
|
+
timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def run_detached(prompt_path:, run_log_path:, chdir:)
|
|
483
|
+
prompt_path = Pathname.new(prompt_path)
|
|
484
|
+
run_log_path = Pathname.new(run_log_path)
|
|
485
|
+
|
|
486
|
+
prompt_io = File.open(prompt_path, "r")
|
|
487
|
+
log = File.open(run_log_path, "w")
|
|
488
|
+
begin
|
|
489
|
+
pid = Process.spawn(*argv, chdir: chdir.to_s, pgroup: true,
|
|
490
|
+
in: prompt_io, out: log, err: log)
|
|
491
|
+
Process.detach(pid)
|
|
492
|
+
ensure
|
|
493
|
+
prompt_io.close
|
|
494
|
+
log.close
|
|
495
|
+
end
|
|
496
|
+
pid
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
private
|
|
500
|
+
|
|
501
|
+
def argv
|
|
502
|
+
args = [@bin, "-p", "--mode", "json", "--model", @model, "--session-dir", @config_dir.to_s, "--no-approve"]
|
|
503
|
+
args += ["--thinking", @effort] if @effort
|
|
504
|
+
args
|
|
505
|
+
end
|
|
506
|
+
end
|
|
349
507
|
end
|
|
350
508
|
end
|
data/lib/space_core/version.rb
CHANGED