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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09d047544f9e1b3a29c56cc1e982a9624f72d45c2198429fcd7ab4564c31da41'
4
- data.tar.gz: ea4e880ceda1c31ba11b390448d2380ca6d45d39394aeef2963c80a0e59a0901
3
+ metadata.gz: 3e8120606794bd415a99f1fffa4f850a141aeb0674a94b809311c19513e2bef3
4
+ data.tar.gz: 45796109310f890c1e20c13cd01c42bc09d88dbd3cb0dd8fb8b3dbb73cad8c0b
5
5
  SHA512:
6
- metadata.gz: 01d09d4cd267afe17f189606d3620bdfa284802c4ccd506a2526172864b85c1124b8096aa3a5ed3fe61cfc3f18929d0aeb8f00d7d9c6238dfdf331ce1e2f8048
7
- data.tar.gz: 313a4cf3c9dc69d5cb82d5d8bb1b95fa5d133de711d9ab5e320145525abddff4aebe8fc89cf58e0552d83147fae79ce697b567dce5a8ac387313f5064f58bf2b
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: "claude-code", model: nil, variant: false, effort: nil, touch: nil, force: false)
607
- if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
608
- raise Space::Core::Error,
609
- "Pass --model when using --harness opencode " \
610
- "(#{Harness::CLAUDE_DEFAULT_MODEL} is a Claude model ID, not valid for opencode — " \
611
- "try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
612
- end
613
- if effort && harness.to_s != "opencode"
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"] = effort if 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, detach: false,
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
- resolved_harness = harness || lane_entry["harness"] || "claude-code"
846
- resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
847
- resolved_effort = effort || lane_entry["effort"]
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
- bin: bin, config_dir: build_dir, effort: resolved_effort)
878
-
879
- # Stamp launch time onto the lane entry: after every preflight validation has passed
880
- # (a dispatch that raises above records nothing) and before the blocking run or a
881
- # detached dispatch returns. A re-dispatch overwrites the prior value.
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#{new_block.strip}"
1224
+ "#{body.strip}\n\n#{block}"
1182
1225
  else
1183
- new_block.strip
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
- return File.read(from) if from
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
- return File.read(message_from) if message_from
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::CLAUDE_DEFAULT_MODEL
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 reference default claude-sonnet-4-6). Any provider/tier; pin a full id, not a floating alias"
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: "Reasoning effort override (opencode only; sets reasoningEffort in the model config)"
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, detach: false,
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] = effort if 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: "claude-code", desc: "Harness (claude-code, opencode)"
603
- option :model, default: nil, desc: "Model (required for opencode)"
604
- option :effort, default: nil, desc: "Reasoning effort (opencode only; sets reasoningEffort in the model config)"
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: "claude-code", model: nil, effort: nil, touch: nil, force: false, **opts)
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
- result = project.worktree_add(repo, iteration, lane, base: base,
615
- harness: harness, model: model, effort: effort, touch: touch_set, force: force)
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-4-6"
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
- # Factory keyed by harness name.
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
- if effort
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: 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
- def initialize(model:, max_turns:, bin: nil,
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "5.1.0"
5
+ VERSION = "5.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs