space-architect 1.3.0 → 2.0.0.rc2
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 +103 -0
- data/README.md +267 -158
- data/exe/architect +1 -1
- data/exe/space +2 -2
- data/exe/src +13 -0
- data/lib/space_architect/{architect_mission.rb → architect_project.rb} +475 -125
- data/lib/space_architect/cli/architect.rb +248 -178
- data/lib/space_architect/cli/research.rb +94 -0
- data/lib/space_architect/cli/space.rb +25 -31
- data/lib/space_architect/cli/src.rb +20 -14
- data/lib/space_architect/cli.rb +22 -22
- data/lib/space_architect/dispatcher.rb +5 -1
- data/lib/space_architect/gate_evaluator.rb +65 -0
- data/lib/space_architect/gate_lint.rb +140 -0
- data/lib/space_architect/harness.rb +145 -17
- data/lib/space_architect/research/mux.rb +127 -0
- data/lib/space_architect/research/registry.rb +70 -0
- data/lib/space_architect/research/renderer.rb +101 -0
- data/lib/space_architect/research/run.rb +7 -0
- data/lib/space_architect/research/supervisor.rb +108 -0
- data/lib/space_architect/research.rb +13 -0
- data/lib/space_architect/run_creator.rb +53 -0
- data/lib/space_architect/skill_installer.rb +81 -79
- data/lib/space_architect/templates/architect.md.erb +15 -4
- data/lib/space_architect/templates/brief.md.erb +5 -5
- data/lib/space_architect/templates/iteration.md.erb +17 -6
- data/lib/space_architect.rb +8 -21
- data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
- data/lib/space_core/cli/base_command.rb +19 -0
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/config.rb +49 -0
- data/lib/space_core/cli/current.rb +16 -0
- data/lib/space_core/cli/help.rb +123 -0
- data/lib/space_core/cli/helpers.rb +115 -0
- data/lib/space_core/cli/init.rb +29 -0
- data/lib/space_core/cli/list.rb +24 -0
- data/lib/space_core/cli/new.rb +38 -0
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/path.rb +16 -0
- data/lib/space_core/cli/repeatable_options.rb +75 -0
- data/lib/space_core/cli/repo.rb +76 -0
- data/lib/space_core/cli/run.rb +29 -0
- data/lib/space_core/cli/shell.rb +125 -0
- data/lib/space_core/cli/show.rb +21 -0
- data/lib/space_core/cli/status.rb +33 -0
- data/lib/space_core/cli/use.rb +17 -0
- data/lib/space_core/cli.rb +177 -0
- data/lib/{space_architect → space_core}/config.rb +1 -1
- data/lib/{space_architect → space_core}/errors.rb +1 -1
- data/lib/{space_architect → space_core}/git_client.rb +1 -1
- data/lib/{space_architect → space_core}/mise_client.rb +1 -1
- data/lib/space_core/oci_builder.rb +56 -0
- data/lib/space_core/oci_packer.rb +99 -0
- data/lib/space_core/oci_runner.rb +73 -0
- data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
- data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
- data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
- data/lib/{space_architect → space_core}/slugger.rb +1 -1
- data/lib/{space_architect → space_core}/space.rb +11 -3
- data/lib/{space_architect → space_core}/space_store.rb +13 -13
- data/lib/{space_architect → space_core}/state.rb +1 -1
- data/lib/space_core/templates/oci/dockerfile.erb +63 -0
- data/lib/space_core/templates/oci/dockerignore.erb +17 -0
- data/lib/space_core/templates/oci/entrypoint.sh.erb +10 -0
- data/lib/{space_architect → space_core}/terminal.rb +1 -1
- data/lib/space_core/version.rb +7 -0
- data/lib/{space_architect → space_core}/warnings.rb +1 -1
- data/lib/{space_architect → space_core}/xdg.rb +1 -1
- data/lib/space_core.rb +24 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
- data/lib/space_src/cli/shell.rb +122 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
- data/lib/space_src/migration.rb +43 -0
- data/lib/space_src/nav.rb +98 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
- data/lib/space_src/shell_integration.rb +321 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
- data/lib/space_src.rb +37 -0
- data/skill/architect/SKILL.md +110 -54
- data/skill/architect/dispatch.md +147 -39
- data/skill/architect/research.md +46 -37
- data/skill/architect-research/SKILL.md +2 -2
- data/skill/architect-vocabulary/SKILL.md +24 -21
- metadata +127 -68
- data/lib/space_architect/cli/config.rb +0 -61
- data/lib/space_architect/cli/current.rb +0 -22
- data/lib/space_architect/cli/helpers.rb +0 -117
- data/lib/space_architect/cli/init.rb +0 -35
- data/lib/space_architect/cli/list.rb +0 -30
- data/lib/space_architect/cli/new.rb +0 -43
- data/lib/space_architect/cli/options.rb +0 -12
- data/lib/space_architect/cli/path.rb +0 -22
- data/lib/space_architect/cli/repo.rb +0 -88
- data/lib/space_architect/cli/shell.rb +0 -137
- data/lib/space_architect/cli/show.rb +0 -27
- data/lib/space_architect/cli/status.rb +0 -39
- data/lib/space_architect/cli/use.rb +0 -23
- data/lib/space_architect/version.rb +0 -5
- data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -44
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Architect
|
|
4
|
+
module CLI
|
|
5
|
+
module Architect
|
|
6
|
+
module Research
|
|
7
|
+
class Dispatch < BaseCommand
|
|
8
|
+
desc "Dispatch detached read-only research lanes (one per prompt file)"
|
|
9
|
+
argument :prompts, required: true,
|
|
10
|
+
desc: "Prompt file(s) to dispatch (space-separated paths)"
|
|
11
|
+
option :model, default: nil, desc: "Researcher model override (default: the reference default claude-sonnet-4-6)"
|
|
12
|
+
option :max_turns, default: "40", desc: "Max turns per researcher"
|
|
13
|
+
|
|
14
|
+
def call(prompts:, model: nil, max_turns: "40", **opts)
|
|
15
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
16
|
+
handle_errors do
|
|
17
|
+
render(store.find(opts[:space])) do |sp|
|
|
18
|
+
paths = Array(prompts)
|
|
19
|
+
supervisor = Space::Architect::Research::Supervisor.new(space: sp)
|
|
20
|
+
kwargs = { max_turns: max_turns.to_i }
|
|
21
|
+
kwargs[:model] = model if model
|
|
22
|
+
runs = supervisor.dispatch(paths, **kwargs)
|
|
23
|
+
runs.each do |run|
|
|
24
|
+
terminal.say "dispatched #{run.id} (pid #{run.pid}) → #{terminal.path(run.run_log_path)}"
|
|
25
|
+
end
|
|
26
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Status < BaseCommand
|
|
33
|
+
desc "Show status of dispatched research runs"
|
|
34
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
35
|
+
|
|
36
|
+
def call(space: nil, **opts)
|
|
37
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
38
|
+
handle_errors do
|
|
39
|
+
render(store.find(space)) do |sp|
|
|
40
|
+
supervisor = Space::Architect::Research::Supervisor.new(space: sp)
|
|
41
|
+
entries = supervisor.status
|
|
42
|
+
if entries.empty?
|
|
43
|
+
terminal.say "No research runs registered"
|
|
44
|
+
else
|
|
45
|
+
rows = entries.map do |e|
|
|
46
|
+
run = e[:run]
|
|
47
|
+
[run.id, run.pid.to_s, e[:state].to_s, run.model, e[:tail].last.to_s.slice(0, 60)]
|
|
48
|
+
end
|
|
49
|
+
terminal.say terminal.table(%w[ID PID State Model LastLine], rows)
|
|
50
|
+
end
|
|
51
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class Wait < BaseCommand
|
|
58
|
+
desc "Wait for all dispatched research runs to complete"
|
|
59
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
60
|
+
option :quiet, type: :boolean, default: false,
|
|
61
|
+
desc: "L0: suppress all output; exit status only"
|
|
62
|
+
option :level, type: :integer, default: 1,
|
|
63
|
+
desc: "Verbosity level: 1=lifecycle 2=+text 3=+tools 4=+io"
|
|
64
|
+
option :thinking, type: :boolean, default: false,
|
|
65
|
+
desc: "Show assistant thinking blocks"
|
|
66
|
+
option :jsonl, type: :boolean, default: false,
|
|
67
|
+
desc: "Emit raw lane-tagged JSONL (mutually exclusive with level/quiet)"
|
|
68
|
+
|
|
69
|
+
def call(space: nil, quiet: false, level: 1, thinking: false, jsonl: false, **opts)
|
|
70
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
71
|
+
handle_errors do
|
|
72
|
+
render(store.find(space)) do |sp|
|
|
73
|
+
supervisor = Space::Architect::Research::Supervisor.new(space: sp)
|
|
74
|
+
result = supervisor.wait(
|
|
75
|
+
quiet: quiet,
|
|
76
|
+
level: level.to_i,
|
|
77
|
+
thinking: thinking,
|
|
78
|
+
jsonl: jsonl
|
|
79
|
+
)
|
|
80
|
+
CLI.record_outcome(Outcome.new(exit_code: result == :ok ? 0 : 1))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Space::Architect::CLI::Registry.register "research" do |r|
|
|
91
|
+
r.register "dispatch", Space::Architect::CLI::Architect::Research::Dispatch
|
|
92
|
+
r.register "status", Space::Architect::CLI::Architect::Research::Status
|
|
93
|
+
r.register "wait", Space::Architect::CLI::Architect::Research::Wait
|
|
94
|
+
end
|
|
@@ -1,35 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
r.register "resolve", SpaceArchitect::CLI::Repo::Resolve
|
|
29
|
-
end
|
|
30
|
-
prefix.register "shell" do |s|
|
|
31
|
-
s.register "init", SpaceArchitect::CLI::Shell::ShellInit
|
|
32
|
-
s.register "fish", SpaceArchitect::CLI::Shell::Fish
|
|
33
|
-
s.register "complete", SpaceArchitect::CLI::Shell::Complete
|
|
3
|
+
require "space_core/cli"
|
|
4
|
+
|
|
5
|
+
module Space::Architect
|
|
6
|
+
module CLI
|
|
7
|
+
# Exit-code bridge to the Space::Core CLI engine.
|
|
8
|
+
# `architect space <args>` (and `exe/space <args>` via Space::Core::CLI directly)
|
|
9
|
+
# hands the raw remainder to Space::Core's own dry-cli registry and translates
|
|
10
|
+
# Space::Core's recorded Outcome into the host exit code.
|
|
11
|
+
# Space::Core has its own Registry, Outcome, and :space_core_cli_outcome thread-local;
|
|
12
|
+
# this is the seam between the two registries (NOT a re-registration).
|
|
13
|
+
def self.dispatch_space(rest, out = $stdout, err = $stderr)
|
|
14
|
+
if ::Space::Core::CLI::TOP_LEVEL_HELP.include?(rest)
|
|
15
|
+
out.puts Dry::CLI::Usage.call(::Space::Core::CLI::Registry.get([]))
|
|
16
|
+
return 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if ::Space::Core::CLI::VERSION_REQUEST.include?(rest)
|
|
20
|
+
out.puts ::Space::Core::VERSION
|
|
21
|
+
return 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Thread.current[:space_core_cli_outcome] = nil
|
|
25
|
+
Dry::CLI.new(::Space::Core::CLI::Registry).call(arguments: rest, out: out, err: err)
|
|
26
|
+
::Space::Core::CLI.last_outcome&.exit_code || 0
|
|
27
|
+
end
|
|
34
28
|
end
|
|
35
29
|
end
|
|
@@ -1,32 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "space_src/cli"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Space::Architect
|
|
6
6
|
module CLI
|
|
7
|
-
# Exit-code bridge to the
|
|
8
|
-
# `architect src <args>` hands the raw remainder to
|
|
9
|
-
# registry and translates
|
|
10
|
-
#
|
|
11
|
-
# this is the seam between the two registries (NOT a re-registration).
|
|
7
|
+
# Exit-code bridge to the Space::Src CLI engine.
|
|
8
|
+
# `architect src <args>` hands the raw remainder to Space::Src's own dry-cli
|
|
9
|
+
# registry and translates Space::Src's recorded Outcome into the host exit code.
|
|
10
|
+
# Space::Src has its own Registry, Outcome, and :space_src_cli_* thread-locals;
|
|
11
|
+
# this is the seam between the two registries (NOT a re-registration). Space::Src's
|
|
12
12
|
# top-level help/version interceptors call Kernel.exit, so we reproduce that
|
|
13
13
|
# interception here against the injected IO instead of delegating to them.
|
|
14
14
|
# dry-cli's internal exit on a bare group / unknown command propagates as
|
|
15
15
|
# SystemExit — same as the host's own bare groups (e.g. `space repo`) — and is
|
|
16
16
|
# intentionally NOT rescued (accepted behavior change).
|
|
17
17
|
def self.dispatch_src(rest, out = $stdout, err = $stderr)
|
|
18
|
-
if
|
|
19
|
-
out.puts Dry::CLI::Usage.call(
|
|
18
|
+
if ::Space::Src::CLI::TOP_LEVEL_HELP.include?(rest)
|
|
19
|
+
out.puts Dry::CLI::Usage.call(::Space::Src::CLI::Registry.get([]))
|
|
20
20
|
return 0
|
|
21
21
|
end
|
|
22
|
-
if
|
|
23
|
-
out.puts
|
|
22
|
+
if ::Space::Src::CLI::VERSION_REQUEST.include?(rest)
|
|
23
|
+
out.puts ::Space::Src::VERSION
|
|
24
24
|
return 0
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
if ::Space::Src::CLI.bare_query?(rest)
|
|
28
|
+
paths = ::Space::Src::CLI.make_paths
|
|
29
|
+
config = ::Space::Src::Config::Store.load(paths.config_file).success
|
|
30
|
+
return ::Space::Src::Nav.dispatch(rest[0], out, err, config.base_dir)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Thread.current[:space_src_cli_outcome] = nil
|
|
34
|
+
Dry::CLI.new(::Space::Src::CLI::Registry).call(arguments: rest, out: out, err: err)
|
|
35
|
+
::Space::Src::CLI.last_outcome&.exit_code || 0
|
|
30
36
|
end
|
|
31
37
|
end
|
|
32
38
|
end
|
data/lib/space_architect/cli.rb
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "dry/cli"
|
|
4
|
+
require "pastel"
|
|
5
|
+
require "space_core/cli"
|
|
4
6
|
|
|
5
|
-
module
|
|
7
|
+
module Space::Architect
|
|
6
8
|
module CLI
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
# Delegate the outcome seam to Space::Core::CLI so both architect commands
|
|
10
|
+
# (which call CLI.record_outcome lexically) and the moved Helpers (which call
|
|
11
|
+
# CLI.record_outcome with Space::Core::CLI as lexical root) share one slot.
|
|
12
|
+
Outcome = Space::Core::CLI::Outcome
|
|
13
|
+
Helpers = Space::Core::CLI::Helpers
|
|
14
|
+
BaseCommand = Space::Core::CLI::BaseCommand
|
|
10
15
|
|
|
11
|
-
def self.record_outcome(o) = (
|
|
12
|
-
def self.last_outcome
|
|
16
|
+
def self.record_outcome(o) = Space::Core::CLI.record_outcome(o)
|
|
17
|
+
def self.last_outcome = Space::Core::CLI.last_outcome
|
|
13
18
|
|
|
14
19
|
module Registry
|
|
15
20
|
extend Dry::CLI::Registry
|
|
@@ -19,7 +24,8 @@ module SpaceArchitect
|
|
|
19
24
|
VERSION_REQUEST = [["version"], ["--version"]].freeze
|
|
20
25
|
|
|
21
26
|
def self.call(argv, out = $stdout, err = $stderr)
|
|
22
|
-
Thread.current[:
|
|
27
|
+
Thread.current[:space_core_cli_outcome] = nil
|
|
28
|
+
Space::Core::CLI.help_pastel = Pastel.new(enabled: Space::Core::CLI.help_colors?(argv, out, err))
|
|
23
29
|
|
|
24
30
|
if TOP_LEVEL_HELP.include?(argv)
|
|
25
31
|
out.puts Dry::CLI::Usage.call(Registry.get([]))
|
|
@@ -27,7 +33,7 @@ module SpaceArchitect
|
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
if VERSION_REQUEST.include?(argv)
|
|
30
|
-
out.puts
|
|
36
|
+
out.puts Space::Core::VERSION
|
|
31
37
|
return 0
|
|
32
38
|
end
|
|
33
39
|
|
|
@@ -35,7 +41,13 @@ module SpaceArchitect
|
|
|
35
41
|
return dispatch_src(argv[1..], out, err)
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
|
|
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)
|
|
39
51
|
last_outcome&.exit_code || 0
|
|
40
52
|
end
|
|
41
53
|
|
|
@@ -84,19 +96,7 @@ module SpaceArchitect
|
|
|
84
96
|
end
|
|
85
97
|
end
|
|
86
98
|
|
|
87
|
-
require_relative "cli/options"
|
|
88
|
-
require_relative "cli/helpers"
|
|
89
|
-
require_relative "cli/init"
|
|
90
|
-
require_relative "cli/new"
|
|
91
|
-
require_relative "cli/list"
|
|
92
|
-
require_relative "cli/show"
|
|
93
|
-
require_relative "cli/path"
|
|
94
|
-
require_relative "cli/use"
|
|
95
|
-
require_relative "cli/current"
|
|
96
|
-
require_relative "cli/status"
|
|
97
|
-
require_relative "cli/config"
|
|
98
|
-
require_relative "cli/repo"
|
|
99
|
-
require_relative "cli/shell"
|
|
100
99
|
require_relative "cli/architect"
|
|
101
100
|
require_relative "cli/space"
|
|
102
101
|
require_relative "cli/src"
|
|
102
|
+
require_relative "cli/research"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "harness"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Space::Architect
|
|
6
6
|
# Thin backward-compat wrapper around Harness::ClaudeCodeHarness.
|
|
7
7
|
# Existing callers that construct Dispatcher.new(...).run(...) continue to work byte-for-byte.
|
|
8
8
|
class Dispatcher
|
|
@@ -17,5 +17,9 @@ module SpaceArchitect
|
|
|
17
17
|
def run(prompt_path:, run_log_path:, chdir:)
|
|
18
18
|
@harness.run(prompt_path: prompt_path, run_log_path: run_log_path, chdir: chdir)
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
def run_detached(prompt_path:, run_log_path:, chdir:)
|
|
22
|
+
@harness.run_detached(prompt_path: prompt_path, run_log_path: run_log_path, chdir: chdir)
|
|
23
|
+
end
|
|
20
24
|
end
|
|
21
25
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Architect
|
|
4
|
+
# Evaluates a single gate's captured output against its frozen expect block.
|
|
5
|
+
# Pure — no I/O. Mirrors GateLint's class shape.
|
|
6
|
+
#
|
|
7
|
+
# GateEvaluator.call(stdout:, exit_code:, expect:) → Result
|
|
8
|
+
# result.pass? → Boolean
|
|
9
|
+
# result.reason → String (empty on pass, first failing matcher on fail)
|
|
10
|
+
class GateEvaluator
|
|
11
|
+
Result = Data.define(:pass, :reason) do
|
|
12
|
+
def pass? = pass
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.call(stdout:, exit_code:, expect:) = new.call(stdout: stdout, exit_code: exit_code, expect: expect)
|
|
16
|
+
|
|
17
|
+
def call(stdout:, exit_code:, expect:)
|
|
18
|
+
e = (expect || {}).transform_keys(&:to_s)
|
|
19
|
+
|
|
20
|
+
if e.key?("exit_code")
|
|
21
|
+
expected = e["exit_code"]
|
|
22
|
+
unless exit_code == expected
|
|
23
|
+
return Result.new(pass: false, reason: "exit_code #{exit_code.inspect} != #{expected}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if (pattern = e["stdout_match"])
|
|
28
|
+
unless Regexp.new(pattern).match?(stdout.to_s)
|
|
29
|
+
return Result.new(pass: false, reason: "stdout did not match /#{pattern}/")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if (thresh = e["threshold"])
|
|
34
|
+
result = check_threshold(stdout.to_s, thresh.transform_keys(&:to_s))
|
|
35
|
+
return result unless result.pass?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Result.new(pass: true, reason: "")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def check_threshold(stdout, thresh)
|
|
44
|
+
re = Regexp.new(thresh["match"])
|
|
45
|
+
m = nil
|
|
46
|
+
stdout.scan(re) { m = Regexp.last_match }
|
|
47
|
+
return Result.new(pass: false, reason: "metric not found") unless m
|
|
48
|
+
|
|
49
|
+
captured = m.captures.first
|
|
50
|
+
begin
|
|
51
|
+
num = Float(captured)
|
|
52
|
+
rescue ArgumentError
|
|
53
|
+
return Result.new(pass: false, reason: "metric capture #{captured.inspect} is not numeric")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
op = thresh["op"]
|
|
57
|
+
value = thresh["value"]
|
|
58
|
+
unless num.public_send(op, value)
|
|
59
|
+
return Result.new(pass: false, reason: "threshold: #{num} #{op} #{value} is false")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Result.new(pass: true, reason: "")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/validation"
|
|
4
|
+
require "dry/validation/extensions/monads"
|
|
5
|
+
require "dry/monads"
|
|
6
|
+
|
|
7
|
+
module Space::Architect
|
|
8
|
+
# Validates the structured gates block parsed from an iteration's
|
|
9
|
+
# ## Acceptance Criteria section. Returns a Dry::Monads::Result:
|
|
10
|
+
# Success(gates) — well-formed (or empty list)
|
|
11
|
+
# Failure([messages]) — aggregated lint errors
|
|
12
|
+
class GateLint
|
|
13
|
+
include Dry::Monads[:result]
|
|
14
|
+
|
|
15
|
+
class GateContract < Dry::Validation::Contract
|
|
16
|
+
VALID_OPS = %w[>= <= > < == !=].freeze
|
|
17
|
+
|
|
18
|
+
json do
|
|
19
|
+
config.validate_keys = true
|
|
20
|
+
|
|
21
|
+
required(:id).filled(:string)
|
|
22
|
+
required(:ac).filled(:string)
|
|
23
|
+
required(:cmd).filled(:string)
|
|
24
|
+
optional(:cwd).maybe(:string)
|
|
25
|
+
optional(:timeout)
|
|
26
|
+
required(:expect).hash do
|
|
27
|
+
optional(:exit_code)
|
|
28
|
+
optional(:stdout_match).filled(:string)
|
|
29
|
+
optional(:threshold).hash do
|
|
30
|
+
required(:match).filled(:string)
|
|
31
|
+
required(:op).filled(:string)
|
|
32
|
+
required(:value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
rule(:cmd) { key.failure("must not be blank") if values[:cmd].is_a?(String) && values[:cmd].strip.empty? }
|
|
38
|
+
rule(:id) { key.failure("must not be blank") if values[:id].is_a?(String) && values[:id].strip.empty? }
|
|
39
|
+
rule(:ac) { key.failure("must not be blank") if values[:ac].is_a?(String) && values[:ac].strip.empty? }
|
|
40
|
+
|
|
41
|
+
rule(:expect) do
|
|
42
|
+
next unless values[:expect].is_a?(Hash)
|
|
43
|
+
known = %i[exit_code stdout_match threshold]
|
|
44
|
+
key.failure("must have at least one of: #{known.join(", ")}") if known.none? { |k| values[:expect].key?(k) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
rule("expect.exit_code") do
|
|
48
|
+
val = values.dig(:expect, :exit_code)
|
|
49
|
+
key.failure("must be an Integer") if !val.nil? && !val.is_a?(Integer)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
rule("expect.threshold.op") do
|
|
53
|
+
val = values.dig(:expect, :threshold, :op)
|
|
54
|
+
key.failure("must be one of: #{VALID_OPS.join(", ")}") if val.is_a?(String) && !VALID_OPS.include?(val)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
rule("expect.threshold.match") do
|
|
58
|
+
val = values.dig(:expect, :threshold, :match)
|
|
59
|
+
next unless val.is_a?(String)
|
|
60
|
+
begin
|
|
61
|
+
n = Regexp.new("#{val}|(?:)").match("").captures.size
|
|
62
|
+
key.failure("must contain exactly one capture group (found #{n})") unless n == 1
|
|
63
|
+
rescue RegexpError => e
|
|
64
|
+
key.failure("invalid regexp: #{e.message}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
rule("expect.threshold.value") do
|
|
69
|
+
val = values.dig(:expect, :threshold, :value)
|
|
70
|
+
key.failure("must be a Number") if !val.nil? && !val.is_a?(Numeric)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
rule(:timeout) do
|
|
74
|
+
val = values[:timeout]
|
|
75
|
+
key.failure("must be a positive number of seconds") if !val.nil? && (!val.is_a?(Numeric) || val <= 0)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.call(gates) = new.call(gates)
|
|
80
|
+
|
|
81
|
+
def call(gates)
|
|
82
|
+
return Success([]) if gates.nil? || (gates.is_a?(Array) && gates.empty?)
|
|
83
|
+
return Failure(["gates block must be a YAML list, got #{gates.class}"]) unless gates.is_a?(Array)
|
|
84
|
+
|
|
85
|
+
errors = []
|
|
86
|
+
seen_ids = {}
|
|
87
|
+
contract = GateContract.new
|
|
88
|
+
|
|
89
|
+
gates.each.with_index(1) do |gate, i|
|
|
90
|
+
pfx = "gate[#{i}]"
|
|
91
|
+
unless gate.is_a?(Hash)
|
|
92
|
+
errors << "#{pfx}: must be a hash, got #{gate.class}"
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
result = contract.call(gate)
|
|
97
|
+
flatten_errors(result.errors.to_h, pfx, errors) unless result.success?
|
|
98
|
+
|
|
99
|
+
id = gate["id"] || gate[:id]
|
|
100
|
+
id = (id.is_a?(String) && !id.strip.empty?) ? id : nil
|
|
101
|
+
next unless id
|
|
102
|
+
|
|
103
|
+
if seen_ids.key?(id)
|
|
104
|
+
errors << "#{pfx}: duplicate id '#{id}' (also at gate[#{seen_ids[id]}])"
|
|
105
|
+
else
|
|
106
|
+
seen_ids[id] = i
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
errors.empty? ? Success(gates) : Failure(errors)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Recursively flatten the nested errors hash from dry-validation into
|
|
116
|
+
# a flat Array<String>, translating "is not allowed" into "unknown key: <name>".
|
|
117
|
+
def flatten_errors(node, prefix, acc)
|
|
118
|
+
case node
|
|
119
|
+
when Array
|
|
120
|
+
node.each do |item|
|
|
121
|
+
case item
|
|
122
|
+
when String
|
|
123
|
+
if item == "is not allowed"
|
|
124
|
+
*parent_parts, key_name = prefix.split(".")
|
|
125
|
+
acc << "#{parent_parts.join(".")}: unknown key: #{key_name}"
|
|
126
|
+
else
|
|
127
|
+
acc << "#{prefix}: #{item}"
|
|
128
|
+
end
|
|
129
|
+
when Array, Hash
|
|
130
|
+
flatten_errors(item, prefix, acc)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
when Hash
|
|
134
|
+
node.each do |k, v|
|
|
135
|
+
flatten_errors(v, "#{prefix}.#{k}", acc)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|