space-architect 7.0.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +5 -3
- data/lib/space_architect/architect_project.rb +128 -200
- data/lib/space_architect/cli/architect.rb +114 -105
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/harness.rb +97 -344
- data/lib/space_architect/pi/builder-guard.ts +103 -0
- data/lib/space_architect/research/mux.rb +47 -32
- data/lib/space_architect/research/pi_events.rb +70 -0
- data/lib/space_architect/research/renderer.rb +39 -39
- data/lib/space_architect/research/supervisor.rb +35 -14
- data/lib/space_architect/research.rb +1 -6
- data/lib/space_architect.rb +0 -1
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +34 -26
- data/skill/architect/dispatch.md +137 -115
- data/skill/architect/research.md +14 -8
- data/skill/architect-research/SKILL.md +18 -11
- data/skill/architect-vocabulary/SKILL.md +1 -1
- metadata +3 -2
- data/lib/space_architect/dispatcher.rb +0 -25
|
@@ -10,23 +10,13 @@ require "uri"
|
|
|
10
10
|
|
|
11
11
|
module Space::Architect
|
|
12
12
|
module Harness
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
54
|
+
raise Space::Core::Error, "config_dir is required for pi harness" unless config_dir
|
|
59
55
|
|
|
60
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
173
|
-
# line naming the streamed model + true elapsed time. A single
|
|
174
|
-
# at liveness_delay would report a healthy child dead
|
|
175
|
-
# moment later, so this polls
|
|
176
|
-
# several delay-lengths, emitting as soon as the log holds a parseable
|
|
177
|
-
# a bounded wait, not an unbounded one, and not
|
|
178
|
-
# child's stderr is teed into the
|
|
179
|
-
#
|
|
180
|
-
# never
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
def
|
|
227
|
-
args = [
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
#
|
|
242
|
-
#
|
|
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
|
|
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
|
|
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["
|
|
263
|
-
|
|
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
|
|
273
|
-
# line to err, naming the true elapsed wall-clock time since dispatch.
|
|
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
|
|
277
|
-
# whole log from the top: when the poll loop already found the
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Builder guard: injected with every architect dispatch (`pi -e <path>`) to
|
|
2
|
+
// deny, before evaluation, the two failure modes a hermetic builder must not
|
|
3
|
+
// reach: git writes (the architect CLI owns all commits in a lane worktree)
|
|
4
|
+
// and commands bash cannot parse (a parse error at tool time burns a turn).
|
|
5
|
+
// Parse-level only: `bash -n` cannot catch runtime failures — those are the
|
|
6
|
+
// bash tool's own domain.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
// Git subcommands that mutate repository state. Reads (log, diff, show, …)
|
|
11
|
+
// stay allowed — only the write verbs are denied.
|
|
12
|
+
const GIT_WRITE_VERBS = new Set([
|
|
13
|
+
"commit",
|
|
14
|
+
"push",
|
|
15
|
+
"pull",
|
|
16
|
+
"reset",
|
|
17
|
+
"merge",
|
|
18
|
+
"rebase",
|
|
19
|
+
"checkout",
|
|
20
|
+
"switch",
|
|
21
|
+
"branch",
|
|
22
|
+
"tag",
|
|
23
|
+
"cherry-pick",
|
|
24
|
+
"revert",
|
|
25
|
+
"stash",
|
|
26
|
+
"worktree",
|
|
27
|
+
"clean",
|
|
28
|
+
"restore",
|
|
29
|
+
"rm",
|
|
30
|
+
"mv",
|
|
31
|
+
"am",
|
|
32
|
+
"apply",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Global flags that consume a following value, when locating the subcommand —
|
|
36
|
+
// `git -C <path> commit` must classify as commit (flags sit between `git` and
|
|
37
|
+
// the subcommand, so an adjacency pattern misses it).
|
|
38
|
+
const GIT_VALUE_FLAGS = new Set(["-C", "-c"]);
|
|
39
|
+
|
|
40
|
+
// Split a command line into simple-command segments at the shell's command
|
|
41
|
+
// separators. Whitespace tokenization only: this is a deny list, not a shell
|
|
42
|
+
// reimplementation, and the deny decision never depends on quoting structure
|
|
43
|
+
// beyond what the separator split already crossed.
|
|
44
|
+
function segments(command: string): string[] {
|
|
45
|
+
return command.split(/\r?\n|\|\||&&|[;|]/);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The git subcommand a segment invokes, or null: walk the tokens after `git`,
|
|
49
|
+
// skipping global flags (and their values) so the first positional token is
|
|
50
|
+
// the subcommand. Never substring-match the raw command — `git log
|
|
51
|
+
// --grep=commit` mentions `commit` but is a read.
|
|
52
|
+
function gitSubcommand(segment: string): string | null {
|
|
53
|
+
const tokens = segment.trim().split(/\s+/);
|
|
54
|
+
if (tokens[0] !== "git") return null;
|
|
55
|
+
|
|
56
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
57
|
+
const tok = tokens[i];
|
|
58
|
+
if (!tok.startsWith("-")) return tok;
|
|
59
|
+
if (GIT_VALUE_FLAGS.has(tok)) i++; // skip the flag's value
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function gitWriteDenial(command: string): string | null {
|
|
65
|
+
for (const segment of segments(command)) {
|
|
66
|
+
const sub = gitSubcommand(segment);
|
|
67
|
+
if (sub && GIT_WRITE_VERBS.has(sub)) {
|
|
68
|
+
return `git ${sub} writes repository state — the architect CLI owns all commits and git mutations; ` +
|
|
69
|
+
`leave repository state alone and report what you would have run`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Parse-check via `bash -n`: stdin carries the command, stderr is the reason.
|
|
76
|
+
function parseDenial(command: string): string | null {
|
|
77
|
+
const check = spawnSync("bash", ["-n"], { input: command, encoding: "utf8" });
|
|
78
|
+
if (check.status === 0) return null;
|
|
79
|
+
return `command does not parse under \`bash -n\` and would burn a turn at tool time; ` +
|
|
80
|
+
`fix the command's syntax — bash said: ${(check.stderr ?? "").trim()}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default function builderGuard(pi: ExtensionAPI): void {
|
|
84
|
+
pi.on("tool_call", (event) => {
|
|
85
|
+
// bash only: the deny list is shell-command shaped; edit/write tools are
|
|
86
|
+
// bounded by the lane's touch set, not this guard.
|
|
87
|
+
if (event.toolName !== "bash") return;
|
|
88
|
+
const command = (event.input as { command?: unknown }).command;
|
|
89
|
+
if (typeof command !== "string") return;
|
|
90
|
+
|
|
91
|
+
// Test the command string itself, never the JSON-rendered whole input —
|
|
92
|
+
// a payload that merely mentions `git commit` in prose must not match.
|
|
93
|
+
const denial = gitWriteDenial(command) ?? parseDenial(command);
|
|
94
|
+
if (denial) {
|
|
95
|
+
return {
|
|
96
|
+
block: true,
|
|
97
|
+
// The reason is the builder's only feedback: it must say what was
|
|
98
|
+
// denied and what to do instead.
|
|
99
|
+
reason: `architect builder guard: ${denial}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|