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.
@@ -11,10 +11,12 @@ module Space::Architect
11
11
  HEARTBEAT_EVERY = 30 # seconds of silence before heartbeat
12
12
  FILE_WAIT_LIMIT = 10 # seconds to wait for run.jsonl to appear
13
13
 
14
- def initialize(runs, renderer:, out: $stdout)
15
- @runs = runs
16
- @renderer = renderer
17
- @out = out
14
+ # heartbeat_every: test seam — defaults to HEARTBEAT_EVERY.
15
+ def initialize(runs, renderer:, out: $stdout, heartbeat_every: HEARTBEAT_EVERY)
16
+ @runs = runs
17
+ @renderer = renderer
18
+ @out = out
19
+ @heartbeat_every = heartbeat_every
18
20
  end
19
21
 
20
22
  # Returns :ok or :failed
@@ -35,7 +37,7 @@ module Space::Architect
35
37
  wait_for_file(run)
36
38
 
37
39
  unless File.exist?(run.run_log_path.to_s)
38
- emit(@renderer.render(lane: run.id, events: [error_event("run.jsonl never appeared")], alive: false))
40
+ emit(@renderer.terminal(lane: run.id, ok: false, reason: "run.jsonl never appeared"))
39
41
  return :failed
40
42
  end
41
43
 
@@ -43,7 +45,7 @@ module Space::Architect
43
45
 
44
46
  events_all = []
45
47
  last_emit = Time.now
46
- terminal = nil
48
+ settled = nil
47
49
 
48
50
  File.open(run.run_log_path.to_s, "r") do |f|
49
51
  loop do
@@ -53,30 +55,29 @@ module Space::Architect
53
55
  next unless ev
54
56
 
55
57
  events_all << ev
56
- terminal = ev if ev["type"] == "result"
58
+ settled = ev if ev["type"] == "agent_settled"
57
59
 
58
60
  new_events = [ev]
59
- rendered = @renderer.render(lane: run.id, events: new_events, alive: terminal.nil?)
61
+ rendered = @renderer.render(lane: run.id, events: new_events, alive: settled.nil?)
60
62
  emit(rendered) unless rendered.empty?
61
63
  last_emit = Time.now
62
64
 
63
- break if terminal
65
+ break if settled
64
66
  else
65
67
  # EOF — check liveness
66
68
  pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
67
69
 
68
70
  unless pid_alive
69
71
  # PID dead and no terminal event → treat as failure
70
- unless terminal
71
- emit(@renderer.render(lane: run.id,
72
- events: [error_event("process died without result event")],
73
- alive: false))
72
+ unless settled
73
+ emit(@renderer.terminal(lane: run.id, ok: false,
74
+ reason: "process died without a terminal event"))
74
75
  return :failed
75
76
  end
76
77
  break
77
78
  end
78
79
 
79
- if Time.now - last_emit > HEARTBEAT_EVERY
80
+ if Time.now - last_emit > @heartbeat_every
80
81
  emit("[#{run.id}] ⏳ still running…\n") if @renderer.lifecycle?
81
82
  last_emit = Time.now
82
83
  end
@@ -86,14 +87,36 @@ module Space::Architect
86
87
  end
87
88
  end
88
89
 
