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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2dfa94f98278929ea382e4411856749de3af270511f60760ee4e3bed83daf53
4
- data.tar.gz: 33c9bf994eafc772c67399876826b77f685a7ac9e3417f38b37054d853b5479b
3
+ metadata.gz: ec005bf012468954875cbbd74a2c3d3a4d2fbc2468523f42ec425777a6e9344a
4
+ data.tar.gz: c93720a06c24e62ed03352c243d78a9895c1d98311547b9860e0ad549eea382f
5
5
  SHA512:
6
- metadata.gz: 2b7c099ff9ffc9b1cf6e37d48a687b2c08c4f2c0463ea5f1e77d156f214bdf9e4f5d0059e1be9fa1247de440c50ea091ffb933db5f02c3bb8e2d8d75ed0f3996
7
- data.tar.gz: 5ce698b9ed448dbab61db46302c5f51f4e0bd850c8748c052131ffea4ef718326a3a4de47562092ba782dcfcfa042c6dedfe1c56675119b9ba2d98e29caf5f49
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
- 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
@@ -929,12 +928,21 @@ module Space::Architect
929
928
  base_explicit = !base.nil?
930
929
  refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force
931
930
 
931
+ # Sync-or-refuse ONCE per distinct repo of the selected set (lane order,
932
+ # first occurrence) BEFORE any worktree is cut — a divergence aborts the
933
+ # entire provision. An explicit --base is the deliberate case (#88): the
934
+ # pin is cut exactly as given, no sync, no divergence refusal.
935
+ synced = {}
936
+ unless base_explicit
937
+ lanes.map { |l| l["repo"] }.uniq.each { |repo| synced[repo] = sync_integration_branch!(repo, iteration) }
938
+ end
939
+
932
940
  lanes.map do |l|
933
941
  name = l["name"]
934
942
  result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
935
943
  base_explicit: base_explicit, force: force, **recorded_lane_fields(l))
936
944
  { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome],
937
- discarded: result[:discarded] }
945
+ discarded: result[:discarded], synced: synced[l["repo"]] }
938
946
  end
939
947
  end
940
948
 
@@ -1261,22 +1269,40 @@ module Space::Architect
1261
1269
  end
1262
1270
  end
1263
1271
 
1264
- # I01: the durable, unbounded counterpart to the CLI's bounded terminal
1265
- # report — one file per non-EMPTY rehearsal under build/rehearse/, holding
1266
- # every gate's full command, dir, exit code, verdict, reason, and complete
1267
- # stdout+stderr, plus the scope-asymmetry findings. The CLI prints this
1268
- # 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
+
1269
1284
  def write_rehearsal_transcript(entry, repo, base_dir, gate_results, scope_report, now)
1270
- 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])
1271
1291
  FileUtils.mkdir_p(dir)
1272
1292
  path = dir.join("#{iteration_id(entry)}-#{now.strftime('%Y%m%dT%H%M%S.%6N')}.md")
1273
1293
 
1274
- lines = ["# Rehearsal transcript — #{entry['name']} — #{now.iso8601}"]
1275
- 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
1276
1302
 
1277
1303
  gate_results.each do |g|
1278
1304
  lines << ""
1279
- 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})"
1280
1306
  lines << "cmd: #{g[:cmd]}"
1281
1307
  lines << "dir: #{g[:dir]}"
1282
1308
  lines << "reason: #{g[:reason]}" unless g[:reason].to_s.empty?
@@ -1306,6 +1332,37 @@ module Space::Architect
1306
1332
  path
1307
1333
  end
1308
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
+
1309
1366
  # I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
1310
1367
  # `cwd` of their own — only those gates need one, and only they are ambiguous
1311
1368
  # when the drafted ```lanes``` block names more than one repo. A gate with its
@@ -1622,6 +1679,15 @@ module Space::Architect
1622
1679
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
1623
1680
  return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
1624
1681
 
