space-architect 7.1.0 → 9.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +58 -246
  4. data/lib/space_architect/architect_project.rb +40 -180
  5. data/lib/space_architect/bug_report.rb +2 -1
  6. data/lib/space_architect/cli/architect.rb +69 -103
  7. data/lib/space_architect/cli/research.rb +1 -1
  8. data/lib/space_architect/cli.rb +9 -14
  9. data/lib/space_architect/harness.rb +97 -344
  10. data/lib/space_architect/pi/builder-guard.ts +103 -0
  11. data/lib/space_architect/research/mux.rb +47 -32
  12. data/lib/space_architect/research/pi_events.rb +70 -0
  13. data/lib/space_architect/research/renderer.rb +39 -39
  14. data/lib/space_architect/research/supervisor.rb +35 -14
  15. data/lib/space_architect/research.rb +1 -6
  16. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  17. data/lib/space_architect/session_sync/cursor.rb +1 -1
  18. data/lib/space_architect/session_sync/plist.rb +1 -1
  19. data/lib/{space_core → space_architect}/version.rb +2 -2
  20. data/lib/space_architect.rb +1 -2
  21. data/skill/architect/SKILL.md +30 -25
  22. data/skill/architect/dispatch.md +137 -115
  23. data/skill/architect/research.md +14 -8
  24. data/skill/architect-research/SKILL.md +18 -11
  25. data/skill/architect-vocabulary/SKILL.md +3 -3
  26. metadata +20 -178
  27. data/exe/space +0 -13
  28. data/exe/src +0 -13
  29. data/lib/space_architect/cli/space.rb +0 -29
  30. data/lib/space_architect/cli/src.rb +0 -38
  31. data/lib/space_architect/dispatcher.rb +0 -25
  32. data/lib/space_core/atomic_write.rb +0 -21
  33. data/lib/space_core/cli/base_command.rb +0 -19
  34. data/lib/space_core/cli/build.rb +0 -27
  35. data/lib/space_core/cli/config.rb +0 -49
  36. data/lib/space_core/cli/current.rb +0 -16
  37. data/lib/space_core/cli/help.rb +0 -195
  38. data/lib/space_core/cli/helpers.rb +0 -115
  39. data/lib/space_core/cli/init.rb +0 -29
  40. data/lib/space_core/cli/list.rb +0 -24
  41. data/lib/space_core/cli/loop_status.rb +0 -47
  42. data/lib/space_core/cli/new.rb +0 -38
  43. data/lib/space_core/cli/pack.rb +0 -29
  44. data/lib/space_core/cli/path.rb +0 -16
  45. data/lib/space_core/cli/repeatable_options.rb +0 -84
  46. data/lib/space_core/cli/repo.rb +0 -76
  47. data/lib/space_core/cli/run.rb +0 -44
  48. data/lib/space_core/cli/shell.rb +0 -125
  49. data/lib/space_core/cli/show.rb +0 -21
  50. data/lib/space_core/cli/status.rb +0 -74
  51. data/lib/space_core/cli/use.rb +0 -17
  52. data/lib/space_core/cli.rb +0 -177
  53. data/lib/space_core/commands.rb +0 -51
  54. data/lib/space_core/config.rb +0 -152
  55. data/lib/space_core/errors.rb +0 -14
  56. data/lib/space_core/git_client.rb +0 -49
  57. data/lib/space_core/mise_client.rb +0 -37
  58. data/lib/space_core/oci_builder.rb +0 -56
  59. data/lib/space_core/oci_packer.rb +0 -103
  60. data/lib/space_core/oci_runner.rb +0 -89
  61. data/lib/space_core/paths.rb +0 -58
  62. data/lib/space_core/repo_reference.rb +0 -19
  63. data/lib/space_core/repo_resolver.rb +0 -167
  64. data/lib/space_core/shell_integration.rb +0 -438
  65. data/lib/space_core/slugger.rb +0 -16
  66. data/lib/space_core/space.rb +0 -174
  67. data/lib/space_core/space_store.rb +0 -319
  68. data/lib/space_core/state.rb +0 -86
  69. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  70. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  71. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  72. data/lib/space_core/terminal.rb +0 -161
  73. data/lib/space_core/warnings.rb +0 -13
  74. data/lib/space_core/xdg.rb +0 -33
  75. data/lib/space_core.rb +0 -26
  76. data/lib/space_src/cli/clone.rb +0 -54
  77. data/lib/space_src/cli/config.rb +0 -65
  78. data/lib/space_src/cli/daemon.rb +0 -362
  79. data/lib/space_src/cli/options.rb +0 -21
  80. data/lib/space_src/cli/org.rb +0 -199
  81. data/lib/space_src/cli/repo.rb +0 -169
  82. data/lib/space_src/cli/shell.rb +0 -121
  83. data/lib/space_src/cli/status.rb +0 -75
  84. data/lib/space_src/cli/sync.rb +0 -161
  85. data/lib/space_src/cli.rb +0 -159
  86. data/lib/space_src/cloner.rb +0 -77
  87. data/lib/space_src/config/contract.rb +0 -54
  88. data/lib/space_src/config/duration.rb +0 -79
  89. data/lib/space_src/config/model.rb +0 -49
  90. data/lib/space_src/config/store.rb +0 -156
  91. data/lib/space_src/forge/client.rb +0 -31
  92. data/lib/space_src/forge/github.rb +0 -98
  93. data/lib/space_src/launchd/agent.rb +0 -195
  94. data/lib/space_src/launchd/plist.rb +0 -129
  95. data/lib/space_src/log_rotator.rb +0 -46
  96. data/lib/space_src/migration.rb +0 -43
  97. data/lib/space_src/nav.rb +0 -99
  98. data/lib/space_src/paths.rb +0 -72
  99. data/lib/space_src/scm/client.rb +0 -87
  100. data/lib/space_src/scm/git.rb +0 -232
  101. data/lib/space_src/scm/status.rb +0 -24
  102. data/lib/space_src/shell.rb +0 -90
  103. data/lib/space_src/shell_integration.rb +0 -321
  104. data/lib/space_src/state/lock.rb +0 -59
  105. data/lib/space_src/state/store.rb +0 -140
  106. data/lib/space_src/sync/engine.rb +0 -503
  107. data/lib/space_src/sync/repo_plan.rb +0 -215
  108. data/lib/space_src/sync/report.rb +0 -18
  109. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  110. data/lib/space_src/ui/json_reporter.rb +0 -39
  111. data/lib/space_src/ui/mode.rb +0 -68
  112. data/lib/space_src/ui/plain_reporter.rb +0 -53
  113. data/lib/space_src/ui/reporter.rb +0 -48
  114. data/lib/space_src/version.rb +0 -7
  115. data/lib/space_src.rb +0 -38