89
- if terminal
90
- extract_report(run, terminal)
91
- rendered = @renderer.render(lane: run.id, events: [terminal], alive: false)
92
- emit(rendered) unless rendered.empty?
93
- terminal["is_error"] ? :failed : :ok
94
- else
95
- :failed
96
- end
90
+ finish(run, events_all, settled)
91
+ end
92
+
93
+ # Classify the settled stream via PiEvents and render the terminal line.
94
+ def finish(run, events, settled)
95
+ text = PiEvents.message_text(PiEvents.last_assistant_message(events))
96
+ outcome, reason = classify(events, settled, text)
97
+
98
+ extract_report(run, text) unless text.empty?
99
+
100
+ emit(@renderer.terminal(lane: run.id, ok: outcome == :ok,
101
+ reason: outcome == :ok ? nil : reason,
102
+ snippet: outcome == :ok ? text : nil,
103
+ duration: PiEvents.duration_seconds(events),
104
+ turns: PiEvents.turn_count(events)))
105
+ outcome
106
+ end
107
+
108
+ # :ok iff the stream settled un-aborted, the last assistant stopReason is
109
+ # "stop", and the final assistant text is non-empty; :failed with a named
110
+ # reason otherwise.
111
+ def classify(events, settled, text)
112
+ reason = PiEvents.stop_reason(events)
113
+
114
+ return [:failed, text.empty? ? "aborted" : text] if settled.is_a?(Hash) && settled["aborted"] == true
115
+ return [:ok, text] if reason == "stop" && !text.empty?
116
+ return [:failed, text.empty? ? reason : text] if PiEvents.failed_stop_reason?(reason)
117
+
118
+ # "stop" or no assistant message at all, but no final text.
119
+ [:failed, "no final assistant text"]
97
120
  end
98
121
 
99
122
  def wait_for_file(run)
@@ -105,16 +128,8 @@ module Space::Architect
105
128
  end
106
129
  end
107
130
 
108
- def extract_report(run, terminal_ev)
109
- result = terminal_ev["result"].to_s
110
- return if result.empty?
111
-
112
- Pathname.new(run.report_path).write(result)
113
- end
114
-
115
- def error_event(msg)
116
- { "type" => "result", "subtype" => "error", "is_error" => true,
117
- "duration_ms" => 0, "num_turns" => 0, "result" => msg }
131
+ def extract_report(run, text)
132
+ Pathname.new(run.report_path).write(text)
118
133
  end
119
134
 
120
135
  def emit(text)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Space::Architect
6
+ module Research
7
+ # Pure helpers over pi's JSONL event stream — parsed event hashes in, values
8
+ # out, no I/O. The one home for the pi-shape predicates: Mux, report
9
+ # extraction, and Supervisor#classify all consume these, so the terminal
10
+ # classification is not duplicated per call site.
11
+ module PiEvents
12
+ # pi's StopReason values that mean the assistant did not finish normally.
13
+ FAILED_STOP_REASONS = %w[error aborted].freeze
14
+
15
+ module_function
16
+
17
+ # Assistant `message_end` messages, in stream order — the sole source of
18
+ # complete message content (`message_start`/`message_update` are inert;
19
+ # `turn_end`/`agent_end` only replay).
20
+ def assistant_messages(events)
21
+ events.filter_map do |e|
22
+ next unless e.is_a?(Hash) && e["type"] == "message_end" &&
23
+ e["message"].is_a?(Hash) && e["message"]["role"] == "assistant"
24
+ e["message"]
25
+ end
26
+ end
27
+
28
+ def last_assistant_message(events)
29
+ assistant_messages(events).last
30
+ end
31
+
32
+ # The last assistant message's stopReason ("stop"/"error"/"aborted"), or
33
+ # nil when the stream has no assistant message_end.
34
+ def stop_reason(events)
35
+ last_assistant_message(events)&.fetch("stopReason", nil)
36
+ end
37
+
38
+ def failed_stop_reason?(reason)
39
+ FAILED_STOP_REASONS.include?(reason)
40
+ end
41
+
42
+ # Joined text blocks of a message — the report source.
43
+ def message_text(message)
44
+ return "" unless message.is_a?(Hash)
45
+ Array(message["content"]).select { |b| b.is_a?(Hash) && b["type"] == "text" }
46
+ .map { |b| b["text"].to_s }
47
+ .join
48
+ end
49
+
50
+ # Run span in whole-tenths of a second: last assistant message_end
51
+ # `.timestamp` (epoch ms) minus `session.timestamp` (ISO 8601). NOT a sum
52
+ # of per-response durationMs — that omits tool time between turns.
53
+ def duration_seconds(events)
54
+ session = events.find { |e| e.is_a?(Hash) && e["type"] == "session" }
55
+ end_ms = last_assistant_message(events)&.fetch("timestamp", nil)
56
+ return nil unless session.is_a?(Hash) && session["timestamp"].is_a?(String) && end_ms.is_a?(Numeric)
57
+
58
+ start_s = Time.iso8601(session["timestamp"]).to_f
59
+ ((end_ms / 1000.0) - start_s).round(1)
60
+ rescue ArgumentError
61
+ nil
62
+ end
63
+
64
+ # One turn_end per turn.
65
+ def turn_count(events)
66
+ events.count { |e| e.is_a?(Hash) && e["type"] == "turn_end" }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Space::Architect
4
6
  module Research
