space-architect 9.1.1 → 9.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 154e74a35cc7d14274f2f6fec65f40400727adbf3ce4bcf69000c2f00405a07e
4
- data.tar.gz: 2d6407b32a61f8c056e866e12dceab65b6a0b35f281ec6b1721c73233517da49
3
+ metadata.gz: 173cd44e1e27f8fc09d46bb0d18a5c7f26138731d4315bc6db1e7cd2c704e0a4
4
+ data.tar.gz: cebbf1f5602efa92a9f3982f31fcc79c202549489531262cfaab3ecc1c25ba13
5
5
  SHA512:
6
- metadata.gz: db472c7ffcff0c4228f9c6bda1baad0c00cb0283defa3a686924267d51d252b9f24e3cdf65a8d50076f0354f5af1894ff6ff9799c2b783069a8bf52567206c2b
7
- data.tar.gz: 444e06a1bbcbefe3f827a065cefba6383d04606215abf0ab016069f7b254a8c61d2343aa91a648a3a2be92ae1a47c97b18dbcdc63bb62a92d23c63b3b8b10e6f
6
+ metadata.gz: 44430c9d2fb759035df014e4023084424b66edd6dfea696f4e0e0f555ab7d0e3d418410ae8fa117936456fd3b4ea365cf9bf217ffadbb0be184e9ccb6835526a
7
+ data.tar.gz: 5abb3a70daaca512e957ed2a577a45904e709be3cd05c6b57ae49f7e58ce9d612b64c33addb6ba191f49777826258e5b481e27c440bacdfd4f4e8d7f4d997dd0
@@ -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
- lanes = entry["lanes"] || []
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 non-EMPTY rehearsal under build/rehearse/, holding
1275
- # every gate's full command, dir, exit code, verdict, reason, and complete
1276
- # stdout+stderr, plus the scope-asymmetry findings. The CLI prints this
1277
- # path so a rerun is never needed just to see output the terminal trimmed.
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
- dir = space.path.join("build", "rehearse")
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 = ["# Rehearsal transcript — #{entry['name']} — #{now.iso8601}"]
1284
- lines << (repo ? "Repo: #{repo} (#{base_dir})" : "Each gate ran in its own declared cwd")
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[:rehearsal].to_s.upcase} (exit #{g[:exit_code].inspect})"
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
@@ -1692,7 +1740,12 @@ module Space::Architect
1692
1740
  if prompt
1693
1741
  src = Pathname.new(prompt)
1694
1742
  raise Space::Core::Error, "prompt file not found: #{src}" unless src.exist?
1695
- File.open(prompt_path, "wb") { |f| f.write(File.binread(src)) }
1743
+ # src == dst: the file is already canonical — skip the copy; a truncate-then-
1744
+ # write onto the source itself would destroy the prompt before dispatch (I61
1745
+ # 20261009-pi-port: --prompt <canonical> emptied the kit to 0 bytes).
1746
+ unless src.expand_path == Pathname(prompt_path).expand_path
1747
+ File.binwrite(prompt_path, File.binread(src))
1748
+ end
1696
1749
  end
1697
1750
 
1698
1751
  raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
@@ -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
- results.each do |r|
775
- marker = r[:status] == :pass ? "PASS" : "FAIL"
776
- terminal.say ""
777
- terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:cmd]} (exit #{r[:exit_code]}) [#{marker}]"
778
- terminal.say " dir: #{terminal.path(r[:dir])}"
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