space-architect 9.1.0 → 9.2.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/lib/space_architect/architect_project.rb +141 -24
- data/lib/space_architect/cli/architect.rb +200 -51
- data/lib/space_architect/extension_installer.rb +44 -0
- data/lib/space_architect/looplog.rb +425 -0
- data/lib/space_architect/routing.rb +4 -4
- data/lib/space_architect/runlog.rb +228 -0
- data/lib/space_architect/space_push.rb +274 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- data/skill/architect/SKILL.md +319 -521
- data/skill/architect/dispatch.md +76 -5
- data/skill/architect-plan/SKILL.md +114 -0
- metadata +6 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "find"
|
|
6
|
+
|
|
7
|
+
module Space
|
|
8
|
+
module Architect
|
|
9
|
+
# looplog — follow every active pi-subagents run in a space, live. A
|
|
10
|
+
# faithful port of the pi-port bin/looplog (the byte-compat oracle; same
|
|
11
|
+
# display bytes), module-ized under Space::Architect.
|
|
12
|
+
#
|
|
13
|
+
# architect looplog # follow active runs as they stream
|
|
14
|
+
# architect looplog --list # inventory only (active/age/size/agent per run)
|
|
15
|
+
# architect looplog --replay # print existing history on adoption, then follow
|
|
16
|
+
# architect looplog --all # adopt every transcript, however stale
|
|
17
|
+
# architect looplog my-space # default: nearest ancestor with space.yaml
|
|
18
|
+
#
|
|
19
|
+
# The source is pi-subagents' artifact transcripts —
|
|
20
|
+
# <session-dir>/**/subagent-artifacts/<runid>_<agent>_transcript.jsonl,
|
|
21
|
+
# nested session dirs included — from TWO roots: pi's default sessions dir
|
|
22
|
+
# for the space slug (agents spawned with cwd = the space) AND the space's
|
|
23
|
+
# build/ tree (the loop launches coordinators from headless
|
|
24
|
+
# `pi -p --session-dir build/loop/<launch>/` bootstraps, so the coordinator,
|
|
25
|
+
# its spec/builder/judge children, and deeper descendants all live inside
|
|
26
|
+
# the space — lane worktrees are pruned). Records are message-granularity:
|
|
27
|
+
# runlog's display discipline carries over — thinking dim, assistant text
|
|
28
|
+
# plain, tool calls one-lined with their args, results one-lined with a
|
|
29
|
+
# line count, ✓ done / ✗ failed with usage summed across the exchange, and
|
|
30
|
+
# truncation at RUNLOG_LIMIT (default 2000) characters. Followed lines carry
|
|
31
|
+
# the record's own timestamp (HH:MM:SS local) after the run tag; --list
|
|
32
|
+
# shows last-activity HH:MM beside the age. Thinking text is NOT truncated —
|
|
33
|
+
# set RUNLOG_THINK to clamp it if a run goes feral.
|
|
34
|
+
module Looplog
|
|
35
|
+
DEFAULT_LIMIT = 2000
|
|
36
|
+
WINDOW_DEFAULT = 900 # seconds: staleness threshold
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Inventory only: one line per transcript, mtime desc, no adoption.
|
|
40
|
+
def list(space:, io: $stdout, env: ENV)
|
|
41
|
+
Follower.new(space: space, io: io, env: env, replay: false, all: false, list_only: true).scan
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Follow active runs live; never returns on its own (Interrupt exits
|
|
45
|
+
# quietly inside Follower#run).
|
|
46
|
+
def follow(space:, io: $stdout, env: ENV, replay: false, all: false)
|
|
47
|
+
Follower.new(space: space, io: io, env: env, replay: replay, all: all).run
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Shared paint/oneline/discovery helpers, ported verbatim from the oracle
|
|
52
|
+
# script. Color and the truncation budgets arrive per call (env/io), not
|
|
53
|
+
# as load-time globals like the script's.
|
|
54
|
+
module Support
|
|
55
|
+
ROLE_ABBREV = {
|
|
56
|
+
"architect-coordinator" => "coord",
|
|
57
|
+
"architect-spec" => "spec",
|
|
58
|
+
"architect-judge" => "judge",
|
|
59
|
+
"architect-builder" => "bld"
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
TAG_COLORS = %w[36 35 33 32 34 95 96 91].freeze
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def paint(text, code)
|
|
67
|
+
@color ? "\e[#{code}m#{text}\e[0m" : text
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dim(s) = paint(s, "2")
|
|
71
|
+
def bold(s) = paint(s, "1")
|
|
72
|
+
def cyan(s) = paint(s, "36")
|
|
73
|
+
def green(s) = paint(s, "32")
|
|
74
|
+
def red(s) = paint(s, "31")
|
|
75
|
+
|
|
76
|
+
# Collapses whitespace and clips, so a tool argument stays on one line.
|
|
77
|
+
def oneline(value, limit)
|
|
78
|
+
s = value.to_s.gsub(/\s+/, " ").strip
|
|
79
|
+
s.length > limit ? "#{s[0, limit]}…" : s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clip(text, limit = nil)
|
|
83
|
+
return text if limit.nil? || text.length <= limit
|
|
84
|
+
|
|
85
|
+
"#{text[0, limit]}…"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sessions_root(env)
|
|
89
|
+
env["PI_SESSIONS_DIR"] || File.join(Dir.home, ".pi", "agent", "sessions")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# pi names a session dir after the project root, dash-cased: the absolute
|
|
93
|
+
# path's leading slash contributes one leading dash, pi adds one more, and a
|
|
94
|
+
# trailing pair closes it (verified: …/20261009-pi-port → --Users-eric-
|
|
95
|
+
# architect-spaces-20261009-pi-port--; /private/tmp/tart-bang-smoke →
|
|
96
|
+
# --private-tmp-tart-bang-smoke--).
|
|
97
|
+
def session_slug(path)
|
|
98
|
+
"-#{path.tr('/', '-')}--"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Two roots, deduped by path: pi's default sessions dir for the space slug
|
|
102
|
+
# (agents spawned with cwd = the space) and the space's build/ tree (the
|
|
103
|
+
# loop's headless --session-dir bootstraps root their whole transcript trees
|
|
104
|
+
# there). Lane worktrees are the only huge subtrees under build/ — pruned.
|
|
105
|
+
def discover(space, env)
|
|
106
|
+
roots = [File.join(sessions_root(env), session_slug(space.to_s)), File.join(space.to_s, "build")]
|
|
107
|
+
.select { |root| File.directory?(root) }
|
|
108
|
+
.flat_map { |root| scan_root(root) }
|
|
109
|
+
roots.uniq { |t| t.path }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def scan_root(root)
|
|
113
|
+
Find.find(root).filter_map do |p| # paths:exempt — walks the foreign pi sessions corpus and the space's build/ tree (nested session dirs included); any-depth discovery of *_transcript.jsonl IS this module's contract, mirroring the bin/looplog oracle
|
|
114
|
+
if File.directory?(p)
|
|
115
|
+
Find.prune if File.basename(p) == "wt"
|
|
116
|
+
next
|
|
117
|
+
end
|
|
118
|
+
next unless p.end_with?("_transcript.jsonl")
|
|
119
|
+
|
|
120
|
+
base = File.basename(p, ".jsonl").delete_suffix("_transcript")
|
|
121
|
+
run_id, agent = base.split("_", 2)
|
|
122
|
+
next unless run_id && agent
|
|
123
|
+
|
|
124
|
+
st = File.stat(p)
|
|
125
|
+
Transcript.new(p, run_id, agent, st.mtime.to_f, st.size, nil, nil)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Bounded backscan for the newest "Task:" user record and the live cwd —
|
|
130
|
+
# every record carries cwd, so the file's tail suffices; the Task: record
|
|
131
|
+
# lives at the head, so a tail miss falls back to the first 64 KB.
|
|
132
|
+
def backscan(path)
|
|
133
|
+
tail = []
|
|
134
|
+
File.open(path, "rb") do |f|
|
|
135
|
+
f.seek(0, IO::SEEK_END)
|
|
136
|
+
window = [f.tell, 64 * 1024].min
|
|
137
|
+
f.seek(-window, IO::SEEK_END)
|
|
138
|
+
f.each_line { |l| tail << l }
|
|
139
|
+
end
|
|
140
|
+
head = []
|
|
141
|
+
File.open(path, "rb") do |f|
|
|
142
|
+
f.each_line { |l| head << l; break if head.sum(&:bytesize) > 64 * 1024 }
|
|
143
|
+
end
|
|
144
|
+
task = nil
|
|
145
|
+
cwd = nil
|
|
146
|
+
tail.reverse_each { |l| cwd, task = note_record(l, cwd, task) }
|
|
147
|
+
head.reverse_each { |l| cwd, task = note_record(l, cwd, task) }
|
|
148
|
+
[cwd, task]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def note_record(line, cwd, task)
|
|
152
|
+
return [cwd, task] unless line.start_with?("{") && (cwd.nil? || task.nil?)
|
|
153
|
+
|
|
154
|
+
rec = begin
|
|
155
|
+
JSON.parse(line)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
return [cwd, task] unless rec
|
|
160
|
+
|
|
161
|
+
cwd ||= rec["cwd"]
|
|
162
|
+
if task.nil? && rec["recordType"] == "message" && rec["role"] == "user" &&
|
|
163
|
+
rec["text"].to_s.start_with?("Task:")
|
|
164
|
+
task = rec["text"].lines.first.chomp[0, 110]
|
|
165
|
+
end
|
|
166
|
+
[cwd, task]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# [bld:I35-editor-multiline-core] when cwd is a lane worktree, else the role.
|
|
170
|
+
def tag_label(agent, cwd)
|
|
171
|
+
role = ROLE_ABBREV.fetch(agent, agent[0, 10])
|
|
172
|
+
return role unless cwd && File.basename(cwd) == "wt"
|
|
173
|
+
|
|
174
|
+
lane = File.basename(File.dirname(cwd)).sub(/\A[Ii]\d+-/, "")
|
|
175
|
+
"#{role}:#{lane[0, 26]}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def paint_tag(label, run_id)
|
|
179
|
+
code = TAG_COLORS[run_id.sum % TAG_COLORS.size]
|
|
180
|
+
paint("[#{label}]", code)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
Transcript = Data.define(:path, :run_id, :agent, :mtime, :size, :cwd, :task)
|
|
185
|
+
|
|
186
|
+
# Adapts runlog's Printer to whole-message transcript records; emits fully
|
|
187
|
+
# painted lines (the follower prefixes the per-run tag). Ported verbatim.
|
|
188
|
+
class Renderer
|
|
189
|
+
include Support
|
|
190
|
+
|
|
191
|
+
def initialize(limit:, think_limit:, color:)
|
|
192
|
+
@limit = limit
|
|
193
|
+
@think_limit = think_limit
|
|
194
|
+
@color = color
|
|
195
|
+
@exchange = nil # usage accumulated across the rounds of an exchange
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def render(rec)
|
|
199
|
+
case rec["recordType"]
|
|
200
|
+
when "tool_start" then [tool_call(rec)]
|
|
201
|
+
when "tool_end" then [] # the result rides the toolResult message
|
|
202
|
+
when "message" then message(rec)
|
|
203
|
+
else []
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def message(rec)
|
|
210
|
+
case rec["role"]
|
|
211
|
+
when "assistant" then assistant(rec)
|
|
212
|
+
when "toolResult" then [tool_result(rec)]
|
|
213
|
+
when "user"
|
|
214
|
+
# A user message opens an exchange; every assistant round that follows
|
|
215
|
+
# belongs to it, up through the final (non-toolUse) stop.
|
|
216
|
+
@exchange = { started_at: rec["ts"], output: 0, cache_read: 0, cost: 0.0 }
|
|
217
|
+
[]
|
|
218
|
+
else # system/custom echoes: everything of interest shows elsewhere
|
|
219
|
+
[]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def assistant(rec)
|
|
224
|
+
lines = []
|
|
225
|
+
(rec.dig("message", "content") || []).each do |block|
|
|
226
|
+
case block["type"]
|
|
227
|
+
when "thinking"
|
|
228
|
+
text = clip(block["thinking"].to_s, @think_limit)
|
|
229
|
+
next if text.empty?
|
|
230
|
+
|
|
231
|
+
lines << dim("· thinking")
|
|
232
|
+
text.lines.each { |l| lines << dim(l.chomp) }
|
|
233
|
+
when "text"
|
|
234
|
+
text = block["text"].to_s
|
|
235
|
+
next if text.empty?
|
|
236
|
+
|
|
237
|
+
lines << bold("● assistant")
|
|
238
|
+
text.lines.each { |l| lines << l.chomp }
|
|
239
|
+
end
|
|
240
|
+
# toolCall blocks: the harness re-emits canonical args on tool_start,
|
|
241
|
+
# which is what we display — one source, reflecting what actually ran.
|
|
242
|
+
end
|
|
243
|
+
lines << usage_line(rec) if final_stop?(rec)
|
|
244
|
+
lines
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def final_stop?(rec)
|
|
248
|
+
reason = rec["stopReason"]
|
|
249
|
+
reason && reason != "toolUse"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def usage_line(rec)
|
|
253
|
+
u = rec["usage"] || {}
|
|
254
|
+
ex = @exchange
|
|
255
|
+
output = (ex ? ex[:output] : 0) + u["output"].to_i
|
|
256
|
+
cache = (ex ? ex[:cache_read] : 0) + u["cacheRead"].to_i
|
|
257
|
+
# cost is nested ({total: …}) in run.jsonl-shaped events but a plain
|
|
258
|
+
# Float in the artifact transcripts — accept both.
|
|
259
|
+
cost_value = u["cost"]
|
|
260
|
+
cost_value = cost_value["total"] if cost_value.is_a?(Hash)
|
|
261
|
+
cost = (ex ? ex[:cost] : 0.0) + cost_value.to_f
|
|
262
|
+
started = ex ? ex[:started_at] : nil
|
|
263
|
+
@exchange = nil
|
|
264
|
+
elapsed = started && rec["ts"] ? (rec["ts"] - started) / 1000 : nil
|
|
265
|
+
stats = [
|
|
266
|
+
"#{output} out",
|
|
267
|
+
"#{cache} cache read",
|
|
268
|
+
("$#{format('%.2f', cost)}" if cost.positive?),
|
|
269
|
+
("#{format('%.0f', elapsed)}s" if elapsed)
|
|
270
|
+
].compact.join(" ")
|
|
271
|
+
failed = rec["stopReason"].to_s.casecmp("error").zero?
|
|
272
|
+
(failed ? red("✗ failed") : green("✓ done")) + dim(" #{stats}")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def tool_call(rec)
|
|
276
|
+
args = begin
|
|
277
|
+
JSON.parse(rec["argsPayload"] || "")
|
|
278
|
+
rescue JSON::ParserError, TypeError
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
summary = if args.is_a?(Hash)
|
|
282
|
+
args.map { |k, v| "#{k}: #{oneline(v, @limit)}" }.join(" ")
|
|
283
|
+
else
|
|
284
|
+
oneline(rec["argsPreview"], @limit)
|
|
285
|
+
end
|
|
286
|
+
"#{cyan("⚒ #{rec['toolName']}")} #{dim(summary)}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def tool_result(rec)
|
|
290
|
+
body = rec["text"].to_s
|
|
291
|
+
note = body.lines.size > 1 ? dim(" (#{body.lines.size} lines)") : ""
|
|
292
|
+
marker = rec["isError"] ? red("✗") : green("←")
|
|
293
|
+
"#{marker} #{oneline(body, @limit)}#{note}"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Scans for transcripts, adopts the fresh ones, and drains their appended
|
|
298
|
+
# bytes — the follow loop's engine, ported verbatim.
|
|
299
|
+
class Follower
|
|
300
|
+
include Support
|
|
301
|
+
|
|
302
|
+
POLL = 0.5
|
|
303
|
+
RESCAN = 2.0
|
|
304
|
+
|
|
305
|
+
Run = Struct.new(:path, :run_id, :agent, :tag, :renderer, :offset, :partial, :stamp)
|
|
306
|
+
|
|
307
|
+
attr_reader :runs
|
|
308
|
+
|
|
309
|
+
def initialize(space:, io:, env:, replay:, all:, list_only: false)
|
|
310
|
+
@space = space
|
|
311
|
+
@io = io
|
|
312
|
+
@env = env
|
|
313
|
+
@limit = (env["RUNLOG_LIMIT"] || DEFAULT_LIMIT).to_i
|
|
314
|
+
@window = (env["LOOPLOG_WINDOW"] || WINDOW_DEFAULT).to_i
|
|
315
|
+
@replay = replay
|
|
316
|
+
@all = all
|
|
317
|
+
@list_only = list_only
|
|
318
|
+
@color = io.tty? && env["NO_COLOR"].nil?
|
|
319
|
+
@runs = {}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def run
|
|
323
|
+
scan
|
|
324
|
+
return if @list_only
|
|
325
|
+
|
|
326
|
+
last_scan = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
327
|
+
loop do
|
|
328
|
+
sleep POLL
|
|
329
|
+
@runs.each_value { |r| drain(r) }
|
|
330
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
331
|
+
if now - last_scan >= RESCAN
|
|
332
|
+
scan
|
|
333
|
+
last_scan = now
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
rescue Interrupt
|
|
337
|
+
# ctrl+c while following: exit quietly with the output already on screen.
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# The incremental seams the tests exercise: scan adopts fresh transcripts
|
|
341
|
+
# (or prints the inventory when list-only), drain consumes newly appended
|
|
342
|
+
# bytes. Public on purpose — tests drive these directly instead of the
|
|
343
|
+
# never-ending follow loop.
|
|
344
|
+
def scan
|
|
345
|
+
now = Time.now.to_f
|
|
346
|
+
if @list_only
|
|
347
|
+
discover(@space, @env).sort_by { |t| -t.mtime }.each do |t|
|
|
348
|
+
age_min = (now - t.mtime) / 60
|
|
349
|
+
active = (now - t.mtime) < @window
|
|
350
|
+
cwd, task = backscan(t.path)
|
|
351
|
+
@io.puts format("%s %-9s %5s %5.0fm %7sK %s %s",
|
|
352
|
+
active ? green("●") : dim("·"),
|
|
353
|
+
tag_label(t.agent, cwd),
|
|
354
|
+
dim(Time.at(t.mtime).strftime("%H:%M")),
|
|
355
|
+
age_min, t.size / 1024,
|
|
356
|
+
dim(t.run_id[0, 8]),
|
|
357
|
+
dim(task.to_s[0, 70]))
|
|
358
|
+
end
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
discover(@space, @env).each do |t|
|
|
362
|
+
next if @runs.key?(t.path)
|
|
363
|
+
next unless @all || (now - t.mtime) < @window
|
|
364
|
+
|
|
365
|
+
adopt(t)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def drain(run)
|
|
370
|
+
return unless File.exist?(run.path) && File.size(run.path) > run.offset
|
|
371
|
+
|
|
372
|
+
File.open(run.path, "rb") do |f|
|
|
373
|
+
f.seek(run.offset)
|
|
374
|
+
run.partial << f.read
|
|
375
|
+
run.offset = f.tell
|
|
376
|
+
end
|
|
377
|
+
while (idx = run.partial.index("\n"))
|
|
378
|
+
line = run.partial.slice!(0..idx)
|
|
379
|
+
next unless line.start_with?("{")
|
|
380
|
+
|
|
381
|
+
rec = begin
|
|
382
|
+
JSON.parse(line)
|
|
383
|
+
rescue JSON::ParserError
|
|
384
|
+
next
|
|
385
|
+
end
|
|
386
|
+
run.stamp = stamp_for(rec)
|
|
387
|
+
emit(run, run.renderer.render(rec))
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
private
|
|
392
|
+
|
|
393
|
+
def adopt(t)
|
|
394
|
+
cwd, task = backscan(t.path)
|
|
395
|
+
tag = paint_tag(tag_label(t.agent, cwd), t.run_id)
|
|
396
|
+
run = Run.new(t.path, t.run_id, t.agent, tag,
|
|
397
|
+
Renderer.new(limit: @limit, think_limit: @env["RUNLOG_THINK"]&.to_i, color: @color),
|
|
398
|
+
@replay ? 0 : t.size, +"", stamp_now)
|
|
399
|
+
@runs[t.path] = run
|
|
400
|
+
emit(run, [bold("▶ #{t.agent} #{t.run_id[0, 8]}") +
|
|
401
|
+
(cwd ? dim(" @ #{cwd.sub(Dir.home, '~')}") : "") +
|
|
402
|
+
(task ? dim(" — #{task}") : "")])
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def emit(run, lines)
|
|
406
|
+
stamp = run.stamp ? "#{dim(run.stamp)} " : ""
|
|
407
|
+
lines.each { |l| @io.puts "#{run.tag} #{stamp}#{l}" unless l.nil? || l.empty? }
|
|
408
|
+
@io.flush
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def stamp_now = Time.now.strftime("%H:%M:%S")
|
|
412
|
+
|
|
413
|
+
# Records carry epoch-ms +"ts" and an ISO +"timestamp+" (UTC); display local.
|
|
414
|
+
def stamp_for(rec)
|
|
415
|
+
t = if rec["ts"].is_a?(Numeric)
|
|
416
|
+
Time.at(rec["ts"] / 1000.0)
|
|
417
|
+
elsif (iso = rec["timestamp"])
|
|
418
|
+
Time.parse(iso) rescue nil
|
|
419
|
+
end
|
|
420
|
+
t&.strftime("%H:%M:%S")
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
@@ -17,9 +17,9 @@ module Space::Architect
|
|
|
17
17
|
# XDG_CONFIG_HOME); space-local is architect.yaml beside the loaded
|
|
18
18
|
# space.yaml (the CLI already resolved the space — no walk-up).
|
|
19
19
|
module Routing
|
|
20
|
-
ROLES = %w[architect spec builder judge coordinator].freeze
|
|
21
|
-
# The
|
|
22
|
-
REQUIRED_ROLES = %w[spec builder judge coordinator].freeze
|
|
20
|
+
ROLES = %w[architect spec builder judge coordinator narrator].freeze
|
|
21
|
+
# The five agent roles that must be routed after the merge; `architect` is optional.
|
|
22
|
+
REQUIRED_ROLES = %w[spec builder judge coordinator narrator].freeze
|
|
23
23
|
ENTRY_KEYS = %w[model thinking].freeze
|
|
24
24
|
|
|
25
25
|
def self.global_path
|
|
@@ -34,7 +34,7 @@ module Space::Architect
|
|
|
34
34
|
|
|
35
35
|
# Parse one file's text into { role => {"model" => id, "thinking" => level|nil} }.
|
|
36
36
|
# +path+ is used only in error messages. Frozen v1 rules: integer version 1,
|
|
37
|
-
# the
|
|
37
|
+
# the six known roles, entries ⊆ {model, thinking} with a non-empty model,
|
|
38
38
|
# duplicate mapping keys rejected (psych's plain load silently keeps the
|
|
39
39
|
# last — the npm yaml the extension parses with throws), aliases accepted,
|
|
40
40
|
# a trailing :level suffix on model parsed via Harness.parse_model_suffix
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Space
|
|
6
|
+
module Architect
|
|
7
|
+
# Pretty-prints a pi agent run.jsonl as it streams — a faithful port of
|
|
8
|
+
# ~/bin/runlog (the byte-compat oracle; same display bytes).
|
|
9
|
+
#
|
|
10
|
+
# tail -f build/I08-.../run.jsonl | architect runlog
|
|
11
|
+
# architect runlog build/I08-.../run.jsonl
|
|
12
|
+
#
|
|
13
|
+
# Thinking is dim, assistant text is plain, tool calls and their results are
|
|
14
|
+
# one-liners. System/user/toolResult message echoes, and non-JSON noise on
|
|
15
|
+
# the stream, are dropped.
|
|
16
|
+
#
|
|
17
|
+
# Truncation of tool inputs/outputs defaults to 2000 characters; override
|
|
18
|
+
# with RUNLOG_LIMIT.
|
|
19
|
+
module Runlog
|
|
20
|
+
DEFAULT_LIMIT = 2000
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Iterates input's lines, dropping non-`{`-prefixed lines and JSON parse
|
|
24
|
+
# failures, feeding a Printer. Interrupt → quiet return with the output
|
|
25
|
+
# already on screen (ctrl+c while tail-following a live run).
|
|
26
|
+
def stream(input:, io: $stdout, env: ENV)
|
|
27
|
+
printer = Printer.new(
|
|
28
|
+
io: io,
|
|
29
|
+
limit: (env["RUNLOG_LIMIT"] || DEFAULT_LIMIT).to_i,
|
|
30
|
+
color: io.tty? && env["NO_COLOR"].nil?
|
|
31
|
+
)
|
|
32
|
+
input.each_line do |raw|
|
|
33
|
+
next unless raw.start_with?("{")
|
|
34
|
+
|
|
35
|
+
event = JSON.parse(raw) rescue next
|
|
36
|
+
printer.handle(event)
|
|
37
|
+
end
|
|
38
|
+
rescue Interrupt
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Ported verbatim from the oracle script: streamed deltas write
|
|
44
|
+
# progressively (label held back until a block actually emits), whole
|
|
45
|
+
# tool events one-line, and usage accumulates across the tool rounds of
|
|
46
|
+
# an exchange so the done line reports the whole turn, not one round.
|
|
47
|
+
class Printer
|
|
48
|
+
def initialize(io:, limit:, color:)
|
|
49
|
+
@io = io
|
|
50
|
+
@limit = limit
|
|
51
|
+
@color = color
|
|
52
|
+
@column = 0 # tracks whether we're mid-line inside a streamed block
|
|
53
|
+
@mode = nil # :thinking or :text, so we only label a block once
|
|
54
|
+
@label = nil # header held back until the block actually emits something
|
|
55
|
+
@turn = nil # usage accumulated across the rounds of the exchange in flight
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle(event)
|
|
59
|
+
case event["type"]
|
|
60
|
+
when "message_update" then delta(event["assistantMessageEvent"], event["usage"])
|
|
61
|
+
when "message_start" then message_start(event["message"])
|
|
62
|
+
when "message_end" then message_end(event["message"])
|
|
63
|
+
when "tool_execution_start" then tool_call(event)
|
|
64
|
+
when "tool_execution_end" then tool_result(event)
|
|
65
|
+
when "turn_end" then turn_end(event["message"])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def message_start(message)
|
|
72
|
+
return unless message["role"] == "assistant"
|
|
73
|
+
|
|
74
|
+
@column = 0
|
|
75
|
+
@mode = nil
|
|
76
|
+
@label = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def delta(ev, usage)
|
|
80
|
+
case ev["type"]
|
|
81
|
+
when "thinking_start" then start_prose(:thinking, dim("· thinking"))
|
|
82
|
+
when "text_start" then start_prose(:text, bold("● assistant"))
|
|
83
|
+
when "thinking_delta" then write(dim(ev["delta"]))
|
|
84
|
+
when "text_delta" then write(ev["delta"])
|
|
85
|
+
when "thinking_end" then catch_up(:thinking, "· thinking", ev["content"], usage&.dig("reasoning").to_i)
|
|
86
|
+
when "text_end" then catch_up(:text, "● assistant", ev["content"], nil)
|
|
87
|
+
# toolcall_start/delta/end carry the model's tool arguments, but the
|
|
88
|
+
# harness re-emits the canonical args on tool_execution_start, which is
|
|
89
|
+
# what we display — one source, and it reflects what actually ran.
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Some models redact their thinking (or stream nothing at all) but still
|
|
94
|
+
# deliver the full block on the *_end event, or at least report a token
|
|
95
|
+
# count in the usage. The label is held back until a delta writes something;
|
|
96
|
+
# if the block ends with the label still pending, nothing was streamed, so
|
|
97
|
+
# show what we have.
|
|
98
|
+
def catch_up(mode, header, content, reasoning)
|
|
99
|
+
return unless @mode == mode && @label
|
|
100
|
+
|
|
101
|
+
if content.to_s.empty?
|
|
102
|
+
@label = nil
|
|
103
|
+
line dim("#{header} (#{reasoning} tokens, not streamed)") if reasoning.to_i.positive?
|
|
104
|
+
else
|
|
105
|
+
write(content)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# "system" / "user" / "toolResult" message echoes carry nothing the
|
|
110
|
+
# deltas and tool_execution_* events don't already show.
|
|
111
|
+
def message_end(message)
|
|
112
|
+
break_line if message["role"] == "assistant"
|
|
113
|
+
start_turn(message) if message["role"] == "user"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A user message opens a conversation exchange: every round that follows
|
|
117
|
+
# belongs to it, up through the final assistant reply.
|
|
118
|
+
def start_turn(message)
|
|
119
|
+
@turn = { started_at: message["timestamp"], output: 0, cache_read: 0, cost: 0.0, duration_ms: 0 }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def tool_call(event)
|
|
123
|
+
break_line
|
|
124
|
+
args = event["args"] || {}
|
|
125
|
+
summary = args.map { |k, v| "#{k}: #{oneline(v)}" }.join(" ")
|
|
126
|
+
line "#{cyan("⚒ #{event["toolName"]}")} #{dim(summary)}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def tool_result(event)
|
|
130
|
+
result = event["result"] || {}
|
|
131
|
+
body = flatten(result["content"])
|
|
132
|
+
body = result.dig("structuredContent", "output").to_s if body.empty?
|
|
133
|
+
lines = body.lines
|
|
134
|
+
marker = event["isError"] ? red("✗") : green("←")
|
|
135
|
+
note = lines.size > 1 ? dim(" (#{lines.size} lines)") : ""
|
|
136
|
+
line "#{marker} #{oneline(body)}#{note}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def flatten(content)
|
|
140
|
+
case content
|
|
141
|
+
when String then content
|
|
142
|
+
when Array then content.map { |b| b.is_a?(Hash) ? b["text"].to_s : b.to_s }.join("\n")
|
|
143
|
+
else content.to_s
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Each API round in the stream is a "turn": turn_end fires whenever the
|
|
148
|
+
# model stops, with stopReason "toolUse" whenever it stopped to run tools.
|
|
149
|
+
# The done line therefore waits for a final stop reason and reports usage
|
|
150
|
+
# summed across the whole exchange — not once per tool round.
|
|
151
|
+
def turn_end(message)
|
|
152
|
+
usage = message["usage"] || {}
|
|
153
|
+
turn = (@turn ||= { started_at: nil, output: 0, cache_read: 0, cost: 0.0, duration_ms: 0 })
|
|
154
|
+
turn[:output] += usage["output"].to_i
|
|
155
|
+
turn[:cache_read] += usage["cacheRead"].to_i
|
|
156
|
+
turn[:cost] += usage.dig("cost", "total").to_f
|
|
157
|
+
turn[:duration_ms] += message["durationMs"].to_i
|
|
158
|
+
return if message["stopReason"] == "toolUse"
|
|
159
|
+
|
|
160
|
+
from, to = turn[:started_at], message["timestamp"]
|
|
161
|
+
elapsed = from && to ? to - from : turn[:duration_ms]
|
|
162
|
+
stats = [
|
|
163
|
+
"#{turn[:output]} out",
|
|
164
|
+
"#{turn[:cache_read]} cache read",
|
|
165
|
+
("$#{format("%.2f", turn[:cost])}" if turn[:cost].positive?),
|
|
166
|
+
"#{elapsed / 1000}s"
|
|
167
|
+
].compact.join(" ")
|
|
168
|
+
failed = message["stopReason"].to_s.casecmp("error").zero?
|
|
169
|
+
line((failed ? red("✗ failed") : green("✓ done")) + dim(" #{stats}"))
|
|
170
|
+
@turn = nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# --- output plumbing ---------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def start_prose(mode, label)
|
|
176
|
+
break_line
|
|
177
|
+
@label = label
|
|
178
|
+
@mode = mode
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def write(text)
|
|
182
|
+
return if text.nil? || text.empty?
|
|
183
|
+
|
|
184
|
+
if @label
|
|
185
|
+
@io.puts @label
|
|
186
|
+
@label = nil
|
|
187
|
+
end
|
|
188
|
+
@io.write(text)
|
|
189
|
+
@io.flush
|
|
190
|
+
@column += text.length
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def line(text)
|
|
194
|
+
@label = nil
|
|
195
|
+
break_line
|
|
196
|
+
@io.puts text
|
|
197
|
+
@io.flush
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def break_line
|
|
201
|
+
return if @column.zero?
|
|
202
|
+
|
|
203
|
+
@io.puts
|
|
204
|
+
@column = 0
|
|
205
|
+
@mode = nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# --- paint ---------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def paint(text, code)
|
|
211
|
+
@color ? "\e[#{code}m#{text}\e[0m" : text
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def dim(s) = paint(s, "2")
|
|
215
|
+
def bold(s) = paint(s, "1")
|
|
216
|
+
def cyan(s) = paint(s, "36")
|
|
217
|
+
def green(s) = paint(s, "32")
|
|
218
|
+
def red(s) = paint(s, "31")
|
|
219
|
+
|
|
220
|
+
# Collapses whitespace and clips, so a tool argument stays on one line.
|
|
221
|
+
def oneline(value, limit = @limit)
|
|
222
|
+
s = value.to_s.gsub(/\s+/, " ").strip
|
|
223
|
+
s.length > limit ? "#{s[0, limit]}…" : s
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|