5
- # Pure, testable verbosity-gated renderer for stream-json events.
7
+ # Pure, testable verbosity-gated renderer for pi JSONL events.
6
8
  #
7
9
  # Levels (§5.3 ladder):
8
10
  # 0 (quiet) — nothing
@@ -22,8 +24,8 @@ module Space::Architect
22
24
 
23
25
  # Render a batch of events for a lane.
24
26
  # alive: true → lane still in flight (lifecycle prefix)
25
- # alive: false → lane finished (terminal line included)
26
- # Returns a String (may be empty).
27
+ # alive: false → lane finished
28
+ # Returns a String (may be empty). Terminal lines come from #terminal.
27
29
  def render(lane:, events:, alive:)
28
30
  return "" if @level == 0 && !@jsonl
29
31
 
@@ -32,69 +34,67 @@ module Space::Architect
32
34
  end
33
35
 
34
36
  lines = []
35
- terminal = nil
36
-
37
37
  events.each do |ev|
38
38
  case ev["type"]
39
- when "assistant"
39
+ when "message_end"
40
+ next unless ev.dig("message", "role") == "assistant"
40
41
  Array(ev.dig("message", "content")).each do |block|
41
42
  case block["type"]
42
43
  when "thinking"
43
44
  lines << "[#{lane}] #{block['thinking'].to_s.strip}" if @thinking && @level >= 1
44
45
  when "text"
45
46
  lines << "[#{lane}] #{block['text'].to_s.strip}" if @level >= 2
46
- when "tool_use"
47
+ when "toolCall"
47
48
  if @level >= 3
48
49
  name_line = "[#{lane}] tool: #{block['name']}"
49
50
  if @level >= 4
50
- input = block["input"]
51
- name_line += " #{JSON.generate(input)}" if input && !input.empty?
51
+ arguments = block["arguments"]
52
+ name_line += " #{JSON.generate(arguments)}" if arguments && !arguments.empty?
52
53
  end
53
54
  lines << name_line
54
55
  end
55
56
  end
56
57
  end
57
- when "user"
58
- Array(ev.dig("message", "content")).each do |block|
59
- next unless block["type"] == "tool_result"
60
- next unless @level >= 4
61
-
62
- content = block["content"]
63
- lines << "[#{lane}] tool_result: #{content.to_s.strip}"
58
+ when "tool_execution_start"
59
+ if @level >= 3
60
+ line = "[#{lane}] tool: #{ev['toolName']}"
61
+ if @level >= 4
62
+ args = ev["args"]
63
+ line += " #{JSON.generate(args)}" if args && !args.empty?
64
+ end
65
+ lines << line
66
+ end
67
+ when "tool_execution_end"
68
+ if @level >= 4
69
+ text = Array(ev.dig("result", "content")).select { |c| c.is_a?(Hash) && c["type"] == "text" }
70
+ .map { |c| c["text"].to_s }.join
71
+ lines << "[#{lane}] tool_result: #{text.strip}"
64
72
  end
65
- when "result"
66
- terminal = ev
67
73
  end
68
74
  end
69
75
 
70
- if terminal
71
- lines << terminal_line(lane, terminal)
72
- elsif alive && @level >= 1 && events.empty?
73
- lines << "[#{lane}] running"
74
- end
76
+ lines << "[#{lane}] running" if alive && @level >= 1 && events.empty?
75
77
 
76
78
  lines.reject(&:empty?).join("\n").then { |s| s.empty? ? s : "#{s}\n" }
77
79
  end
78
80
 
