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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +5 -3
- data/lib/space_architect/architect_project.rb +40 -180
- data/lib/space_architect/cli/architect.rb +49 -96
- 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 +27 -23
- 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
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
17
|
-
@
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
71
|
-
emit(@renderer.
|
|
72
|
-
|
|
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 >
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
109
|
-
|
|
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
|
|
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
|
|
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 "
|
|
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 "
|
|
47
|
+
when "toolCall"
|
|
47
48
|
if @level >= 3
|
|
48
49
|
name_line = "[#{lane}] tool: #{block['name']}"
|
|
49
50
|
if @level >= 4
|
|
50
|
-
|
|
51
|
-
name_line += " #{JSON.generate(
|
|
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 "
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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}]
|
|
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::
|
|
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
|
|
19
|
-
#
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
96
|
-
return :failed if
|
|
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
|
-
|
|
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"
|
data/lib/space_architect.rb
CHANGED
|
@@ -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"
|
data/lib/space_core/version.rb
CHANGED
data/skill/architect/SKILL.md
CHANGED
|
@@ -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
|
-
`
|
|
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: `
|
|
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
|
-
`
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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: `
|
|
138
|
-
resolves (`echo ok |
|
|
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 `
|
|
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
|
|
247
|
-
researchers (
|
|
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
|
|
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>`,
|
|
314
|
-
|
|
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 `
|
|
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
|
|
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 `
|
|
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 `
|
|
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.
|