space-architect 9.1.1 → 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 +71 -23
- data/lib/space_architect/cli/architect.rb +193 -50
- data/lib/space_architect/extension_installer.rb +44 -0
- data/lib/space_architect/looplog.rb +425 -0
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec005bf012468954875cbbd74a2c3d3a4d2fbc2468523f42ec425777a6e9344a
|
|
4
|
+
data.tar.gz: c93720a06c24e62ed03352c243d78a9895c1d98311547b9860e0ad549eea382f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 416269e2e0d4a3fab56cfd8a3cf5f8afdc63db75ad0b49982e98724fb0eaca66f85970f9c86f07901a543eb198405840c460464e5cb1491fd9d1eaafb9b3624b
|
|
7
|
+
data.tar.gz: 1ba4e232aeb7bdea2670fce2864bf4db7484cc60940ec4f242d3744f393f1082592adab9ac422ef39d6f870bcedd7c6cc89c78770d6409ebf9676f1ea01fa120
|
|
@@ -560,25 +560,24 @@ module Space::Architect
|
|
|
560
560
|
gates = parse_gates(text)
|
|
561
561
|
raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if gates.empty?
|
|
562
562
|
|
|
563
|
-
|
|
564
|
-
repo_root = nil
|
|
565
|
-
base_dir =
|
|
566
|
-
if lane
|
|
567
|
-
le = lanes.find { |l| l["name"] == lane }
|
|
568
|
-
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
|
|
569
|
-
le = ensure_lane_materialized(iteration, lane)
|
|
570
|
-
repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil
|
|
571
|
-
space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
|
|
572
|
-
else
|
|
573
|
-
repo = lanes.first&.dig("repo")
|
|
574
|
-
raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
|
|
575
|
-
space.path.join("repos", repo)
|
|
576
|
-
end
|
|
563
|
+
entry, base_dir, repo = resolve_gate_run(iteration, lane)
|
|
564
|
+
repo_root = lane && repo ? space.path.join("repos", repo) : nil
|
|
577
565
|
raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
|
|
578
566
|
|
|
579
567
|
execute_gates(gates, base_dir: base_dir, lane: lane, repo_root: repo_root)
|
|
580
568
|
end
|
|
581
569
|
|
|
570
|
+
# I07: the gate-side transcript — one durable file under build/gate/ per
|
|
571
|
+
# gate run, so a judge reading whole gates never depends on a terminal tail
|
|
572
|
+
# (agent runners read the last lines; the file is the record read whole).
|
|
573
|
+
# Re-slices the entry itself and re-resolves the run directory exactly as
|
|
574
|
+
# #run_gates does — a no-op when the Gate CLI composes it right after
|
|
575
|
+
# #run_gates, the only intended caller shape. Returns the path.
|
|
576
|
+
def gate_transcript(iteration, gate_results, lane: nil, now: Time.now)
|
|
577
|
+
entry, base_dir, repo = resolve_gate_run(iteration, lane)
|
|
578
|
+
write_transcript(:gate, entry, repo, base_dir, lane, gate_results, nil, now)
|
|
579
|
+
end
|
|
580
|
+
|
|
582
581
|
# Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the
|
|
583
582
|
# freeze, while they can still be fixed — through the identical execution
|
|
584
583
|
# path #run_gates uses at judge time (#execute_gates). space.yaml records no
|
|
@@ -1270,22 +1269,40 @@ module Space::Architect
|
|
|
1270
1269
|
end
|
|
1271
1270
|
end
|
|
1272
1271
|
|
|
1273
|
-
# I01: the durable, unbounded counterpart to the CLI's bounded terminal
|
|
1274
|
-
# report — one file per
|
|
1275
|
-
# every gate's full command, dir, exit code, verdict, reason,
|
|
1276
|
-
# stdout+stderr
|
|
1277
|
-
#
|
|
1272
|
+
# I01 + I07: the durable, unbounded counterpart to the CLI's bounded terminal
|
|
1273
|
+
# report — one file per run under build/rehearse/ (rehearse) or build/gate/
|
|
1274
|
+
# (gate), holding every gate's full command, dir, exit code, verdict, reason,
|
|
1275
|
+
# and complete stdout+stderr. ONE writer, parameterized by kind: rehearse
|
|
1276
|
+
# additionally carries the scope-asymmetry findings and its RED/GREEN/BROKEN
|
|
1277
|
+
# verdict; gate carries the mechanical PASS/FAIL and no scope section — but
|
|
1278
|
+
# the per-gate emission is shared code, so the two transcripts can never drift.
|
|
1279
|
+
TRANSCRIPT_KINDS = {
|
|
1280
|
+
rehearse: { subdir: "rehearse", header: "Rehearsal transcript" },
|
|
1281
|
+
gate: { subdir: "gate", header: "Gate transcript" }
|
|
1282
|
+
}.freeze
|
|
1283
|
+
|
|
1278
1284
|
def write_rehearsal_transcript(entry, repo, base_dir, gate_results, scope_report, now)
|
|
1279
|
-
|
|
1285
|
+
write_transcript(:rehearse, entry, repo, base_dir, nil, gate_results, scope_report, now)
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def write_transcript(kind, entry, repo, base_dir, lane, gate_results, scope_report, now)
|
|
1289
|
+
spec = TRANSCRIPT_KINDS.fetch(kind)
|
|
1290
|
+
dir = space.path.join("build", spec[:subdir])
|
|
1280
1291
|
FileUtils.mkdir_p(dir)
|
|
1281
1292
|
path = dir.join("#{iteration_id(entry)}-#{now.strftime('%Y%m%dT%H%M%S.%6N')}.md")
|
|
1282
1293
|
|
|
1283
|
-
lines = ["#
|
|
1284
|
-
lines <<
|
|
1294
|
+
lines = ["# #{spec[:header]} — #{entry['name']} — #{now.iso8601}"]
|
|
1295
|
+
lines << if lane
|
|
1296
|
+
"Lane: #{lane} (#{base_dir})"
|
|
1297
|
+
elsif repo
|
|
1298
|
+
"Repo: #{repo} (#{base_dir})"
|
|
1299
|
+
else
|
|
1300
|
+
"Each gate ran in its own declared cwd"
|
|
1301
|
+
end
|
|
1285
1302
|
|
|
1286
1303
|
gate_results.each do |g|
|
|
1287
1304
|
lines << ""
|
|
1288
|
-
lines << "## #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} — #{g
|
|
1305
|
+
lines << "## #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} — #{transcript_verdict(kind, g)} (exit #{g[:exit_code].inspect})"
|
|
1289
1306
|
lines << "cmd: #{g[:cmd]}"
|
|
1290
1307
|
lines << "dir: #{g[:dir]}"
|
|
1291
1308
|
lines << "reason: #{g[:reason]}" unless g[:reason].to_s.empty?
|
|
@@ -1315,6 +1332,37 @@ module Space::Architect
|
|
|
1315
1332
|
path
|
|
1316
1333
|
end
|
|
1317
1334
|
|
|
1335
|
+
# rehearse reports the RED/GREEN/BROKEN discrimination verdict per gate;
|
|
1336
|
+
# gate reports the mechanical PASS/FAIL.
|
|
1337
|
+
def transcript_verdict(kind, g)
|
|
1338
|
+
if kind == :rehearse
|
|
1339
|
+
g[:rehearsal].to_s.upcase
|
|
1340
|
+
else
|
|
1341
|
+
g[:status] == :pass ? "PASS" : "FAIL"
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
# WHERE gates run for an iteration — shared by #run_gates and
|
|
1346
|
+
# #gate_transcript so the two can never disagree about it: the lane worktree
|
|
1347
|
+
# when a lane is given (materialized on demand), else the integration repo
|
|
1348
|
+
# checkout. Returns [entry, base_dir, repo_name] — repo_name is the run's
|
|
1349
|
+
# repo (the lane's declared repo, else the first recorded lane's), nil only
|
|
1350
|
+
# for a lane run in a repo-less lane.
|
|
1351
|
+
def resolve_gate_run(iteration, lane)
|
|
1352
|
+
entry = slice_entry(iteration)
|
|
1353
|
+
lanes = entry["lanes"] || []
|
|
1354
|
+
if lane
|
|
1355
|
+
le = lanes.find { |l| l["name"] == lane }
|
|
1356
|
+
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
|
|
1357
|
+
le = ensure_lane_materialized(iteration, lane)
|
|
1358
|
+
[entry, space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt"), le["repo"]]
|
|
1359
|
+
else
|
|
1360
|
+
repo = lanes.first&.dig("repo")
|
|
1361
|
+
raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
|
|
1362
|
+
[entry, space.path.join("repos", repo), repo]
|
|
1363
|
+
end
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1318
1366
|
# I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
|
|
1319
1367
|
# `cwd` of their own — only those gates need one, and only they are ambiguous
|
|
1320
1368
|
# when the drafted ```lanes``` block names more than one repo. A gate with its
|
|
@@ -6,6 +6,10 @@ require_relative "../jobs_client"
|
|
|
6
6
|
require_relative "../conversations_client"
|
|
7
7
|
require_relative "../loop_cost"
|
|
8
8
|
require_relative "../session_sync"
|
|
9
|
+
require_relative "../runlog"
|
|
10
|
+
require_relative "../looplog"
|
|
11
|
+
require_relative "../extension_installer"
|
|
12
|
+
require_relative "../space_push"
|
|
9
13
|
|
|
10
14
|
module Space::Architect
|
|
11
15
|
module CLI
|
|
@@ -211,6 +215,41 @@ module Space::Architect
|
|
|
211
215
|
end
|
|
212
216
|
end
|
|
213
217
|
|
|
218
|
+
class Push < BaseCommand
|
|
219
|
+
desc "Pack the current space and push it to a space-server for import"
|
|
220
|
+
phase 53, "Project"
|
|
221
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
222
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
223
|
+
option :token, required: true, desc: "Bearer token for authorization"
|
|
224
|
+
|
|
225
|
+
def call(space: nil, host: nil, token: nil, **opts)
|
|
226
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
227
|
+
handle_errors do
|
|
228
|
+
raise Space::Core::Error, "--host is required" unless host
|
|
229
|
+
raise Space::Core::Error, "--token is required" unless token
|
|
230
|
+
|
|
231
|
+
render(store.find(space)) do |sp|
|
|
232
|
+
bundle = SpacePush::Packer.new(sp.path).pack
|
|
233
|
+
begin
|
|
234
|
+
terminal.say "Packed #{sp.id}: #{format_bytes(File.size(bundle))}"
|
|
235
|
+
summary = SpacePush.new(host, token).push(bundle)
|
|
236
|
+
terminal.say "Imported: #{summary['slug']} (id #{summary['id']}) — " \
|
|
237
|
+
"#{summary['iterations']} iterations, #{summary['artifacts']} artifacts, #{summary['runs']} runs"
|
|
238
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
239
|
+
ensure
|
|
240
|
+
File.delete(bundle)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def format_bytes(size)
|
|
249
|
+
format("%.1f MiB", size.to_f / (1024 * 1024))
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
214
253
|
class Sync < BaseCommand
|
|
215
254
|
desc "Sync tracked repo clones with their remotes (fast-forward only, no rebase/reset)"
|
|
216
255
|
phase 53, "Project"
|
|
@@ -230,7 +269,57 @@ module Space::Architect
|
|
|
230
269
|
end
|
|
231
270
|
end
|
|
232
271
|
|
|
272
|
+
# I07: the bounded per-gate terminal report, shared byte-for-byte by
|
|
273
|
+
# rehearse and gate — one identity line per gate, at most
|
|
274
|
+
# OUTPUT_LINE_CAP lines of tail output, and a pointer at the transcript
|
|
275
|
+
# that holds the rest (the same one-instrument discipline as
|
|
276
|
+
# #execute_gates below the CLI). The verdict label, the reason
|
|
277
|
+
# visibility, and any verdict-specific advisory are hooks; everything
|
|
278
|
+
# else — identity line shape, dir line, output elision — is shared so the
|
|
279
|
+
# two commands can never drift.
|
|
280
|
+
module GateRendering
|
|
281
|
+
# Identity (AC label, gate id, verdict, exit code) always lands on one
|
|
282
|
+
# stdout line, however many lines `cmd` spans — the command itself is
|
|
283
|
+
# shown as at most its first line, truncated; the full command lives
|
|
284
|
+
# in the transcript.
|
|
285
|
+
CMD_LINE_MAX = 100
|
|
286
|
+
OUTPUT_LINE_CAP = 20
|
|
287
|
+
|
|
288
|
+
def render_gate(g, transcript_path)
|
|
289
|
+
terminal.say ""
|
|
290
|
+
terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]}: #{truncated_cmd(g[:cmd])} (exit #{g[:exit_code].inspect}) [#{gate_verdict(g)}]"
|
|
291
|
+
terminal.say " dir: #{terminal.path(g[:dir])}"
|
|
292
|
+
terminal.say " reason: #{g[:reason]}" if gate_reason_visible?(g) && !g[:reason].to_s.empty?
|
|
293
|
+
if g[:shared_with]&.any?
|
|
294
|
+
terminal.say " shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)"
|
|
295
|
+
end
|
|
296
|
+
render_gate_extras(g)
|
|
297
|
+
render_gate_output(g, transcript_path)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def truncated_cmd(cmd)
|
|
301
|
+
first, *rest = cmd.to_s.each_line.map(&:chomp)
|
|
302
|
+
clipped = first.length > CMD_LINE_MAX ? "#{first[0, CMD_LINE_MAX]}…" : first
|
|
303
|
+
rest.any? || clipped != first ? "#{clipped} …" : clipped
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def render_gate_output(g, transcript_path)
|
|
307
|
+
combined = [g[:stdout], g[:stderr]].map { |s| s.to_s.rstrip }.reject(&:empty?).join("\n")
|
|
308
|
+
return if combined.empty?
|
|
309
|
+
|
|
310
|
+
lines = combined.lines(chomp: true)
|
|
311
|
+
if lines.size > OUTPUT_LINE_CAP
|
|
312
|
+
terminal.say lines.last(OUTPUT_LINE_CAP).join("\n")
|
|
313
|
+
terminal.say " … #{lines.size - OUTPUT_LINE_CAP} more line(s) elided — full output in #{terminal.path(transcript_path)}"
|
|
314
|
+
else
|
|
315
|
+
terminal.say lines.join("\n")
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
233
320
|
class Rehearse < BaseCommand
|
|
321
|
+
include GateRendering
|
|
322
|
+
|
|
234
323
|
desc "Rehearse the DRAFTED (unfrozen, working-tree) gates against the repo checkout and report RED/GREEN/BROKEN/EMPTY — runs and reports, never judges"
|
|
235
324
|
phase 12, "Spec"
|
|
236
325
|
argument :iteration, required: true, desc: "Iteration name"
|
|
@@ -283,6 +372,20 @@ module Space::Architect
|
|
|
283
372
|
"Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
|
|
284
373
|
end
|
|
285
374
|
|
|
375
|
+
# GateRendering hooks: rehearse renders the RED/GREEN/BROKEN verdict,
|
|
376
|
+
# shows a gate's reason whenever one exists, and adds the BROKEN
|
|
377
|
+
# advisory — everything else comes from the shared concern.
|
|
378
|
+
def gate_verdict(g) = g[:rehearsal].to_s.upcase
|
|
379
|
+
|
|
380
|
+
def gate_reason_visible?(g) = true
|
|
381
|
+
|
|
382
|
+
def render_gate_extras(g)
|
|
383
|
+
return unless g[:rehearsal] == :broken
|
|
384
|
+
|
|
385
|
+
terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
|
|
386
|
+
"hasn't written yet). Confirm before treating it as a defect."
|
|
387
|
+
end
|
|
388
|
+
|
|
286
389
|
# #90/AC7: with a single defaultable repo, name it as before; a cross-repo
|
|
287
390
|
# run (or one where every gate declares its own `cwd`) has no one repo to
|
|
288
391
|
# name — each gate's own "dir:" line (below) already says where it ran.
|
|
@@ -294,47 +397,6 @@ module Space::Architect
|
|
|
294
397
|
end
|
|
295
398
|
end
|
|
296
399
|
|
|
297
|
-
# I01/AC "bounded terminal report": identity (AC label, gate id, verdict,
|
|
298
|
-
# exit code) always lands on one stdout line, however many lines `cmd`
|
|
299
|
-
# spans — the command itself is shown as at most its first line,
|
|
300
|
-
# truncated; the full command lives in the transcript.
|
|
301
|
-
CMD_LINE_MAX = 100
|
|
302
|
-
OUTPUT_LINE_CAP = 20
|
|
303
|
-
|
|
304
|
-
def render_gate(g, transcript_path)
|
|
305
|
-
terminal.say ""
|
|
306
|
-
terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]}: #{truncated_cmd(g[:cmd])} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
|
|
307
|
-
terminal.say " dir: #{terminal.path(g[:dir])}"
|
|
308
|
-
terminal.say " reason: #{g[:reason]}" unless g[:reason].to_s.empty?
|
|
309
|
-
if g[:shared_with]&.any?
|
|
310
|
-
terminal.say " shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)"
|
|
311
|
-
end
|
|
312
|
-
if g[:rehearsal] == :broken
|
|
313
|
-
terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
|
|
314
|
-
"hasn't written yet). Confirm before treating it as a defect."
|
|
315
|
-
end
|
|
316
|
-
render_gate_output(g, transcript_path)
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def truncated_cmd(cmd)
|
|
320
|
-
first, *rest = cmd.to_s.each_line.map(&:chomp)
|
|
321
|
-
clipped = first.length > CMD_LINE_MAX ? "#{first[0, CMD_LINE_MAX]}…" : first
|
|
322
|
-
rest.any? || clipped != first ? "#{clipped} …" : clipped
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def render_gate_output(g, transcript_path)
|
|
326
|
-
combined = [g[:stdout], g[:stderr]].map { |s| s.to_s.rstrip }.reject(&:empty?).join("\n")
|
|
327
|
-
return if combined.empty?
|
|
328
|
-
|
|
329
|
-
lines = combined.lines(chomp: true)
|
|
330
|
-
if lines.size > OUTPUT_LINE_CAP
|
|
331
|
-
terminal.say lines.last(OUTPUT_LINE_CAP).join("\n")
|
|
332
|
-
terminal.say " … #{lines.size - OUTPUT_LINE_CAP} more line(s) elided — full output in #{terminal.path(transcript_path)}"
|
|
333
|
-
else
|
|
334
|
-
terminal.say lines.join("\n")
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
|
|
338
400
|
# I12/AC3-AC4: reports scope asymmetry only — never affects RED/GREEN/
|
|
339
401
|
# BROKEN, rehearse's exit code, or the stamp. A file outside every
|
|
340
402
|
# declared lane's touch set is flagged loudest: it's the one no lane
|
|
@@ -759,6 +821,8 @@ module Space::Architect
|
|
|
759
821
|
end
|
|
760
822
|
|
|
761
823
|
class Gate < BaseCommand
|
|
824
|
+
include GateRendering
|
|
825
|
+
|
|
762
826
|
desc "Run the frozen Acceptance Criteria gate commands and report PASS/FAIL"
|
|
763
827
|
phase 32, "Judge"
|
|
764
828
|
argument :iteration, required: true, desc: "Iteration name"
|
|
@@ -771,22 +835,29 @@ module Space::Architect
|
|
|
771
835
|
render(store.find(space)) do |sp|
|
|
772
836
|
project = ArchitectProject.new(space: sp)
|
|
773
837
|
results = project.run_gates(iteration, lane: lane)
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
terminal.say " reason: #{r[:reason]}" if r[:status] == :fail && !r[:reason].to_s.empty?
|
|
780
|
-
terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
|
|
781
|
-
terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
|
|
782
|
-
end
|
|
838
|
+
# I07: the Gate CLI composes run_gates + gate_transcript — the
|
|
839
|
+
# transcript is the durable record a judge reads whole, so the
|
|
840
|
+
# bounded terminal output can always point at it.
|
|
841
|
+
transcript = project.gate_transcript(iteration, results, lane: lane)
|
|
842
|
+
results.each { |r| render_gate(r, transcript) }
|
|
783
843
|
terminal.say ""
|
|
784
844
|
terminal.say "Mechanical gate results above; the Acceptance-Criteria verdict — necessary, not sufficient — remains the architect's."
|
|
845
|
+
terminal.say "transcript: #{terminal.path(transcript)}"
|
|
785
846
|
any_fail = results.any? { |r| r[:status] == :fail }
|
|
786
847
|
CLI.record_outcome(Outcome.new(exit_code: any_fail ? 1 : 0))
|
|
787
848
|
end
|
|
788
849
|
end
|
|
789
850
|
end
|
|
851
|
+
|
|
852
|
+
private
|
|
853
|
+
|
|
854
|
+
# GateRendering hooks: gate renders the mechanical verdict, shows the
|
|
855
|
+
# reason only on a fail, and adds no advisory of its own.
|
|
856
|
+
def gate_verdict(g) = g[:status] == :pass ? "PASS" : "FAIL"
|
|
857
|
+
|
|
858
|
+
def gate_reason_visible?(g) = g[:status] == :fail
|
|
859
|
+
|
|
860
|
+
def render_gate_extras(g) = nil
|
|
790
861
|
end
|
|
791
862
|
|
|
792
863
|
class BugReport < BaseCommand
|
|
@@ -819,6 +890,56 @@ module Space::Architect
|
|
|
819
890
|
end
|
|
820
891
|
end
|
|
821
892
|
|
|
893
|
+
# Pretty-print a pi agent run.jsonl as it streams — the ported runlog
|
|
894
|
+
# printer. No space resolution: the stream is the whole input.
|
|
895
|
+
class Runlog < BaseCommand
|
|
896
|
+
desc "Pretty-print a pi agent run.jsonl as it streams (thinking dim, tool calls one-lined)"
|
|
897
|
+
phase 54, "Project"
|
|
898
|
+
argument :file, required: false, desc: "run.jsonl to read (default: stdin)"
|
|
899
|
+
|
|
900
|
+
def call(file: nil, **opts)
|
|
901
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
902
|
+
handle_errors do
|
|
903
|
+
if file
|
|
904
|
+
begin
|
|
905
|
+
File.open(file) { |f| Space::Architect::Runlog.stream(input: f, io: out, env: project_config.env) }
|
|
906
|
+
rescue Errno::ENOENT
|
|
907
|
+
raise Space::Core::Error, "file not found: #{file}"
|
|
908
|
+
end
|
|
909
|
+
else
|
|
910
|
+
Space::Architect::Runlog.stream(input: $stdin, io: out, env: project_config.env)
|
|
911
|
+
end
|
|
912
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
# Follow every active pi-subagents run transcript in a space, live — the
|
|
918
|
+
# ported looplog follower. BLOCKS until ctrl+c (quiet exit).
|
|
919
|
+
class Looplog < BaseCommand
|
|
920
|
+
desc "Follow every active pi-subagents run in this space, live (--list inventories only)"
|
|
921
|
+
phase 54, "Project"
|
|
922
|
+
argument :space, required: false, desc: "Space identifier (default: nearest ancestor with space.yaml)"
|
|
923
|
+
option :list, type: :flag, default: false, desc: "Inventory only — one line per run (active/age/size/agent/task), no follow"
|
|
924
|
+
option :replay, type: :flag, default: false, desc: "Print existing history on adoption, then follow"
|
|
925
|
+
option :all, type: :flag, default: false, desc: "Adopt every transcript, however stale (default: only within LOOPLOG_WINDOW seconds)"
|
|
926
|
+
|
|
927
|
+
def call(space: nil, list: false, replay: false, all: false, **opts)
|
|
928
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
929
|
+
handle_errors do
|
|
930
|
+
render(store.find(space)) do |sp|
|
|
931
|
+
if list
|
|
932
|
+
Space::Architect::Looplog.list(space: sp.path.to_s, io: out, env: project_config.env)
|
|
933
|
+
else
|
|
934
|
+
Space::Architect::Looplog.follow(space: sp.path.to_s, io: out, env: project_config.env,
|
|
935
|
+
replay: replay, all: all)
|
|
936
|
+
end
|
|
937
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
|
|
822
943
|
# Read-only loop accounting: spend and speed from the pi session
|
|
823
944
|
# artifacts already on disk (run metas + interactive/runner session
|
|
824
945
|
# files), stdout only. All view math lives in LoopCost; this class only
|
|
@@ -1001,6 +1122,24 @@ module Space::Architect
|
|
|
1001
1122
|
end
|
|
1002
1123
|
end
|
|
1003
1124
|
|
|
1125
|
+
# I07: install the architect-loop pi extension package via pi's own
|
|
1126
|
+
# mechanics — the gem owns the standing declaration (PACKAGE_SOURCE), pi
|
|
1127
|
+
# owns settings.json. No space resolution: a machine-level operation.
|
|
1128
|
+
class InstallExtensions < BaseCommand
|
|
1129
|
+
desc "Install the architect-loop pi extension package via pi (idempotent by pi's semantics)"
|
|
1130
|
+
phase 53, "Project"
|
|
1131
|
+
|
|
1132
|
+
def call(**opts)
|
|
1133
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
1134
|
+
handle_errors do
|
|
1135
|
+
result = ExtensionInstaller.install(env: project_config.env)
|
|
1136
|
+
result[:steps].each { |s| terminal.say " #{s[:cmd]} (exit #{s[:exit_code]})" }
|
|
1137
|
+
terminal.say "Extensions source: #{result[:source]}"
|
|
1138
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
1139
|
+
end
|
|
1140
|
+
end
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1004
1143
|
module Worktree
|
|
1005
1144
|
class Add < BaseCommand
|
|
1006
1145
|
desc "Create a worktree for a lane"
|
|
@@ -1444,6 +1583,7 @@ Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Archit
|
|
|
1444
1583
|
Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
|
|
1445
1584
|
Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
|
|
1446
1585
|
Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
|
|
1586
|
+
Space::Architect::CLI::Registry.register "push", Space::Architect::CLI::Architect::Push
|
|
1447
1587
|
Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
|
|
1448
1588
|
Space::Architect::CLI::Registry.register "rehearse", Space::Architect::CLI::Architect::Rehearse
|
|
1449
1589
|
Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
|
|
@@ -1457,6 +1597,9 @@ Space::Architect::CLI::Registry.register "merge", Space::Architect::CLI::Arc
|
|
|
1457
1597
|
Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Architect::Integrate
|
|
1458
1598
|
Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
|
|
1459
1599
|
Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
|
|
1600
|
+
Space::Architect::CLI::Registry.register "install-extensions", Space::Architect::CLI::Architect::InstallExtensions
|
|
1601
|
+
Space::Architect::CLI::Registry.register "runlog", Space::Architect::CLI::Architect::Runlog
|
|
1602
|
+
Space::Architect::CLI::Registry.register "looplog", Space::Architect::CLI::Architect::Looplog
|
|
1460
1603
|
Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
|
|
1461
1604
|
Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
|
|
1462
1605
|
Space::Architect::CLI::Registry.register "brief" do |b|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Space
|
|
6
|
+
module Architect
|
|
7
|
+
# Installs the architect-loop pi extension package through pi's own
|
|
8
|
+
# mechanics. The gem owns the standing declaration (PACKAGE_SOURCE) and the
|
|
9
|
+
# two-step sequence; pi owns settings.json and the rest — idempotent by
|
|
10
|
+
# pi's semantics, safe to re-run.
|
|
11
|
+
module ExtensionInstaller
|
|
12
|
+
PACKAGE_SOURCE = "git:git@github.com:jetpks/pi-extensions.git"
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Runs `<pi_bin> install PACKAGE_SOURCE` then
|
|
16
|
+
# `<pi_bin> update --extension PACKAGE_SOURCE`, in order, aborting on
|
|
17
|
+
# the first failure. --extension (singular) on purpose: the gem
|
|
18
|
+
# reconciles exactly the package it declares — not the operator's other
|
|
19
|
+
# packages. Returns { source:, steps: [{cmd:, exit_code:, output:}] }.
|
|
20
|
+
def install(pi_bin: "pi", env: ENV)
|
|
21
|
+
steps = [
|
|
22
|
+
run_step(pi_bin, ["install", PACKAGE_SOURCE], env),
|
|
23
|
+
run_step(pi_bin, ["update", "--extension", PACKAGE_SOURCE], env)
|
|
24
|
+
]
|
|
25
|
+
{ source: PACKAGE_SOURCE, steps: steps }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def run_step(pi_bin, args, env)
|
|
31
|
+
cmd = [pi_bin, *args].join(" ")
|
|
32
|
+
out, err, status = Open3.capture3(env, pi_bin, *args)
|
|
33
|
+
output = out + err
|
|
34
|
+
raise Space::Core::Error, "#{cmd} failed (exit #{status.exitstatus}):\n#{output}" unless status.success?
|
|
35
|
+
|
|
36
|
+
{ cmd: cmd, exit_code: status.exitstatus, output: output }
|
|
37
|
+
rescue Errno::ENOENT
|
|
38
|
+
raise Space::Core::Error,
|
|
39
|
+
"#{pi_bin}: command not found — '#{cmd}' could not run. Install pi or check your PATH."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|