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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +39 -464
- data/lib/space_architect/architect_project.rb +90 -39
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +189 -11
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/cli.rb +9 -14
- 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/session_sync/bin_path.rb +1 -1
- data/lib/space_architect/session_sync/cursor.rb +1 -1
- data/lib/space_architect/session_sync/plist.rb +1 -1
- data/lib/{space_core → space_architect}/version.rb +2 -2
- data/lib/space_architect.rb +2 -1
- data/skill/architect/SKILL.md +3 -2
- data/skill/architect-vocabulary/SKILL.md +2 -2
- metadata +20 -177
- data/exe/space +0 -13
- data/exe/src +0 -13
- data/lib/space_architect/cli/space.rb +0 -29
- data/lib/space_architect/cli/src.rb +0 -38
- data/lib/space_core/atomic_write.rb +0 -21
- data/lib/space_core/cli/base_command.rb +0 -19
- data/lib/space_core/cli/build.rb +0 -27
- data/lib/space_core/cli/config.rb +0 -49
- data/lib/space_core/cli/current.rb +0 -16
- data/lib/space_core/cli/help.rb +0 -195
- data/lib/space_core/cli/helpers.rb +0 -115
- data/lib/space_core/cli/init.rb +0 -29
- data/lib/space_core/cli/list.rb +0 -24
- data/lib/space_core/cli/loop_status.rb +0 -47
- data/lib/space_core/cli/new.rb +0 -38
- data/lib/space_core/cli/pack.rb +0 -29
- data/lib/space_core/cli/path.rb +0 -16
- data/lib/space_core/cli/repeatable_options.rb +0 -84
- data/lib/space_core/cli/repo.rb +0 -76
- data/lib/space_core/cli/run.rb +0 -44
- data/lib/space_core/cli/shell.rb +0 -125
- data/lib/space_core/cli/show.rb +0 -21
- data/lib/space_core/cli/status.rb +0 -74
- data/lib/space_core/cli/use.rb +0 -17
- data/lib/space_core/cli.rb +0 -177
- data/lib/space_core/commands.rb +0 -51
- data/lib/space_core/config.rb +0 -152
- data/lib/space_core/errors.rb +0 -14
- data/lib/space_core/git_client.rb +0 -49
- data/lib/space_core/mise_client.rb +0 -37
- data/lib/space_core/oci_builder.rb +0 -56
- data/lib/space_core/oci_packer.rb +0 -103
- data/lib/space_core/oci_runner.rb +0 -89
- data/lib/space_core/paths.rb +0 -58
- data/lib/space_core/repo_reference.rb +0 -19
- data/lib/space_core/repo_resolver.rb +0 -167
- data/lib/space_core/shell_integration.rb +0 -438
- data/lib/space_core/slugger.rb +0 -16
- data/lib/space_core/space.rb +0 -174
- data/lib/space_core/space_store.rb +0 -319
- data/lib/space_core/state.rb +0 -86
- data/lib/space_core/templates/oci/dockerfile.erb +0 -73
- data/lib/space_core/templates/oci/dockerignore.erb +0 -17
- data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
- data/lib/space_core/terminal.rb +0 -161
- data/lib/space_core/warnings.rb +0 -13
- data/lib/space_core/xdg.rb +0 -33
- data/lib/space_core.rb +0 -26
- data/lib/space_src/cli/clone.rb +0 -54
- data/lib/space_src/cli/config.rb +0 -65
- data/lib/space_src/cli/daemon.rb +0 -362
- data/lib/space_src/cli/options.rb +0 -21
- data/lib/space_src/cli/org.rb +0 -199
- data/lib/space_src/cli/repo.rb +0 -169
- data/lib/space_src/cli/shell.rb +0 -121
- data/lib/space_src/cli/status.rb +0 -75
- data/lib/space_src/cli/sync.rb +0 -161
- data/lib/space_src/cli.rb +0 -159
- data/lib/space_src/cloner.rb +0 -77
- data/lib/space_src/config/contract.rb +0 -54
- data/lib/space_src/config/duration.rb +0 -79
- data/lib/space_src/config/model.rb +0 -49
- data/lib/space_src/config/store.rb +0 -156
- data/lib/space_src/forge/client.rb +0 -31
- data/lib/space_src/forge/github.rb +0 -98
- data/lib/space_src/launchd/agent.rb +0 -195
- data/lib/space_src/launchd/plist.rb +0 -129
- data/lib/space_src/log_rotator.rb +0 -46
- data/lib/space_src/migration.rb +0 -43
- data/lib/space_src/nav.rb +0 -99
- data/lib/space_src/paths.rb +0 -72
- data/lib/space_src/scm/client.rb +0 -87
- data/lib/space_src/scm/git.rb +0 -232
- data/lib/space_src/scm/status.rb +0 -24
- data/lib/space_src/shell.rb +0 -90
- data/lib/space_src/shell_integration.rb +0 -321
- data/lib/space_src/state/lock.rb +0 -59
- data/lib/space_src/state/store.rb +0 -140
- data/lib/space_src/sync/engine.rb +0 -503
- data/lib/space_src/sync/repo_plan.rb +0 -215
- data/lib/space_src/sync/report.rb +0 -18
- data/lib/space_src/ui/interactive_reporter.rb +0 -280
- data/lib/space_src/ui/json_reporter.rb +0 -39
- data/lib/space_src/ui/mode.rb +0 -68
- data/lib/space_src/ui/plain_reporter.rb +0 -53
- data/lib/space_src/ui/reporter.rb +0 -48
- data/lib/space_src/version.rb +0 -7
- 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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
151
|
+
FileUtils.mkdir_p(path.dirname)
|
|
152
|
+
path.write(render_iteration(nn, name))
|
|
145
153
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
1609
|
-
# default model. Shared by worktree_add
|
|
1610
|
-
# defaults. A stored model is only honored
|
|
1611
|
-
# the resolved harness — a dispatch-time
|
|
1612
|
-
#
|
|
1613
|
-
# legacy harness name survives resolution
|
|
1614
|
-
# surfaces as Harness.for's actionable pi-only error,
|
|
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
|
-
|
|
1620
|
-
[
|
|
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
|
-
|
|
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]
|
|
@@ -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 —
|
|
16
|
-
#
|
|
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"]
|
|
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::
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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)
|
data/lib/space_architect/cli.rb
CHANGED
|
@@ -33,21 +33,11 @@ module Space::Architect
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
if VERSION_REQUEST.include?(argv)
|
|
36
|
-
out.puts Space::
|
|
36
|
+
out.puts Space::Architect::VERSION
|
|
37
37
|
return 0
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
|
|
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"
|