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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +58 -246
- data/lib/space_architect/architect_project.rb +40 -180
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +69 -103
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/cli.rb +9 -14
- data/lib/space_architect/harness.rb +97 -344
- data/lib/space_architect/pi/builder-guard.ts +103 -0
- data/lib/space_architect/research/mux.rb +47 -32
- data/lib/space_architect/research/pi_events.rb +70 -0
- data/lib/space_architect/research/renderer.rb +39 -39
- data/lib/space_architect/research/supervisor.rb +35 -14
- data/lib/space_architect/research.rb +1 -6
- data/lib/space_architect/session_sync/bin_path.rb +1 -1
- data/lib/space_architect/session_sync/cursor.rb +1 -1
- data/lib/space_architect/session_sync/plist.rb +1 -1
- data/lib/{space_core → space_architect}/version.rb +2 -2
- data/lib/space_architect.rb +1 -2
- data/skill/architect/SKILL.md +30 -25
- data/skill/architect/dispatch.md +137 -115
- data/skill/architect/research.md +14 -8
- data/skill/architect-research/SKILL.md +18 -11
- data/skill/architect-vocabulary/SKILL.md +3 -3
- metadata +20 -178
- data/exe/space +0 -13
- data/exe/src +0 -13
- data/lib/space_architect/cli/space.rb +0 -29
- data/lib/space_architect/cli/src.rb +0 -38
- data/lib/space_architect/dispatcher.rb +0 -25
- data/lib/space_core/atomic_write.rb +0 -21
- data/lib/space_core/cli/base_command.rb +0 -19
- data/lib/space_core/cli/build.rb +0 -27
- data/lib/space_core/cli/config.rb +0 -49
- data/lib/space_core/cli/current.rb +0 -16
- data/lib/space_core/cli/help.rb +0 -195
- data/lib/space_core/cli/helpers.rb +0 -115
- data/lib/space_core/cli/init.rb +0 -29
- data/lib/space_core/cli/list.rb +0 -24
- data/lib/space_core/cli/loop_status.rb +0 -47
- data/lib/space_core/cli/new.rb +0 -38
- data/lib/space_core/cli/pack.rb +0 -29
- data/lib/space_core/cli/path.rb +0 -16
- data/lib/space_core/cli/repeatable_options.rb +0 -84
- data/lib/space_core/cli/repo.rb +0 -76
- data/lib/space_core/cli/run.rb +0 -44
- data/lib/space_core/cli/shell.rb +0 -125
- data/lib/space_core/cli/show.rb +0 -21
- data/lib/space_core/cli/status.rb +0 -74
- data/lib/space_core/cli/use.rb +0 -17
- data/lib/space_core/cli.rb +0 -177
- data/lib/space_core/commands.rb +0 -51
- data/lib/space_core/config.rb +0 -152
- data/lib/space_core/errors.rb +0 -14
- data/lib/space_core/git_client.rb +0 -49
- data/lib/space_core/mise_client.rb +0 -37
- data/lib/space_core/oci_builder.rb +0 -56
- data/lib/space_core/oci_packer.rb +0 -103
- data/lib/space_core/oci_runner.rb +0 -89
- data/lib/space_core/paths.rb +0 -58
- data/lib/space_core/repo_reference.rb +0 -19
- data/lib/space_core/repo_resolver.rb +0 -167
- data/lib/space_core/shell_integration.rb +0 -438
- data/lib/space_core/slugger.rb +0 -16
- data/lib/space_core/space.rb +0 -174
- data/lib/space_core/space_store.rb +0 -319
- data/lib/space_core/state.rb +0 -86
- data/lib/space_core/templates/oci/dockerfile.erb +0 -73
- data/lib/space_core/templates/oci/dockerignore.erb +0 -17
- data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
- data/lib/space_core/terminal.rb +0 -161
- data/lib/space_core/warnings.rb +0 -13
- data/lib/space_core/xdg.rb +0 -33
- data/lib/space_core.rb +0 -26
- data/lib/space_src/cli/clone.rb +0 -54
- data/lib/space_src/cli/config.rb +0 -65
- data/lib/space_src/cli/daemon.rb +0 -362
- data/lib/space_src/cli/options.rb +0 -21
- data/lib/space_src/cli/org.rb +0 -199
- data/lib/space_src/cli/repo.rb +0 -169
- data/lib/space_src/cli/shell.rb +0 -121
- data/lib/space_src/cli/status.rb +0 -75
- data/lib/space_src/cli/sync.rb +0 -161
- data/lib/space_src/cli.rb +0 -159
- data/lib/space_src/cloner.rb +0 -77
- data/lib/space_src/config/contract.rb +0 -54
- data/lib/space_src/config/duration.rb +0 -79
- data/lib/space_src/config/model.rb +0 -49
- data/lib/space_src/config/store.rb +0 -156
- data/lib/space_src/forge/client.rb +0 -31
- data/lib/space_src/forge/github.rb +0 -98
- data/lib/space_src/launchd/agent.rb +0 -195
- data/lib/space_src/launchd/plist.rb +0 -129
- data/lib/space_src/log_rotator.rb +0 -46
- data/lib/space_src/migration.rb +0 -43
- data/lib/space_src/nav.rb +0 -99
- data/lib/space_src/paths.rb +0 -72
- data/lib/space_src/scm/client.rb +0 -87
- data/lib/space_src/scm/git.rb +0 -232
- data/lib/space_src/scm/status.rb +0 -24
- data/lib/space_src/shell.rb +0 -90
- data/lib/space_src/shell_integration.rb +0 -321
- data/lib/space_src/state/lock.rb +0 -59
- data/lib/space_src/state/store.rb +0 -140
- data/lib/space_src/sync/engine.rb +0 -503
- data/lib/space_src/sync/repo_plan.rb +0 -215
- data/lib/space_src/sync/report.rb +0 -18
- data/lib/space_src/ui/interactive_reporter.rb +0 -280
- data/lib/space_src/ui/json_reporter.rb +0 -39
- data/lib/space_src/ui/mode.rb +0 -68
- data/lib/space_src/ui/plain_reporter.rb +0 -53
- data/lib/space_src/ui/reporter.rb +0 -48
- data/lib/space_src/version.rb +0 -7
- 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
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
17
|
-
@
|
|
14
|
+
# heartbeat_every: test seam — defaults to HEARTBEAT_EVERY.
|
|
15
|
+
def initialize(runs, renderer:, out: $stdout, heartbeat_every: HEARTBEAT_EVERY)
|
|
16
|
+
@runs = runs
|
|
17
|
+
@renderer = renderer
|
|
18
|
+
@out = out
|
|
19
|
+
@heartbeat_every = heartbeat_every
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
# Returns :ok or :failed
|
|
@@ -35,7 +37,7 @@ module Space::Architect
|
|
|
35
37
|
wait_for_file(run)
|
|
36
38
|
|
|
37
39
|
unless File.exist?(run.run_log_path.to_s)
|
|
38
|
-
emit(@renderer.
|
|
40
|
+
emit(@renderer.terminal(lane: run.id, ok: false, reason: "run.jsonl never appeared"))
|
|
39
41
|
return :failed
|
|
40
42
|
end
|
|
41
43
|
|
|
@@ -43,7 +45,7 @@ module Space::Architect
|
|
|
43
45
|
|
|
44
46
|
events_all = []
|
|
45
47
|
last_emit = Time.now
|
|
46
|
-
|
|
48
|
+
settled = nil
|
|
47
49
|
|
|
48
50
|
File.open(run.run_log_path.to_s, "r") do |f|
|
|
49
51
|
loop do
|
|
@@ -53,30 +55,29 @@ module Space::Architect
|
|
|
53
55
|
next unless ev
|
|
54
56
|
|
|
55
57
|
events_all << ev
|
|
56
|
-
|
|
58
|
+
settled = ev if ev["type"] == "agent_settled"
|
|
57
59
|
|
|
58
60
|
new_events = [ev]
|
|
59
|
-
rendered = @renderer.render(lane: run.id, events: new_events, alive:
|
|
61
|
+
rendered = @renderer.render(lane: run.id, events: new_events, alive: settled.nil?)
|
|
60
62
|
emit(rendered) unless rendered.empty?
|
|
61
63
|
last_emit = Time.now
|
|
62
64
|
|
|
63
|
-
break if
|
|
65
|
+
break if settled
|
|
64
66
|
else
|
|
65
67
|
# EOF — check liveness
|
|
66
68
|
pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
|
|
67
69
|
|
|
68
70
|
unless pid_alive
|
|
69
71
|
# PID dead and no terminal event → treat as failure
|
|
70
|
-
unless
|
|
71
|
-
emit(@renderer.
|
|
72
|
-
|
|
73
|
-
alive: false))
|
|
72
|
+
unless settled
|
|
73
|
+
emit(@renderer.terminal(lane: run.id, ok: false,
|
|
74
|
+
reason: "process died without a terminal event"))
|
|
74
75
|
return :failed
|
|
75
76
|
end
|
|
76
77
|
break
|
|
77
78
|
end
|
|
78
79
|
|
|
79
|
-
if Time.now - last_emit >
|
|
80
|
+
if Time.now - last_emit > @heartbeat_every
|
|
80
81
|
emit("[#{run.id}] ⏳ still running…\n") if @renderer.lifecycle?
|
|
81
82
|
last_emit = Time.now
|
|
82
83
|
end
|
|
@@ -86,14 +87,36 @@ module Space::Architect
|
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
finish(run, events_all, settled)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Classify the settled stream via PiEvents and render the terminal line.
|
|
94
|
+
def finish(run, events, settled)
|
|
95
|
+
text = PiEvents.message_text(PiEvents.last_assistant_message(events))
|
|
96
|
+
outcome, reason = classify(events, settled, text)
|
|
97
|
+
|
|
98
|
+
extract_report(run, text) unless text.empty?
|
|
99
|
+
|
|
100
|
+
emit(@renderer.terminal(lane: run.id, ok: outcome == :ok,
|
|
101
|
+
reason: outcome == :ok ? nil : reason,
|
|
102
|
+
snippet: outcome == :ok ? text : nil,
|
|
103
|
+
duration: PiEvents.duration_seconds(events),
|
|
104
|
+
turns: PiEvents.turn_count(events)))
|
|
105
|
+
outcome
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# :ok iff the stream settled un-aborted, the last assistant stopReason is
|
|
109
|
+
# "stop", and the final assistant text is non-empty; :failed with a named
|
|
110
|
+
# reason otherwise.
|
|
111
|
+
def classify(events, settled, text)
|
|
112
|
+
reason = PiEvents.stop_reason(events)
|
|
113
|
+
|
|
114
|
+
return [:failed, text.empty? ? "aborted" : text] if settled.is_a?(Hash) && settled["aborted"] == true
|
|
115
|
+
return [:ok, text] if reason == "stop" && !text.empty?
|
|
116
|
+
return [:failed, text.empty? ? reason : text] if PiEvents.failed_stop_reason?(reason)
|
|
117
|
+
|
|
118
|
+
# "stop" or no assistant message at all, but no final text.
|
|
119
|
+
[:failed, "no final assistant text"]
|
|
97
120
|
end
|
|
98
121
|
|
|
99
122
|
def wait_for_file(run)
|
|
@@ -105,16 +128,8 @@ module Space::Architect
|
|
|
105
128
|
end
|
|
106
129
|
end
|
|
107
130
|
|
|
108
|
-
def extract_report(run,
|
|
109
|
-
|
|
110
|
-
return if result.empty?
|
|
111
|
-
|
|
112
|
-
Pathname.new(run.report_path).write(result)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def error_event(msg)
|
|
116
|
-
{ "type" => "result", "subtype" => "error", "is_error" => true,
|
|
117
|
-
"duration_ms" => 0, "num_turns" => 0, "result" => msg }
|
|
131
|
+
def extract_report(run, text)
|
|
132
|
+
Pathname.new(run.report_path).write(text)
|
|
118
133
|
end
|
|
119
134
|
|
|
120
135
|
def emit(text)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Space::Architect
|
|
6
|
+
module Research
|
|
7
|
+
# Pure helpers over pi's JSONL event stream — parsed event hashes in, values
|
|
8
|
+
# out, no I/O. The one home for the pi-shape predicates: Mux, report
|
|
9
|
+
# extraction, and Supervisor#classify all consume these, so the terminal
|
|
10
|
+
# classification is not duplicated per call site.
|
|
11
|
+
module PiEvents
|
|
12
|
+
# pi's StopReason values that mean the assistant did not finish normally.
|
|
13
|
+
FAILED_STOP_REASONS = %w[error aborted].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Assistant `message_end` messages, in stream order — the sole source of
|
|
18
|
+
# complete message content (`message_start`/`message_update` are inert;
|
|
19
|
+
# `turn_end`/`agent_end` only replay).
|
|
20
|
+
def assistant_messages(events)
|
|
21
|
+
events.filter_map do |e|
|
|
22
|
+
next unless e.is_a?(Hash) && e["type"] == "message_end" &&
|
|
23
|
+
e["message"].is_a?(Hash) && e["message"]["role"] == "assistant"
|
|
24
|
+
e["message"]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def last_assistant_message(events)
|
|
29
|
+
assistant_messages(events).last
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The last assistant message's stopReason ("stop"/"error"/"aborted"), or
|
|
33
|
+
# nil when the stream has no assistant message_end.
|
|
34
|
+
def stop_reason(events)
|
|
35
|
+
last_assistant_message(events)&.fetch("stopReason", nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failed_stop_reason?(reason)
|
|
39
|
+
FAILED_STOP_REASONS.include?(reason)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Joined text blocks of a message — the report source.
|
|
43
|
+
def message_text(message)
|
|
44
|
+
return "" unless message.is_a?(Hash)
|
|
45
|
+
Array(message["content"]).select { |b| b.is_a?(Hash) && b["type"] == "text" }
|
|
46
|
+
.map { |b| b["text"].to_s }
|
|
47
|
+
.join
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Run span in whole-tenths of a second: last assistant message_end
|
|
51
|
+
# `.timestamp` (epoch ms) minus `session.timestamp` (ISO 8601). NOT a sum
|
|
52
|
+
# of per-response durationMs — that omits tool time between turns.
|
|
53
|
+
def duration_seconds(events)
|
|
54
|
+
session = events.find { |e| e.is_a?(Hash) && e["type"] == "session" }
|
|
55
|
+
end_ms = last_assistant_message(events)&.fetch("timestamp", nil)
|
|
56
|
+
return nil unless session.is_a?(Hash) && session["timestamp"].is_a?(String) && end_ms.is_a?(Numeric)
|
|
57
|
+
|
|
58
|
+
start_s = Time.iso8601(session["timestamp"]).to_f
|
|
59
|
+
((end_ms / 1000.0) - start_s).round(1)
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One turn_end per turn.
|
|
65
|
+
def turn_count(events)
|
|
66
|
+
events.count { |e| e.is_a?(Hash) && e["type"] == "turn_end" }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Space::Architect
|
|
4
6
|
module Research
|
|
5
|
-
# Pure, testable verbosity-gated renderer for
|
|
7
|
+
# Pure, testable verbosity-gated renderer for pi JSONL events.
|
|
6
8
|
#
|
|
7
9
|
# Levels (§5.3 ladder):
|
|
8
10
|
# 0 (quiet) — nothing
|
|
@@ -22,8 +24,8 @@ module Space::Architect
|
|
|
22
24
|
|
|
23
25
|
# Render a batch of events for a lane.
|
|
24
26
|
# alive: true → lane still in flight (lifecycle prefix)
|
|
25
|
-
# alive: false → lane finished
|
|
26
|
-
# Returns a String (may be empty).
|
|
27
|
+
# alive: false → lane finished
|
|
28
|
+
# Returns a String (may be empty). Terminal lines come from #terminal.
|
|
27
29
|
def render(lane:, events:, alive:)
|
|
28
30
|
return "" if @level == 0 && !@jsonl
|
|
29
31
|
|
|
@@ -32,69 +34,67 @@ module Space::Architect
|
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
lines = []
|
|
35
|
-
terminal = nil
|
|
36
|
-
|
|
37
37
|
events.each do |ev|
|
|
38
38
|
case ev["type"]
|
|
39
|
-
when "
|
|
39
|
+
when "message_end"
|
|
40
|
+
next unless ev.dig("message", "role") == "assistant"
|
|
40
41
|
Array(ev.dig("message", "content")).each do |block|
|
|
41
42
|
case block["type"]
|
|
42
43
|
when "thinking"
|
|
43
44
|
lines << "[#{lane}] #{block['thinking'].to_s.strip}" if @thinking && @level >= 1
|
|
44
45
|
when "text"
|
|
45
46
|
lines << "[#{lane}] #{block['text'].to_s.strip}" if @level >= 2
|
|
46
|
-
when "
|
|
47
|
+
when "toolCall"
|
|
47
48
|
if @level >= 3
|
|
48
49
|
name_line = "[#{lane}] tool: #{block['name']}"
|
|
49
50
|
if @level >= 4
|
|
50
|
-
|
|
51
|
-
name_line += " #{JSON.generate(
|
|
51
|
+
arguments = block["arguments"]
|
|
52
|
+
name_line += " #{JSON.generate(arguments)}" if arguments && !arguments.empty?
|
|
52
53
|
end
|
|
53
54
|
lines << name_line
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
end
|
|
57
|
-
when "
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
when "tool_execution_start"
|
|
59
|
+
if @level >= 3
|
|
60
|
+
line = "[#{lane}] tool: #{ev['toolName']}"
|
|
61
|
+
if @level >= 4
|
|
62
|
+
args = ev["args"]
|
|
63
|
+
line += " #{JSON.generate(args)}" if args && !args.empty?
|
|
64
|
+
end
|
|
65
|
+
lines << line
|
|
66
|
+
end
|
|
67
|
+
when "tool_execution_end"
|
|
68
|
+
if @level >= 4
|
|
69
|
+
text = Array(ev.dig("result", "content")).select { |c| c.is_a?(Hash) && c["type"] == "text" }
|
|
70
|
+
.map { |c| c["text"].to_s }.join
|
|
71
|
+
lines << "[#{lane}] tool_result: #{text.strip}"
|
|
64
72
|
end
|
|
65
|
-
when "result"
|
|
66
|
-
terminal = ev
|
|
67
73
|
end
|
|
68
74
|
end
|
|
69
75
|
|
|
70
|
-
if
|
|
71
|
-
lines << terminal_line(lane, terminal)
|
|
72
|
-
elsif alive && @level >= 1 && events.empty?
|
|
73
|
-
lines << "[#{lane}] running"
|
|
74
|
-
end
|
|
76
|
+
lines << "[#{lane}] running" if alive && @level >= 1 && events.empty?
|
|
75
77
|
|
|
76
78
|
lines.reject(&:empty?).join("\n").then { |s| s.empty? ? s : "#{s}\n" }
|
|
77
79
|
end
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
# The terminal line, fed terminal FACTS by the mux (never a fabricated
|
|
82
|
+
# event): ok/failed, the failure reason or the final-text snippet, the
|
|
83
|
+
# run span, and the turn count.
|
|
84
|
+
def terminal(lane:, ok:, reason: nil, snippet: nil, duration: nil, turns: nil)
|
|
85
|
+
return "" if @level == 0 || @jsonl
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if ev["is_error"]
|
|
87
|
-
reason = ev["result"].to_s.strip
|
|
88
|
-
reason = ev["subtype"] if reason.empty?
|
|
89
|
-
"[#{lane}] ✗ failed #{reason}"
|
|
90
|
-
elsif ev["subtype"] == "success"
|
|
91
|
-
dur = ev["duration_ms"] ? "#{(ev['duration_ms'] / 1000.0).round(1)}s" : "-"
|
|
92
|
-
turns = ev["num_turns"] || "-"
|
|
93
|
-
result_snip = ev["result"].to_s.strip.slice(0, 80)
|
|
94
|
-
"[#{lane}] ✓ complete · STATUS: #{result_snip} · #{dur} · #{turns} turns"
|
|
87
|
+
line = if ok
|
|
88
|
+
span = duration ? "#{duration}s" : "-"
|
|
89
|
+
"[#{lane}] ✓ complete · STATUS: #{snippet.to_s.strip.slice(0, 80)} · #{span} · #{turns || '-'} turns"
|
|
95
90
|
else
|
|
96
|
-
"[#{lane}]
|
|
91
|
+
"[#{lane}] ✗ failed #{reason.to_s.strip}"
|
|
97
92
|
end
|
|
93
|
+
"#{line}\n"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def lifecycle?
|
|
97
|
+
@level >= 1 && !@jsonl
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
end
|
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
4
5
|
require "time"
|
|
5
6
|
|
|
6
7
|
module Space::Architect
|
|
7
8
|
module Research
|
|
8
9
|
class Supervisor
|
|
9
|
-
DEFAULT_MODEL = Harness::
|
|
10
|
+
DEFAULT_MODEL = Harness::DEFAULT_MODEL
|
|
10
11
|
DEFAULT_MAX_TURNS = 40
|
|
11
12
|
|
|
13
|
+
# The vendored builder-guard extension ships in the gem next to this file's
|
|
14
|
+
# sibling pi/ dir — copied per-run and injected with the harness.
|
|
15
|
+
VENDORED_GUARD = Pathname.new(__dir__).join("../pi/builder-guard.ts")
|
|
16
|
+
|
|
12
17
|
def initialize(space:, bin: nil)
|
|
13
18
|
@space = space
|
|
14
19
|
@bin = bin
|
|
15
20
|
@registry = Registry.new(space.path.join("build", "research", "registry.yaml"))
|
|
16
21
|
end
|
|
17
22
|
|
|
18
|
-
# Dispatch each prompt file as a detached
|
|
19
|
-
#
|
|
23
|
+
# Dispatch each prompt file as a detached pi child. Returns array of Run
|
|
24
|
+
# objects (non-blocking).
|
|
20
25
|
def dispatch(prompts, model: DEFAULT_MODEL, max_turns: DEFAULT_MAX_TURNS)
|
|
21
26
|
prompts.map { |path| dispatch_one(Pathname.new(path), model: model, max_turns: max_turns) }
|
|
22
27
|
end
|
|
@@ -54,12 +59,19 @@ module Space::Architect
|
|
|
54
59
|
|
|
55
60
|
FileUtils.cp(path.to_s, prompt_path.to_s)
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
# Vendor the guard into the run dir (idempotent overwrite) and inject it via
|
|
63
|
+
# the harness — research runs get the same deny-only builder guard.
|
|
64
|
+
guard_path = dir.join("builder-guard.ts")
|
|
65
|
+
FileUtils.cp(VENDORED_GUARD, guard_path)
|
|
66
|
+
|
|
67
|
+
# config_dir: is pi's --session-dir — research sessions land under
|
|
68
|
+
# build/research/<id>/ instead of ~/.pi/agent/sessions/.
|
|
69
|
+
harness = Harness::PiHarness.new(
|
|
70
|
+
model: model,
|
|
71
|
+
max_turns: max_turns,
|
|
72
|
+
bin: @bin,
|
|
73
|
+
config_dir: dir,
|
|
74
|
+
guard_path: guard_path
|
|
63
75
|
)
|
|
64
76
|
|
|
65
77
|
pid = harness.run_detached(
|
|
@@ -87,18 +99,27 @@ module Space::Architect
|
|
|
87
99
|
File.basename(path.to_s).sub(/\.prompt\.md\z/, "").sub(/\.md\z/, "")
|
|
88
100
|
end
|
|
89
101
|
|
|
102
|
+
# pi has no terminal "result" event: the run's outcome is the LAST assistant
|
|
103
|
+
# message's stopReason ("stop" finished normally; "error"/"aborted" failed —
|
|
104
|
+
# pi's StopReason vocabulary; PiEvents is the one home for these
|
|
105
|
+
# predicates). Without one, a live pid is still running and a dead pid
|
|
106
|
+
# died without finishing.
|
|
90
107
|
def classify(run)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
terminal = events.find { |e| e["type"] == "result" }
|
|
108
|
+
events = read_events(run.run_log_path)
|
|
109
|
+
reason = PiEvents.stop_reason(events)
|
|
94
110
|
|
|
95
|
-
return :complete if
|
|
96
|
-
return :failed if
|
|
111
|
+
return :complete if reason == "stop"
|
|
112
|
+
return :failed if PiEvents.failed_stop_reason?(reason)
|
|
97
113
|
|
|
98
114
|
pid_alive = begin; Process.kill(0, run.pid); true; rescue Errno::ESRCH, Errno::EPERM; false; end
|
|
99
115
|
pid_alive ? :running : :failed
|
|
100
116
|
end
|
|
101
117
|
|
|
118
|
+
def read_events(path)
|
|
119
|
+
return [] unless File.exist?(path.to_s)
|
|
120
|
+
File.readlines(path.to_s).filter_map { |l| JSON.parse(l.chomp) rescue nil }
|
|
121
|
+
end
|
|
122
|
+
|
|
102
123
|
def tail_lines(path, n)
|
|
103
124
|
return [] unless File.exist?(path.to_s)
|
|
104
125
|
File.readlines(path.to_s).last(n).map(&:chomp)
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
module Research
|
|
5
|
-
READONLY_TOOLS = "Read,Grep,Glob,WebSearch,WebFetch"
|
|
6
|
-
end
|
|
7
|
-
end
|
|
8
|
-
|
|
3
|
+
require_relative "research/pi_events"
|
|
9
4
|
require_relative "research/run"
|
|
10
5
|
require_relative "research/registry"
|
|
11
6
|
require_relative "research/renderer"
|
|
@@ -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
|
|
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
|
|
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
|
-
#
|
|
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
|
data/lib/space_architect.rb
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "space_core"
|
|
4
|
-
|
|
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"
|