space-architect 8.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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +39 -464
  4. data/lib/space_architect/architect_project.rb +90 -39
  5. data/lib/space_architect/bug_report.rb +2 -1
  6. data/lib/space_architect/cli/architect.rb +189 -11
  7. data/lib/space_architect/cli/research.rb +1 -1
  8. data/lib/space_architect/cli.rb +9 -14
  9. data/lib/space_architect/loop_cost.rb +592 -0
  10. data/lib/space_architect/research/supervisor.rb +9 -2
  11. data/lib/space_architect/routing.rb +149 -0
  12. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  13. data/lib/space_architect/session_sync/cursor.rb +1 -1
  14. data/lib/space_architect/session_sync/plist.rb +1 -1
  15. data/lib/{space_core → space_architect}/version.rb +2 -2
  16. data/lib/space_architect.rb +2 -1
  17. data/skill/architect/SKILL.md +3 -2
  18. data/skill/architect-vocabulary/SKILL.md +2 -2
  19. metadata +20 -177
  20. data/exe/space +0 -13
  21. data/exe/src +0 -13
  22. data/lib/space_architect/cli/space.rb +0 -29
  23. data/lib/space_architect/cli/src.rb +0 -38
  24. data/lib/space_core/atomic_write.rb +0 -21
  25. data/lib/space_core/cli/base_command.rb +0 -19
  26. data/lib/space_core/cli/build.rb +0 -27
  27. data/lib/space_core/cli/config.rb +0 -49
  28. data/lib/space_core/cli/current.rb +0 -16
  29. data/lib/space_core/cli/help.rb +0 -195
  30. data/lib/space_core/cli/helpers.rb +0 -115
  31. data/lib/space_core/cli/init.rb +0 -29
  32. data/lib/space_core/cli/list.rb +0 -24
  33. data/lib/space_core/cli/loop_status.rb +0 -47
  34. data/lib/space_core/cli/new.rb +0 -38
  35. data/lib/space_core/cli/pack.rb +0 -29
  36. data/lib/space_core/cli/path.rb +0 -16
  37. data/lib/space_core/cli/repeatable_options.rb +0 -84
  38. data/lib/space_core/cli/repo.rb +0 -76
  39. data/lib/space_core/cli/run.rb +0 -44
  40. data/lib/space_core/cli/shell.rb +0 -125
  41. data/lib/space_core/cli/show.rb +0 -21
  42. data/lib/space_core/cli/status.rb +0 -74
  43. data/lib/space_core/cli/use.rb +0 -17
  44. data/lib/space_core/cli.rb +0 -177
  45. data/lib/space_core/commands.rb +0 -51
  46. data/lib/space_core/config.rb +0 -152
  47. data/lib/space_core/errors.rb +0 -14
  48. data/lib/space_core/git_client.rb +0 -49
  49. data/lib/space_core/mise_client.rb +0 -37
  50. data/lib/space_core/oci_builder.rb +0 -56
  51. data/lib/space_core/oci_packer.rb +0 -103
  52. data/lib/space_core/oci_runner.rb +0 -89
  53. data/lib/space_core/paths.rb +0 -58
  54. data/lib/space_core/repo_reference.rb +0 -19
  55. data/lib/space_core/repo_resolver.rb +0 -167
  56. data/lib/space_core/shell_integration.rb +0 -438
  57. data/lib/space_core/slugger.rb +0 -16
  58. data/lib/space_core/space.rb +0 -174
  59. data/lib/space_core/space_store.rb +0 -319
  60. data/lib/space_core/state.rb +0 -86
  61. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  62. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  63. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  64. data/lib/space_core/terminal.rb +0 -161
  65. data/lib/space_core/warnings.rb +0 -13
  66. data/lib/space_core/xdg.rb +0 -33
  67. data/lib/space_core.rb +0 -26
  68. data/lib/space_src/cli/clone.rb +0 -54
  69. data/lib/space_src/cli/config.rb +0 -65
  70. data/lib/space_src/cli/daemon.rb +0 -362
  71. data/lib/space_src/cli/options.rb +0 -21
  72. data/lib/space_src/cli/org.rb +0 -199
  73. data/lib/space_src/cli/repo.rb +0 -169
  74. data/lib/space_src/cli/shell.rb +0 -121
  75. data/lib/space_src/cli/status.rb +0 -75
  76. data/lib/space_src/cli/sync.rb +0 -161
  77. data/lib/space_src/cli.rb +0 -159
  78. data/lib/space_src/cloner.rb +0 -77
  79. data/lib/space_src/config/contract.rb +0 -54
  80. data/lib/space_src/config/duration.rb +0 -79
  81. data/lib/space_src/config/model.rb +0 -49
  82. data/lib/space_src/config/store.rb +0 -156
  83. data/lib/space_src/forge/client.rb +0 -31
  84. data/lib/space_src/forge/github.rb +0 -98
  85. data/lib/space_src/launchd/agent.rb +0 -195
  86. data/lib/space_src/launchd/plist.rb +0 -129
  87. data/lib/space_src/log_rotator.rb +0 -46
  88. data/lib/space_src/migration.rb +0 -43
  89. data/lib/space_src/nav.rb +0 -99
  90. data/lib/space_src/paths.rb +0 -72
  91. data/lib/space_src/scm/client.rb +0 -87
  92. data/lib/space_src/scm/git.rb +0 -232
  93. data/lib/space_src/scm/status.rb +0 -24
  94. data/lib/space_src/shell.rb +0 -90
  95. data/lib/space_src/shell_integration.rb +0 -321
  96. data/lib/space_src/state/lock.rb +0 -59
  97. data/lib/space_src/state/store.rb +0 -140
  98. data/lib/space_src/sync/engine.rb +0 -503
  99. data/lib/space_src/sync/repo_plan.rb +0 -215
  100. data/lib/space_src/sync/report.rb +0 -18
  101. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  102. data/lib/space_src/ui/json_reporter.rb +0 -39
  103. data/lib/space_src/ui/mode.rb +0 -68
  104. data/lib/space_src/ui/plain_reporter.rb +0 -53
  105. data/lib/space_src/ui/reporter.rb +0 -48
  106. data/lib/space_src/version.rb +0 -7
  107. data/lib/space_src.rb +0 -38
