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.
- checksums.yaml +4 -4
- data/README.md +32 -267
- data/lib/space_architect/architect_project.rb +160 -40
- data/lib/space_architect/cli/architect.rb +176 -5
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/loop_cost.rb +592 -0
- data/lib/space_architect/research/supervisor.rb +9 -2
- data/lib/space_architect/routing.rb +149 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- metadata +3 -1
|
@@ -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"]
|
|
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>
|
|
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
|
|
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 (
|
|
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)
|