@@ -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"]
@@ -79,7 +79,8 @@ module Space
79
79
  <<~MD
80
80
  ## Diagnostics
81
81
 
82
- - space-architect: #{Space::Core::VERSION}
82
+ - space-architect: #{Space::Architect::VERSION}
83
+ - space-cadet: #{Space::Core::VERSION}
83
84
  - ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})
84
85
  MD
85
86
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
- require "space_src/launchd/agent"
6
5
  require_relative "../jobs_client"
7
6
  require_relative "../conversations_client"
8
7
  require_relative "../session_sync"
@@ -12,8 +11,8 @@ module Space::Architect
12
11
  # Optional loop-phase DSL on the shared command base, read by
13
12
  # Space::Core::CLI::Help to group the help listing. A command may declare
14
13
  # `phase <order>, "<Label>"`; groups, and members within a group, sort by
15
- # <order>. Only architect commands declare one — space commands leave it nil
16
- # and render as the single ungrouped default listing (unchanged).
14
+ # <order>. Only architect commands declare one — commands without a phase
15
+ # render as the single ungrouped default listing (unchanged).
17
16
  class BaseCommand
18
17
  def self.phase(order = nil, label = nil)
19
18
  return @phase if order.nil?
@@ -178,18 +177,10 @@ module Space::Architect
178
177
  nn = s["ordinal"] ? format("%02d", s["ordinal"]) : "-"
179
178
  lane_list = s["lanes"] || []
180
179
  lanes_str = lane_list.map do |l|
181
- h = l["harness"] || "claude-code"
182
- m = l["model"] || Harness.default_model_for(h)
180
+ h = l["harness"] || "pi"
181
+ m = l["model"] || Harness::DEFAULT_MODEL
183
182
  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})"
183
+ "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
193
184
  end.join(", ")
194
185
  lanes = lane_list.any? { |l| l["variant"] } ? "variant: #{lanes_str}" : lanes_str
195
186
  lanes = "#{lanes} → winner: #{s['winner']}" if s["winner"]
@@ -493,107 +484,68 @@ module Space::Architect
493
484
  argument :lane, required: true, desc: "Lane name"
494
485
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
495
486
  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"
487
+ 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
488
  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)"
489
+ option :harness, default: nil, desc: "Harness (pi)"
490
+ option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
491
+ option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max)"
492
+ option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max)"
493
+ 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)"
494
+ 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)"
495
+ 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
496
  option :quiet, type: :boolean, default: false, desc: "Suppress thinking-translation and harness run-time warn lines (liveness/push) on $stderr for this dispatch"
508
497
  option :detach, type: :boolean, default: false, desc: "Detach the builder process (returns immediately with PID; poll report for completion)"
509
498
  option :timeout, default: "14400", desc: "Wall-clock timeout in seconds (0 disables; default 4h); foreground only"
510
499
  option :push_url, default: nil, desc: "HTTP endpoint for streaming push (POST body to this URL)"
511
500
  option :push_token, default: nil, desc: "Bearer token for push endpoint authorization"
512
501
  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
502
 
520
503
  def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
521
504
  max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
522
- allowed_tools: nil, append_allowed_tools: nil,
523
505
  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)
506
+ timeout: "14400", push_url: nil, push_token: nil, push_host: nil, **opts)
526
507
  setup_terminal(**opts.slice(:color, :colors))
527
508
  handle_errors do
528
509
  level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
529
510
  forced_level = resolve_force_thinking_alias(force_effort: force_effort,
530
511
  force_thinking: force_thinking, force_reasoning: force_reasoning)
531
512
 
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
513
  render(store.find(space)) do |sp|
541
514
  project = ArchitectProject.new(space: sp)
542
515
 
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}"
516
+ kwargs = { max_turns: max_turns.to_i, detach: detach }
517
+ kwargs[:prompt] = prompt if prompt
518
+ kwargs[:model] = model if model
519
+ kwargs[:harness] = harness if harness
520
+ kwargs[:effort] = forced_level || level if forced_level || level
521
+ kwargs[:force] = true if forced_level
522
+ kwargs[:quiet] = true if quiet
523
+ kwargs[:timeout] = timeout.to_i unless detach
524
+ kwargs[:push_url] = push_url if push_url
525
+ kwargs[:push_token] = push_token if push_token
526
+ kwargs[:push_host] = push_host if push_host
527
+ res = project.dispatch(iteration, lane, **kwargs)
528
+ terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
529
+ if detach
530
+ terminal.say "PID: #{res[:pid]}"
531
+ terminal.say "Run log: #{terminal.path(res[:run_log])}"
532
+ terminal.say "Report: #{terminal.path(res[:report])}"
533
+ terminal.say "Dispatched detached — poll #{terminal.path(res[:report])} for completion"
559
534
  CLI.record_outcome(Outcome.new(exit_code: 0))
535
+ elsif res[:timed_out]
536
+ terminal.say "Run log: #{terminal.path(res[:run_log])}"
537
+ terminal.say "Report: #{terminal.path(res[:report])}"
538
+ terminal.say "Builder TIMED OUT after #{timeout}s — process group killed. Re-dispatch (lanes are cheap)."
539
+ CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
560
540
  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]))
541
+ terminal.say "Run log: #{terminal.path(res[:run_log])}"
542
+ terminal.say "Report: #{terminal.path(res[:report])}"
543
+ terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
544
+ terminal.say "Builder exited with status #{res[:exit_code]}"
545
+ unless res[:report].exist? && !res[:report].read.strip.empty?
546
+ terminal.say "WARNING: no report at #{terminal.path(res[:report])} — the lane produced no deliverable."
596
547
  end
548
+ CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
597
549
  end
598
550
  end
599
551
  end
@@ -847,7 +799,8 @@ module Space::Architect
847
799
  terminal.say result[:command]
848
800
  terminal.say ""
849
801
  terminal.say "Diagnostics:"
850
- terminal.say " space-architect #{Space::Core::VERSION}"
802
+ terminal.say " space-architect #{Space::Architect::VERSION}"
803
+ terminal.say " space-cadet #{Space::Core::VERSION}"
851
804
  terminal.say " ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
852
805
  terminal.say " space: #{space.id} — #{space.title}" if space
853
806
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -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
 
@@ -1215,6 +1168,17 @@ module Space::Architect
1215
1168
  end
1216
1169
 
1217
1170
  def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
1171
+
1172
+ # The launchd-agent seam is repo-tender's — a soft dependency, never a
1173
+ # gemspec runtime dep. Required lazily so `sessions agent …` degrades
1174
+ # with an actionable hint instead of failing at boot when the gem is
1175
+ # absent; handle_errors turns the raise into a red message + exit 1.
1176
+ def agent
1177
+ require "repo_tender"
1178
+ RepoTender::Launchd::Agent
1179
+ rescue LoadError
1180
+ raise Space::Core::Error, "sessions agent needs the repo-tender gem — install it with: gem install repo-tender"
1181
+ end
1218
1182
  end
1219
1183
 
1220
1184
  class Install < BaseCommand
@@ -1243,7 +1207,7 @@ module Space::Architect
1243
1207
  File.write(pp, xml)
1244
1208
  File.chmod(0o600, pp)
1245
1209
 
1246
- result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).install(pp)
1210
+ result = agent.new(label: SessionSync::LABEL).install(pp)
1247
1211
  raise Space::Core::Error, "bootstrap failed: #{format_failure(result.failure)}" if result.failure?
1248
1212
 
1249
1213
  terminal.say "Installed: #{terminal.path(pp)}"
@@ -1260,7 +1224,7 @@ module Space::Architect
1260
1224
  setup_terminal(**opts.slice(:color, :colors))
1261
1225
  handle_errors do
1262
1226
  pp = plist_path
1263
- result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).uninstall
1227
+ result = agent.new(label: SessionSync::LABEL).uninstall
1264
1228
  terminal.say "bootout reported: #{format_failure(result.failure)}" if result.failure?
1265
1229
 
1266
1230
  if File.exist?(pp)
@@ -1281,7 +1245,7 @@ module Space::Architect
1281
1245
  def call(**opts)
1282
1246
  setup_terminal(**opts.slice(:color, :colors))
1283
1247
  handle_errors do
1284
- result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).status
1248
+ result = agent.new(label: SessionSync::LABEL).status
1285
1249
  raise Space::Core::Error, "status failed: #{format_failure(result.failure)}" if result.failure?
1286
1250
 
1287
1251
  s = result.success
@@ -1303,6 +1267,8 @@ end
1303
1267
  # Loop-phase declarations above sort the architect help listing; its namespaces
1304
1268
  # (brief/worktree/variant/research) declare no phase and list under this header.
1305
1269
  Space::Core::CLI::Help.trailing_group_label = "Groups"
1270
+ Space::Core::CLI::Help.product_name = "architect"
1271
+ Space::Core::CLI::Help.product_version = Space::Architect::VERSION
1306
1272
 
1307
1273
  Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Architect::Init
1308
1274
  Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
@@ -8,7 +8,7 @@ module Space::Architect
8
8
  desc "Dispatch detached read-only research lanes (one per prompt file)"
9
9
  argument :prompts, type: :array, required: true,
10
10
  desc: "Prompt file(s) to dispatch (space-separated paths)"
11
- option :model, default: nil, desc: "Researcher model override (default: the reference default claude-sonnet-4-6)"
11
+ option :model, default: nil, desc: "Researcher model override (default: the pi default #{Space::Architect::Harness::DEFAULT_MODEL})"
12
12
  option :max_turns, default: "40", desc: "Max turns per researcher"
13
13
 
14
14
  def call(prompts:, model: nil, max_turns: "40", **opts)