@@ -127,38 +127,47 @@ module Space::Architect
127
127
  end
128
128
 
129
129
  # Allocate the next ordinal and scaffold architecture/I<NN>-<iteration>.md.
130
+ # Serialized across processes: ordinal allocation is a read-modify-write of
131
+ # space.yaml's project block, and two concurrent `architect new` processes
132
+ # would duplicate the ordinal and silently drop the loser's iterations
133
+ # entry (last writer wins). The metadata is saved by atomic replace, so the
134
+ # lock is a sibling file under tmp/ (gitignored, space-standard) — never
135
+ # space.yaml itself — and the metadata is re-loaded inside the lock because
136
+ # every process booted with its own pre-scaffold snapshot.
130
137
  def new_iteration!(name, message: nil)
131
- block = space.data["project"] || {}
132
- iterations = block["iterations"] || []
133
- if iterations.any? { |s| s["name"] == name }
134
- raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
135
- end
138
+ with_scaffold_lock do
139
+ block = space.data["project"] || {}
140
+ iterations = block["iterations"] || []
141
+ if iterations.any? { |s| s["name"] == name }
142
+ raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
143
+ end
136
144
 
137
- ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
138
- nn = format("%02d", ordinal)
139
- rel = "architecture/I#{nn}-#{name}.md"
140
- path = space.path.join(rel)
141
- raise Space::Core::Error, "#{rel} already exists" if path.exist?
145
+ ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
146
+ nn = format("%02d", ordinal)
147
+ rel = "architecture/I#{nn}-#{name}.md"
148
+ path = space.path.join(rel)
149
+ raise Space::Core::Error, "#{rel} already exists" if path.exist?
142
150
 
