space-architect 7.1.0 → 9.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +58 -246
  4. data/lib/space_architect/architect_project.rb +40 -180
  5. data/lib/space_architect/bug_report.rb +2 -1
  6. data/lib/space_architect/cli/architect.rb +69 -103
  7. data/lib/space_architect/cli/research.rb +1 -1
  8. data/lib/space_architect/cli.rb +9 -14
  9. data/lib/space_architect/harness.rb +97 -344
  10. data/lib/space_architect/pi/builder-guard.ts +103 -0
  11. data/lib/space_architect/research/mux.rb +47 -32
  12. data/lib/space_architect/research/pi_events.rb +70 -0
  13. data/lib/space_architect/research/renderer.rb +39 -39
  14. data/lib/space_architect/research/supervisor.rb +35 -14
  15. data/lib/space_architect/research.rb +1 -6
  16. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  17. data/lib/space_architect/session_sync/cursor.rb +1 -1
  18. data/lib/space_architect/session_sync/plist.rb +1 -1
  19. data/lib/{space_core → space_architect}/version.rb +2 -2
  20. data/lib/space_architect.rb +1 -2
  21. data/skill/architect/SKILL.md +30 -25
  22. data/skill/architect/dispatch.md +137 -115
  23. data/skill/architect/research.md +14 -8
  24. data/skill/architect-research/SKILL.md +18 -11
  25. data/skill/architect-vocabulary/SKILL.md +3 -3
  26. metadata +20 -178
  27. data/exe/space +0 -13
  28. data/exe/src +0 -13
  29. data/lib/space_architect/cli/space.rb +0 -29
  30. data/lib/space_architect/cli/src.rb +0 -38
  31. data/lib/space_architect/dispatcher.rb +0 -25
  32. data/lib/space_core/atomic_write.rb +0 -21
  33. data/lib/space_core/cli/base_command.rb +0 -19
  34. data/lib/space_core/cli/build.rb +0 -27
  35. data/lib/space_core/cli/config.rb +0 -49
  36. data/lib/space_core/cli/current.rb +0 -16
  37. data/lib/space_core/cli/help.rb +0 -195
  38. data/lib/space_core/cli/helpers.rb +0 -115
  39. data/lib/space_core/cli/init.rb +0 -29
  40. data/lib/space_core/cli/list.rb +0 -24
  41. data/lib/space_core/cli/loop_status.rb +0 -47
  42. data/lib/space_core/cli/new.rb +0 -38
  43. data/lib/space_core/cli/pack.rb +0 -29
  44. data/lib/space_core/cli/path.rb +0 -16
  45. data/lib/space_core/cli/repeatable_options.rb +0 -84
  46. data/lib/space_core/cli/repo.rb +0 -76
  47. data/lib/space_core/cli/run.rb +0 -44
  48. data/lib/space_core/cli/shell.rb +0 -125
  49. data/lib/space_core/cli/show.rb +0 -21
  50. data/lib/space_core/cli/status.rb +0 -74
  51. data/lib/space_core/cli/use.rb +0 -17
  52. data/lib/space_core/cli.rb +0 -177
  53. data/lib/space_core/commands.rb +0 -51
  54. data/lib/space_core/config.rb +0 -152
  55. data/lib/space_core/errors.rb +0 -14
  56. data/lib/space_core/git_client.rb +0 -49
  57. data/lib/space_core/mise_client.rb +0 -37
  58. data/lib/space_core/oci_builder.rb +0 -56
  59. data/lib/space_core/oci_packer.rb +0 -103
  60. data/lib/space_core/oci_runner.rb +0 -89
  61. data/lib/space_core/paths.rb +0 -58
  62. data/lib/space_core/repo_reference.rb +0 -19
  63. data/lib/space_core/repo_resolver.rb +0 -167
  64. data/lib/space_core/shell_integration.rb +0 -438
  65. data/lib/space_core/slugger.rb +0 -16
  66. data/lib/space_core/space.rb +0 -174
  67. data/lib/space_core/space_store.rb +0 -319
  68. data/lib/space_core/state.rb +0 -86
  69. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  70. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  71. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  72. data/lib/space_core/terminal.rb +0 -161
  73. data/lib/space_core/warnings.rb +0 -13
  74. data/lib/space_core/xdg.rb +0 -33
  75. data/lib/space_core.rb +0 -26
  76. data/lib/space_src/cli/clone.rb +0 -54
  77. data/lib/space_src/cli/config.rb +0 -65
  78. data/lib/space_src/cli/daemon.rb +0 -362
  79. data/lib/space_src/cli/options.rb +0 -21
  80. data/lib/space_src/cli/org.rb +0 -199
  81. data/lib/space_src/cli/repo.rb +0 -169
  82. data/lib/space_src/cli/shell.rb +0 -121
  83. data/lib/space_src/cli/status.rb +0 -75
  84. data/lib/space_src/cli/sync.rb +0 -161
  85. data/lib/space_src/cli.rb +0 -159
  86. data/lib/space_src/cloner.rb +0 -77
  87. data/lib/space_src/config/contract.rb +0 -54
  88. data/lib/space_src/config/duration.rb +0 -79
  89. data/lib/space_src/config/model.rb +0 -49
  90. data/lib/space_src/config/store.rb +0 -156
  91. data/lib/space_src/forge/client.rb +0 -31
  92. data/lib/space_src/forge/github.rb +0 -98
  93. data/lib/space_src/launchd/agent.rb +0 -195
  94. data/lib/space_src/launchd/plist.rb +0 -129
  95. data/lib/space_src/log_rotator.rb +0 -46
  96. data/lib/space_src/migration.rb +0 -43
  97. data/lib/space_src/nav.rb +0 -99
  98. data/lib/space_src/paths.rb +0 -72
  99. data/lib/space_src/scm/client.rb +0 -87
  100. data/lib/space_src/scm/git.rb +0 -232
  101. data/lib/space_src/scm/status.rb +0 -24
  102. data/lib/space_src/shell.rb +0 -90
  103. data/lib/space_src/shell_integration.rb +0 -321
  104. data/lib/space_src/state/lock.rb +0 -59
  105. data/lib/space_src/state/store.rb +0 -140
  106. data/lib/space_src/sync/engine.rb +0 -503
  107. data/lib/space_src/sync/repo_plan.rb +0 -215
  108. data/lib/space_src/sync/report.rb +0 -18
  109. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  110. data/lib/space_src/ui/json_reporter.rb +0 -39
  111. data/lib/space_src/ui/mode.rb +0 -68
  112. data/lib/space_src/ui/plain_reporter.rb +0 -53
  113. data/lib/space_src/ui/reporter.rb +0 -48
  114. data/lib/space_src/version.rb +0 -7
  115. data/lib/space_src.rb +0 -38
