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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +248 -155
  4. data/exe/architect +1 -1
  5. data/exe/space +2 -2
  6. data/exe/src +13 -0
  7. data/lib/space_architect/architect_mission.rb +84 -53
  8. data/lib/space_architect/cli/architect.rb +92 -132
  9. data/lib/space_architect/cli/research.rb +94 -0
  10. data/lib/space_architect/cli/space.rb +25 -31
  11. data/lib/space_architect/cli/src.rb +20 -14
  12. data/lib/space_architect/cli.rb +22 -22
  13. data/lib/space_architect/dispatcher.rb +5 -1
  14. data/lib/space_architect/harness.rb +123 -16
  15. data/lib/space_architect/research/mux.rb +127 -0
  16. data/lib/space_architect/research/registry.rb +70 -0
  17. data/lib/space_architect/research/renderer.rb +101 -0
  18. data/lib/space_architect/research/run.rb +7 -0
  19. data/lib/space_architect/research/supervisor.rb +108 -0
  20. data/lib/space_architect/research.rb +13 -0
  21. data/lib/space_architect/run_creator.rb +53 -0
  22. data/lib/space_architect/skill_installer.rb +81 -79
  23. data/lib/space_architect.rb +5 -20
  24. data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
  25. data/lib/space_core/cli/base_command.rb +19 -0
  26. data/lib/space_core/cli/config.rb +49 -0
  27. data/lib/space_core/cli/current.rb +16 -0
  28. data/lib/space_core/cli/help.rb +110 -0
  29. data/lib/space_core/cli/helpers.rb +115 -0
  30. data/lib/space_core/cli/init.rb +29 -0
  31. data/lib/space_core/cli/list.rb +24 -0
  32. data/lib/space_core/cli/new.rb +38 -0
  33. data/lib/space_core/cli/path.rb +16 -0
  34. data/lib/space_core/cli/repeatable_options.rb +75 -0
  35. data/lib/space_core/cli/repo.rb +76 -0
  36. data/lib/space_core/cli/shell.rb +125 -0
  37. data/lib/space_core/cli/show.rb +21 -0
  38. data/lib/space_core/cli/status.rb +33 -0
  39. data/lib/space_core/cli/use.rb +17 -0
  40. data/lib/space_core/cli.rb +171 -0
  41. data/lib/{space_architect → space_core}/config.rb +1 -1
  42. data/lib/{space_architect → space_core}/errors.rb +1 -1
  43. data/lib/{space_architect → space_core}/git_client.rb +1 -1
  44. data/lib/{space_architect → space_core}/mise_client.rb +1 -1
  45. data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
  46. data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
  47. data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
  48. data/lib/{space_architect → space_core}/slugger.rb +1 -1
  49. data/lib/{space_architect → space_core}/space.rb +1 -1
  50. data/lib/{space_architect → space_core}/space_store.rb +12 -12
  51. data/lib/{space_architect → space_core}/state.rb +1 -1
  52. data/lib/{space_architect → space_core}/terminal.rb +1 -1
  53. data/lib/space_core/version.rb +7 -0
  54. data/lib/{space_architect → space_core}/warnings.rb +1 -1
  55. data/lib/{space_architect → space_core}/xdg.rb +1 -1
  56. data/lib/space_core.rb +24 -0
  57. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
  58. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
  59. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
  60. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
  61. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
  62. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
  63. data/lib/space_src/cli/shell.rb +122 -0
  64. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
  65. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
  66. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
  67. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
  68. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
  69. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
  70. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
  71. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
  72. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
  73. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
  74. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
  75. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
  76. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
  77. data/lib/space_src/migration.rb +43 -0
  78. data/lib/space_src/nav.rb +98 -0
  79. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
  80. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
  81. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
  82. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
  83. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
  84. data/lib/space_src/shell_integration.rb +321 -0
  85. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
  86. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
  87. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
  88. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
  89. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
  90. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
  91. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
  92. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
  93. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
  94. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
  95. data/lib/space_src.rb +37 -0
  96. data/skill/architect/SKILL.md +2 -2
  97. data/skill/architect/research.md +46 -37
  98. metadata +115 -67
  99. data/lib/space_architect/cli/config.rb +0 -61
  100. data/lib/space_architect/cli/current.rb +0 -22
  101. data/lib/space_architect/cli/helpers.rb +0 -117
  102. data/lib/space_architect/cli/init.rb +0 -35
  103. data/lib/space_architect/cli/list.rb +0 -30
  104. data/lib/space_architect/cli/new.rb +0 -43
  105. data/lib/space_architect/cli/options.rb +0 -12
  106. data/lib/space_architect/cli/path.rb +0 -22
  107. data/lib/space_architect/cli/repo.rb +0 -88
  108. data/lib/space_architect/cli/shell.rb +0 -137
  109. data/lib/space_architect/cli/show.rb +0 -27
  110. data/lib/space_architect/cli/status.rb +0 -39
  111. data/lib/space_architect/cli/use.rb +0 -23
  112. data/lib/space_architect/version.rb +0 -5
  113. 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 SpaceArchitect
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 SpaceArchitect
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
- @model = model
46
- @max_turns = max_turns
47
- @bin = bin || ENV.fetch("ARCHITECT_CLAUDE_BIN", "claude")
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
- status = Sync do
57
- Async::Process.spawn(*argv, chdir: chdir.to_s, in: prompt_io, out: log, err: log)
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", ALLOWED_TOOLS,
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Space::Architect
4
+ module Research
5
+ Run = Data.define(:id, :topic, :pid, :dir, :prompt_path, :run_log_path, :report_path, :model, :dispatched_at)
6
+ end
7
+ 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"