1682
+ # Same sync-or-refuse as provision, immediately before the cut — a
1683
+ # dispatch/gate/integrate/verify that materializes a missing worktree must
1684
+ # never cut it from a stale integration tip. Already-materialized lanes
1685
+ # returned above and stay a true no-op. The inform goes on $stderr, like
1686
+ # worktree_add's own informs on this path; no-op syncs print nothing.
1687
+ report = sync_integration_branch!(lane_entry["repo"], iteration)
1688
+ $stderr.puts "#{report[:repo]}: fast-forwarded #{report[:branch]} to #{report[:sha][0, 8]} — " \
1689
+ "synced before materializing #{iteration}/#{lane}" if report
1690
+
1625
1691
  worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
1626
1692
  base_explicit: false, **recorded_lane_fields(lane_entry))
1627
1693
  (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
@@ -1735,6 +1801,57 @@ module Space::Architect
1735
1801
  st.success? && !out.strip.empty? ? out.strip : "HEAD"
1736
1802
  end
1737
1803
 
1804
+ # Fast-forward the repo's project integration branch from its default branch
1805
+ # BEFORE any lane worktree is cut from it (I06 provision-sync — the I05 burn:
1806
+ # a worktree cut from a stale project/<slug> tip). Local-to-local only — no
1807
+ # fetch/pull, no remote-tracking ref is read or written. Compare before moving:
1808
+ # default not ahead → no-op (nil, callers print nothing); default ahead and
1809
+ # integration not ahead → fast-forward; both ahead → refuse (a human merges
1810
+ # deliberately, or pins --base) — --force has no bypass. Bang: it raises.
1811
+ # Returns nil for a no-op, or { repo:, branch:, sha: } for the moved ref so
1812
+ # callers can report the movement (never silently move a branch).
1813
+ def sync_integration_branch!(repo, iteration)
1814
+ repo_path = space.path.join("repos", repo)
1815
+ integration = project_integration_branch
1816
+ return nil unless branch_exists?(repo_path, integration)
1817
+
1818
+ default = repo_default_branch(repo_path)
1819
+ counts_out, counts_err, counts_st = git_capture("-C", repo_path.to_s, "rev-list", "--left-right", "--count",
1820
+ "#{default}...#{integration}")
1821
+ unless counts_st.success?
1822
+ raise Space::Core::Error, "Cannot compare #{default}...#{integration} in repos/#{repo} — #{counts_err.strip}"
1823
+ end
1824
+
1825
+ default_ahead, integration_ahead = counts_out.strip.split.map(&:to_i)
1826
+ return nil if default_ahead.zero?
1827
+
1828
+ default_tip, = git_capture("-C", repo_path.to_s, "rev-parse", default)
1829
+ integration_tip, = git_capture("-C", repo_path.to_s, "rev-parse", integration)
1830
+ if integration_ahead.positive?
1831
+ raise Space::Core::Error,
1832
+ "Refusing to provision '#{iteration}' — repos/#{repo} has diverged: #{default} (#{default_tip[0, 8]}) and " \
1833
+ "#{integration} (#{integration_tip[0, 8]}) each hold commits the other lacks " \
1834
+ "(#{default} ahead by #{default_ahead}, #{integration} ahead by #{integration_ahead}) — not fast-forwardable. " \
1835
+ "A human must merge #{default} into #{integration} deliberately, or pin the base explicitly with " \
1836
+ "`architect provision #{iteration} --base <sha>`. --force does not bypass this refusal."
1837
+ end
1838
+
1839
+ head_out, _, head_st = git_capture("-C", repo_path.to_s, "symbolic-ref", "--short", "HEAD")
1840
+ if head_st.success? && head_out.strip == integration
1841
+ # The integration branch is the clone's checked-out branch (the real
1842
+ # post-integrate state — merge_lane! checks it out) — fetch refuses a
1843
+ # checked-out dst, so fast-forward via merge instead.
1844
+ git_run("-C", repo_path.to_s, "merge", "--ff-only", default)
1845
+ else
1846
+ # Unprefixed refspecs are ff-only by construction — git rejects a non-ff
1847
+ # update here, so the branch can only ever move forward.
1848
+ git_run("-C", repo_path.to_s, "fetch", ".", "#{default}:#{integration}")
1849
+ end
1850
+
1851
+ new_tip, = git_capture("-C", repo_path.to_s, "rev-parse", integration)
1852
+ { repo: repo, branch: integration, sha: new_tip.strip }
1853
+ end
1854
+
1738
1855
  def slice_entry(iteration)
1739
1856
  block = space.data["project"] || {}
1740
1857
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
@@ -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
@@ -562,7 +624,7 @@ module Space::Architect
562
624
  phase 20, "Build"
563
625
  argument :iteration, required: true, desc: "Iteration name"
564
626
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
565
- option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
627
+ option :base, default: nil, desc: "Base ref override (default: project/<slug> fast-forward-synced from the repo's default branch first — diverged → refuse; --base skips the sync and cuts exactly the given ref)"
566
628
  option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
567
629
  option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
568
630
 
@@ -575,6 +637,12 @@ module Space::Architect
575
637
  if results.empty?
576
638
  terminal.say "No declared lanes to provision for '#{iteration}'"
577
639
  else
640
+ # Sync report first (the sync ran before any cut): one line per
641
+ # moved branch — repo, branch, new short tip. No-op syncs are
642
+ # nil here and print nothing.
643
+ results.filter_map { |r| r[:synced] }.uniq.each do |s|
644
+ terminal.say "#{s[:repo]}: fast-forwarded #{s[:branch]} to #{s[:sha][0, 8]}"
645
+ end
578
646
  results.each do |r|
579
647
  state = case r[:outcome]
580
648
  when :created then "created"
@@ -753,6 +821,8 @@ module Space::Architect
753
821
  end
754
822
 
755
823
  class Gate < BaseCommand
824
+ include GateRendering
825
+
756
826
  desc "Run the frozen Acceptance Criteria gate commands and report PASS/FAIL"
757
827
  phase 32, "Judge"
758
828
  argument :iteration, required: true, desc: "Iteration name"
@@ -765,22 +835,29 @@ module Space::Architect
765
835
  render(store.find(space)) do |sp|
766
836
  project = ArchitectProject.new(space: sp)
767
837
  results = project.run_gates(iteration, lane: lane)
768
- results.each do |r|
769
- marker = r[:status] == :pass ? "PASS" : "FAIL"
770
- terminal.say ""
771
- terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:cmd]} (exit #{r[:exit_code]}) [#{marker}]"
772
- terminal.say " dir: #{terminal.path(r[:dir])}"
773
- terminal.say " reason: #{r[:reason]}" if r[:status] == :fail && !r[:reason].to_s.empty?
774
- terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
775
- terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
776
- 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) }
777
843
  terminal.say ""
