space-architect 9.0.0 → 9.1.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.
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../jobs_client"
6
6
  require_relative "../conversations_client"
7
+ require_relative "../loop_cost"
7
8
  require_relative "../session_sync"
8
9
 
9
10
  module Space::Architect
@@ -165,6 +166,10 @@ module Space::Architect
165
166
  project = ArchitectProject.new(space: sp)
166
167
  info = project.status
167
168
  block = info[:block]
169
+ # Legacy lanes store no model — resolve the config layer once so the
170
+ # display shows what they would actually route, and mark the bare
171
+ # constant as the last resort it is, not ordinary behavior.
172
+ config_model = Routing.for_space(sp.path)&.dig("builder", "model")
168
173
 
169
174
  terminal.say "Project status: #{block['status'] || '(none)'}"
170
175
  terminal.say "Current iteration: #{block['current_iteration'] || '(none)'}"
@@ -178,7 +183,7 @@ module Space::Architect
178
183
  lane_list = s["lanes"] || []
179
184
  lanes_str = lane_list.map do |l|
180
185
  h = l["harness"] || "pi"
181
- m = l["model"] || Harness::DEFAULT_MODEL
186
+ m = l["model"] || config_model || "#{Harness::DEFAULT_MODEL} (last resort)"
182
187
  eff = l["effort"] ? "·#{l['effort']}" : ""
183
188
  "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
184
189
  end.join(", ")
@@ -484,7 +489,7 @@ module Space::Architect
484
489
  argument :lane, required: true, desc: "Lane name"
485
490
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
486
491
  option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
487
- option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL}). Any provider/tier; pin a full id, not a floating alias"
492
+ option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else architect.yaml builder role, else space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL} — a loudly-informed last resort). Any provider/tier; pin a full id, not a floating alias"
488
493
  option :max_turns, default: "200", desc: "Max turns for the builder"
489
494
  option :harness, default: nil, desc: "Harness (pi)"
490
495
  option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
@@ -557,7 +562,7 @@ module Space::Architect
557
562
  phase 20, "Build"
558
563
  argument :iteration, required: true, desc: "Iteration name"
559
564
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
560
- option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
565
+ 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)"
561
566
  option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
562
567
  option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
563
568
 
@@ -570,6 +575,12 @@ module Space::Architect
570
575
  if results.empty?
571
576
  terminal.say "No declared lanes to provision for '#{iteration}'"
572
577
  else
578
+ # Sync report first (the sync ran before any cut): one line per
579
+ # moved branch — repo, branch, new short tip. No-op syncs are
580
+ # nil here and print nothing.
581
+ results.filter_map { |r| r[:synced] }.uniq.each do |s|
582
+ terminal.say "#{s[:repo]}: fast-forwarded #{s[:branch]} to #{s[:sha][0, 8]}"
583
+ end
573
584
  results.each do |r|
574
585
  state = case r[:outcome]
575
586
  when :created then "created"
@@ -808,6 +819,165 @@ module Space::Architect
808
819
  end
809
820
  end
810
821
 