@@ -0,0 +1,103 @@
1
+ // Builder guard: injected with every architect dispatch (`pi -e <path>`) to
2
+ // deny, before evaluation, the two failure modes a hermetic builder must not
3
+ // reach: git writes (the architect CLI owns all commits in a lane worktree)
4
+ // and commands bash cannot parse (a parse error at tool time burns a turn).
5
+ // Parse-level only: `bash -n` cannot catch runtime failures — those are the
6
+ // bash tool's own domain.
7
+ import { spawnSync } from "node:child_process";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+
10
+ // Git subcommands that mutate repository state. Reads (log, diff, show, …)
11
+ // stay allowed — only the write verbs are denied.
12
+ const GIT_WRITE_VERBS = new Set([
13
+ "commit",
14
+ "push",
15
+ "pull",
16
+ "reset",
17
+ "merge",
18
+ "rebase",
19
+ "checkout",
20
+ "switch",
21
+ "branch",
22
+ "tag",
23
+ "cherry-pick",
24
+ "revert",
25
+ "stash",
26
+ "worktree",
27
+ "clean",
28
+ "restore",
29
+ "rm",
30
+ "mv",
31
+ "am",
32
+ "apply",
33
+ ]);
34
+
35
+ // Global flags that consume a following value, when locating the subcommand —
36
+ // `git -C <path> commit` must classify as commit (flags sit between `git` and
37
+ // the subcommand, so an adjacency pattern misses it).
38
+ const GIT_VALUE_FLAGS = new Set(["-C", "-c"]);
39
+
40
+ // Split a command line into simple-command segments at the shell's command
41
+ // separators. Whitespace tokenization only: this is a deny list, not a shell
42
+ // reimplementation, and the deny decision never depends on quoting structure
43
+ // beyond what the separator split already crossed.
44
+ function segments(command: string): string[] {
45
+ return command.split(/\r?\n|\|\||&&|[;|]/);
46
+ }
47
+
48
+ // The git subcommand a segment invokes, or null: walk the tokens after `git`,
49
+ // skipping global flags (and their values) so the first positional token is
50
+ // the subcommand. Never substring-match the raw command — `git log
51
+ // --grep=commit` mentions `commit` but is a read.
52
+ function gitSubcommand(segment: string): string | null {
53
+ const tokens = segment.trim().split(/\s+/);
54
+ if (tokens[0] !== "git") return null;
55
+
56
+ for (let i = 1; i < tokens.length; i++) {
57
+ const tok = tokens[i];
58
+ if (!tok.startsWith("-")) return tok;
59
+ if (GIT_VALUE_FLAGS.has(tok)) i++; // skip the flag's value
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function gitWriteDenial(command: string): string | null {
65
+ for (const segment of segments(command)) {
66
+ const sub = gitSubcommand(segment);
67
+ if (sub && GIT_WRITE_VERBS.has(sub)) {
68
+ return `git ${sub} writes repository state — the architect CLI owns all commits and git mutations; ` +
69
+ `leave repository state alone and report what you would have run`;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ // Parse-check via `bash -n`: stdin carries the command, stderr is the reason.
76
+ function parseDenial(command: string): string | null {
77
+ const check = spawnSync("bash", ["-n"], { input: command, encoding: "utf8" });
78
+ if (check.status === 0) return null;
79
+ return `command does not parse under \`bash -n\` and would burn a turn at tool time; ` +
80
+ `fix the command's syntax — bash said: ${(check.stderr ?? "").trim()}`;
81
+ }
82
+
83
+ export default function builderGuard(pi: ExtensionAPI): void {
84
+ pi.on("tool_call", (event) => {
85
+ // bash only: the deny list is shell-command shaped; edit/write tools are
86
+ // bounded by the lane's touch set, not this guard.
87
+ if (event.toolName !== "bash") return;
88
+ const command = (event.input as { command?: unknown }).command;
89
+ if (typeof command !== "string") return;
90
+
91
+ // Test the command string itself, never the JSON-rendered whole input —
92
+ // a payload that merely mentions `git commit` in prose must not match.
93
+ const denial = gitWriteDenial(command) ?? parseDenial(command);
94
+ if (denial) {
95
+ return {
96
+ block: true,
97
+ // The reason is the builder's only feedback: it must say what was
98
+ // denied and what to do instead.
99
+ reason: `architect builder guard: ${denial}`,
100
+ };
101
+ }
102
+ });
103
+ }
@@ -11,10 +11,12 @@ module Space::Architect
11
11
  HEARTBEAT_EVERY = 30 # seconds of silence before heartbeat
12
12
  FILE_WAIT_LIMIT = 10 # seconds to wait for run.jsonl to appear
13
13
 
14
- def initialize(runs, renderer:, out: $stdout)
15
- @runs = runs
16
- @renderer = renderer
17
- @out = out
14
+ # heartbeat_every: test seam — defaults to HEARTBEAT_EVERY.
15
+ def initialize(runs, renderer:, out: $stdout, heartbeat_every: HEARTBEAT_EVERY)
16
+ @runs = runs
17
+ @renderer = renderer
18
+ @out = out
19
+ @heartbeat_every = heartbeat_every
18
20
  end
19
21
 
20
22
  # Returns :ok or :failed
@@ -35,7 +37,7 @@ module Space::Architect
35
37
  wait_for_file(run)
36
38
 
37
39
  unless File.exist?(run.run_log_path.to_s)
38
- emit(@renderer.render(lane: run.id, events: [error_event("run.jsonl never appeared")], alive: false))
40
+ emit(@renderer.terminal(lane: run.id, ok: false, reason: "run.jsonl never appeared"))
39
41
  return :failed
40
42
  end
41
43
 
@@ -43,7 +45,7 @@ module Space::Architect
43
45
 
44
46
  events_all = []
45
47
  last_emit = Time.now
46
- terminal = nil
48
+ settled = nil
47
49
 
48
50
  File.open(run.run_log_path.to_s, "r") do |f|
49
51
  loop do
@@ -53,30 +55,29 @@ module Space::Architect
53
55
  next unless ev
54
56
 
55
57
  events_all << ev
56
- terminal = ev if ev["type"] == "result"
58
+ settled = ev if ev["type"] == "agent_settled"
57
59
 
58
60
  new_events = [ev]
59
- rendered = @renderer.render(lane: run.id, events: new_events, alive: terminal.nil?)
61
+ rendered = @renderer.render(lane: run.id, events: new_events, alive: settled.nil?)
60
62
  emit(rendered) unless rendered.empty?
61
63
  last_emit = Time.now
62
64
 
63
- break if terminal
65
+ break if settled
64
66
  else
65
67
  # EOF — check liveness
66
68
  pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
67
69
 
68
70
  unless pid_alive
69
71
  # PID dead and no terminal event → treat as failure
70
- unless terminal
71
- emit(@renderer.render(lane: run.id,
72
- events: [error_event("process died without result event")],
73
- alive: false))
72
+ unless settled
73
+ emit(@renderer.terminal(lane: run.id, ok: false,
74
+ reason: "process died without a terminal event"))
74
75
  return :failed
75
76
  end
76
77
  break
77
78
  end
78
79
 
79
- if Time.now - last_emit > HEARTBEAT_EVERY
80
+ if Time.now - last_emit > @heartbeat_every
80
81
  emit("[#{run.id}] ⏳ still running…\n") if @renderer.lifecycle?
81
82
  last_emit = Time.now
82
83
  end
@@ -86,14 +87,36 @@ module Space::Architect
86
87
  end
87
88
  end
88
89
 
89
- if terminal
90
- extract_report(run, terminal)
91
- rendered = @renderer.render(lane: run.id, events: [terminal], alive: false)
92
- emit(rendered) unless rendered.empty?
93
- terminal["is_error"] ? :failed : :ok
94
- else
95
- :failed
96
- end
90
+ finish(run, events_all, settled)
91
+ end
92
+
93
+ # Classify the settled stream via PiEvents and render the terminal line.
94
+ def finish(run, events, settled)
95
+ text = PiEvents.message_text(PiEvents.last_assistant_message(events))
96
+ outcome, reason = classify(events, settled, text)
97
+
98
+ extract_report(run, text) unless text.empty?
99
+
100
+ emit(@renderer.terminal(lane: run.id, ok: outcome == :ok,
101
+ reason: outcome == :ok ? nil : reason,
102
+ snippet: outcome == :ok ? text : nil,
103
+ duration: PiEvents.duration_seconds(events),
104
+ turns: PiEvents.turn_count(events)))
105
+ outcome
106
+ end
107
+
108
+ # :ok iff the stream settled un-aborted, the last assistant stopReason is
109
+ # "stop", and the final assistant text is non-empty; :failed with a named
110
+ # reason otherwise.
111
+ def classify(events, settled, text)
112
+ reason = PiEvents.stop_reason(events)
113
+
114
+ return [:failed, text.empty? ? "aborted" : text] if settled.is_a?(Hash) && settled["aborted"] == true
115
+ return [:ok, text] if reason == "stop" && !text.empty?
116
+ return [:failed, text.empty? ? reason : text] if PiEvents.failed_stop_reason?(reason)
117
+
118
+ # "stop" or no assistant message at all, but no final text.
119
+ [:failed, "no final assistant text"]
97
120
  end
98
121
 
99
122
  def wait_for_file(run)
@@ -105,16 +128,8 @@ module Space::Architect
105
128
  end
106
129
  end
107
130
 
108
- def extract_report(run, terminal_ev)
109
- result = terminal_ev["result"].to_s
110
- return if result.empty?
111
-
112
- Pathname.new(run.report_path).write(result)
113
- end
114
-
115
- def error_event(msg)
116
- { "type" => "result", "subtype" => "error", "is_error" => true,
117
- "duration_ms" => 0, "num_turns" => 0, "result" => msg }
131
+ def extract_report(run, text)
132
+ Pathname.new(run.report_path).write(text)
118
133
  end
119
134
 
120
135
  def emit(text)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Space::Architect
6
+ module Research
7
+ # Pure helpers over pi's JSONL event stream — parsed event hashes in, values
8
+ # out, no I/O. The one home for the pi-shape predicates: Mux, report
9
+ # extraction, and Supervisor#classify all consume these, so the terminal
10
+ # classification is not duplicated per call site.
11
+ module PiEvents
12
+ # pi's StopReason values that mean the assistant did not finish normally.
13
+ FAILED_STOP_REASONS = %w[error aborted].freeze
14
+
15
+ module_function
16
+
17
+ # Assistant `message_end` messages, in stream order — the sole source of
18
+ # complete message content (`message_start`/`message_update` are inert;
19
+ # `turn_end`/`agent_end` only replay).
20
+ def assistant_messages(events)
21
+ events.filter_map do |e|
22
+ next unless e.is_a?(Hash) && e["type"] == "message_end" &&
23
+ e["message"].is_a?(Hash) && e["message"]["role"] == "assistant"
24
+ e["message"]
25
+ end
26
+ end
27
+
28
+ def last_assistant_message(events)
29
+ assistant_messages(events).last
30
+ end
31
+
32
+ # The last assistant message's stopReason ("stop"/"error"/"aborted"), or
33
+ # nil when the stream has no assistant message_end.
34
+ def stop_reason(events)
35
+ last_assistant_message(events)&.fetch("stopReason", nil)
36
+ end
37
+
38
+ def failed_stop_reason?(reason)
39
+ FAILED_STOP_REASONS.include?(reason)
40
+ end
41
+
42
+ # Joined text blocks of a message — the report source.
43
+ def message_text(message)
44
+ return "" unless message.is_a?(Hash)
45
+ Array(message["content"]).select { |b| b.is_a?(Hash) && b["type"] == "text" }
46
+ .map { |b| b["text"].to_s }
47
+ .join
48
+ end
49
+
50
+ # Run span in whole-tenths of a second: last assistant message_end
51
+ # `.timestamp` (epoch ms) minus `session.timestamp` (ISO 8601). NOT a sum
52
+ # of per-response durationMs — that omits tool time between turns.
53
+ def duration_seconds(events)
54
+ session = events.find { |e| e.is_a?(Hash) && e["type"] == "session" }
55
+ end_ms = last_assistant_message(events)&.fetch("timestamp", nil)
56
+ return nil unless session.is_a?(Hash) && session["timestamp"].is_a?(String) && end_ms.is_a?(Numeric)
57
+
58
+ start_s = Time.iso8601(session["timestamp"]).to_f
59
+ ((end_ms / 1000.0) - start_s).round(1)
60
+ rescue ArgumentError
61
+ nil
62
+ end
63
+
64
+ # One turn_end per turn.
65
+ def turn_count(events)
66
+ events.count { |e| e.is_a?(Hash) && e["type"] == "turn_end" }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Space::Architect
4
6
  module Research
5
- # Pure, testable verbosity-gated renderer for stream-json events.
7
+ # Pure, testable verbosity-gated renderer for pi JSONL events.
6
8
  #
7
9
  # Levels (§5.3 ladder):
8
10
  # 0 (quiet) — nothing
@@ -22,8 +24,8 @@ module Space::Architect
22
24
 
23
25
  # Render a batch of events for a lane.
24
26
  # alive: true → lane still in flight (lifecycle prefix)
25
- # alive: false → lane finished (terminal line included)
26
- # Returns a String (may be empty).
27
+ # alive: false → lane finished
28
+ # Returns a String (may be empty). Terminal lines come from #terminal.
27
29
  def render(lane:, events:, alive:)
28
30
  return "" if @level == 0 && !@jsonl
29
31
 
@@ -32,69 +34,67 @@ module Space::Architect
32
34
  end
33
35
 
34
36
  lines = []
35
- terminal = nil
36
-
37
37
  events.each do |ev|
38
38
  case ev["type"]
39
- when "assistant"
39
+ when "message_end"
40
+ next unless ev.dig("message", "role") == "assistant"
40
41
  Array(ev.dig("message", "content")).each do |block|
41
42
  case block["type"]
42
43
  when "thinking"
43
44
  lines << "[#{lane}] #{block['thinking'].to_s.strip}" if @thinking && @level >= 1
44
45
  when "text"
45
46
  lines << "[#{lane}] #{block['text'].to_s.strip}" if @level >= 2
46
- when "tool_use"
47
+ when "toolCall"
47
48
  if @level >= 3
48
49
  name_line = "[#{lane}] tool: #{block['name']}"
49
50
  if @level >= 4
50
- input = block["input"]
51
- name_line += " #{JSON.generate(input)}" if input && !input.empty?
51
+ arguments = block["arguments"]
52
+ name_line += " #{JSON.generate(arguments)}" if arguments && !arguments.empty?
52
53
  end
53
54
  lines << name_line
54
55
  end
55
56
  end
56
57
  end
57
- when "user"
58
- Array(ev.dig("message", "content")).each do |block|
59
- next unless block["type"] == "tool_result"
60
- next unless @level >= 4
61
-
62
- content = block["content"]
63
- lines << "[#{lane}] tool_result: #{content.to_s.strip}"
58
+ when "tool_execution_start"
59
+ if @level >= 3
60
+ line = "[#{lane}] tool: #{ev['toolName']}"
61
+ if @level >= 4
62
+ args = ev["args"]
63
+ line += " #{JSON.generate(args)}" if args && !args.empty?
64
+ end
65
+ lines << line
66
+ end
67
+ when "tool_execution_end"
68
+ if @level >= 4
69
+ text = Array(ev.dig("result", "content")).select { |c| c.is_a?(Hash) && c["type"] == "text" }
70
+ .map { |c| c["text"].to_s }.join
71
+ lines << "[#{lane}] tool_result: #{text.strip}"
64
72
  end
65
- when "result"
66
- terminal = ev
67
73
  end
68
74
  end
69
75
 
70
- if terminal
71
- lines << terminal_line(lane, terminal)
72
- elsif alive && @level >= 1 && events.empty?
73
- lines << "[#{lane}] running"
74
- end
76
+ lines << "[#{lane}] running" if alive && @level >= 1 && events.empty?
75
77
 
76
78
  lines.reject(&:empty?).join("\n").then { |s| s.empty? ? s : "#{s}\n" }
77
79
  end
78
80
 
79
- def lifecycle?
80
- @level >= 1 && !@jsonl
81
- end
81
+ # The terminal line, fed terminal FACTS by the mux (never a fabricated
82
+ # event): ok/failed, the failure reason or the final-text snippet, the
83
+ # run span, and the turn count.
84
+ def terminal(lane:, ok:, reason: nil, snippet: nil, duration: nil, turns: nil)
85
+ return "" if @level == 0 || @jsonl
82
86
 
83
- private
84
-
85
- def terminal_line(lane, ev)
86
- if ev["is_error"]
87
- reason = ev["result"].to_s.strip
88
- reason = ev["subtype"] if reason.empty?
89
- "[#{lane}] ✗ failed #{reason}"
90
- elsif ev["subtype"] == "success"
91
- dur = ev["duration_ms"] ? "#{(ev['duration_ms'] / 1000.0).round(1)}s" : "-"
92
- turns = ev["num_turns"] || "-"
93
- result_snip = ev["result"].to_s.strip.slice(0, 80)
94
- "[#{lane}] ✓ complete · STATUS: #{result_snip} · #{dur} · #{turns} turns"
87
+ line = if ok
88
+ span = duration ? "#{duration}s" : "-"
89
+ "[#{lane}] ✓ complete · STATUS: #{snippet.to_s.strip.slice(0, 80)} · #{span} · #{turns || '-'} turns"
95
90
  else
96
- "[#{lane}] ⚠ nonzero exit"
91
+ "[#{lane}] ✗ failed #{reason.to_s.strip}"
97
92
  end
93
+ "#{line}\n"
94
+ end
95
+
96
+ def lifecycle?
97
+ @level >= 1 && !@jsonl
98
98
  end
99
99
  end
100
100
  end
@@ -1,22 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "pathname"
4
5
  require "time"
5
6
 
6
7
  module Space::Architect
7
8
  module Research
8
9
  class Supervisor
9
- DEFAULT_MODEL = Harness::CLAUDE_DEFAULT_MODEL
10
+ DEFAULT_MODEL = Harness::DEFAULT_MODEL
10
11
  DEFAULT_MAX_TURNS = 40
11
12
 
13
+ # The vendored builder-guard extension ships in the gem next to this file's
14
+ # sibling pi/ dir — copied per-run and injected with the harness.
15
+ VENDORED_GUARD = Pathname.new(__dir__).join("../pi/builder-guard.ts")
16
+
12
17
  def initialize(space:, bin: nil)
13
18
  @space = space
14
19
  @bin = bin
15
20
  @registry = Registry.new(space.path.join("build", "research", "registry.yaml"))
16
21
  end
17
22
 
18
- # Dispatch each prompt file as a detached read-only claude -p child.
19
- # Returns array of Run objects (non-blocking).
23
+ # Dispatch each prompt file as a detached pi child. Returns array of Run
24
+ # objects (non-blocking).
20
25
  def dispatch(prompts, model: DEFAULT_MODEL, max_turns: DEFAULT_MAX_TURNS)
21
26
  prompts.map { |path| dispatch_one(Pathname.new(path), model: model, max_turns: max_turns) }
22
27
  end
@@ -54,12 +59,19 @@ module Space::Architect
54
59
 
55
60
  FileUtils.cp(path.to_s, prompt_path.to_s)
56
61
 
57
- harness = Harness::ClaudeCodeHarness.new(
58
- model: model,
59
- max_turns: max_turns,
60
- bin: @bin,
61
- allowed_tools: READONLY_TOOLS,
62
- disallowed_tools: ""
62
+ # Vendor the guard into the run dir (idempotent overwrite) and inject it via
63
+ # the harness — research runs get the same deny-only builder guard.
64
+ guard_path = dir.join("builder-guard.ts")
65
+ FileUtils.cp(VENDORED_GUARD, guard_path)
66
+
67
+ # config_dir: is pi's --session-dir — research sessions land under
68
+ # build/research/<id>/ instead of ~/.pi/agent/sessions/.
69
+ harness = Harness::PiHarness.new(
70
+ model: model,
71
+ max_turns: max_turns,
72
+ bin: @bin,
73
+ config_dir: dir,
74
+ guard_path: guard_path
63
75
  )
64
76
 
65
77
  pid = harness.run_detached(
@@ -87,18 +99,27 @@ module Space::Architect
87
99
  File.basename(path.to_s).sub(/\.prompt\.md\z/, "").sub(/\.md\z/, "")
88
100
  end
89
101
 
102
+ # pi has no terminal "result" event: the run's outcome is the LAST assistant
103
+ # message's stopReason ("stop" finished normally; "error"/"aborted" failed —
104
+ # pi's StopReason vocabulary; PiEvents is the one home for these
105
+ # predicates). Without one, a live pid is still running and a dead pid
106
+ # died without finishing.
90
107
  def classify(run)
91
- content = File.exist?(run.run_log_path.to_s) ? File.read(run.run_log_path.to_s) : ""
92
- events = content.lines.filter_map { |l| JSON.parse(l.chomp) rescue nil }
93
- terminal = events.find { |e| e["type"] == "result" }
108
+ events = read_events(run.run_log_path)
109
+ reason = PiEvents.stop_reason(events)
94
110
 
95
- return :complete if terminal && !terminal["is_error"]
96
- return :failed if terminal && terminal["is_error"]
111
+ return :complete if reason == "stop"
112
+ return :failed if PiEvents.failed_stop_reason?(reason)
97
113
 
98
114
  pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
99
115
  pid_alive ? :running : :failed
100
116
  end
101
117
 
118
+ def read_events(path)
119
+ return [] unless File.exist?(path.to_s)
120
+ File.readlines(path.to_s).filter_map { |l| JSON.parse(l.chomp) rescue nil }
121
+ end
122
+
102
123
  def tail_lines(path, n)
103
124
  return [] unless File.exist?(path.to_s)
104
125
  File.readlines(path.to_s).last(n).map(&:chomp)
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Space::Architect
4
- module Research
5
- READONLY_TOOLS = "Read,Grep,Glob,WebSearch,WebFetch"
6
- end
7
- end
8
-
3
+ require_relative "research/pi_events"
9
4
  require_relative "research/run"
10
5
  require_relative "research/registry"
11
6
  require_relative "research/renderer"
@@ -5,7 +5,7 @@ require "open3"
5
5
  module Space::Architect
6
6
  module SessionSync
7
7
  # Resolves the absolute path to the `architect` executable the plist
8
- # should invoke, imitating Space::Src::CLI::Daemon::Helpers::Resolve's
8
+ # should invoke, imitating RepoTender::CLI::Daemon::Helpers::Resolve's
9
9
  # detect_bin_path precedence: an env override (tests), the dev checkout's
10
10
  # exe/architect, `which architect`, then the installed gem's bin.
11
11
  module BinPath
@@ -6,7 +6,7 @@ require "fileutils"
6
6
  module Space::Architect
7
7
  module SessionSync
8
8
  # Machine-managed cursor at $XDG_STATE_HOME/space-architect/session-sync.yaml
9
- # (imitates Space::Src::State::Store's load/write/emit shape). Keyed by
9
+ # (imitates RepoTender::State::Store's load/write/emit shape). Keyed by
10
10
  # absolute source path; each entry records the size + mtime observed the
11
11
  # last time that file was uploaded.
12
12
  class Cursor
@@ -3,7 +3,7 @@
3
3
  module Space::Architect
4
4
  module SessionSync
5
5
  # Hand-rolled launchd plist emitter for the session-sync agent, imitating
6
- # Space::Src::Launchd::Plist's shape. Under launchd's bare PATH, a bare
6
+ # RepoTender::Launchd::Plist's shape. Under launchd's bare PATH, a bare
7
7
  # env-relative ruby binstub shebang resolves to macOS system Ruby instead
8
8
  # of the caller's toolchain, so ProgramArguments names the interpreter
9
9
  # explicitly (ruby_bin) rather than relying on shebang resolution — no
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Space
4
- module Core
5
- VERSION = "7.1.0"
4
+ module Architect
5
+ VERSION = "9.0.0"
6
6
  end
7
7
  end
@@ -1,11 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "space_core"
4
- require "space_src"
4
+ require_relative "space_architect/version"
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"