space-architect 1.3.0 → 2.0.0.rc2

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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +267 -158
  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 → architect_project.rb} +475 -125
  8. data/lib/space_architect/cli/architect.rb +248 -178
  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/gate_evaluator.rb +65 -0
  15. data/lib/space_architect/gate_lint.rb +140 -0
  16. data/lib/space_architect/harness.rb +145 -17
  17. data/lib/space_architect/research/mux.rb +127 -0
  18. data/lib/space_architect/research/registry.rb +70 -0
  19. data/lib/space_architect/research/renderer.rb +101 -0
  20. data/lib/space_architect/research/run.rb +7 -0
  21. data/lib/space_architect/research/supervisor.rb +108 -0
  22. data/lib/space_architect/research.rb +13 -0
  23. data/lib/space_architect/run_creator.rb +53 -0
  24. data/lib/space_architect/skill_installer.rb +81 -79
  25. data/lib/space_architect/templates/architect.md.erb +15 -4
  26. data/lib/space_architect/templates/brief.md.erb +5 -5
  27. data/lib/space_architect/templates/iteration.md.erb +17 -6
  28. data/lib/space_architect.rb +8 -21
  29. data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
  30. data/lib/space_core/cli/base_command.rb +19 -0
  31. data/lib/space_core/cli/build.rb +27 -0
  32. data/lib/space_core/cli/config.rb +49 -0
  33. data/lib/space_core/cli/current.rb +16 -0
  34. data/lib/space_core/cli/help.rb +123 -0
  35. data/lib/space_core/cli/helpers.rb +115 -0
  36. data/lib/space_core/cli/init.rb +29 -0
  37. data/lib/space_core/cli/list.rb +24 -0
  38. data/lib/space_core/cli/new.rb +38 -0
  39. data/lib/space_core/cli/pack.rb +29 -0
  40. data/lib/space_core/cli/path.rb +16 -0
  41. data/lib/space_core/cli/repeatable_options.rb +75 -0
  42. data/lib/space_core/cli/repo.rb +76 -0
  43. data/lib/space_core/cli/run.rb +29 -0
  44. data/lib/space_core/cli/shell.rb +125 -0
  45. data/lib/space_core/cli/show.rb +21 -0
  46. data/lib/space_core/cli/status.rb +33 -0
  47. data/lib/space_core/cli/use.rb +17 -0
  48. data/lib/space_core/cli.rb +177 -0
  49. data/lib/{space_architect → space_core}/config.rb +1 -1
  50. data/lib/{space_architect → space_core}/errors.rb +1 -1
  51. data/lib/{space_architect → space_core}/git_client.rb +1 -1
  52. data/lib/{space_architect → space_core}/mise_client.rb +1 -1
  53. data/lib/space_core/oci_builder.rb +56 -0
  54. data/lib/space_core/oci_packer.rb +99 -0
  55. data/lib/space_core/oci_runner.rb +73 -0
  56. data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
  57. data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
  58. data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
  59. data/lib/{space_architect → space_core}/slugger.rb +1 -1
  60. data/lib/{space_architect → space_core}/space.rb +11 -3
  61. data/lib/{space_architect → space_core}/space_store.rb +13 -13
  62. data/lib/{space_architect → space_core}/state.rb +1 -1
  63. data/lib/space_core/templates/oci/dockerfile.erb +63 -0
  64. data/lib/space_core/templates/oci/dockerignore.erb +17 -0
  65. data/lib/space_core/templates/oci/entrypoint.sh.erb +10 -0
  66. data/lib/{space_architect → space_core}/terminal.rb +1 -1
  67. data/lib/space_core/version.rb +7 -0
  68. data/lib/{space_architect → space_core}/warnings.rb +1 -1
  69. data/lib/{space_architect → space_core}/xdg.rb +1 -1
  70. data/lib/space_core.rb +24 -0
  71. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
  72. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
  73. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
  74. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
  75. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
  76. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
  77. data/lib/space_src/cli/shell.rb +122 -0
  78. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
  79. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
  80. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
  81. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
  82. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
  83. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
  84. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
  85. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
  86. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
  87. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
  88. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
  89. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
  90. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
  91. data/lib/space_src/migration.rb +43 -0
  92. data/lib/space_src/nav.rb +98 -0
  93. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
  94. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
  95. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
  96. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
  97. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
  98. data/lib/space_src/shell_integration.rb +321 -0
  99. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
  100. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
  101. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
  102. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
  103. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
  104. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
  105. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
  106. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
  107. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
  108. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
  109. data/lib/space_src.rb +37 -0
  110. data/skill/architect/SKILL.md +110 -54
  111. data/skill/architect/dispatch.md +147 -39
  112. data/skill/architect/research.md +46 -37
  113. data/skill/architect-research/SKILL.md +2 -2
  114. data/skill/architect-vocabulary/SKILL.md +24 -21
  115. metadata +127 -68
  116. data/lib/space_architect/cli/config.rb +0 -61
  117. data/lib/space_architect/cli/current.rb +0 -22
  118. data/lib/space_architect/cli/helpers.rb +0 -117
  119. data/lib/space_architect/cli/init.rb +0 -35
  120. data/lib/space_architect/cli/list.rb +0 -30
  121. data/lib/space_architect/cli/new.rb +0 -43
  122. data/lib/space_architect/cli/options.rb +0 -12
  123. data/lib/space_architect/cli/path.rb +0 -22
  124. data/lib/space_architect/cli/repo.rb +0 -88
  125. data/lib/space_architect/cli/shell.rb +0 -137
  126. data/lib/space_architect/cli/show.rb +0 -27
  127. data/lib/space_architect/cli/status.rb +0 -39
  128. data/lib/space_architect/cli/use.rb +0 -23
  129. data/lib/space_architect/version.rb +0 -5
  130. data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -44