822
+ # Read-only loop accounting: spend and speed from the pi session
823
+ # artifacts already on disk (run metas + interactive/runner session
824
+ # files), stdout only. All view math lives in LoopCost; this class only
825
+ # resolves the space and renders — text tables and --json come from the
826
+ # same report hash so the two surfaces can never disagree.
827
+ class Cost < BaseCommand
828
+ desc "Account loop cost and latency from pi session artifacts (read-only)"
829
+ phase 54, "Project"
830
+ # The three view flags declare dry-cli's :flag type, not :boolean —
831
+ # the frozen cost-flags gate pins the literal `--json`-style tokens in
832
+ # --help, which :boolean would render as `--[no-]json`.
833
+ option :json, type: :flag, default: false, desc: "Emit the frozen JSON contract instead of tables"
834
+ option :space, desc: "Space identifier or path (default: nearest ancestor with space.yaml)"
835
+ option :sessions_root, desc: "pi sessions root (default: ~/.pi/agent/sessions via SessionSync)"
836
+ option :since, desc: "Only artifacts starting at/after this ISO-8601 timestamp or YYYY-MM-DD date"
837
+ option :by_iteration, type: :flag, default: false, desc: "Split the cost table into per-iteration windows"
838
+ option :latency, type: :flag, default: false, desc: "Per-model latency: round wall, turn vs generation tok/s, tool wait share"
839
+ option :precise, type: :flag, default: false, desc: "Exact token counts with thousands separators (default: 3 significant figures)"
840
+
841
+ def call(json: false, space: nil, sessions_root: nil, since: nil,
842
+ by_iteration: false, latency: false, precise: false, **opts)
843
+ setup_terminal(**opts.slice(:color, :colors))
844
+ handle_errors do
845
+ if by_iteration && latency
846
+ raise Space::Core::Error, "--by-iteration and --latency are mutually exclusive — one view per invocation"
847
+ end
848
+ render(store.find(space)) do |sp|
849
+ report = LoopCost.report(
850
+ space: sp,
851
+ sessions_root: sessions_root || SessionSync.default_pi_root,
852
+ since: since,
853
+ by_iteration: by_iteration,
854
+ latency: latency
855
+ )
856
+ terminal.say(json ? JSON.pretty_generate(report) : render_cost_text(report, by_iteration, latency, precise))
857
+ CLI.record_outcome(Outcome.new(exit_code: 0))
858
+ end
859
+ end
860
+ end
861
+
862
+ private
863
+
864
+ def render_cost_text(report, by_iteration, latency, precise)
865
+ if latency
866
+ latency_table(report["latency"], precise)
867
+ elsif by_iteration
868
+ by_iteration_text(report, precise)
869
+ else
870
+ cost_table(report["rows"], report["totals"], precise)
871
+ end
872
+ end
873
+
874
+ def cost_table(rows, totals, precise)
875
+ headers = %w[Agent Model Runs Input Output CacheRead CacheWrite $ Turns Hit%]
876
+ body = rows.map { |row| cost_cells(row, precise) }
877
+ body << totals_cells(totals, precise)
878
+ render_table(headers, body)
879
+ end
880
+
881
+ def by_iteration_text(report, precise)
882
+ report["iterations"].flat_map do |bucket|
883
+ label = bucket["starts_at"] || "-"
884
+ [
885
+ "—— #{bucket['name']} (starts_at #{label})",
886
+ cost_table(bucket["rows"], bucket["totals"], precise),
887
+ ""
888
+ ]
889
+ end + ["Grand total", totals_table(report["grand_total"], precise)]
890
+ end
891
+
892
+ def latency_table(rows, precise)
893
+ headers = %w[Model Rounds Mean\ wall\ (s) Turn\ tok/s Gen\ tok/s Wait%]
894
+ body = rows.map do |row|
895
+ [row["model"], count_label(row["rounds"], precise), format("%.2f", row["mean_wall_s"]),
896
+ format("%.2f", row["output_toks_per_s"]), format("%.2f", row["gen_toks_per_s"]),
897
+ format("%.1f%%", row["wait_share"] * 100)]
898
+ end
899
+ render_table(headers, body)
900
+ end
901
+
902
+ def totals_table(totals, precise)
903
+ render_table(%w[Runs Input Output CacheRead CacheWrite $ Turns Hit%], [totals_cells(totals, precise)[2..]])
904
+ end
905
+
906
+ def totals_cells(totals, precise)
907
+ ["TOTAL", "", count_label(totals["runs"], precise), count_label(totals["input"], precise),
908
+ count_label(totals["output"], precise), count_label(totals["cache_read"], precise),
909
+ count_label(totals["cache_write"], precise), format("%.4f", totals["cost"]),
910
+ count_label(totals["turns"], precise), hit_label(totals["cache_hit_ratio"])]
911
+ end
912
+
913
+ def cost_cells(row, precise)
914
+ [row["agent"], row["model"], count_label(row["runs"], precise), count_label(row["input"], precise),
915
+ count_label(row["output"], precise), count_label(row["cache_read"], precise),
916
+ count_label(row["cache_write"], precise), format("%.4f", row["cost"]),
917
+ count_label(row["turns"], precise), hit_label(row["cache_hit_ratio"])]
918
+ end
919
+
920
+ def hit_label(ratio)
921
+ format("%.1f%%", ratio * 100)
922
+ end
923
+
924
+ # Token counts are unreadable as raw digit strings at loop scale
925
+ # (1,833,889,482 cache-read tokens): 3 significant figures with an SI
926
+ # suffix by default, exact thousands-separated integers for --precise.
927
+ SIG_UNITS = [["T", 1e12], ["B", 1e9], ["M", 1e6], ["K", 1e3]].freeze
928
+ private_constant :SIG_UNITS
929
+
930
+ def count_label(value, precise)
931
+ integer = value.to_i
932
+ return integer.to_s if integer < 1000
933
+ # stdlib has no thousands-separator format (the %'d flag is glibc, not Ruby)
934
+ return integer.to_s.reverse.scan(/\d{1,3}/).join(",").reverse if precise
935
+
936
+ SIG_UNITS.each_with_index do |(suffix, scale), index|
937
+ next unless integer >= scale
938
+ mantissa = integer.fdiv(scale)
939
+ decimals = mantissa >= 100 ? 0 : mantissa >= 10 ? 1 : 2
940
+ text = format("%.#{decimals}f", mantissa)
941
+ # Rounding overflowed the mantissa (999,500 → "1000K") — bump one
942
+ # scale up (units run large → small).
943
+ return "1#{SIG_UNITS[index - 1][0]}" if text.to_f >= 1000 && index.positive?
944
+ return "#{text}#{suffix}"
945
+ end
946
+ end
947
+
948
+ # Colors on the human views: header and TOTAL rows bold, the $ column
949
+ # yellow. terminal.table cannot do this — it pads by raw length before
950
+ # styling, so a pre-colored cell would skew alignment (and its by-header
951
+ # color dispatch belongs to space-cadet) — so the cost views pad from
952
+ # plain strings here, then style through terminal.pastel: the
953
+ # --color/--colors gating is inherited unchanged.
954
+ def render_table(headers, rows)
955
+ widths = headers.each_index.map do |index|
956
+ ([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
957
+ end
958
+ lines = [table_line(headers, widths, bold: true)]
959
+ cost_index = headers.index("$")
960
+ rows.each do |row|
961
+ lines << table_line(row, widths, bold: row.first.to_s == "TOTAL", color_index: cost_index)
962
+ end
963
+ lines.join("\n")
964
+ end
965
+
966
+ def table_line(cells, widths, bold: false, color_index: nil)
967
+ cells.each_with_index.map do |cell, index|
968
+ text = cell.to_s
969
+ styled = if bold
970
+ terminal.pastel.bold(text)
971
+ elsif index == color_index
972
+ terminal.pastel.yellow(text)
973
+ else
974
+ text
975
+ end
976
+ "#{styled}#{' ' * (widths[index] - text.length)}"
977
+ end.join(" ").rstrip
978
+ end
979
+ end
980
+
811
981
  class InstallSkills < BaseCommand
812
982
  desc "Install bundled skills (architect, architect-research, architect-vocabulary) for a harness"
813
983
  phase 53, "Project"
@@ -840,7 +1010,7 @@ module Space::Architect
840
1010
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
841
1011
  option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
842
1012
  option :harness, default: nil, desc: "Harness (pi; default: space.yaml project.harness, else pi)"
843
- option :model, default: nil, desc: "Model; a trailing :<level> suffix (e.g. foo:high) is parsed into --effort (default: space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL})"
1013
+ option :model, default: nil, desc: "Model; a trailing :<level> suffix (e.g. foo:high) is parsed into --effort (default: architect.yaml builder role, else space.yaml project.model, else the pi default #{Harness::DEFAULT_MODEL} — a loudly-informed last resort)"
844
1014
  option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
845
1015
  option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max)"
846
1016
  option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max)"