778
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)}"
779
846
  any_fail = results.any? { |r| r[:status] == :fail }
780
847
  CLI.record_outcome(Outcome.new(exit_code: any_fail ? 1 : 0))
781
848
  end
782
849
  end
783
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
784
861
  end
785
862
 
786
863
  class BugReport < BaseCommand
@@ -813,6 +890,56 @@ module Space::Architect
813
890
  end
814
891
  end
815
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
+
816
943
  # Read-only loop accounting: spend and speed from the pi session
817
944
  # artifacts already on disk (run metas + interactive/runner session
818
945
  # files), stdout only. All view math lives in LoopCost; this class only
@@ -995,6 +1122,24 @@ module Space::Architect
995
1122
  end
996
1123
  end
997
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
+
998
1143
  module Worktree
999
1144
  class Add < BaseCommand
1000
1145
  desc "Create a worktree for a lane"
@@ -1438,6 +1583,7 @@ Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Archit
1438
1583
  Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
1439
1584
  Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
1440
1585
  Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
1586
+ Space::Architect::CLI::Registry.register "push", Space::Architect::CLI::Architect::Push
1441
1587
  Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
1442
1588
  Space::Architect::CLI::Registry.register "rehearse", Space::Architect::CLI::Architect::Rehearse
1443
1589
  Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
@@ -1451,6 +1597,9 @@ Space::Architect::CLI::Registry.register "merge", Space::Architect::CLI::Arc
1451
1597
  Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Architect::Integrate
1452
1598
  Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
1453
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
1454
1603
  Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
1455
1604
  Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
1456
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