space-architect 9.0.0 → 9.1.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.
@@ -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)"
@@ -808,6 +813,165 @@ module Space::Architect
808
813
  end
809
814
  end
810
815
 
816
+ # Read-only loop accounting: spend and speed from the pi session
817
+ # artifacts already on disk (run metas + interactive/runner session
818
+ # files), stdout only. All view math lives in LoopCost; this class only
819
+ # resolves the space and renders — text tables and --json come from the
820
+ # same report hash so the two surfaces can never disagree.
821
+ class Cost < BaseCommand
822
+ desc "Account loop cost and latency from pi session artifacts (read-only)"
823
+ phase 54, "Project"
824
+ # The three view flags declare dry-cli's :flag type, not :boolean —
825
+ # the frozen cost-flags gate pins the literal `--json`-style tokens in
826
+ # --help, which :boolean would render as `--[no-]json`.
827
+ option :json, type: :flag, default: false, desc: "Emit the frozen JSON contract instead of tables"
828
+ option :space, desc: "Space identifier or path (default: nearest ancestor with space.yaml)"
829
+ option :sessions_root, desc: "pi sessions root (default: ~/.pi/agent/sessions via SessionSync)"
830
+ option :since, desc: "Only artifacts starting at/after this ISO-8601 timestamp or YYYY-MM-DD date"
831
+ option :by_iteration, type: :flag, default: false, desc: "Split the cost table into per-iteration windows"
832
+ option :latency, type: :flag, default: false, desc: "Per-model latency: round wall, turn vs generation tok/s, tool wait share"
833
+ option :precise, type: :flag, default: false, desc: "Exact token counts with thousands separators (default: 3 significant figures)"
834
+
835
+ def call(json: false, space: nil, sessions_root: nil, since: nil,
836
+ by_iteration: false, latency: false, precise: false, **opts)
837
+ setup_terminal(**opts.slice(:color, :colors))
838
+ handle_errors do
839
+ if by_iteration && latency
840
+ raise Space::Core::Error, "--by-iteration and --latency are mutually exclusive — one view per invocation"
841
+ end
842
+ render(store.find(space)) do |sp|
843
+ report = LoopCost.report(
844
+ space: sp,
845
+ sessions_root: sessions_root || SessionSync.default_pi_root,
846
+ since: since,
847
+ by_iteration: by_iteration,
848
+ latency: latency
849
+ )
850
+ terminal.say(json ? JSON.pretty_generate(report) : render_cost_text(report, by_iteration, latency, precise))
851
+ CLI.record_outcome(Outcome.new(exit_code: 0))
852
+ end
853
+ end
854
+ end
855
+
856
+ private
857
+
858
+ def render_cost_text(report, by_iteration, latency, precise)
859
+ if latency
860
+ latency_table(report["latency"], precise)
861
+ elsif by_iteration
862
+ by_iteration_text(report, precise)
863
+ else
864
+ cost_table(report["rows"], report["totals"], precise)
865
+ end
866
+ end
867
+
868
+ def cost_table(rows, totals, precise)
869
+ headers = %w[Agent Model Runs Input Output CacheRead CacheWrite $ Turns Hit%]
870
+ body = rows.map { |row| cost_cells(row, precise) }
871
+ body << totals_cells(totals, precise)
872
+ render_table(headers, body)
873
+ end
874
+
875
+ def by_iteration_text(report, precise)
876
+ report["iterations"].flat_map do |bucket|
877
+ label = bucket["starts_at"] || "-"
878
+ [
879
+ "—— #{bucket['name']} (starts_at #{label})",
880
+ cost_table(bucket["rows"], bucket["totals"], precise),
881
+ ""
882
+ ]
883
+ end + ["Grand total", totals_table(report["grand_total"], precise)]
884
+ end
885
+
886
+ def latency_table(rows, precise)
887
+ headers = %w[Model Rounds Mean\ wall\ (s) Turn\ tok/s Gen\ tok/s Wait%]
888
+ body = rows.map do |row|
889
+ [row["model"], count_label(row["rounds"], precise), format("%.2f", row["mean_wall_s"]),
890
+ format("%.2f", row["output_toks_per_s"]), format("%.2f", row["gen_toks_per_s"]),
891
+ format("%.1f%%", row["wait_share"] * 100)]
892
+ end
893
+ render_table(headers, body)
894
+ end
895
+
896
+ def totals_table(totals, precise)
897
+ render_table(%w[Runs Input Output CacheRead CacheWrite $ Turns Hit%], [totals_cells(totals, precise)[2..]])
898
+ end
899
+
900
+ def totals_cells(totals, precise)
901
+ ["TOTAL", "", count_label(totals["runs"], precise), count_label(totals["input"], precise),
902
+ count_label(totals["output"], precise), count_label(totals["cache_read"], precise),
903
+ count_label(totals["cache_write"], precise), format("%.4f", totals["cost"]),
904
+ count_label(totals["turns"], precise), hit_label(totals["cache_hit_ratio"])]
905
+ end
906
+
907
+ def cost_cells(row, precise)
908
+ [row["agent"], row["model"], count_label(row["runs"], precise), count_label(row["input"], precise),
909
+ count_label(row["output"], precise), count_label(row["cache_read"], precise),
910
+ count_label(row["cache_write"], precise), format("%.4f", row["cost"]),
911
+ count_label(row["turns"], precise), hit_label(row["cache_hit_ratio"])]
912
+ end
913
+
914
+ def hit_label(ratio)
915
+ format("%.1f%%", ratio * 100)
916
+ end
917
+
918
+ # Token counts are unreadable as raw digit strings at loop scale
919
+ # (1,833,889,482 cache-read tokens): 3 significant figures with an SI
920
+ # suffix by default, exact thousands-separated integers for --precise.
921
+ SIG_UNITS = [["T", 1e12], ["B", 1e9], ["M", 1e6], ["K", 1e3]].freeze
922
+ private_constant :SIG_UNITS
923
+
924
+ def count_label(value, precise)
925
+ integer = value.to_i
926
+ return integer.to_s if integer < 1000
927
+ # stdlib has no thousands-separator format (the %'d flag is glibc, not Ruby)
928
+ return integer.to_s.reverse.scan(/\d{1,3}/).join(",").reverse if precise
929
+
930
+ SIG_UNITS.each_with_index do |(suffix, scale), index|
931
+ next unless integer >= scale
932
+ mantissa = integer.fdiv(scale)
933
+ decimals = mantissa >= 100 ? 0 : mantissa >= 10 ? 1 : 2
934
+ text = format("%.#{decimals}f", mantissa)
935
+ # Rounding overflowed the mantissa (999,500 → "1000K") — bump one
936
+ # scale up (units run large → small).
937
+ return "1#{SIG_UNITS[index - 1][0]}" if text.to_f >= 1000 && index.positive?
938
+ return "#{text}#{suffix}"
939
+ end
940
+ end
941
+
942
+ # Colors on the human views: header and TOTAL rows bold, the $ column
943
+ # yellow. terminal.table cannot do this — it pads by raw length before
944
+ # styling, so a pre-colored cell would skew alignment (and its by-header
945
+ # color dispatch belongs to space-cadet) — so the cost views pad from
946
+ # plain strings here, then style through terminal.pastel: the
947
+ # --color/--colors gating is inherited unchanged.
948
+ def render_table(headers, rows)
949
+ widths = headers.each_index.map do |index|
950
+ ([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
951
+ end
952
+ lines = [table_line(headers, widths, bold: true)]
953
+ cost_index = headers.index("$")
954
+ rows.each do |row|
955
+ lines << table_line(row, widths, bold: row.first.to_s == "TOTAL", color_index: cost_index)
956
+ end
957
+ lines.join("\n")
958
+ end
959
+
960
+ def table_line(cells, widths, bold: false, color_index: nil)
961
+ cells.each_with_index.map do |cell, index|
962
+ text = cell.to_s
963
+ styled = if bold
964
+ terminal.pastel.bold(text)
965
+ elsif index == color_index
966
+ terminal.pastel.yellow(text)
967
+ else
968
+ text
969
+ end
970
+ "#{styled}#{' ' * (widths[index] - text.length)}"
971
+ end.join(" ").rstrip
972
+ end
973
+ end
974
+
811
975
  class InstallSkills < BaseCommand
812
976
  desc "Install bundled skills (architect, architect-research, architect-vocabulary) for a harness"
813
977
  phase 53, "Project"
@@ -840,7 +1004,7 @@ module Space::Architect
840
1004
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
841
1005
  option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
842
1006
  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})"
1007
+ 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
1008
  option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
845
1009
  option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max)"
846
1010
  option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max)"
@@ -923,7 +1087,7 @@ module Space::Architect
923
1087
  argument :repo, required: true, desc: "Repo name (under repos/)"
924
1088
  argument :iteration, required: true, desc: "Iteration name"
925
1089
  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})"
1090
+ 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
1091
  option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
928
1092
  option :prompt, default: nil, desc: "Prompt file to fan-out byte-identical to each variant"
929
1093
 
@@ -1288,6 +1452,7 @@ Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Arc
1288
1452
  Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
1289
1453
  Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
1290
1454
  Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
1455
+ Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
1291
1456
  Space::Architect::CLI::Registry.register "brief" do |b|
1292
1457
  b.register "new", Space::Architect::CLI::Architect::Brief::New
1293
1458
  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)