143
- FileUtils.mkdir_p(path.dirname)
144
- path.write(render_iteration(nn, name))
151
+ FileUtils.mkdir_p(path.dirname)
152
+ path.write(render_iteration(nn, name))
145
153
 
146
- update_architect_block do |b|
147
- b["current_iteration"] = name
148
- list = b["iterations"] || []
149
- list << {
150
- "name" => name, "ordinal" => ordinal, "file" => rel,
151
- "freeze_sha" => nil, "verdict" => "pending", "lanes" => []
152
- }
153
- b["iterations"] = list
154
- b
155
- end
154
+ update_architect_block do |b|
155
+ b["current_iteration"] = name
156
+ list = b["iterations"] || []
157
+ list << {
158
+ "name" => name, "ordinal" => ordinal, "file" => rel,
159
+ "freeze_sha" => nil, "verdict" => "pending", "lanes" => []
160
+ }
161
+ b["iterations"] = list
162
+ b
163
+ end
156
164
 
157
- git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
158
- git_run("-C", space.path.to_s, "commit", "-m",
159
- compose_message("I#{nn} scaffold:", "I#{nn}: scaffold #{name}", message))
165
+ git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
166
+ git_run("-C", space.path.to_s, "commit", "-m",
167
+ compose_message("I#{nn} scaffold:", "I#{nn}: scaffold #{name}", message))
160
168
 
161
- path
169
+ path
170
+ end
162
171
  end
163
172
 
164
173
  def status
@@ -701,8 +710,12 @@ module Space::Architect
701
710
  def worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?, harness: nil, model: nil,
702
711
  variant: false, effort: nil, touch: nil, force: false, err: $stderr)
703
712
  model, suffix_level = Harness.parse_model_suffix(model)
704
- harness, model = resolve_harness_model(harness, model)
705
- resolved_level = effort || suffix_level || project_defaults["effort"]
713
+ harness, model, inform = resolve_harness_model(harness, model)
714
+ err.puts inform if inform
715
+ # The config layer sits above project.effort — architect.yaml is the
716
+ # dedicated routing layer; unconfigured spaces fall through unchanged.
717
+ resolved_level = effort || suffix_level ||
718
+ Routing.for_space(space.path)&.dig("builder", "thinking") || project_defaults["effort"]
706
719
  Harness.validate_thinking_level!(resolved_level)
707
720
  if effort.nil? && suffix_level
708
721
  err.puts "thinking: model suffix ':#{suffix_level}' parsed from lane model → level=#{suffix_level} (model stripped)"
@@ -1033,6 +1046,25 @@ module Space::Architect
1033
1046
 
1034
1047
  attr_reader :space
1035
1048
 
1049
+ # Exclusive cross-process lock around one whole critical section (used by
1050
+ # new_iteration!): flock on .git/architect.lock — the metadata file itself
1051
+ # cannot be the lock because save() replaces it by atomic rename (a new
1052
+ # inode would not contend), and .git is the one directory every scaffoldable
1053
+ # space guarantees (new_iteration! commits) that git never reports as
1054
+ # untracked noise (git's own index.lock lives by the same convention).
1055
+ # Inside the lock the metadata is adopted in place (data.replace, keeping
1056
+ # object identity for every holder of this space) — each process booted
1057
+ # with its own pre-scaffold snapshot, so the ordinal must be read from
1058
+ # disk truth.
1059
+ def with_scaffold_lock
1060
+ lock_path = space.path.join(".git", "architect.lock")
1061
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
1062
+ lock.flock(File::LOCK_EX)
1063
+ space.data.replace(Space::Core::Space.load(space.path).data)
1064
+ yield
1065
+ end
1066
+ end
1067
+
1036
1068
  # The vendored builder-guard extension shipped in the gem (lib/space_architect/pi/).
1037
1069
  def vendored_guard_path
1038
1070
  Pathname.new(__dir__).join("pi/builder-guard.ts")
@@ -1605,19 +1637,35 @@ module Space::Architect
1605
1637
  end
1606
1638
 
1607
1639
  # Resolve harness + model by precedence: explicit value > (dispatch only) the
