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
@@ -33,21 +33,11 @@ module Space::Architect
33
33
  end
34
34
 
35
35
  if VERSION_REQUEST.include?(argv)
36
- out.puts Space::Core::VERSION
36
+ out.puts Space::Architect::VERSION
37
37
  return 0
38
38
  end
39
39
 
40
- if argv.first == "src"
41
- return dispatch_src(argv[1..], out, err)
42
- end
43
-
44
- normalized = normalize_args(argv)
45
-
46
- if normalized.first == "space"
47
- return dispatch_space(normalized[1..], out, err)
48
- end
49
-
50
- Dry::CLI.new(Registry).call(arguments: normalized, out: out, err: err)
40
+ Dry::CLI.new(Registry).call(arguments: normalize_args(argv), out: out, err: err)
51
41
  last_outcome&.exit_code || 0
52
42
  end
53
43
 
@@ -88,6 +78,13 @@ module Space::Architect
88
78
  end
89
79
 
90
80
  def self.run(argv, out = $stdout, err = $stderr)
81
+ # One-shot 8.x app-dir migration (the substrate's config/state, owned by
82
+ # Space::Core::Migration in space-cadet) before dispatch — skipped for
83
+ # the pure help/version queries so they stay side-effect-free. Running it
84
+ # here too means `architect` picks up an 8.x user's config even if they
85
+ # never ran `space` first.
86
+ Space::Core::Migration.run(err: err) unless TOP_LEVEL_HELP.include?(argv) || VERSION_REQUEST.include?(argv)
87
+
91
88
  Kernel.exit(call(argv, out, err))
92
89
  rescue Interrupt
93
90
  err.puts "interrupted"
@@ -97,6 +94,4 @@ module Space::Architect
97
94
  end
98
95
 
99
96
  require_relative "cli/architect"
100
- require_relative "cli/space"
101
- require_relative "cli/src"
102
97
  require_relative "cli/research"
@@ -10,23 +10,13 @@ require "uri"
10
10
 
11
11
  module Space::Architect
12
12
  module Harness
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
13
+ # The single pi default model, used when neither an explicit --model nor a
14
+ # space.yaml project.model is given.
15
+ DEFAULT_MODEL = "accounts/fireworks/models/glm-5p3-flash"
26
16
 
27
17
  # The normalized internal thinking vocabulary — pi's fullest set. Every alias
28
18
  # (--effort/--thinking/--reasoning) is normalized to a member of this array;
29
- # each harness then translates + clamps down to its own flag's vocabulary.
19
+ # pi then clamps further with its own per-model thinkingLevelMap.
30
20
  THINKING_LEVELS = %w[off minimal low medium high xhigh max].freeze
31
21
 
32
22
  def self.validate_thinking_level!(level)
@@ -45,91 +35,40 @@ module Space::Architect
45
35
  [base, suffix]
46
36
  end
47
37
 
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
38
+ # Factory keyed by harness name. pi is the only backend; the seam stays so
39
+ # callers (dispatch, research supervisor) keep one construction path.
40
+ # Translates + clamps the normalized `effort` level through the pi passthrough,
41
+ # printing one inform line to `err` (default $stderr; thread a null writer to
42
+ # suppress) when forced.
43
+ #
44
+ # A stored legacy harness name from an old space.yaml lands here too —
45
+ # raise an actionable error naming pi as the only valid harness, never a
46
+ # mysterious unknown-key failure.
47
+ def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil,
48
+ guard_path: nil, force: false, err: $stderr)
49
+ unless name.to_s == "pi"
50
+ raise Space::Core::Error,
51
+ "Unknown harness '#{name}' — 'pi' is the only valid harness (the other backends were removed). " \
52
+ "Re-provision the lane (or drop the stored harness:/project.harness from space.yaml)."
57
53
  end
58
- end
54
+ raise Space::Core::Error, "config_dir is required for pi harness" unless config_dir
59
55
 
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.
64
- # For opencode: config_dir is required (build/<id>-<lane> dir outside the worktree).
65
- def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil, allowed_tools: nil,
66
- force: false, err: $stderr)
67
- translated, inform = translate_thinking(name, effort, force: force)
56
+ translated, inform = PiHarness.translate_thinking(effort, force: force)
68
57
  err.puts(inform) if inform
