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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17559f544d11b36996a3e8f927a8e4c9d2db3f178687c726fee14793c7ec6de4
4
- data.tar.gz: 5b7c00fc0748f05e5c557339d011f3583816399a2261c79ae45e93e3f9e78ae5
3
+ metadata.gz: b63abfece4c105ef5b2befd0d8c4e4301d8d5cade10570cd2f41bd0641b8c4be
4
+ data.tar.gz: 6b932ae13952dad48f81a2ee68ed52b7cee28ef8edc4a99e7a06f23952c4f107
5
5
  SHA512:
6
- metadata.gz: db1f7cf0ed0ce5401483b7f3e2e429e5caf2200d1baeb450940f6afddd017744bd449a5b05dc2cfbbfc70787967ec01b6542adbd301c69e470917f6b8887f41c
7
- data.tar.gz: b1c4fb81c6eeb1c9158e77a4e75aa21e01d4d77ac203e448c8ba564c76c2977bff9959fb481f480858a647f52262f3bcc516743220f6113d8a17c3f614f0f5fc
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-json to an ingest server for
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 `claude -p` researchers (no
371
- Edit/Write/Bash) that you supervise:
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"] || "claude-code",
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
- claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
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
- bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
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
- allowed_tools: resolved_allowed_tools, force: force, err: err)
977
+ guard_path: guard_path, force: force, err: err)
986
978
 
987
- # Stamp launch time and the resolved harness/model/effort/allowed_tools onto the
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. #89/AC8: the resolved
992
- # tool grant is recorded the same way, so a denial traces back to a grant a
993
- # human can read — replace_tools/append_tools are the pre-composition components
994
- # (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later
995
- # bare re-dispatch re-resolves from the same components instead of compounding.
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"] = now.iso8601
1002
- l["harness"] = resolved_harness
1003
- l["model"] = resolved_model
1004
- l["effort"] = resolved_effort if resolved_effort
1005
- l["allowed_tools"] = replace_tools if replace_tools
1006
- l["append_allowed_tools"] = append_tools if append_tools
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 resolved_harness == "claude-code"
1032
- run_kwargs[:push_url] = push_url if push_url
1033
- run_kwargs[:push_token] = push_token if push_token
1034
- run_kwargs[:push_client] = push_client if push_client
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::ClaudeCodeHarness::TIMEOUT_EXIT_CODE
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
- # per-harness sensible default (model only, keyed on the resolved harness).
1690
- # Shared by worktree_add and dispatch so both read the same defaults. A stored
1691
- # model is only honored when its stored harness still matches the resolved
1692
- # harness — a dispatch-time harness override drops the old harness's stored
1693
- # model instead of leaking it into the new harness's run.
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"] || "claude-code").to_s
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.default_model_for(resolved_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. Shared by
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 (local or --as-job), shared so the
1720
- # two dispatch paths can never drift on precedence or thinking-level translation.
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"] || "claude-code",
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"] || "claude-code"
182
- m = l["model"] || Harness.default_model_for(h)
181
+ h = l["harness"] || "pi"
182
+ m = l["model"] || Harness::DEFAULT_MODEL
183
183
  eff = l["effort"] ? "·#{l['effort']}" : ""
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})"
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 per-harness sensible default). Any provider/tier; pin a full id, not a floating alias"
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 override (claude-code, opencode, pi)"
499
- 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"
500
- 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"
501
- 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"
502
- 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"
503
- 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"
504
- 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)"
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
- if as_job
544
- kwargs = { host: host, token: token, backend_url: backend_url, max_turns: max_turns.to_i }
545
- kwargs[:prompt] = prompt if prompt
546
- kwargs[:model] = model if model
547
- kwargs[:harness] = harness if harness
548
- kwargs[:effort] = forced_level || level if forced_level || level
549
- kwargs[:allowed_tools] = allowed_tools if allowed_tools
550
- kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
551
- kwargs[:force] = true if forced_level
552
- kwargs[:quiet] = true if quiet
553
- kwargs[:job_model] = job_model if job_model
554
- kwargs[:api_key_ref] = api_key_ref if api_key_ref
555
- res = project.dispatch_as_job(iteration, lane, **kwargs)
556
- terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
557
- terminal.say "Job: #{res[:job_id]}"
558
- terminal.say "Watch: architect jobs watch #{res[:job_id]} --host #{host} --token #{token}"
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
- kwargs = { max_turns: max_turns.to_i, detach: detach }
562
- kwargs[:prompt] = prompt if prompt
563
- kwargs[:model] = model if model
564
- kwargs[:harness] = harness if harness
565
- kwargs[:effort] = forced_level || level if forced_level || level
566
- kwargs[:allowed_tools] = allowed_tools if allowed_tools
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: "Harness: claude, codex, opencode, pi"
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 (claude-code, opencode, pi; default: space.yaml project.harness, else claude-code)"
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 per-harness sensible default)"
891
- 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"
892
- 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"
893
- 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"
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. claude-code,opencode:fireworks-ai/accounts/fireworks/models/glm-5p2)"
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