1608
- # lane's stored value > space.yaml project.harness/project.model > the pi
1609
- # default model. Shared by worktree_add and dispatch so both read the same
1610
- # defaults. A stored model is only honored when its stored harness still matches
1611
- # the resolved harness — a dispatch-time harness override drops the old harness's
1612
- # stored model instead of leaking it into the new harness's run. A stored
1613
- # legacy harness name survives resolution (so a stale space.yaml
1614
- # surfaces as Harness.for's actionable pi-only error, not a silent default).
1640
+ # lane's stored value > architect.yaml builder-role model > space.yaml
1641
+ # project.harness/project.model > the pi default model. Shared by worktree_add
1642
+ # and dispatch so both read the same defaults. A stored model is only honored
1643
+ # when its stored harness still matches the resolved harness — a dispatch-time
1644
+ # harness override drops the old harness's stored model instead of leaking it
1645
+ # into the new harness's run. A stored legacy harness name survives resolution
1646
+ # (so a stale space.yaml surfaces as Harness.for's actionable pi-only error,
1647
+ # not a silent default).
1648
+ #
1649
+ # Returns [harness, model, inform] — inform is the loud last-resort line (nil
1650
+ # otherwise) for the caller to print once to its err stream when the model
1651
+ # fell all the way through to Harness::DEFAULT_MODEL (no flag, no stored
1652
+ # value, no architect.yaml builder role, no project.model).
1615
1653
  def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
1616
1654
  defaults = project_defaults
1617
1655
  resolved_harness = (harness || stored_harness || defaults["harness"] || "pi").to_s
1618
1656
  stored_model = nil unless stored_harness.nil? || stored_harness.to_s == resolved_harness
1619
- resolved_model = model || stored_model || defaults["model"] || Harness::DEFAULT_MODEL
1620
- [resolved_harness, resolved_model]
1657
+ config_model = Routing.for_space(space.path)&.dig("builder", "model")
1658
+ resolved_model = model || stored_model || config_model || defaults["model"] || Harness::DEFAULT_MODEL
1659
+ inform = default_model_inform if model.nil? && stored_model.nil? && config_model.nil? && defaults["model"].nil?
1660
+ [resolved_harness, resolved_model, inform]
1661
+ end
1662
+
1663
+ # The loud last-resort inform — the bare DEFAULT_MODEL constant is a last
1664
+ # resort, not a silent fallback: name that no architect.yaml was found, the
1665
+ # two paths looked at, and the default in use.
1666
+ def default_model_inform
1667
+ "model: no architect.yaml found (looked at #{Routing.space_path(space.path)} + #{Routing.global_path}) — " \
1668
+ "using default '#{Harness::DEFAULT_MODEL}'"
1621
1669
  end
1622
1670
 
1623
1671
  # --prompt: the caller authors the lane prompt anywhere (a fresh scratch file) and
@@ -1640,8 +1688,9 @@ module Space::Architect
1640
1688
  # precedence and thinking-level translation from one place.
1641
1689
  def resolve_dispatch_harness(lane_entry, model:, harness:, effort:, force:, err:)
1642
1690
  model, suffix_level = Harness.parse_model_suffix(model)
1643
- resolved_harness, resolved_model = resolve_harness_model(harness, model,
1691
+ resolved_harness, resolved_model, inform = resolve_harness_model(harness, model,
1644
1692
  stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
1693
+ err.puts inform if inform
1645
1694
 
1646
1695
  resolved_effort =
1647
1696
  if effort
@@ -1651,7 +1700,9 @@ module Space::Architect
1651
1700
  err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
1652
1701
  suffix_level
1653
1702
  else
1654
- lane_entry["effort"]
1703
+ # Dispatch's tail is the lane's stored effort (project.effort was never
1704
+ # in the dispatch chain); architect.yaml builder thinking slots in above it.
1705
+ lane_entry["effort"] || Routing.for_space(space.path)&.dig("builder", "thinking")
1655
1706
  end
1656
1707
 
1657
1708
  [resolved_harness, resolved_model, resolved_effort]
@@ -79,7 +79,8 @@ module Space
79
79
  <<~MD
80
80
  ## Diagnostics
81
81
 
82
- - space-architect: #{Space::Core::VERSION}
82
+ - space-architect: #{Space::Architect::VERSION}
83
+ - space-cadet: #{Space::Core::VERSION}
83
84
  - ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})