69
58
 
70
- case name.to_s
71
- when "claude-code"
72
- kwargs = { model: model, max_turns: max_turns, bin: bin, effort: translated }
73
- kwargs[:allowed_tools] = allowed_tools if allowed_tools
74
- ClaudeCodeHarness.new(**kwargs)
75
- when "opencode"
76
- raise Space::Core::Error, "config_dir is required for opencode harness" unless config_dir
77
- OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
78
- when "pi"
79
- raise Space::Core::Error, "config_dir is required for pi harness" unless config_dir
80
- PiHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
81
- else
82
- raise Space::Core::Error, "Unknown harness '#{name}' — valid values: claude-code, opencode, pi"
83
- end
59
+ PiHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir,
60
+ effort: translated, guard_path: guard_path)
84
61
  end
85
62
 
86
- class ClaudeCodeHarness
87
- ALLOWED_TOOLS = "Read,Edit,Write,Grep,Glob,Bash,WebSearch,WebFetch"
88
- DISALLOWED_TOOLS = [
89
- "Bash(git commit:*)", "Bash(git push:*)", "Bash(git reset:*)",
90
- "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git checkout:*)",
91
- "Bash(git branch:*)"
92
- ].join(",")
93
-
94
- # claude-code's --effort accepts low/medium/high/xhigh/max; it has no off level
95
- # (stripped — omit --effort) and no minimal level (clamped to low).
96
- ACCEPTED_LEVELS = %w[low medium high xhigh max].freeze
97
- CLAMP_MAP = { "minimal" => "low" }.freeze
98
-
99
- # #89: compose the --allowedTools value from an optional replace + append pair —
100
- # resolved once here regardless of which surface (--allowed-tools/--append-allowed-tools
101
- # flag, or the lane's frozen allowed_tools:/append_allowed_tools: declaration) supplied
102
- # them, since replace/append combine identically either way.
103
- def self.resolve_tools(replace: nil, append: nil)
104
- base = replace || ALLOWED_TOOLS
105
- append ? "#{base},#{append}" : base
106
- end
107
-
108
- def self.translate_thinking(level, force: false)
109
- return [nil, nil] if level.nil?
110
- return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
111
- return [level, nil] if ACCEPTED_LEVELS.include?(level)
112
- return [nil, "thinking: off → claude-code (no --effort; claude-code has no off level)"] if level == "off"
113
-
114
- clamped = CLAMP_MAP.fetch(level)
115
- [clamped, "thinking: #{level} → claude-code #{clamped} (clamped; claude-code has no #{level} level)"]
116
- end
117
-
118
- def initialize(model:, max_turns:, bin: nil, effort: nil,
119
- allowed_tools: ALLOWED_TOOLS, disallowed_tools: DISALLOWED_TOOLS)
120
- @model = model
121
- @max_turns = max_turns
122
- @bin = bin || ENV.fetch("ARCHITECT_CLAUDE_BIN", "claude")
123
- @effort = effort
124
- @allowed_tools = allowed_tools
125
- @disallowed_tools = disallowed_tools
126
- end
127
-
63
+ # `pi -p --mode json`, session redirected into the run's build dir via
64
+ # --session-dir (pi otherwise auto-saves under ~/.pi/agent/sessions/,
65
+ # organized by mangled CWD).
66
+ class PiHarness
128
67
  TIMEOUT_EXIT_CODE = 124
129
68
 
130
69
  # The liveness fiber's total wait budget before it gives up on the run log's
131
- # stream-json init event. Injectable via the run(liveness_delay:) kwarg so tests
132
- # need not sleep seconds.
70
+ # first assistant-model event. Injectable via the run(liveness_delay:) kwarg
71
+ # so tests need not sleep seconds.
133
72
  LIVENESS_DELAY_SECONDS = 5.0
134
73
 
135
74
  # The liveness fiber's actual deadline is liveness_delay * LIVENESS_BUDGET_FACTOR —
@@ -140,8 +79,35 @@ module Space::Architect
140
79
  # How often the liveness fiber re-checks the run log for growth while waiting.
141
80
  LIVENESS_POLL_INTERVAL = 0.05
142
81
 
143
- def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil,
144
- liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
82
+ # pi's --thinking accepts the full normalized vocabulary unchanged — pi's own
83
+ # per-model thinkingLevelMap clamps further; architect does not clamp for pi.
84
+ def self.translate_thinking(level, force: false)
85
+ return [nil, nil] if level.nil?
86
+ return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
87
+ [level, nil]
88
+ end
89
+
90
+ # max_turns is accepted for interface parity with the factory but is
91
+ # intentionally absent from argv — pi has no turn cap; the wall-clock timeout
92
+ # (below) is the only bound on a run.
93
+ #
94
+ # guard_path: absolute path to the vendored builder-guard extension, injected
95
+ # via `-e` so every dispatch denies git-write and bash-unparseable commands
96
+ # before evaluation. --no-approve pins project-trust to untrusted
97
+ # (projectTrustOverride=false): the builder's run loads no .pi/ project
98
+ # resources (settings.json, mcp.json, extensions, skills, prompts, themes,
99
+ # SYSTEM.md/APPEND_SYSTEM.md), while AGENTS.md/CLAUDE.md context files still
100
+ # load and explicit -e paths (the guard itself) still load.
101
+ def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil, guard_path: nil)
102
+ @model = model
103
+ @bin = bin || ENV.fetch("ARCHITECT_PI_BIN", "pi")
104
+ @config_dir = Pathname.new(config_dir)
105
+ @effort = effort
106
+ @guard_path = guard_path
107
+ end
108
+
109
+ def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil,
110
+ push_client: nil, timeout: nil, liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
145
111
  prompt_path = Pathname.new(prompt_path)
146
112
  run_log_path = Pathname.new(run_log_path)
147
113
 
@@ -169,21 +135,22 @@ module Space::Architect
169
135
  end
170
136
  end
171
137
 
172
- # Liveness self-check: read the run log's stream-json init event and print ONE
173
- # line naming the streamed model + true elapsed time. A single point-sample right
174
- # at liveness_delay would report a healthy child dead if its first write landed a
175
- # moment later, so this polls (like Research::Mux#wait_for_file) to a deadline of
176
- # several delay-lengths, emitting as soon as the log holds a parseable init event —
177
- # a bounded wait, not an unbounded one, and not satisfied by mere non-emptiness (the
178
- # child's stderr is teed into the same run log, so one early stderr byte must not
179
- # count). transient: true so it never keeps the reactor alive; best-effort so it
180
- # never raises into the run path. run_detached gets no such fiber.
138
+ # Liveness self-check: read the run log's first assistant-model event and
139
+ # print ONE line naming the streamed model + true elapsed time. A single
140
+ # point-sample right at liveness_delay would report a healthy child dead
141
+ # if its first write landed a moment later, so this polls to a deadline of
142
+ # several delay-lengths, emitting as soon as the log holds a parseable
143
+ # assistant event — a bounded wait, not an unbounded one, and not
144
+ # satisfied by mere non-emptiness (the child's stderr is teed into the
145
+ # same run log, so one early stderr byte must not count). transient: true
146
+ # so it never keeps the reactor alive; best-effort so it never raises
147
+ # into the run path. run_detached gets no such fiber.
181
148
  liveness_task = nil
182
149
  if liveness_delay && liveness_delay > 0
183
150
  liveness_task = Async(transient: true) do
184
151
  start = Time.now
185
152
  deadline = start + (liveness_delay * LIVENESS_BUDGET_FACTOR)
186
- until init_event_ready?(run_log_path) || Time.now >= deadline
153
+ until assistant_model_ready?(run_log_path) || Time.now >= deadline
187
154
  sleep LIVENESS_POLL_INTERVAL
188
155
  end
189
156
  emit_liveness(run_log_path, Time.now - start, err)
@@ -218,34 +185,30 @@ module Space::Architect
218
185
  pid
219
186
  end
220
187
 
221
- # The builder flag set independent of prompt delivery, --model, and the
222
- # --output-format/--verbose pair — a sandboxed `dispatch --as-job` executor
223
- # supplies -p/the prompt/--model/--output-format itself (see the space-server's
224
- # SandboxArgv#build), so JobsClient spec composition reuses this verbatim
225
- # instead of duplicating a second flag list (DRY).
226
- def builder_args
227
- args = [
228
- "--permission-mode", "acceptEdits",
229
- "--allowedTools", @allowed_tools,
230
- "--include-partial-messages",
231
- "--max-turns", @max_turns.to_s
232
- ]
233
- args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
234
- args += ["--effort", @effort] if @effort
188
+ private
189
+
190
+ # The builder flag set: print mode + JSON event stream, session redirected
191
+ # into the run's config dir, untrusted-project runs, and the vendored
192
+ # builder-guard extension when dispatch wired one.
193
+ def argv
194
+ args = [@bin, "-p", "--mode", "json", "--model", @model,
195
+ "--session-dir", @config_dir.to_s, "--no-approve"]
196
+ args += ["-e", @guard_path.to_s] if @guard_path
197
+ args += ["--thinking", @effort] if @effort
235
198
  args
236
199
  end
237
200
 
238
- private
239
-
240
201
  # The liveness fiber's wait predicate: ready once the run log holds a parseable
241
- # stream-json init event — not merely once it holds any bytes, since the child's
242
- # stderr is teed into the same run log and one early stderr byte must not satisfy it.
202
+ # pi JSONL line whose message.role == "assistant" carries a non-empty model —
203
+ # pi's direct analog of the legacy stream-json init event. Not merely once
204
+ # the log holds any bytes: the child's stderr is teed into the same run log and
205
+ # one early stderr byte must not satisfy it.
243
206
  #
244
207
  # Scans only the bytes appended since the previous poll (remembering the offset
245
208
  # in @liveness_offset) instead of re-parsing the whole log from the top on every
246
- # 50ms tick — the child that never emits an init event would otherwise cost
209
+ # 50ms tick — the child that never emits an assistant event would otherwise cost
247
210
  # O(polls x log size) inside a fiber sharing the reactor with the dispatch itself.
248
- def init_event_ready?(run_log_path)
211
+ def assistant_model_ready?(run_log_path)
249
212
  return true if @liveness_model
250
213
 
251
214
  @liveness_offset ||= 0
@@ -259,8 +222,11 @@ module Space::Architect
259
222
  rescue JSON::ParserError
260
223
  next
261
224
  end
262
- next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
263
- @liveness_model = ev["model"]
225
+ next unless ev.is_a?(Hash) && ev["message"].is_a?(Hash) &&
226
+ ev["message"]["role"] == "assistant"
227
+ model = ev["message"]["model"]
228
+ next unless model.is_a?(String) && !model.empty?
229
+ @liveness_model = model
264
230
  break
265
231
  end
266
232
  end
@@ -269,12 +235,12 @@ module Space::Architect
269
235
  false
270
236
  end
271
237
 
272
- # Read the run log's stream-json init event and print exactly one bounded liveness
273
- # line to err, naming the true elapsed wall-clock time since dispatch. Best-effort:
274
- # swallows any read/parse error so it never raises into run.
238
+ # Read the run log's first assistant-model event and print exactly one bounded
239
+ # liveness line to err, naming the true elapsed wall-clock time since dispatch.
240
+ # Best-effort: swallows any read/parse error so it never raises into run.
275
241
  #
276
- # Reuses init_event_ready?'s own incremental scan rather than re-parsing the
277
- # whole log from the top: when the poll loop already found the init event,
242
+ # Reuses assistant_model_ready?'s own incremental scan rather than re-parsing
243
+ # the whole log from the top: when the poll loop already found the event,
278
244
  # @liveness_model is set and this is a no-op re-check; when it ended on the
279
245
  # deadline without one, this performs exactly one more forward scan from
280
246
  # @liveness_offset, so bytes appended between the last poll and this call are
@@ -287,10 +253,10 @@ module Space::Architect
287
253
  return
288
254
  end
289
255
 
290
- init_event_ready?(run_log_path)
256
+ assistant_model_ready?(run_log_path)
291
257
  streamed = @liveness_model
292
258
  if streamed.nil?
293
- err.puts "liveness: WARN model unverified — no stream-json init event after #{elapsed}s (run log #{bytes} bytes)"
259
+ err.puts "liveness: WARN model unverified — no assistant model event after #{elapsed}s (run log #{bytes} bytes)"
294
260
  elsif streamed == @model