@@ -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,140 @@ 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
+ TIMEOUT_EXIT_CODE = 124
58
+
59
+ def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil)
51
60
  prompt_path = Pathname.new(prompt_path)
52
61
  run_log_path = Pathname.new(run_log_path)
53
62
 
54
63
  File.open(prompt_path, "r") do |prompt_io|
55
64
  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)
65
+ r, w = IO.pipe
66
+ Sync do
67
+ child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: w, err: log)
68
+ w.close
69
+ tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client)
70
+ timed_out = false
71
+ timeout_task = nil
72
+
73
+ # Async::Task#with_timeout cannot do TERM→grace→KILL because
74
+ # Async::Process::Child#wait_thread's ensure goes straight to KILL.
75
+ # Instead: a concurrent fiber fires after the deadline and escalates.
76
+ # transient: true so the reactor doesn't wait for it when main work finishes.
77
+ if timeout && timeout > 0
78
+ timeout_task = Async(transient: true) do
79
+ sleep timeout
80
+ timed_out = true
81
+ Process.kill("TERM", -child.pid) rescue nil
82
+ sleep 0.5
83
+ Process.kill("KILL", -child.pid) rescue nil
84
+ end
85
+ end
86
+
87
+ status = child.wait
88
+ timeout_task&.stop
89
+
90
+ tasks.each(&:wait)
91
+ timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
58
92
  end
59
- status.exitstatus
60
93
  end
61
94
  end
62
95
  end
63
96
 
97
+ def run_detached(prompt_path:, run_log_path:, chdir:)
98
+ prompt_path = Pathname.new(prompt_path)
99
+ run_log_path = Pathname.new(run_log_path)
100
+
101
+ prompt_io = File.open(prompt_path, "r")
102
+ log = File.open(run_log_path, "w")
103
+ begin
104
+ pid = Process.spawn(*argv, chdir: chdir.to_s, pgroup: true,
105
+ in: prompt_io, out: log, err: log)
106
+ Process.detach(pid)
107
+ ensure
108
+ prompt_io.close
109
+ log.close
110
+ end
111
+ pid
112
+ end
113
+
64
114
  private
65
115
 
66
116
  def argv