79
- def lifecycle?
80
- @level >= 1 && !@jsonl
81
- end
81
+ # The terminal line, fed terminal FACTS by the mux (never a fabricated
82
+ # event): ok/failed, the failure reason or the final-text snippet, the
83
+ # run span, and the turn count.
84
+ def terminal(lane:, ok:, reason: nil, snippet: nil, duration: nil, turns: nil)
85
+ return "" if @level == 0 || @jsonl
82
86
 
83
- private
84
-
85
- def terminal_line(lane, ev)
86
- if ev["is_error"]
87
- reason = ev["result"].to_s.strip
88
- reason = ev["subtype"] if reason.empty?
89
- "[#{lane}] ✗ failed #{reason}"
90
- elsif ev["subtype"] == "success"
91
- dur = ev["duration_ms"] ? "#{(ev['duration_ms'] / 1000.0).round(1)}s" : "-"
92
- turns = ev["num_turns"] || "-"
93
- result_snip = ev["result"].to_s.strip.slice(0, 80)
94
- "[#{lane}] ✓ complete · STATUS: #{result_snip} · #{dur} · #{turns} turns"
87
+ line = if ok
88
+ span = duration ? "#{duration}s" : "-"
89
+ "[#{lane}] ✓ complete · STATUS: #{snippet.to_s.strip.slice(0, 80)} · #{span} · #{turns || '-'} turns"
95
90
  else
96
- "[#{lane}] ⚠ nonzero exit"
91
+ "[#{lane}] ✗ failed #{reason.to_s.strip}"
97
92
  end
93
+ "#{line}\n"
94
+ end
95
+
96
+ def lifecycle?
97
+ @level >= 1 && !@jsonl
98
98
  end
99
99
  end
100
100
  end
@@ -1,22 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "pathname"
4
5
  require "time"
5
6
 
6
7
  module Space::Architect
7
8
  module Research
8
9
  class Supervisor
9
- DEFAULT_MODEL = Harness::CLAUDE_DEFAULT_MODEL
10
+ DEFAULT_MODEL = Harness::DEFAULT_MODEL
10
11
  DEFAULT_MAX_TURNS = 40
11
12
 
13
+ # The vendored builder-guard extension ships in the gem next to this file's
14
+ # sibling pi/ dir — copied per-run and injected with the harness.
15
+ VENDORED_GUARD = Pathname.new(__dir__).join("../pi/builder-guard.ts")
16
+
12
17
  def initialize(space:, bin: nil)
13
18
  @space = space
14
19
  @bin = bin
15
20
  @registry = Registry.new(space.path.join("build", "research", "registry.yaml"))
16
21
  end
17
22
 
18
- # Dispatch each prompt file as a detached read-only claude -p child.
19
- # Returns array of Run objects (non-blocking).
23
+ # Dispatch each prompt file as a detached pi child. Returns array of Run
24
+ # objects (non-blocking).
20
25
  def dispatch(prompts, model: DEFAULT_MODEL, max_turns: DEFAULT_MAX_TURNS)
21
26
  prompts.map { |path| dispatch_one(Pathname.new(path), model: model, max_turns: max_turns) }
22
27
  end
@@ -54,12 +59,19 @@ module Space::Architect
54
59
 
55
60
  FileUtils.cp(path.to_s, prompt_path.to_s)
56
61
 
