space-architect 7.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -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
@@ -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
+ }