84
85
  MD
85
86
  end
@@ -2,9 +2,9 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
- require "space_src/launchd/agent"
6
5
  require_relative "../jobs_client"
7
6
  require_relative "../conversations_client"
7
+ require_relative "../loop_cost"
8
8
  require_relative "../session_sync"
9
9
 
10
10
  module Space::Architect
@@ -12,8 +12,8 @@ module Space::Architect
12
12
  # Optional loop-phase DSL on the shared command base, read by
13
13
  # Space::Core::CLI::Help to group the help listing. A command may declare
14
14
  # `phase <order>, "<Label>"`; groups, and members within a group, sort by
15
- # <order>. Only architect commands declare one — space commands leave it nil
16
- # and render as the single ungrouped default listing (unchanged).
15
+ # <order>. Only architect commands declare one — commands without a phase
16
+ # render as the single ungrouped default listing (unchanged).
17
17
  class BaseCommand
18
18
  def self.phase(order = nil, label = nil)
19
19
  return @phase if order.nil?
@@ -166,6 +166,10 @@ module Space::Architect
166
166
  project = ArchitectProject.new(space: sp)
167
167
  info = project.status
168
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")
169
173
 
170
174
  terminal.say "Project status: #{block['status'] || '(none)'}"
171
175
  terminal.say "Current iteration: #{block['current_iteration'] || '(none)'}"
@@ -179,7 +183,7 @@ module Space::Architect
179
183
  lane_list = s["lanes"] || []
180
184
  lanes_str = lane_list.map do |l|
181
185
  h = l["harness"] || "pi"
182
- m = l["model"] || Harness::DEFAULT_MODEL
186
+ m = l["model"] || config_model || "#{Harness::DEFAULT_MODEL} (last resort)"
183
187
  eff = l["effort"] ? "·#{l['effort']}" : ""
184
188
  "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
185
189
  end.join(", ")
@@ -485,7 +489,7 @@ module Space::Architect
485
489
  argument :lane, required: true, desc: "Lane name"
486
490
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
487
491
  option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
488
- 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"
489
493
  option :max_turns, default: "200", desc: "Max turns for the builder"
490
494
  option :harness, default: nil, desc: "Harness (pi)"
491
495
  option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max)"
@@ -800,7 +804,8 @@ module Space::Architect
800
804
  terminal.say result[:command]
801
805
  terminal.say ""
802
806
  terminal.say "Diagnostics:"
803
- terminal.say " space-architect #{Space::Core::VERSION}"
807
+ terminal.say " space-architect #{Space::Architect::VERSION}"
808
+ terminal.say " space-cadet #{Space::Core::VERSION}"
804
809
  terminal.say " ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
805
810
  terminal.say " space: #{space.id} — #{space.title}" if space
806
811
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -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
 
@@ -1168,6 +1332,17 @@ module Space::Architect
1168
1332
  end
1169
1333
 
1170
1334
  def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
1335
+
1336
+ # The launchd-agent seam is repo-tender's — a soft dependency, never a
1337
+ # gemspec runtime dep. Required lazily so `sessions agent …` degrades
1338
+ # with an actionable hint instead of failing at boot when the gem is
1339
+ # absent; handle_errors turns the raise into a red message + exit 1.
1340
+ def agent
1341
+ require "repo_tender"
1342
+ RepoTender::Launchd::Agent
1343
+ rescue LoadError
1344
+ raise Space::Core::Error, "sessions agent needs the repo-tender gem — install it with: gem install repo-tender"
1345
+ end
1171
1346
  end
1172
1347
 
1173
1348
  class Install < BaseCommand
@@ -1196,7 +1371,7 @@ module Space::Architect
1196
1371
  File.write(pp, xml)
1197
1372
  File.chmod(0o600, pp)