@@ -923,7 +1093,7 @@ module Space::Architect
923
1093
  argument :repo, required: true, desc: "Repo name (under repos/)"
924
1094
  argument :iteration, required: true, desc: "Iteration name"
925
1095
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
926
- option :pairs, required: true, desc: "Comma-separated harness[:model] pairs (e.g. pi,pi:#{Harness::DEFAULT_MODEL})"
1096
+ option :pairs, required: true, desc: "Comma-separated harness[:model] pairs (e.g. \"pi,pi:provider/model-id:high\"); a pair without a model resolves via the lane model chain (see worktree add)"
927
1097
  option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
928
1098
  option :prompt, default: nil, desc: "Prompt file to fan-out byte-identical to each variant"
929
1099
 
@@ -1288,6 +1458,7 @@ Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Arc
1288
1458
  Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
1289
1459
  Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
1290
1460
  Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
1461
+ Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
1291
1462
  Space::Architect::CLI::Registry.register "brief" do |b|
1292
1463
  b.register "new", Space::Architect::CLI::Architect::Brief::New
1293
1464
  end
@@ -8,7 +8,7 @@ module Space::Architect
8
8
  desc "Dispatch detached read-only research lanes (one per prompt file)"
9
9
  argument :prompts, type: :array, required: true,
10
10
  desc: "Prompt file(s) to dispatch (space-separated paths)"
11
- option :model, default: nil, desc: "Researcher model override (default: the pi default #{Space::Architect::Harness::DEFAULT_MODEL})"
11
+ option :model, default: nil, desc: "Researcher model override (researchers are not architect.yaml-routed; without it the pi default #{Space::Architect::Harness::DEFAULT_MODEL} is used — a loudly-informed last resort)"
12
12
  option :max_turns, default: "40", desc: "Max turns per researcher"
13
13
 
14
14
  def call(prompts:, model: nil, max_turns: "40", **opts)