57
- harness = Harness::ClaudeCodeHarness.new(
58
- model: model,
59
- max_turns: max_turns,
60
- bin: @bin,
61
- allowed_tools: READONLY_TOOLS,
62
- disallowed_tools: ""
62
+ # Vendor the guard into the run dir (idempotent overwrite) and inject it via
63
+ # the harness — research runs get the same deny-only builder guard.
64
+ guard_path = dir.join("builder-guard.ts")
65
+ FileUtils.cp(VENDORED_GUARD, guard_path)
66
+
67
+ # config_dir: is pi's --session-dir — research sessions land under
68
+ # build/research/<id>/ instead of ~/.pi/agent/sessions/.
69
+ harness = Harness::PiHarness.new(
70
+ model: model,
71
+ max_turns: max_turns,
72
+ bin: @bin,
73
+ config_dir: dir,
74
+ guard_path: guard_path
63
75
  )
64
76
 
65
77
  pid = harness.run_detached(
@@ -87,18 +99,27 @@ module Space::Architect
87
99
  File.basename(path.to_s).sub(/\.prompt\.md\z/, "").sub(/\.md\z/, "")
88
100
  end
89
101
 
102
+ # pi has no terminal "result" event: the run's outcome is the LAST assistant
103
+ # message's stopReason ("stop" finished normally; "error"/"aborted" failed —
104
+ # pi's StopReason vocabulary; PiEvents is the one home for these
105
+ # predicates). Without one, a live pid is still running and a dead pid
106
+ # died without finishing.
90
107
  def classify(run)
91
- content = File.exist?(run.run_log_path.to_s) ? File.read(run.run_log_path.to_s) : ""
92
- events = content.lines.filter_map { |l| JSON.parse(l.chomp) rescue nil }
93
- terminal = events.find { |e| e["type"] == "result" }
108
+ events = read_events(run.run_log_path)
109
+ reason = PiEvents.stop_reason(events)
94
110
 
95
- return :complete if terminal && !terminal["is_error"]
96
- return :failed if terminal && terminal["is_error"]
111
+ return :complete if reason == "stop"
112
+ return :failed if PiEvents.failed_stop_reason?(reason)
97
113
 
98
114
  pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
99
115
  pid_alive ? :running : :failed
100
116
  end
101
117
 
118
+ def read_events(path)
119
+ return [] unless File.exist?(path.to_s)
120
+ File.readlines(path.to_s).filter_map { |l| JSON.parse(l.chomp) rescue nil }
121
+ end
122
+
102
123
  def tail_lines(path, n)
103
124
  return [] unless File.exist?(path.to_s)
104
125
  File.readlines(path.to_s).last(n).map(&:chomp)
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Space::Architect
4
- module Research
5
- READONLY_TOOLS = "Read,Grep,Glob,WebSearch,WebFetch"
6
- end
7
- end
8
-
3
+ require_relative "research/pi_events"
9
4
  require_relative "research/run"
10
5
  require_relative "research/registry"
11
6
  require_relative "research/renderer"
@@ -5,7 +5,6 @@ require "space_src"
5
5
 
6
6
  require_relative "space_architect/harness"
7
7
  require_relative "space_architect/run_creator"
8
- require_relative "space_architect/dispatcher"
9
8
  require_relative "space_architect/gate_lint"
10
9
  require_relative "space_architect/gate_evaluator"
11
10
  require_relative "space_architect/architect_project"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "7.1.0"
5
+ VERSION = "8.0.0"
6
6
  end
7
7
  end
@@ -5,7 +5,7 @@ description: >
5
5
  — judgment only: arbitration, judging raw evidence against frozen Acceptance
6
6
  Criteria, splitting iterations into disjoint lanes, kill/continue calls. The
7
7
  BUILDERS are 1-4 parallel cheaper agents run headless (reference harness:
8
- `claude -p`), each in its own git worktree; the architect reviews, merges, and
8
+ `pi -p`), each in its own git worktree; the architect reviews, merges, and
9
9
  integrates their work.
10
10
  The space is the memory: one file per iteration at
11
11
  architecture/I<NN>-<name>.md (Grounds / Specification / Acceptance Criteria /
@@ -19,7 +19,7 @@ description: >
19
19
 
20
20
  You are the ARCHITECT — the judgment role: a strong reasoning model (or a human),
21
21
  run interactively. The BUILDER is a cheaper model run headless (reference
22
- harness: `claude -p`), one or more per iteration — the models filling both roles
22
+ harness: `pi -p`), one or more per iteration — the models filling both roles
23
23
  are an operator choice (see `docs/DESIGN.md` §1–§2), not fixed. The space is the
24
24
  memory — project artifacts live in the space's `architecture/` dir (committed),
25
25
  scratch in `build/` (gitignored); the project spans the repos under `repos/`.
@@ -116,12 +116,13 @@ loop.
116
116
  6. **Audit every status claim** — yours and the builder's — against a tool
117
117
  result from the session before reporting it.
118
118
  7. **Fresh builder context per lane, worktree isolation between lanes.**
119
- `claude -p --continue` (from the lane's worktree) only for follow-ups within
120
- the current lane. Builders never commit — Claude Code has no sandbox to
121
- enforce that, so verify it yourself post-flight (`git -C <worktree> log
122
- <repo-base>..` must be empty). If a run leaves a worktree broken or
123
- committed, discard that lane + re-dispatch over rescue prompting — lanes are
124
- cheap by construction.
119
+ `pi -p --continue --session-dir <space>/build/<id>-<lane>` (from the lane's
120
+ worktree) only for follow-ups within
121
+ the current lane. Builders never commit — the injected builder guard denies
122
+ git writes at dispatch, so verify it yourself post-flight (`git -C
123
+ <worktree> log <repo-base>..` must be empty). If a run leaves a worktree
124
+ broken or committed, discard that lane + re-dispatch over rescue prompting —
125
+ lanes are cheap by construction.
125
126
  8. **Stop conditions:** failing verification you can't root-cause, instructions
126
127
  conflicting with project docs, irreversible/destructive calls, or scope
127
128
  growth beyond the iteration → checkpoint to the handoff and ask the human.
@@ -134,8 +135,8 @@ loop.
134
135
  - Read the project's operating docs in authority order: `CLAUDE.md` /
135
136
  `AGENTS.md` → `README.md` → architecture docs. Learn the exact verification
136
137
  gate (test/lint/typecheck/build commands) from docs or CI config.
137
- - Once per environment: `claude --version` and confirm the builder model
138
- resolves (`echo ok | claude -p --model <builder-model> --max-turns 1`;
138
+ - Once per environment: `pi --version` and confirm the builder model
139
+ resolves (`echo ok | pi -p --model <builder-model>`;
139
140
  details in `dispatch.md`). Past that one-time check, every foreground dispatch
140
141
  self-verifies — it prints a liveness line naming the streamed model and
141
142
  confirming the run log is growing (a WARN line instead if the streamed model
@@ -208,7 +209,7 @@ the builder's work — a capability-gap read whose independence depends on the
208
209
  pairing: a same-lab architect/builder shares the builder's blind spots (so the
209
210
  frozen gates stay the independent check), while a cross-vendor pairing is more
210
211
  independent (see `docs/DESIGN.md` §1/R3). For an extra adversarial pass, pipe the
211
- diff to a fresh read-only `claude -p` reviewer (command in `dispatch.md`) or a
212
+ diff to a fresh read-only `pi -p` reviewer (command in `dispatch.md`) or a
212
213
  fresh-context subagent prompted to break confidence — calibrated to flag only
213
214
  correctness/requirement/invariant gaps with file:line evidence, no style.
214
215
 
@@ -243,8 +244,9 @@ Two scales, two routes:
243
244
  researching well-understood iterations is pure cost.
244
245
 
245
246
  When a trigger fires, read `research.md` next to this file and follow it:
246
- 3–5 narrow non-overlapping questions → parallel read-only `claude -p`
247
- researchers (built-in `WebSearch`/`WebFetch`) in the background → you
247
+ 3–5 narrow non-overlapping questions → parallel detached `pi -p --mode json`
248
+ researchers (read-only by prompt contract; web via pi's loaded web tools) in
249
+ the background → you
248
250
  adversarially verify the load-bearing claims → you write the iteration's
249
251
  **Grounds** section with citations and commit it. Researchers gather; you judge
250
252
  and write Grounds. Findings without a source URL don't enter Grounds.
@@ -277,9 +279,7 @@ contract, self-contained:
277
279
  its **target repo + file-touch set, checked for overlap**: name the repo
278
280
  (`repos/<repo>`) and every file each lane may touch. The machine-readable
279
281
  declaration lives in a fenced ` ```lanes ` block in the Specification — one
280
- entry per lane (`name`, `repo`, `touch` globs; optionally `allowed_tools`/
281
- `append_allowed_tools` to replace/extend the lane's builder tool grant — see
282
- `dispatch.md`) — the single frozen source of
282
+ entry per lane (`name`, `repo`, `touch` globs) — the single frozen source of
283
283
  truth `architect freeze` records into `space.yaml` and `architect provision`
284
284
  materializes. The same boundary is stated twice more — the lane-prompt's
285
285
  may-touch list, and the scope gate that checks the lane's diff at judge
@@ -310,8 +310,11 @@ contract, self-contained:
310
310
  pending status, and a later session in the same worktree audits it once it
311
311
  exits — see `### Long-running sweep` in `dispatch.md`).
312
312
  - **Effort call** — thinking budget set per dispatch with `architect dispatch
313
- --effort <level>`, translated and clamped to the lane's harness (the
314
- escalation keywords `think hard` … `ultrathink` still work in-prompt);
313
+ --effort <level>`, normalized to the canonical
314
+ `off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max` set and passed straight
315
+ through to pi's `--thinking` (each model's `thinkingLevelMap` in
316
+ `~/.pi/agent/models.json` clamps it further per model); the escalation
317
+ keywords `think hard` … `ultrathink` still work in-prompt;
315
318
  default unattended builder work high, downgrade a routine, tightly-specified
316
319
  lane (record which and why). Levels and mechanics: `dispatch.md`.
317
320
 
@@ -404,15 +407,14 @@ dispatch. You needn't sequence Grounds and Specification into separate commits
404
407
  first — the freeze snapshots the whole frozen region and refuses to re-freeze
405
408
  once a frozen section changed afterward.
406
409
 
407
- ### 5. Dispatch (one fresh `claude -p` per lane, worktree-isolated)
410
+ ### 5. Dispatch (one fresh `pi -p` per lane, worktree-isolated)
408
411
 
409
412
  Per the mechanics in `dispatch.md`. The lane lifecycle is **declare → rehearse
410
413
  → freeze → provision → write prompts → dispatch** — every lane gets a worktree;
411
414
  there is no dispatch-in-the-checkout path:
412
415
 
413
416
  - **Declare** — at spec time, each lane is one entry in the Specification's
414
- fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs, and optionally
415
- `allowed_tools`/`append_allowed_tools`.
417
+ fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs.
416
418
  - **Rehearse** — `architect rehearse <iteration>` dry-runs the drafted gates
417
419
  from the working tree (§4's pre-freeze check), resolving its run dir from the
418
420
  drafted ` ```lanes ` block, and stamps `space.yaml`; `freeze` refuses without
@@ -440,7 +442,9 @@ with `architect section <iteration> prompt --append --lane <lane> --from
440
442
  <that scratch file>`. Then run `architect dispatch <iteration> <lane> --prompt
441
443
  <that scratch file>` — it copies the prompt to the canonical
442
444
  `build/<id>-<lane>/prompt.md` (fed to the builder on stdin), assembles the
443
- canonical `claude -p` argv, pins the model, and streams stream-json to
445
+ canonical `pi -p --mode json` argv, pins the model, copies the vendored
446
+ builder-guard into the lane's build dir and injects it with `-e`, and streams
447
+ pi's JSONL event stream to
444
448
  `build/<id>-<lane>/run.jsonl`. Launch one dispatch per worktree — each as its
445
449
  **own background Bash tool call** (your harness's `run_in_background`), **not**
446
450
  shell `&`. The harness keeps each lane alive for its full run and notifies you
@@ -452,7 +456,7 @@ stay untouchable.
452
456
 
453
457
  Do not block — end the turn or do other judgment work; long runs (30–60 minutes) are
454
458
  normal. Print the lane-prompts too, so the human can run any lane in an
455
- interactive `claude` session instead. Whenever you return to a running lane,
459
+ interactive `pi` session instead. Whenever you return to a running lane,
456
460
  check liveness: the lane's `run.jsonl` must still be growing. If it has been
457
461
  silent 15+ minutes on one in-flight command, follow "Stall detection and
458
462
  rescue" in `dispatch.md` — kill the stuck child process, not the run.