1198
1373
 
1199
- result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).install(pp)
1374
+ result = agent.new(label: SessionSync::LABEL).install(pp)
1200
1375
  raise Space::Core::Error, "bootstrap failed: #{format_failure(result.failure)}" if result.failure?
1201
1376
 
1202
1377
  terminal.say "Installed: #{terminal.path(pp)}"
@@ -1213,7 +1388,7 @@ module Space::Architect
1213
1388
  setup_terminal(**opts.slice(:color, :colors))
1214
1389
  handle_errors do
1215
1390
  pp = plist_path
1216
- result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).uninstall
1391
+ result = agent.new(label: SessionSync::LABEL).uninstall
1217
1392
  terminal.say "bootout reported: #{format_failure(result.failure)}" if result.failure?
1218
1393
 
1219
1394
  if File.exist?(pp)
@@ -1234,7 +1409,7 @@ module Space::Architect
1234
1409
  def call(**opts)
1235
1410
  setup_terminal(**opts.slice(:color, :colors))
1236
1411
  handle_errors do
1237
- result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).status
1412
+ result = agent.new(label: SessionSync::LABEL).status
1238
1413
  raise Space::Core::Error, "status failed: #{format_failure(result.failure)}" if result.failure?
1239
1414
 
1240
1415
  s = result.success
@@ -1256,6 +1431,8 @@ end
1256
1431
  # Loop-phase declarations above sort the architect help listing; its namespaces
1257
1432
  # (brief/worktree/variant/research) declare no phase and list under this header.
1258
1433
  Space::Core::CLI::Help.trailing_group_label = "Groups"
1434
+ Space::Core::CLI::Help.product_name = "architect"
1435
+ Space::Core::CLI::Help.product_version = Space::Architect::VERSION
1259
1436
 
1260
1437
  Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Architect::Init
1261
1438
  Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
@@ -1275,6 +1452,7 @@ Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Arc
1275
1452
  Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
1276
1453
  Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
1277
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
1278
1456
  Space::Architect::CLI::Registry.register "brief" do |b|
1279
1457
  b.register "new", Space::Architect::CLI::Architect::Brief::New
1280
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)
@@ -33,21 +33,11 @@ module Space::Architect
33
33
  end
34
34
 
35
35
  if VERSION_REQUEST.include?(argv)
36
- out.puts Space::Core::VERSION
36
+ out.puts Space::Architect::VERSION
37
37
  return 0
38
38
  end
39
39
 
40
- if argv.first == "src"
41
- return dispatch_src(argv[1..], out, err)
42
- end
43
-
44
- normalized = normalize_args(argv)
45
-
46
- if normalized.first == "space"
47
- return dispatch_space(normalized[1..], out, err)
48
- end
49
-
50
- Dry::CLI.new(Registry).call(arguments: normalized, out: out, err: err)
40
+ Dry::CLI.new(Registry).call(arguments: normalize_args(argv), out: out, err: err)
51
41
  last_outcome&.exit_code || 0
52
42
  end
53
43
 
@@ -88,6 +78,13 @@ module Space::Architect
88
78
  end
89
79
 
90
80
  def self.run(argv, out = $stdout, err = $stderr)
81
+ # One-shot 8.x app-dir migration (the substrate's config/state, owned by
82
+ # Space::Core::Migration in space-cadet) before dispatch — skipped for
83
+ # the pure help/version queries so they stay side-effect-free. Running it
84
+ # here too means `architect` picks up an 8.x user's config even if they
85
+ # never ran `space` first.
86
+ Space::Core::Migration.run(err: err) unless TOP_LEVEL_HELP.include?(argv) || VERSION_REQUEST.include?(argv)
87
+
91
88
  Kernel.exit(call(argv, out, err))
92
89
  rescue Interrupt
93
90
  err.puts "interrupted"
@@ -97,6 +94,4 @@ module Space::Architect
97
94
  end
98
95
 
99
96
  require_relative "cli/architect"
100
- require_relative "cli/space"
101
- require_relative "cli/src"
102
97
  require_relative "cli/research"