space-architect 7.1.0 → 8.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 +54 -0
- data/README.md +5 -3
- data/lib/space_architect/architect_project.rb +40 -180
- data/lib/space_architect/cli/architect.rb +49 -96
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/harness.rb +97 -344
- data/lib/space_architect/pi/builder-guard.ts +103 -0
- data/lib/space_architect/research/mux.rb +47 -32
- data/lib/space_architect/research/pi_events.rb +70 -0
- data/lib/space_architect/research/renderer.rb +39 -39
- data/lib/space_architect/research/supervisor.rb +35 -14
- data/lib/space_architect/research.rb +1 -6
- data/lib/space_architect.rb +0 -1
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +27 -23
- data/skill/architect/dispatch.md +137 -115
- data/skill/architect/research.md +14 -8
- data/skill/architect-research/SKILL.md +18 -11
- data/skill/architect-vocabulary/SKILL.md +1 -1
- metadata +3 -2
- data/lib/space_architect/dispatcher.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b63abfece4c105ef5b2befd0d8c4e4301d8d5cade10570cd2f41bd0641b8c4be
|
|
4
|
+
data.tar.gz: 6b932ae13952dad48f81a2ee68ed52b7cee28ef8edc4a99e7a06f23952c4f107
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3cbb55f4fab850bc9fb7b4cf55bb318bf846d54c3f78a399c50b76d325ddbcd12ad52072d224d75a25b6e5830c8fffa6d7e6f51ad522aae231bb2b374ac3980e
|
|
7
|
+
data.tar.gz: 4adfa615512c8f6652b85d5948c0a58c9851d30711fc2a1a7f870060601bb6fe0fab9af4ade0c8eebfb5e93a67bf60d1d6516e12fcd0ba96595d530049e0d0da
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,60 @@ 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
|
+
## [8.0.0] - 2026-10-09
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **pi is the only dispatch harness.** The `claude-code` and `opencode`
|
|
13
|
+
backends are deleted, along with everything that only served them: the
|
|
14
|
+
per-harness `DEFAULT_MODELS` map (one `Harness::DEFAULT_MODEL`:
|
|
15
|
+
`accounts/fireworks/models/glm-5p3-flash`), the three-way thinking-level
|
|
16
|
+
dispatch, the `--allowed-tools`/`--append-allowed-tools` flags and their
|
|
17
|
+
#89 resolution machinery (the frozen lanes block records `name`/`repo`/
|
|
18
|
+
`touch` only), `dispatch --as-job` with its `--host`/`--token`/
|
|
19
|
+
`--backend-url`/`--job-model`/`--api-key-ref` flags, and the backward-
|
|
20
|
+
compat `Dispatcher` wrapper. The `Harness.for` seam stays: any name but
|
|
21
|
+
`pi` — including a stored legacy harness in an old space.yaml — raises an
|
|
22
|
+
actionable error naming `pi` as the only valid harness. The push kwargs
|
|
23
|
+
(`--push-url`/`--push-token`/`--push-host`) and `--quiet` now apply to pi
|
|
24
|
+
dispatches, with liveness lines parsed from pi's own JSONL stream.
|
|
25
|
+
- **`PiHarness` reaches parity with the old default backend:** a liveness
|
|
26
|
+
line per run (first assistant-role JSONL event carrying `model`; OK when
|
|
27
|
+
it matches the pin, WARN on no growth / no assistant model within the
|
|
28
|
+
budget / pinned-vs-streamed mismatch; incremental scan, `--quiet`
|
|
29
|
+
suppressible), stdout tee through a pipe to the run log and — when push
|
|
30
|
+
is wired — streamed to the ingest endpoint, and TERM→grace→KILL timeout
|
|
31
|
+
escalation unchanged.
|
|
32
|
+
- **A vendored builder-guard extension ships with every dispatch.**
|
|
33
|
+
`lib/space_architect/pi/builder-guard.ts` is copied byte-for-byte into the
|
|
34
|
+
run's build dir and injected via `pi -e`, denying git-write subcommands
|
|
35
|
+
(parsed per command segment — `git -C <path> commit` is caught, `git log
|
|
36
|
+
--grep=commit` passes) and `bash -n`-unparseable commands before they run.
|
|
37
|
+
The deny reason tells the builder what was denied and what to do instead;
|
|
38
|
+
the architect CLI owns all commits.
|
|
39
|
+
- **Research runs run on pi too.** The research supervisor constructs a
|
|
40
|
+
`PiHarness` per run (sessions under `build/research/<id>/`), passes the
|
|
41
|
+
same vendored guard, and defaults to the pi default model; read-only
|
|
42
|
+
remains prompt-enforced. `run.detached` research classification keys off
|
|
43
|
+
pi's terminal assistant `stopReason` instead of a stream-json `result`
|
|
44
|
+
event.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`architect research wait` works on pi runs.** `Research::Mux` and
|
|
49
|
+
`Research::Renderer` now speak pi's JSONL event vocabulary natively (the
|
|
50
|
+
last claude-code stream-json dialect in the gem): the tail breaks on
|
|
51
|
+
`agent_settled`, `wait` classifies via the last assistant `stopReason` plus
|
|
52
|
+
`agent_settled.aborted` and the final assistant text (no final text →
|
|
53
|
+
`:failed` "no final assistant text"), and the report is the final assistant
|
|
54
|
+
message's joined text blocks. The shared pi-shape predicates live in one
|
|
55
|
+
new module, `Research::PiEvents` (last assistant message/stopReason, joined
|
|
56
|
+
text, run span from `session.timestamp` → last assistant `timestamp`,
|
|
57
|
+
`turn_end` count) — `Supervisor#classify` consumes it too. The renderer's
|
|
58
|
+
§5.3 verbosity ladder, `--thinking`, and `--jsonl` passthrough are
|
|
59
|
+
unchanged, fed by pi events (`message_end`, `tool_execution_start/end`)
|
|
60
|
+
with the terminal line built from native facts (no fabricated fields).
|
|
61
|
+
|
|
8
62
|
## [7.1.0] - 2026-08-16
|
|
9
63
|
|
|
10
64
|
One iteration of the Architect Loop (I01, space
|
data/README.md
CHANGED
|
@@ -343,7 +343,8 @@ architect integrate my-feature --lanes lane-a # merge passing lanes → proje
|
|
|
343
343
|
|
|
344
344
|
### Streaming builder output 📡
|
|
345
345
|
|
|
346
|
-
`architect dispatch` can push the builder's stream
|
|
346
|
+
`architect dispatch` can push the builder's pi JSONL event stream to an ingest
|
|
347
|
+
server for
|
|
347
348
|
live viewing:
|
|
348
349
|
|
|
349
350
|
```sh
|
|
@@ -367,8 +368,9 @@ can be combined with `--detach` (the push tees the live pipe in-process).
|
|
|
367
368
|
### Research lanes 🔭
|
|
368
369
|
|
|
369
370
|
When an iteration needs facts the repo doesn't already have, fan out parallel
|
|
370
|
-
**read-only** research lanes — detached `
|
|
371
|
-
|
|
371
|
+
**read-only** research lanes — detached `pi -p --mode json` researchers
|
|
372
|
+
(read-only by prompt contract; the supervisor-injected guard denies git writes)
|
|
373
|
+
that you supervise:
|
|
372
374
|
|
|
373
375
|
```sh
|
|
374
376
|
architect research dispatch 01-official-api.prompt.md 02-changelog.prompt.md
|
|
@@ -241,13 +241,6 @@ 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"]
|
|
251
244
|
existing = lanes.find { |l| l["name"] == d["name"] }
|
|
252
245
|
existing ? existing.merge!(fields) : lanes << fields
|
|
253
246
|
end
|
|
@@ -841,7 +834,7 @@ module Space::Architect
|
|
|
841
834
|
variants: variant_lanes.map do |l|
|
|
842
835
|
{
|
|
843
836
|
name: l["name"],
|
|
844
|
-
harness: l["harness"] || "
|
|
837
|
+
harness: l["harness"] || "pi",
|
|
845
838
|
model: l["model"],
|
|
846
839
|
effort: l["effort"],
|
|
847
840
|
base_sha: l["base_sha"],
|
|
@@ -940,9 +933,8 @@ module Space::Architect
|
|
|
940
933
|
end
|
|
941
934
|
end
|
|
942
935
|
|
|
943
|
-
def dispatch(iteration, lane, model: nil, max_turns: 200,
|
|
944
|
-
|
|
945
|
-
allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false,
|
|
936
|
+
def dispatch(iteration, lane, model: nil, max_turns: 200, bin: nil, harness: nil, effort: nil,
|
|
937
|
+
force: false, quiet: false,
|
|
946
938
|
detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
|
|
947
939
|
push_client: nil, timeout: nil, prompt: nil, now: Time.now)
|
|
948
940
|
raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
|
|
@@ -959,12 +951,6 @@ module Space::Architect
|
|
|
959
951
|
|
|
960
952
|
resolved_harness, resolved_model, resolved_effort =
|
|
961
953
|
resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
|
|
962
|
-
resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
|
|
963
|
-
resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
|
|
964
|
-
err.puts(tools_provenance) if replace_tools || append_tools
|
|
965
|
-
|
|
966
|
-
raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
|
|
967
|
-
if push_host && resolved_harness != "claude-code"
|
|
968
954
|
|
|
969
955
|
id = iteration_id(entry)
|
|
970
956
|
wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
|
|
@@ -979,31 +965,31 @@ module Space::Architect
|
|
|
979
965
|
# and the CLI owns the canonical copy — byte-for-byte, like variant_add.
|
|
980
966
|
copy_and_validate_prompt!(prompt, prompt_path)
|
|
981
967
|
|
|
982
|
-
|
|
968
|
+
# Vendor the builder guard into the lane's build dir (idempotent overwrite) and
|
|
969
|
+
# inject it via -e on every dispatch — blocking and detached alike. The guard
|
|
970
|
+
# denies git-write and bash-unparseable commands before evaluation; the builder
|
|
971
|
+
# itself never needs to remember to load it.
|
|
972
|
+
guard_path = build_dir.join("builder-guard.ts")
|
|
973
|
+
FileUtils.cp(vendored_guard_path, guard_path)
|
|
974
|
+
|
|
983
975
|
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin,
|
|
984
976
|
config_dir: build_dir, effort: resolved_effort,
|
|
985
|
-
|
|
977
|
+
guard_path: guard_path, force: force, err: err)
|
|
986
978
|
|
|
987
|
-
# Stamp launch time and the resolved harness/model/effort
|
|
979
|
+
# Stamp launch time and the resolved harness/model/effort onto the
|
|
988
980
|
# lane entry: after every preflight validation has passed (a dispatch that raises
|
|
989
981
|
# above records nothing) and before the blocking run or a detached dispatch
|
|
990
982
|
# returns. A re-dispatch overwrites the prior values, so `architect status`
|
|
991
|
-
# always reads what actually ran on the last dispatch.
|
|
992
|
-
# tool grant is recorded the same way, so a denial traces back to a grant a
|
|
993
|
-
# human can read — replace_tools/append_tools are the pre-composition components
|
|
994
|
-
# (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later
|
|
995
|
-
# bare re-dispatch re-resolves from the same components instead of compounding.
|
|
983
|
+
# always reads what actually ran on the last dispatch.
|
|
996
984
|
update_architect_block do |b|
|
|
997
985
|
(b["iterations"] || []).each do |s|
|
|
998
986
|
next unless s["name"] == iteration
|
|
999
987
|
(s["lanes"] || []).each do |l|
|
|
1000
988
|
next unless l["name"] == lane
|
|
1001
|
-
l["dispatched_at"]
|
|
1002
|
-
l["harness"]
|
|
1003
|
-
l["model"]
|
|
1004
|
-
l["effort"]
|
|
1005
|
-
l["allowed_tools"] = replace_tools if replace_tools
|
|
1006
|
-
l["append_allowed_tools"] = append_tools if append_tools
|
|
989
|
+
l["dispatched_at"] = now.iso8601
|
|
990
|
+
l["harness"] = resolved_harness
|
|
991
|
+
l["model"] = resolved_model
|
|
992
|
+
l["effort"] = resolved_effort if resolved_effort
|
|
1007
993
|
end
|
|
1008
994
|
end
|
|
1009
995
|
b
|
|
@@ -1028,96 +1014,30 @@ module Space::Architect
|
|
|
1028
1014
|
|
|
1029
1015
|
run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path }
|
|
1030
1016
|
run_kwargs[:timeout] = timeout if timeout
|
|
1031
|
-
if
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
run_kwargs[:err] = err if quiet
|
|
1036
|
-
end
|
|
1017
|
+
run_kwargs[:push_url] = push_url if push_url
|
|
1018
|
+
run_kwargs[:push_token] = push_token if push_token
|
|
1019
|
+
run_kwargs[:push_client] = push_client if push_client
|
|
1020
|
+
run_kwargs[:err] = err if quiet
|
|
1037
1021
|
exit_code = harness_obj.run(**run_kwargs)
|
|
1038
1022
|
|
|
1039
1023
|
result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
|
|
1040
1024
|
result[:prompt_copied] = prompt_path if prompt
|
|
1041
|
-
result[:timed_out] = true if exit_code == Harness::
|
|
1025
|
+
result[:timed_out] = true if exit_code == Harness::PiHarness::TIMEOUT_EXIT_CODE
|
|
1042
1026
|
result[:created_run_id] = created_run_id if created_run_id
|
|
1043
1027
|
result[:push_url] = push_url if push_url
|
|
1044
1028
|
result
|
|
1045
1029
|
end
|
|
1046
1030
|
end
|
|
1047
1031
|
|
|
1048
|
-
# Submit the lane's builder run as a job to the space-server's queue instead of
|
|
1049
|
-
# running it locally: the sandboxed executor mounts the lane worktree + the repo
|
|
1050
|
-
# checkout at their identical host absolute paths (so the worktree's gitdir
|
|
1051
|
-
# pointer resolves) and runs the harness itself — no local run.jsonl, the
|
|
1052
|
-
# transcript lives server-side. jobs_client: is the injectable seam (mirrors
|
|
1053
|
-
# dispatch's run_creator:).
|
|
1054
|
-
def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
|
|
1055
|
-
max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil,
|
|
1056
|
-
force: false, quiet: false,
|
|
1057
|
-
job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now)
|
|
1058
|
-
err = quiet ? File.open(File::NULL, "w") : $stderr
|
|
1059
|
-
|
|
1060
|
-
entry = slice_entry(iteration)
|
|
1061
|
-
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
1062
|
-
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
1063
|
-
lane_entry = ensure_lane_materialized(iteration, lane)
|
|
1064
|
-
|
|
1065
|
-
resolved_harness, resolved_model, resolved_effort =
|
|
1066
|
-
resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
|
|
1067
|
-
raise Space::Core::Error, "--as-job only supports the claude-code harness (lane '#{lane}' resolves to '#{resolved_harness}')" \
|
|
1068
|
-
unless resolved_harness == "claude-code"
|
|
1069
|
-
raise Space::Core::Error, "--job-model is required with --as-job" unless job_model
|
|
1070
|
-
|
|
1071
|
-
# #89: same replace/append resolution as the local dispatch path — the sandboxed
|
|
1072
|
-
# executor still runs `claude -p` with harness_args server-side.
|
|
1073
|
-
resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
|
|
1074
|
-
resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
|
|
1075
|
-
err.puts(tools_provenance) if replace_tools || append_tools
|
|
1076
|
-
|
|
1077
|
-
id = iteration_id(entry)
|
|
1078
|
-
wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
|
|
1079
|
-
raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
|
|
1080
|
-
|
|
1081
|
-
build_dir = space.path.join("build", "#{id}-#{lane}")
|
|
1082
|
-
prompt_path = build_dir.join("prompt.md")
|
|
1083
|
-
copy_and_validate_prompt!(prompt, prompt_path)
|
|
1084
|
-
|
|
1085
|
-
repo_path = space.path.join("repos", lane_entry["repo"])
|
|
1086
|
-
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
|
|
1087
|
-
effort: resolved_effort, allowed_tools: resolved_allowed_tools,
|
|
1088
|
-
force: force, err: err)
|
|
1089
|
-
|
|
1090
|
-
spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir,
|
|
1091
|
-
repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url,
|
|
1092
|
-
job_model: job_model, api_key_ref: api_key_ref, harness_args: harness_obj.builder_args)
|
|
1093
|
-
|
|
1094
|
-
client = jobs_client || JobsClient.new(host, token)
|
|
1095
|
-
job_id = client.create(spec)
|
|
1096
|
-
|
|
1097
|
-
# Dispatch bookkeeping, mirroring the local path's dispatched_at stamp: a
|
|
1098
|
-
# re-dispatch overwrites the prior job_id, so `architect status` always
|
|
1099
|
-
# reflects the last dispatch regardless of mode.
|
|
1100
|
-
update_architect_block do |b|
|
|
1101
|
-
(b["iterations"] || []).each do |s|
|
|
1102
|
-
next unless s["name"] == iteration
|
|
1103
|
-
(s["lanes"] || []).each do |l|
|
|
1104
|
-
next unless l["name"] == lane
|
|
1105
|
-
l["dispatched_at"] = now.iso8601
|
|
1106
|
-
l["job_id"] = job_id
|
|
1107
|
-
end
|
|
1108
|
-
end
|
|
1109
|
-
b
|
|
1110
|
-
end
|
|
1111
|
-
|
|
1112
|
-
result = { job_id: job_id, spec: spec }
|
|
1113
|
-
result[:prompt_copied] = prompt_path if prompt
|
|
1114
|
-
result
|
|
1115
|
-
end
|
|
1116
|
-
|
|
1117
1032
|
private
|
|
1118
1033
|
|
|
1119
1034
|
attr_reader :space
|
|
1120
1035
|
|
|
1036
|
+
# The vendored builder-guard extension shipped in the gem (lib/space_architect/pi/).
|
|
1037
|
+
def vendored_guard_path
|
|
1038
|
+
Pathname.new(__dir__).join("pi/builder-guard.ts")
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1121
1041
|
# Compose a commit message. Without a custom message, the canonical default
|
|
1122
1042
|
# (unchanged). With one, a short canonical prefix keeps the loop's commit
|
|
1123
1043
|
# taxonomy grep-able while the author's first line owns the subject; any
|
|
@@ -1685,23 +1605,23 @@ module Space::Architect
|
|
|
1685
1605
|
end
|
|
1686
1606
|
|
|
1687
1607
|
# Resolve harness + model by precedence: explicit value > (dispatch only) the
|
|
1688
|
-
# lane's stored value > space.yaml project.harness/project.model > the
|
|
1689
|
-
#
|
|
1690
|
-
#
|
|
1691
|
-
#
|
|
1692
|
-
#
|
|
1693
|
-
#
|
|
1608
|
+
# lane's stored value > space.yaml project.harness/project.model > the pi
|
|
1609
|
+
# default model. Shared by worktree_add and dispatch so both read the same
|
|
1610
|
+
# defaults. A stored model is only honored when its stored harness still matches
|
|
1611
|
+
# the resolved harness — a dispatch-time harness override drops the old harness's
|
|
1612
|
+
# stored model instead of leaking it into the new harness's run. A stored
|
|
1613
|
+
# legacy harness name survives resolution (so a stale space.yaml
|
|
1614
|
+
# surfaces as Harness.for's actionable pi-only error, not a silent default).
|
|
1694
1615
|
def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
|
|
1695
1616
|
defaults = project_defaults
|
|
1696
|
-
resolved_harness = (harness || stored_harness || defaults["harness"] || "
|
|
1617
|
+
resolved_harness = (harness || stored_harness || defaults["harness"] || "pi").to_s
|
|
1697
1618
|
stored_model = nil unless stored_harness.nil? || stored_harness.to_s == resolved_harness
|
|
1698
|
-
resolved_model = model || stored_model || defaults["model"] || Harness
|
|
1619
|
+
resolved_model = model || stored_model || defaults["model"] || Harness::DEFAULT_MODEL
|
|
1699
1620
|
[resolved_harness, resolved_model]
|
|
1700
1621
|
end
|
|
1701
1622
|
|
|
1702
1623
|
# --prompt: the caller authors the lane prompt anywhere (a fresh scratch file) and
|
|
1703
|
-
# the CLI owns the canonical copy — byte-for-byte, like variant_add.
|
|
1704
|
-
# dispatch and dispatch_as_job so both refuse the same empty/stub prompt.
|
|
1624
|
+
# the CLI owns the canonical copy — byte-for-byte, like variant_add.
|
|
1705
1625
|
def copy_and_validate_prompt!(prompt, prompt_path)
|
|
1706
1626
|
if prompt
|
|
1707
1627
|
src = Pathname.new(prompt)
|
|
@@ -1716,8 +1636,8 @@ module Space::Architect
|
|
|
1716
1636
|
if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
|
|
1717
1637
|
end
|
|
1718
1638
|
|
|
1719
|
-
# Resolve harness/model/effort for a dispatch
|
|
1720
|
-
#
|
|
1639
|
+
# Resolve harness/model/effort for a dispatch, so every dispatch path reads
|
|
1640
|
+
# precedence and thinking-level translation from one place.
|
|
1721
1641
|
def resolve_dispatch_harness(lane_entry, model:, harness:, effort:, force:, err:)
|
|
1722
1642
|
model, suffix_level = Harness.parse_model_suffix(model)
|
|
1723
1643
|
resolved_harness, resolved_model = resolve_harness_model(harness, model,
|
|
@@ -1737,69 +1657,9 @@ module Space::Architect
|
|
|
1737
1657
|
[resolved_harness, resolved_model, resolved_effort]
|
|
1738
1658
|
end
|
|
1739
1659
|
|
|
1740
|
-
# #89/AC1-AC5: resolve the claude-code --allowedTools grant for a dispatch (local or
|
|
1741
|
-
# --as-job), shared so both paths resolve precedence identically. The CLI flag beats
|
|
1742
|
-
# the lane's recorded allowed_tools:/append_allowed_tools: (frozen-declaration or a
|
|
1743
|
-
# prior dispatch's stamp) — resolved independently for replace vs. append, so a flag
|
|
1744
|
-
# on one axis and a lane value on the other both apply (Objective A). Meaningless for
|
|
1745
|
-
# opencode/pi (no equivalent grant mechanism) — still resolved/recorded for visibility,
|
|
1746
|
-
# but Harness.for only wires it into the claude-code branch's argv.
|
|
1747
|
-
def resolve_allowed_tools(lane_entry, allowed_tools:, append_allowed_tools:)
|
|
1748
|
-
replace, replace_from =
|
|
1749
|
-
if allowed_tools
|
|
1750
|
-
[allowed_tools, "--allowed-tools flag"]
|
|
1751
|
-
elsif lane_entry["allowed_tools"]
|
|
1752
|
-
[lane_entry["allowed_tools"], "lane's allowed_tools:"]
|
|
1753
|
-
else
|
|
1754
|
-
[nil, "default"]
|
|
1755
|
-
end
|
|
1756
|
-
|
|
1757
|
-
append, append_from =
|
|
1758
|
-
if append_allowed_tools
|
|
1759
|
-
[append_allowed_tools, "--append-allowed-tools flag"]
|
|
1760
|
-
elsif lane_entry["append_allowed_tools"]
|
|
1761
|
-
[lane_entry["append_allowed_tools"], "lane's append_allowed_tools:"]
|
|
1762
|
-
end
|
|
1763
|
-
|
|
1764
|
-
final = Harness::ClaudeCodeHarness.resolve_tools(replace: replace, append: append)
|
|
1765
|
-
provenance = "allowed-tools: #{replace_from}#{append_from ? " + #{append_from}" : ""} → #{final}"
|
|
1766
|
-
[final, replace, append, provenance]
|
|
1767
|
-
end
|
|
1768
|
-
|
|
1769
|
-
# Compose a dispatch --as-job spec per the space-server's job contract: prompt +
|
|
1770
|
-
# workspace.dir (the lane worktree) + environment (deps/network/mounts/env or
|
|
1771
|
-
# secrets) + harness (claude/backend/args) + provenance. mounts are src:dst with
|
|
1772
|
-
# src == dst — the sandboxed executor requires the lane worktree AND the repo
|
|
1773
|
-
# checkout mounted at their identical host absolute paths for the worktree's
|
|
1774
|
-
# gitdir pointer to resolve.
|
|
1775
|
-
def job_spec(iteration:, lane:, wt_path:, build_dir:, repo_path:, prompt_content:, backend_url:,
|
|
1776
|
-
job_model:, api_key_ref:, harness_args:)
|
|
1777
|
-
harness = { "type" => "claude", "backend" => { "base_url" => backend_url }, "args" => harness_args }
|
|
1778
|
-
harness["model"] = job_model if job_model
|
|
1779
|
-
harness["backend"]["api_key_ref"] = api_key_ref if api_key_ref
|
|
1780
|
-
|
|
1781
|
-
environment = {
|
|
1782
|
-
"env" => api_key_ref ? {} : { "ANTHROPIC_API_KEY" => "unused-for-keyless-backends" },
|
|
1783
|
-
"deps" => ["git"],
|
|
1784
|
-
"permissions" => {
|
|
1785
|
-
"network" => true,
|
|
1786
|
-
"mounts" => ["#{build_dir}:#{build_dir}", "#{repo_path}:#{repo_path}"]
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
environment["secrets"] = [{ "ref" => api_key_ref, "name" => "ANTHROPIC_API_KEY" }] if api_key_ref
|
|
1790
|
-
|
|
1791
|
-
{
|
|
1792
|
-
"prompt" => prompt_content,
|
|
1793
|
-
"workspace" => { "dir" => wt_path.to_s },
|
|
1794
|
-
"environment" => environment,
|
|
1795
|
-
"harness" => harness,
|
|
1796
|
-
"provenance" => { "space" => Space::Core::Slugger.slug(space.title), "iteration" => iteration, "lane" => lane }
|
|
1797
|
-
}
|
|
1798
|
-
end
|
|
1799
|
-
|
|
1800
1660
|
def recorded_lane_fields(lane_entry)
|
|
1801
1661
|
{
|
|
1802
|
-
harness: lane_entry["harness"] || "
|
|
1662
|
+
harness: lane_entry["harness"] || "pi",
|
|
1803
1663
|
model: lane_entry["model"],
|
|
1804
1664
|
variant: lane_entry["variant"] || false,
|
|
1805
1665
|
effort: lane_entry["effort"]
|
|
@@ -178,18 +178,10 @@ module Space::Architect
|
|
|
178
178
|
nn = s["ordinal"] ? format("%02d", s["ordinal"]) : "-"
|
|
179
179
|
lane_list = s["lanes"] || []
|
|
180
180
|
lanes_str = lane_list.map do |l|
|
|
181
|
-
h = l["harness"] || "
|
|
182
|
-
m = l["model"] || Harness
|
|
181
|
+
h = l["harness"] || "pi"
|
|
182
|
+
m = l["model"] || Harness::DEFAULT_MODEL
|
|
183
183
|
eff = l["effort"] ? "·#{l['effort']}" : ""
|
|
184
|
-
#
|
|
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})"
|
|
184
|
+
"#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
|
|
193
185
|
end.join(", ")
|
|
194
186
|
lanes = lane_list.any? { |l| l["variant"] } ? "variant: #{lanes_str}" : lanes_str
|
|
195
187
|
lanes = "#{lanes} → winner: #{s['winner']}" if s["winner"]
|
|
@@ -493,107 +485,68 @@ module Space::Architect
|
|
|
493
485
|
argument :lane, required: true, desc: "Lane name"
|
|
494
486
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
495
487
|
option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
|
|
496
|
-
option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else space.yaml project.model, else the
|
|
488
|
+
option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL}). Any provider/tier; pin a full id, not a floating alias"
|
|
497
489
|
option :max_turns, default: "200", desc: "Max turns for the builder"
|
|
498
|
-
option :harness, default: nil, desc: "Harness
|
|
499
|
-
option :
|
|
500
|
-
option :
|
|
501
|
-
option :
|
|
502
|
-
option :
|
|
503
|
-
option :
|
|
504
|
-
option :
|
|
505
|
-
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)"
|
|
506
|
-
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)"
|
|
490
|
+
option :harness, default: nil, desc: "Harness (pi)"
|
|
491
|
+
option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
|
|
492
|
+
option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max)"
|
|
493
|
+
option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max)"
|
|
494
|
+
option :force_effort, default: nil, desc: "Force the literal level onto the --thinking flag, skipping architect's validation — alias for --force-thinking/--force-reasoning (dispatch only; the binary's rejection is final)"
|
|
495
|
+
option :force_thinking, default: nil, desc: "Force the literal level onto the --thinking flag, skipping architect's validation — alias for --force-effort/--force-reasoning (dispatch only; the binary's rejection is final)"
|
|
496
|
+
option :force_reasoning, default: nil, desc: "Force the literal level onto the --thinking flag, skipping architect's validation — alias for --force-effort/--force-thinking (dispatch only; the binary's rejection is final)"
|
|
507
497
|
option :quiet, type: :boolean, default: false, desc: "Suppress thinking-translation and harness run-time warn lines (liveness/push) on $stderr for this dispatch"
|
|
508
498
|
option :detach, type: :boolean, default: false, desc: "Detach the builder process (returns immediately with PID; poll report for completion)"
|
|
509
499
|
option :timeout, default: "14400", desc: "Wall-clock timeout in seconds (0 disables; default 4h); foreground only"
|
|
510
500
|
option :push_url, default: nil, desc: "HTTP endpoint for streaming push (POST body to this URL)"
|
|
511
501
|
option :push_token, default: nil, desc: "Bearer token for push endpoint authorization"
|
|
512
502
|
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)"
|
|
513
|
-
option :as_job, type: :boolean, default: false, desc: "Submit the run as a job to the space-server's queue instead of running locally (sandboxed executor; requires --host/--token/--backend-url)"
|
|
514
|
-
option :host, default: nil, desc: "Base URL of the space-server (required with --as-job)"
|
|
515
|
-
option :token, default: nil, desc: "Bearer token for authorization (required with --as-job)"
|
|
516
|
-
option :backend_url, default: nil, desc: "Base URL of the harness backend the sandboxed job talks to (required with --as-job)"
|
|
517
|
-
option :job_model, default: nil, desc: "Model pinned for the sandboxed job's harness — distinct from --model, which is meaningless against a non-Anthropic backend"
|
|
518
|
-
option :api_key_ref, default: nil, desc: "op:// reference resolved into ANTHROPIC_API_KEY server-side (--as-job only; omit for keyless backends)"
|
|
519
503
|
|
|
520
504
|
def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
|
|
521
505
|
max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
|
|
522
|
-
allowed_tools: nil, append_allowed_tools: nil,
|
|
523
506
|
force_effort: nil, force_thinking: nil, force_reasoning: nil, quiet: false, detach: false,
|
|
524
|
-
timeout: "14400", push_url: nil, push_token: nil, push_host: nil,
|
|
525
|
-
as_job: false, host: nil, token: nil, backend_url: nil, job_model: nil, api_key_ref: nil, **opts)
|
|
507
|
+
timeout: "14400", push_url: nil, push_token: nil, push_host: nil, **opts)
|
|
526
508
|
setup_terminal(**opts.slice(:color, :colors))
|
|
527
509
|
handle_errors do
|
|
528
510
|
level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
|
|
529
511
|
forced_level = resolve_force_thinking_alias(force_effort: force_effort,
|
|
530
512
|
force_thinking: force_thinking, force_reasoning: force_reasoning)
|
|
531
513
|
|
|
532
|
-
if as_job
|
|
533
|
-
Jobs.require_credentials!(host, token)
|
|
534
|
-
raise Space::Core::Error, "--backend-url is required with --as-job" unless backend_url
|
|
535
|
-
raise Space::Core::Error, "--job-model is required with --as-job" unless job_model
|
|
536
|
-
raise Space::Core::Error, "--as-job cannot be combined with --push-url/--push-token/--push-host/--detach" \
|
|
537
|
-
if push_url || push_token || push_host || detach
|
|
538
|
-
end
|
|
539
|
-
|
|
540
514
|
render(store.find(space)) do |sp|
|
|
541
515
|
project = ArchitectProject.new(space: sp)
|
|
542
516
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
terminal.say "
|
|
558
|
-
terminal.say "
|
|
517
|
+
kwargs = { max_turns: max_turns.to_i, detach: detach }
|
|
518
|
+
kwargs[:prompt] = prompt if prompt
|
|
519
|
+
kwargs[:model] = model if model
|
|
520
|
+
kwargs[:harness] = harness if harness
|
|
521
|
+
kwargs[:effort] = forced_level || level if forced_level || level
|
|
522
|
+
kwargs[:force] = true if forced_level
|
|
523
|
+
kwargs[:quiet] = true if quiet
|
|
524
|
+
kwargs[:timeout] = timeout.to_i unless detach
|
|
525
|
+
kwargs[:push_url] = push_url if push_url
|
|
526
|
+
kwargs[:push_token] = push_token if push_token
|
|
527
|
+
kwargs[:push_host] = push_host if push_host
|
|
528
|
+
res = project.dispatch(iteration, lane, **kwargs)
|
|
529
|
+
terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
|
|
530
|
+
if detach
|
|
531
|
+
terminal.say "PID: #{res[:pid]}"
|
|
532
|
+
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
533
|
+
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
534
|
+
terminal.say "Dispatched detached — poll #{terminal.path(res[:report])} for completion"
|
|
559
535
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
536
|
+
elsif res[:timed_out]
|
|
537
|
+
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
538
|
+
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
539
|
+
terminal.say "Builder TIMED OUT after #{timeout}s — process group killed. Re-dispatch (lanes are cheap)."
|
|
540
|
+
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
560
541
|
else
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
|
|
568
|
-
kwargs[:force] = true if forced_level
|
|
569
|
-
kwargs[:quiet] = true if quiet
|
|
570
|
-
kwargs[:timeout] = timeout.to_i unless detach
|
|
571
|
-
kwargs[:push_url] = push_url if push_url
|
|
572
|
-
kwargs[:push_token] = push_token if push_token
|
|
573
|
-
kwargs[:push_host] = push_host if push_host
|
|
574
|
-
res = project.dispatch(iteration, lane, **kwargs)
|
|
575
|
-
terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
|
|
576
|
-
if detach
|
|
577
|
-
terminal.say "PID: #{res[:pid]}"
|
|
578
|
-
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
579
|
-
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
580
|
-
terminal.say "Dispatched detached — poll #{terminal.path(res[:report])} for completion"
|
|
581
|
-
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
582
|
-
elsif res[:timed_out]
|
|
583
|
-
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
584
|
-
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
585
|
-
terminal.say "Builder TIMED OUT after #{timeout}s — process group killed. Re-dispatch (lanes are cheap)."
|
|
586
|
-
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
587
|
-
else
|
|
588
|
-
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
589
|
-
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
590
|
-
terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
|
|
591
|
-
terminal.say "Builder exited with status #{res[:exit_code]}"
|
|
592
|
-
unless res[:report].exist? && !res[:report].read.strip.empty?
|
|
593
|
-
terminal.say "WARNING: no report at #{terminal.path(res[:report])} — the lane produced no deliverable."
|
|
594
|
-
end
|
|
595
|
-
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
542
|
+
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
543
|
+
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
544
|
+
terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
|
|
545
|
+
terminal.say "Builder exited with status #{res[:exit_code]}"
|
|
546
|
+
unless res[:report].exist? && !res[:report].read.strip.empty?
|
|
547
|
+
terminal.say "WARNING: no report at #{terminal.path(res[:report])} — the lane produced no deliverable."
|
|
596
548
|
end
|
|
549
|
+
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
597
550
|
end
|
|
598
551
|
end
|
|
599
552
|
end
|
|
@@ -858,7 +811,7 @@ module Space::Architect
|
|
|
858
811
|
class InstallSkills < BaseCommand
|
|
859
812
|
desc "Install bundled skills (architect, architect-research, architect-vocabulary) for a harness"
|
|
860
813
|
phase 53, "Project"
|
|
861
|
-
option :provider, default: "claude", desc: "
|
|
814
|
+
option :provider, default: "claude", desc: "Skill install target (default: claude; validated by the installer)"
|
|
862
815
|
option :project, type: :boolean, default: false, desc: "Install to CWD instead of global"
|
|
863
816
|
option :force, type: :boolean, default: false, desc: "Overwrite existing skills that differ"
|
|
864
817
|
option :dry_run, type: :boolean, default: false, desc: "Print what would happen without writing files"
|
|
@@ -886,11 +839,11 @@ module Space::Architect
|
|
|
886
839
|
argument :lane, required: true, desc: "Lane name"
|
|
887
840
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
888
841
|
option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
|
|
889
|
-
option :harness, default: nil, desc: "Harness (
|
|
890
|
-
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
|
|
891
|
-
option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)
|
|
892
|
-
option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max)
|
|
893
|
-
option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max)
|
|
842
|
+
option :harness, default: nil, desc: "Harness (pi; default: space.yaml project.harness, else pi)"
|
|
843
|
+
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 pi default #{Harness::DEFAULT_MODEL})"
|
|
844
|
+
option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
|
|
845
|
+
option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max)"
|
|
846
|
+
option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max)"
|
|
894
847
|
option :quiet, type: :boolean, default: false, desc: "Suppress the thinking-translation inform line on $stderr"
|
|
895
848
|
option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
|
|
896
849
|
option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
|
|
@@ -970,7 +923,7 @@ module Space::Architect
|
|
|
970
923
|
argument :repo, required: true, desc: "Repo name (under repos/)"
|
|
971
924
|
argument :iteration, required: true, desc: "Iteration name"
|
|
972
925
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
973
|
-
option :pairs, required: true, desc: "Comma-separated harness[:model] pairs (e.g.
|
|
926
|
+
option :pairs, required: true, desc: "Comma-separated harness[:model] pairs (e.g. pi,pi:#{Harness::DEFAULT_MODEL})"
|
|
974
927
|
option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
|
|
975
928
|
option :prompt, default: nil, desc: "Prompt file to fan-out byte-identical to each variant"
|
|
976
929
|
|