295
261
  err.puts "liveness: OK streaming model=#{streamed} (run log growing, #{bytes} bytes)"
296
262
  else
@@ -300,10 +266,6 @@ module Space::Architect
300
266
  # Best-effort: an internal read/parse failure must never break the run.
301
267
  end
302
268
 
303
- def argv
304
- [@bin, "-p", "--model", @model, "--output-format", "stream-json", "--verbose"] + builder_args
305
- end
306
-
307
269
  def start_tee(r, log, push_url:, push_token:, push_client:, err: $stderr)
308
270
  if push_url || push_client
309
271
  body = Protocol::HTTP::Body::Writable.new(queue: Thread::SizedQueue.new(32))
@@ -357,214 +319,5 @@ module Space::Architect
357
319
  r.close
358
320
  end
359
321
  end
360
-
361
- class OpenCodeHarness
362
- # opencode's reasoningEffort accepts only low/medium/high; off/minimal are
363
- # stripped (omit reasoningEffort) and xhigh/max are clamped down to high.
364
- ACCEPTED_LEVELS = %w[low medium high].freeze
365
- CLAMP_MAP = { "xhigh" => "high", "max" => "high" }.freeze
366
-
367
- def self.translate_thinking(level, force: false)
368
- return [nil, nil] if level.nil?
369
- return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
370
- return [level, nil] if ACCEPTED_LEVELS.include?(level)
371
-
372
- if (clamped = CLAMP_MAP[level])
373
- [clamped, "thinking: #{level} → opencode #{clamped} (clamped; opencode accepts low/medium/high)"]
374
- else
375
- [nil, "thinking: #{level} → opencode (omitting reasoningEffort; opencode has no #{level} level)"]
376
- end
377
- end
378
-
379
- def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil)
380
- @model = model
381
- @max_turns = max_turns
382
- @bin = bin || ENV.fetch("ARCHITECT_OPENCODE_BIN", "opencode")
383
- @config_dir = Pathname.new(config_dir)
384
- @effort = effort
385
- end
386
-
387
- # Returns the agent config hash (deterministic, unit-testable).
388
- def builder_config
389
- cfg = {
390
- "agent" => {
391
- "builder" => {
392
- "steps" => @max_turns,
393
- "permission" => {
394
- "bash" => {
395
- "git commit *" => "deny",
396
- "git push *" => "deny",
397
- "git reset *" => "deny",
398
- "git rebase *" => "deny",
399
- "git checkout *" => "deny",
400
- "*" => "allow"
401
- }
402
- }
403
- }
404
- }
405
- }
406
- cfg.merge!(reasoning_provider_config) if @effort
407
- cfg
408
- end
409
-
410
- def run(prompt_path:, run_log_path:, chdir:, timeout: nil) # timeout: deferred — opencode kill path is out of scope
411
- prompt_path = Pathname.new(prompt_path)
412
- run_log_path = Pathname.new(run_log_path)
413
- config_path = write_config
414
-
415
- env = {
416
- "OPENCODE_CONFIG" => config_path.to_s,
417
- "OPENCODE_DISABLE_PROJECT_CONFIG" => "1"
418
- }
419
-
420
- File.open(prompt_path, "r") do |prompt_io|
421
- File.open(run_log_path, "w") do |log|
422
- status = Sync do
423
- Async::Process.spawn(env, *argv(chdir),
424
- chdir: chdir.to_s, in: prompt_io, out: log, err: log)
425
- end
426
- status.exitstatus
427
- end
428
- end
429
- end
430
-
431
- def run_detached(prompt_path:, run_log_path:, chdir:)
432
- prompt_path = Pathname.new(prompt_path)
433
- run_log_path = Pathname.new(run_log_path)
434
- config_path = write_config
435
-
436
- env = {
437
- "OPENCODE_CONFIG" => config_path.to_s,
438
- "OPENCODE_DISABLE_PROJECT_CONFIG" => "1"
439
- }
440
-
441
- prompt_io = File.open(prompt_path, "r")
442
- log = File.open(run_log_path, "w")
443
- begin
444
- pid = Process.spawn(env, *argv(chdir), chdir: chdir.to_s, pgroup: true,
445
- in: prompt_io, out: log, err: log)
446
- Process.detach(pid)
447
- ensure
448
- prompt_io.close
449
- log.close
450
- end
451
- pid
452
- end
453
-
454
- private
455
-
456
- def reasoning_provider_config
457
- provider, model_id = @model.split("/", 2)
458
- {
459
- "provider" => {
460
- provider => {
461
- "models" => {
462
- model_id => { "options" => { "reasoningEffort" => @effort } }
463
- }
464
- }
465
- }
466
- }
467
- end
468
-
469
- def write_config
470
- path = @config_dir.join("opencode.json")
471
- path.write(JSON.generate(builder_config))
472
- path
473
- end
474
-
475
- # --dir sets the working directory for opencode's tooling layer.
476
- def argv(chdir)
477
- args = [
478
- @bin, "run",
479
- "--format", "json",
480
- "--model", @model,
481
- "--dangerously-skip-permissions",
482
- "--agent", "builder",
483
- "--dir", chdir.to_s
484
- ]
485
- args
486
- end
487
- end
488
-
489
- # `pi -p --mode json`, session redirected into the lane's build dir via --session-dir
490
- # (pi otherwise auto-saves under ~/.pi/agent/sessions/, organized by mangled CWD).
491
- class PiHarness
492
- TIMEOUT_EXIT_CODE = 124
493
-
494
- # pi's --thinking accepts the full normalized vocabulary unchanged — pi's own
495
- # per-model thinkingLevelMap clamps further; architect does not clamp for pi.
496
- def self.translate_thinking(level, force: false)
497
- return [nil, nil] if level.nil?
498
- return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
499
- [level, nil]
500
- end
501
-
502
- # max_turns is accepted for interface parity with the other harnesses but is
503
- # intentionally absent from argv — pi has no turn cap; the wall-clock timeout
504
- # (below) is the only bound on a run.
505
- def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil)
506
- @model = model
507
- @bin = bin || ENV.fetch("ARCHITECT_PI_BIN", "pi")
508
- @config_dir = Pathname.new(config_dir)
509
- @effort = effort
510
- end
511
-
512
- def run(prompt_path:, run_log_path:, chdir:, timeout: nil, **)
513
- prompt_path = Pathname.new(prompt_path)
514
- run_log_path = Pathname.new(run_log_path)
515
-
516
- File.open(prompt_path, "r") do |prompt_io|
517
- File.open(run_log_path, "w") do |log|
518
- Sync do
519
- child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: log, err: log)
520
- timed_out = false
521
- timeout_task = nil
522
-
523
- # Same TERM→grace→KILL escalation as ClaudeCodeHarness — with: Async::Process::Child's
524
- # #wait_thread ensure goes straight to KILL, so a concurrent fiber does the escalation.
525
- if timeout && timeout > 0
526
- timeout_task = Async(transient: true) do
527
- sleep timeout
528
- timed_out = true
529
- Process.kill("TERM", -child.pid) rescue nil
530
- sleep 0.5
531
- Process.kill("KILL", -child.pid) rescue nil
532
- end
533
- end
534
-
535
- status = child.wait
536
- timeout_task&.stop
537
-
538
- timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
539
- end
540
- end
541
- end
542
- end
543
-
544
- def run_detached(prompt_path:, run_log_path:, chdir:)
545
- prompt_path = Pathname.new(prompt_path)
546
- run_log_path = Pathname.new(run_log_path)
547
-
548
- prompt_io = File.open(prompt_path, "r")
549
- log = File.open(run_log_path, "w")
550
- begin
551
- pid = Process.spawn(*argv, chdir: chdir.to_s, pgroup: true,
552
- in: prompt_io, out: log, err: log)
553
- Process.detach(pid)
554
- ensure
555
- prompt_io.close
556
- log.close
557
- end
558
- pid
559
- end
560
-
561
- private
562
-
563
- def argv
564
- args = [@bin, "-p", "--mode", "json", "--model", @model, "--session-dir", @config_dir.to_s, "--no-approve"]
565
- args += ["--thinking", @effort] if @effort
566
- args
567
- end
568
- end
569
322
  end
570
323
  end