67
- [
117
+ args = [
68
118
  @bin, "-p",
69
119
  "--model", @model,
70
120
  "--permission-mode", "acceptEdits",
71
- "--allowedTools", ALLOWED_TOOLS,
72
- "--disallowedTools", DISALLOWED_TOOLS,
121
+ "--allowedTools", @allowed_tools,
73
122
  "--output-format", "stream-json",
74
123
  "--verbose",
124
+ "--include-partial-messages",
75
125
  "--max-turns", @max_turns.to_s
76
126
  ]
127
+ args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
128
+ args
129
+ end
130
+
131
+ def start_tee(r, log, push_url:, push_token:, push_client:)
132
+ if push_url || push_client
133
+ body = Protocol::HTTP::Body::Writable.new(queue: Thread::SizedQueue.new(32))
134
+ push = Async { push_body(body, push_url: push_url, push_token: push_token, push_client: push_client) }
135
+ [Async { tee_pipe(r, log, body) }, push]
136
+ else
137
+ [Async { drain_pipe(r, log) }]
138
+ end
139
+ end
140
+
141
+ def push_body(body, push_url:, push_token:, push_client:)
142
+ path = push_url ? URI.parse(push_url).path : "/"
143
+ headers = [["content-type", "application/x-ndjson"]]
144
+ headers << ["authorization", "Bearer #{push_token}"] if push_token
145
+ if push_client
146
+ push_client.post(path, headers: headers, body: body).discard
147
+ else
148
+ Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(push_url)) do |c|
149
+ c.post(path, headers: headers, body: body).discard
150
+ end
151
+ end
152
+ rescue StandardError => e
153
+ $stderr.puts "push_body: transport error (best-effort, run log intact): #{e.class}: #{e.message}"
154
+ end
155
+
156
+ def tee_pipe(r, log, body)
157
+ pushing = true
158
+ while (chunk = r.gets)
159
+ log.write(chunk)
160
+ log.flush
161
+ if pushing
162
+ begin
163
+ body.write(chunk)
164
+ rescue StandardError => e
165
+ $stderr.puts "tee_pipe: push write failed (best-effort, continuing log): #{e.class}: #{e.message}"
166
+ pushing = false
167
+ end
168
+ end
169
+ end
170
+ ensure
171
+ body.close_write
172
+ r.close
173
+ end
174
+
175
+ def drain_pipe(r, log)
176
+ while (chunk = r.gets)
177
+ log.write(chunk)
178
+ log.flush
179
+ end
180
+ ensure
181
+ r.close
77
182
  end
78
183
  end
79
184
 
@@ -109,7 +214,7 @@ module SpaceArchitect
109
214
  cfg
110
215
  end
111
216
 
112
- def run(prompt_path:, run_log_path:, chdir:)
217
+ def run(prompt_path:, run_log_path:, chdir:, timeout: nil) # timeout: deferred — opencode kill path is out of scope
113
218
  prompt_path = Pathname.new(prompt_path)
114
219
  run_log_path = Pathname.new(run_log_path)
115
220
  config_path = write_config
@@ -130,6 +235,29 @@ module SpaceArchitect
130
235
  end
131
236
  end
132
237
 
238
+ def run_detached(prompt_path:, run_log_path:, chdir:)
239
+ prompt_path = Pathname.new(prompt_path)
240
+ run_log_path = Pathname.new(run_log_path)
241
+ config_path = write_config
242
+
243
+ env = {
244
+ "OPENCODE_CONFIG" => config_path.to_s,
245
+ "OPENCODE_DISABLE_PROJECT_CONFIG" => "1"
246
+ }
247
+
248
+ prompt_io = File.open(prompt_path, "r")
249
+ log = File.open(run_log_path, "w")
250
+ begin
251
+ pid = Process.spawn(env, *argv(chdir), chdir: chdir.to_s, pgroup: true,
252
+ in: prompt_io, out: log, err: log)
253
+ Process.detach(pid)
254
+ ensure
255
+ prompt_io.close
256
+ log.close
257
+ end
258
+ pid
259
+ end
260
+
133
261
  private
134
262
 
135
263
  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"