space-architect 1.3.0 → 2.0.0.rc1
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 +103 -0
- data/README.md +248 -155
- data/exe/architect +1 -1
- data/exe/space +2 -2
- data/exe/src +13 -0
- data/lib/space_architect/architect_mission.rb +84 -53
- data/lib/space_architect/cli/architect.rb +92 -132
- data/lib/space_architect/cli/research.rb +94 -0
- data/lib/space_architect/cli/space.rb +25 -31
- data/lib/space_architect/cli/src.rb +20 -14
- data/lib/space_architect/cli.rb +22 -22
- data/lib/space_architect/dispatcher.rb +5 -1
- data/lib/space_architect/harness.rb +123 -16
- data/lib/space_architect/research/mux.rb +127 -0
- data/lib/space_architect/research/registry.rb +70 -0
- data/lib/space_architect/research/renderer.rb +101 -0
- data/lib/space_architect/research/run.rb +7 -0
- data/lib/space_architect/research/supervisor.rb +108 -0
- data/lib/space_architect/research.rb +13 -0
- data/lib/space_architect/run_creator.rb +53 -0
- data/lib/space_architect/skill_installer.rb +81 -79
- data/lib/space_architect.rb +5 -20
- data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
- data/lib/space_core/cli/base_command.rb +19 -0
- data/lib/space_core/cli/config.rb +49 -0
- data/lib/space_core/cli/current.rb +16 -0
- data/lib/space_core/cli/help.rb +110 -0
- data/lib/space_core/cli/helpers.rb +115 -0
- data/lib/space_core/cli/init.rb +29 -0
- data/lib/space_core/cli/list.rb +24 -0
- data/lib/space_core/cli/new.rb +38 -0
- data/lib/space_core/cli/path.rb +16 -0
- data/lib/space_core/cli/repeatable_options.rb +75 -0
- data/lib/space_core/cli/repo.rb +76 -0
- data/lib/space_core/cli/shell.rb +125 -0
- data/lib/space_core/cli/show.rb +21 -0
- data/lib/space_core/cli/status.rb +33 -0
- data/lib/space_core/cli/use.rb +17 -0
- data/lib/space_core/cli.rb +171 -0
- data/lib/{space_architect → space_core}/config.rb +1 -1
- data/lib/{space_architect → space_core}/errors.rb +1 -1
- data/lib/{space_architect → space_core}/git_client.rb +1 -1
- data/lib/{space_architect → space_core}/mise_client.rb +1 -1
- data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
- data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
- data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
- data/lib/{space_architect → space_core}/slugger.rb +1 -1
- data/lib/{space_architect → space_core}/space.rb +1 -1
- data/lib/{space_architect → space_core}/space_store.rb +12 -12
- data/lib/{space_architect → space_core}/state.rb +1 -1
- data/lib/{space_architect → space_core}/terminal.rb +1 -1
- data/lib/space_core/version.rb +7 -0
- data/lib/{space_architect → space_core}/warnings.rb +1 -1
- data/lib/{space_architect → space_core}/xdg.rb +1 -1
- data/lib/space_core.rb +24 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
- data/lib/space_src/cli/shell.rb +122 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
- data/lib/space_src/migration.rb +43 -0
- data/lib/space_src/nav.rb +98 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
- data/lib/space_src/shell_integration.rb +321 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
- data/lib/space_src.rb +37 -0
- data/skill/architect/SKILL.md +2 -2
- data/skill/architect/research.md +46 -37
- metadata +115 -67
- data/lib/space_architect/cli/config.rb +0 -61
- data/lib/space_architect/cli/current.rb +0 -22
- data/lib/space_architect/cli/helpers.rb +0 -117
- data/lib/space_architect/cli/init.rb +0 -35
- data/lib/space_architect/cli/list.rb +0 -30
- data/lib/space_architect/cli/new.rb +0 -43
- data/lib/space_architect/cli/options.rb +0 -12
- data/lib/space_architect/cli/path.rb +0 -22
- data/lib/space_architect/cli/repo.rb +0 -88
- data/lib/space_architect/cli/shell.rb +0 -137
- data/lib/space_architect/cli/show.rb +0 -27
- data/lib/space_architect/cli/status.rb +0 -39
- data/lib/space_architect/cli/use.rb +0 -23
- data/lib/space_architect/version.rb +0 -5
- data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -44
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "harness"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Space::Architect
|
|
6
6
|
# Thin backward-compat wrapper around Harness::ClaudeCodeHarness.
|
|
7
7
|
# Existing callers that construct Dispatcher.new(...).run(...) continue to work byte-for-byte.
|
|
8
8
|
class Dispatcher
|
|
@@ -17,5 +17,9 @@ module SpaceArchitect
|
|
|
17
17
|
def run(prompt_path:, run_log_path:, chdir:)
|
|
18
18
|
@harness.run(prompt_path: prompt_path, run_log_path: run_log_path, chdir: chdir)
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
def run_detached(prompt_path:, run_log_path:, chdir:)
|
|
22
|
+
@harness.run_detached(prompt_path: prompt_path, run_log_path: run_log_path, chdir: chdir)
|
|
23
|
+
end
|
|
20
24
|
end
|
|
21
25
|
end
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "async/process"
|
|
4
|
+
require "async/http/client"
|
|
5
|
+
require "async/http/endpoint"
|
|
6
|
+
require "protocol/http/body/writable"
|
|
4
7
|
require "json"
|
|
5
8
|
require "pathname"
|
|
9
|
+
require "uri"
|
|
6
10
|
|
|
7
|
-
module
|
|
11
|
+
module Space::Architect
|
|
8
12
|
module Harness
|
|
9
13
|
CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
10
14
|
|
|
@@ -14,22 +18,22 @@ module SpaceArchitect
|
|
|
14
18
|
case name.to_s
|
|
15
19
|
when "claude-code"
|
|
16
20
|
if effort
|
|
17
|
-
raise Error,
|
|
21
|
+
raise Space::Core::Error,
|
|
18
22
|
"effort is opencode-only (sets opencode reasoningEffort) — " \
|
|
19
23
|
"claude-code effort is set via the prompt"
|
|
20
24
|
end
|
|
21
25
|
ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin)
|
|
22
26
|
when "opencode"
|
|
23
27
|
if model == CLAUDE_DEFAULT_MODEL
|
|
24
|
-
raise Error,
|
|
28
|
+
raise Space::Core::Error,
|
|
25
29
|
"Pass --model when using --harness opencode (the claude-sonnet-4-6 default " \
|
|
26
30
|
"is a Claude model ID and will not work with opencode — " \
|
|
27
31
|
"try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
|
|
28
32
|
end
|
|
29
|
-
raise Error, "config_dir is required for opencode harness" unless config_dir
|
|
33
|
+
raise Space::Core::Error, "config_dir is required for opencode harness" unless config_dir
|
|
30
34
|
OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: effort)
|
|
31
35
|
else
|
|
32
|
-
raise Error, "Unknown harness '#{name}' — valid values: claude-code, opencode"
|
|
36
|
+
raise Space::Core::Error, "Unknown harness '#{name}' — valid values: claude-code, opencode"
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -41,39 +45,119 @@ module SpaceArchitect
|
|
|
41
45
|
"Bash(git branch:*)"
|
|
42
46
|
].join(",")
|
|
43
47
|
|
|
44
|
-
def initialize(model:, max_turns:, bin: nil
|
|
45
|
-
|
|
46
|
-
@
|
|
47
|
-
@
|
|
48
|
+
def initialize(model:, max_turns:, bin: nil,
|
|
49
|
+
allowed_tools: ALLOWED_TOOLS, disallowed_tools: DISALLOWED_TOOLS)
|
|
50
|
+
@model = model
|
|
51
|
+
@max_turns = max_turns
|
|
52
|
+
@bin = bin || ENV.fetch("ARCHITECT_CLAUDE_BIN", "claude")
|
|
53
|
+
@allowed_tools = allowed_tools
|
|
54
|
+
@disallowed_tools = disallowed_tools
|
|
48
55
|
end
|
|
49
56
|
|
|
50
|
-
def run(prompt_path:, run_log_path:, chdir:)
|
|
57
|
+
def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil)
|
|
51
58
|
prompt_path = Pathname.new(prompt_path)
|
|
52
59
|
run_log_path = Pathname.new(run_log_path)
|
|
53
60
|
|
|
54
61
|
File.open(prompt_path, "r") do |prompt_io|
|
|
55
62
|
File.open(run_log_path, "w") do |log|
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
r, w = IO.pipe
|
|
64
|
+
Sync do
|
|
65
|
+
child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: w, err: log)
|
|
66
|
+
w.close
|
|
67
|
+
tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client)
|
|
68
|
+
status = child.wait
|
|
69
|
+
tasks.each(&:wait)
|
|
70
|
+
status.exitstatus
|
|
58
71
|
end
|
|
59
|
-
status.exitstatus
|
|
60
72
|
end
|
|
61
73
|
end
|
|
62
74
|
end
|
|
63
75
|
|
|
76
|
+
def run_detached(prompt_path:, run_log_path:, chdir:)
|
|
77
|
+
prompt_path = Pathname.new(prompt_path)
|
|
78
|
+
run_log_path = Pathname.new(run_log_path)
|
|
79
|
+
|
|
80
|
+
prompt_io = File.open(prompt_path, "r")
|
|
81
|
+
log = File.open(run_log_path, "w")
|
|
82
|
+
begin
|
|
83
|
+
pid = Process.spawn(*argv, chdir: chdir.to_s, pgroup: true,
|
|
84
|
+
in: prompt_io, out: log, err: log)
|
|
85
|
+
Process.detach(pid)
|
|
86
|
+
ensure
|
|
87
|
+
prompt_io.close
|
|
88
|
+
log.close
|
|
89
|
+
end
|
|
90
|
+
pid
|
|
91
|
+
end
|
|
92
|
+
|
|
64
93
|
private
|
|
65
94
|
|
|
66
95
|
def argv
|
|
67
|
-
[
|
|
96
|
+
args = [
|
|
68
97
|
@bin, "-p",
|
|
69
98
|
"--model", @model,
|
|
70
99
|
"--permission-mode", "acceptEdits",
|
|
71
|
-
"--allowedTools",
|
|
72
|
-
"--disallowedTools", DISALLOWED_TOOLS,
|
|
100
|
+
"--allowedTools", @allowed_tools,
|
|
73
101
|
"--output-format", "stream-json",
|
|
74
102
|
"--verbose",
|
|
103
|
+
"--include-partial-messages",
|
|
75
104
|
"--max-turns", @max_turns.to_s
|
|
76
105
|
]
|
|
106
|
+
args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
|
|
107
|
+
args
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def start_tee(r, log, push_url:, push_token:, push_client:)
|
|
111
|
+
if push_url || push_client
|
|
112
|
+
body = Protocol::HTTP::Body::Writable.new(queue: Thread::SizedQueue.new(32))
|
|
113
|
+
push = Async { push_body(body, push_url: push_url, push_token: push_token, push_client: push_client) }
|
|
114
|
+
[Async { tee_pipe(r, log, body) }, push]
|
|
115
|
+
else
|
|
116
|
+
[Async { drain_pipe(r, log) }]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def push_body(body, push_url:, push_token:, push_client:)
|
|
121
|
+
path = push_url ? URI.parse(push_url).path : "/"
|
|
122
|
+
headers = [["content-type", "application/x-ndjson"]]
|
|
123
|
+
headers << ["authorization", "Bearer #{push_token}"] if push_token
|
|
124
|
+
if push_client
|
|
125
|
+
push_client.post(path, headers: headers, body: body).discard
|
|
126
|
+
else
|
|
127
|
+
Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(push_url)) do |c|
|
|
128
|
+
c.post(path, headers: headers, body: body).discard
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
$stderr.puts "push_body: transport error (best-effort, run log intact): #{e.class}: #{e.message}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def tee_pipe(r, log, body)
|
|
136
|
+
pushing = true
|
|
137
|
+
while (chunk = r.gets)
|
|
138
|
+
log.write(chunk)
|
|
139
|
+
log.flush
|
|
140
|
+
if pushing
|
|
141
|
+
begin
|
|
142
|
+
body.write(chunk)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
$stderr.puts "tee_pipe: push write failed (best-effort, continuing log): #{e.class}: #{e.message}"
|
|
145
|
+
pushing = false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
ensure
|
|
150
|
+
body.close_write
|
|
151
|
+
r.close
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def drain_pipe(r, log)
|
|
155
|
+
while (chunk = r.gets)
|
|
156
|
+
log.write(chunk)
|
|
157
|
+
log.flush
|
|
158
|
+
end
|
|
159
|
+
ensure
|
|
160
|
+
r.close
|
|
77
161
|
end
|
|
78
162
|
end
|
|
79
163
|
|
|
@@ -130,6 +214,29 @@ module SpaceArchitect
|
|
|
130
214
|
end
|
|
131
215
|
end
|
|
132
216
|
|
|
217
|
+
def run_detached(prompt_path:, run_log_path:, chdir:)
|
|
218
|
+
prompt_path = Pathname.new(prompt_path)
|
|
219
|
+
run_log_path = Pathname.new(run_log_path)
|
|
220
|
+
config_path = write_config
|
|
221
|
+
|
|
222
|
+
env = {
|
|
223
|
+
"OPENCODE_CONFIG" => config_path.to_s,
|
|
224
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG" => "1"
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
prompt_io = File.open(prompt_path, "r")
|
|
228
|
+
log = File.open(run_log_path, "w")
|
|
229
|
+
begin
|
|
230
|
+
pid = Process.spawn(env, *argv(chdir), chdir: chdir.to_s, pgroup: true,
|
|
231
|
+
in: prompt_io, out: log, err: log)
|
|
232
|
+
Process.detach(pid)
|
|
233
|
+
ensure
|
|
234
|
+
prompt_io.close
|
|
235
|
+
log.close
|
|
236
|
+
end
|
|
237
|
+
pid
|
|
238
|
+
end
|
|
239
|
+
|
|
133
240
|
private
|
|
134
241
|
|
|
135
242
|
def reasoning_provider_config
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Space::Architect
|
|
6
|
+
module Research
|
|
7
|
+
# Async multiplexer: one fiber per in-flight run, each tailing its run.jsonl.
|
|
8
|
+
# Uses socketry/async fibers — NEVER threads.
|
|
9
|
+
class Mux
|
|
10
|
+
POLL_INTERVAL = 0.15 # seconds between read attempts
|
|
11
|
+
HEARTBEAT_EVERY = 30 # seconds of silence before heartbeat
|
|
12
|
+
FILE_WAIT_LIMIT = 10 # seconds to wait for run.jsonl to appear
|
|
13
|
+
|
|
14
|
+
def initialize(runs, renderer:, out: $stdout)
|
|
15
|
+
@runs = runs
|
|
16
|
+
@renderer = renderer
|
|
17
|
+
@out = out
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns :ok or :failed
|
|
21
|
+
def run
|
|
22
|
+
results = Sync do
|
|
23
|
+
tasks = @runs.map do |run|
|
|
24
|
+
Async { tail_run(run) }
|
|
25
|
+
end
|
|
26
|
+
tasks.map(&:wait)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
results.all? { |r| r == :ok } ? :ok : :failed
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def tail_run(run)
|
|
35
|
+
wait_for_file(run)
|
|
36
|
+
|
|
37
|
+
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))
|
|
39
|
+
return :failed
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
emit(@renderer.render(lane: run.id, events: [], alive: true))
|
|
43
|
+
|
|
44
|
+
events_all = []
|
|
45
|
+
last_emit = Time.now
|
|
46
|
+
terminal = nil
|
|
47
|
+
|
|
48
|
+
File.open(run.run_log_path.to_s, "r") do |f|
|
|
49
|
+
loop do
|
|
50
|
+
line = f.gets
|
|
51
|
+
if line && !line.strip.empty?
|
|
52
|
+
ev = begin; JSON.parse(line.chomp); rescue JSON::ParserError; nil; end
|
|
53
|
+
next unless ev
|
|
54
|
+
|
|
55
|
+
events_all << ev
|
|
56
|
+
terminal = ev if ev["type"] == "result"
|
|
57
|
+
|
|
58
|
+
new_events = [ev]
|
|
59
|
+
rendered = @renderer.render(lane: run.id, events: new_events, alive: terminal.nil?)
|
|
60
|
+
emit(rendered) unless rendered.empty?
|
|
61
|
+
last_emit = Time.now
|
|
62
|
+
|
|
63
|
+
break if terminal
|
|
64
|
+
else
|
|
65
|
+
# EOF — check liveness
|
|
66
|
+
pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
|
|
67
|
+
|
|
68
|
+
unless pid_alive
|
|
69
|
+
# 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))
|
|
74
|
+
return :failed
|
|
75
|
+
end
|
|
76
|
+
break
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if Time.now - last_emit > HEARTBEAT_EVERY
|
|
80
|
+
emit("[#{run.id}] ⏳ still running…\n") if @renderer.lifecycle?
|
|
81
|
+
last_emit = Time.now
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sleep POLL_INTERVAL
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
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
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def wait_for_file(run)
|
|
100
|
+
deadline = Time.now + FILE_WAIT_LIMIT
|
|
101
|
+
until File.exist?(run.run_log_path.to_s) || Time.now > deadline
|
|
102
|
+
pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
|
|
103
|
+
break unless pid_alive
|
|
104
|
+
sleep 0.05
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
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 }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def emit(text)
|
|
121
|
+
return if text.nil? || text.empty?
|
|
122
|
+
@out.print(text)
|
|
123
|
+
@out.flush if @out.respond_to?(:flush)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Space::Architect
|
|
7
|
+
module Research
|
|
8
|
+
class Registry
|
|
9
|
+
def initialize(path)
|
|
10
|
+
@path = Pathname.new(path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add(run)
|
|
14
|
+
runs = load_runs
|
|
15
|
+
runs.reject! { |r| r["id"] == run.id }
|
|
16
|
+
runs << serialize(run)
|
|
17
|
+
write(runs)
|
|
18
|
+
run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all
|
|
22
|
+
load_runs.map { |h| deserialize(h) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find(id)
|
|
26
|
+
load_runs.find { |h| h["id"] == id }.then { |h| h ? deserialize(h) : nil }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def load_runs
|
|
32
|
+
return [] unless @path.exist?
|
|
33
|
+
YAML.safe_load(@path.read, aliases: false) || []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write(runs)
|
|
37
|
+
@path.parent.mkpath
|
|
38
|
+
@path.write(YAML.dump(runs))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def serialize(run)
|
|
42
|
+
{
|
|
43
|
+
"id" => run.id,
|
|
44
|
+
"topic" => run.topic,
|
|
45
|
+
"pid" => run.pid,
|
|
46
|
+
"dir" => run.dir.to_s,
|
|
47
|
+
"prompt_path" => run.prompt_path.to_s,
|
|
48
|
+
"run_log_path" => run.run_log_path.to_s,
|
|
49
|
+
"report_path" => run.report_path.to_s,
|
|
50
|
+
"model" => run.model,
|
|
51
|
+
"dispatched_at" => run.dispatched_at.iso8601
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def deserialize(h)
|
|
56
|
+
Run.new(
|
|
57
|
+
id: h["id"],
|
|
58
|
+
topic: h["topic"],
|
|
59
|
+
pid: h["pid"].to_i,
|
|
60
|
+
dir: Pathname.new(h["dir"]),
|
|
61
|
+
prompt_path: Pathname.new(h["prompt_path"]),
|
|
62
|
+
run_log_path: Pathname.new(h["run_log_path"]),
|
|
63
|
+
report_path: Pathname.new(h["report_path"]),
|
|
64
|
+
model: h["model"],
|
|
65
|
+
dispatched_at: Time.parse(h["dispatched_at"].to_s)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Architect
|
|
4
|
+
module Research
|
|
5
|
+
# Pure, testable verbosity-gated renderer for stream-json events.
|
|
6
|
+
#
|
|
7
|
+
# Levels (§5.3 ladder):
|
|
8
|
+
# 0 (quiet) — nothing
|
|
9
|
+
# 1 (default) — lifecycle + terminal line only
|
|
10
|
+
# 2 (-v) — + assistant text
|
|
11
|
+
# 3 (-vv) — + tool-call names
|
|
12
|
+
# 4 (-vvv) — + tool-call inputs and results
|
|
13
|
+
#
|
|
14
|
+
# --thinking: + assistant thinking blocks (any level > 0)
|
|
15
|
+
# --jsonl: emit raw lane-tagged jsonl instead of human text
|
|
16
|
+
class Renderer
|
|
17
|
+
def initialize(level:, thinking: false, jsonl: false)
|
|
18
|
+
@level = level
|
|
19
|
+
@thinking = thinking
|
|
20
|
+
@jsonl = jsonl
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Render a batch of events for a lane.
|
|
24
|
+
# alive: true → lane still in flight (lifecycle prefix)
|
|
25
|
+
# alive: false → lane finished (terminal line included)
|
|
26
|
+
# Returns a String (may be empty).
|
|
27
|
+
def render(lane:, events:, alive:)
|
|
28
|
+
return "" if @level == 0 && !@jsonl
|
|
29
|
+
|
|
30
|
+
if @jsonl
|
|
31
|
+
return events.map { |ev| "[#{lane}] #{JSON.generate(ev)}" }.join("\n").then { |s| s.empty? ? s : "#{s}\n" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
lines = []
|
|
35
|
+
terminal = nil
|
|
36
|
+
|
|
37
|
+
events.each do |ev|
|
|
38
|
+
case ev["type"]
|
|
39
|
+
when "assistant"
|
|
40
|
+
Array(ev.dig("message", "content")).each do |block|
|
|
41
|
+
case block["type"]
|
|
42
|
+
when "thinking"
|
|
43
|
+
lines << "[#{lane}] #{block['thinking'].to_s.strip}" if @thinking && @level >= 1
|
|
44
|
+
when "text"
|
|
45
|
+
lines << "[#{lane}] #{block['text'].to_s.strip}" if @level >= 2
|
|
46
|
+
when "tool_use"
|
|
47
|
+
if @level >= 3
|
|
48
|
+
name_line = "[#{lane}] tool: #{block['name']}"
|
|
49
|
+
if @level >= 4
|
|
50
|
+
input = block["input"]
|
|
51
|
+
name_line += " #{JSON.generate(input)}" if input && !input.empty?
|
|
52
|
+
end
|
|
53
|
+
lines << name_line
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
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}"
|
|
64
|
+
end
|
|
65
|
+
when "result"
|
|
66
|
+
terminal = ev
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if terminal
|
|
71
|
+
lines << terminal_line(lane, terminal)
|
|
72
|
+
elsif alive && @level >= 1 && events.empty?
|
|
73
|
+
lines << "[#{lane}] running"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
lines.reject(&:empty?).join("\n").then { |s| s.empty? ? s : "#{s}\n" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def lifecycle?
|
|
80
|
+
@level >= 1 && !@jsonl
|
|
81
|
+
end
|
|
82
|
+
|
|
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"
|
|
95
|
+
else
|
|
96
|
+
"[#{lane}] ⚠ nonzero exit"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Space::Architect
|
|
7
|
+
module Research
|
|
8
|
+
class Supervisor
|
|
9
|
+
DEFAULT_MODEL = Harness::CLAUDE_DEFAULT_MODEL
|
|
10
|
+
DEFAULT_MAX_TURNS = 40
|
|
11
|
+
|
|
12
|
+
def initialize(space:, bin: nil)
|
|
13
|
+
@space = space
|
|
14
|
+
@bin = bin
|
|
15
|
+
@registry = Registry.new(space.path.join("build", "research", "registry.yaml"))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Dispatch each prompt file as a detached read-only claude -p child.
|
|
19
|
+
# Returns array of Run objects (non-blocking).
|
|
20
|
+
def dispatch(prompts, model: DEFAULT_MODEL, max_turns: DEFAULT_MAX_TURNS)
|
|
21
|
+
prompts.map { |path| dispatch_one(Pathname.new(path), model: model, max_turns: max_turns) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Classify each registered run and return per-run state hashes.
|
|
25
|
+
def status
|
|
26
|
+
@registry.all.map do |run|
|
|
27
|
+
state = classify(run)
|
|
28
|
+
tail = tail_lines(run.run_log_path, 5)
|
|
29
|
+
{ run: run, state: state, tail: tail }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Async mux: tail all runs to terminal. Returns :ok or :failed.
|
|
34
|
+
def wait(quiet: false, level: 1, thinking: false, jsonl: false, out: $stdout)
|
|
35
|
+
effective_level = quiet ? 0 : level
|
|
36
|
+
renderer = Renderer.new(level: effective_level, thinking: thinking, jsonl: jsonl)
|
|
37
|
+
runs = @registry.all
|
|
38
|
+
return :ok if runs.empty?
|
|
39
|
+
|
|
40
|
+
Mux.new(runs, renderer: renderer, out: out).run
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def dispatch_one(path, model:, max_turns:)
|
|
46
|
+
id = derive_id(path)
|
|
47
|
+
topic = id.sub(/\A\d+-/, "")
|
|
48
|
+
dir = @space.path.join("build", "research", id)
|
|
49
|
+
FileUtils.mkdir_p(dir)
|
|
50
|
+
|
|
51
|
+
prompt_path = dir.join("prompt.md")
|
|
52
|
+
run_log_path = dir.join("run.jsonl")
|
|
53
|
+
report_path = dir.join("report.md")
|
|
54
|
+
|
|
55
|
+
FileUtils.cp(path.to_s, prompt_path.to_s)
|
|
56
|
+
|
|
57
|
+
harness = Harness::ClaudeCodeHarness.new(
|
|
58
|
+
model: model,
|
|
59
|
+
max_turns: max_turns,
|
|
60
|
+
bin: @bin,
|
|
61
|
+
allowed_tools: READONLY_TOOLS,
|
|
62
|
+
disallowed_tools: ""
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
pid = harness.run_detached(
|
|
66
|
+
prompt_path: prompt_path,
|
|
67
|
+
run_log_path: run_log_path,
|
|
68
|
+
chdir: @space.path
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
run = Run.new(
|
|
72
|
+
id: id,
|
|
73
|
+
topic: topic,
|
|
74
|
+
pid: pid,
|
|
75
|
+
dir: dir,
|
|
76
|
+
prompt_path: prompt_path,
|
|
77
|
+
run_log_path: run_log_path,
|
|
78
|
+
report_path: report_path,
|
|
79
|
+
model: model,
|
|
80
|
+
dispatched_at: Time.now
|
|
81
|
+
)
|
|
82
|
+
@registry.add(run)
|
|
83
|
+
run
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def derive_id(path)
|
|
87
|
+
File.basename(path.to_s).sub(/\.prompt\.md\z/, "").sub(/\.md\z/, "")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
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" }
|
|
94
|
+
|
|
95
|
+
return :complete if terminal && !terminal["is_error"]
|
|
96
|
+
return :failed if terminal && terminal["is_error"]
|
|
97
|
+
|
|
98
|
+
pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
|
|
99
|
+
pid_alive ? :running : :failed
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def tail_lines(path, n)
|
|
103
|
+
return [] unless File.exist?(path.to_s)
|
|
104
|
+
File.readlines(path.to_s).last(n).map(&:chomp)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Architect
|
|
4
|
+
module Research
|
|
5
|
+
READONLY_TOOLS = "Read,Grep,Glob,WebSearch,WebFetch"
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "research/run"
|
|
10
|
+
require_relative "research/registry"
|
|
11
|
+
require_relative "research/renderer"
|
|
12
|
+
require_relative "research/mux"
|
|
13
|
+
require_relative "research/supervisor"
|