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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +267 -158
  4. data/exe/architect +1 -1
  5. data/exe/space +2 -2
  6. data/exe/src +13 -0
  7. data/lib/space_architect/{architect_mission.rb → architect_project.rb} +475 -125
  8. data/lib/space_architect/cli/architect.rb +248 -178
  9. data/lib/space_architect/cli/research.rb +94 -0
  10. data/lib/space_architect/cli/space.rb +25 -31
  11. data/lib/space_architect/cli/src.rb +20 -14
  12. data/lib/space_architect/cli.rb +22 -22
  13. data/lib/space_architect/dispatcher.rb +5 -1
  14. data/lib/space_architect/gate_evaluator.rb +65 -0
  15. data/lib/space_architect/gate_lint.rb +140 -0
  16. data/lib/space_architect/harness.rb +145 -17
  17. data/lib/space_architect/research/mux.rb +127 -0
  18. data/lib/space_architect/research/registry.rb +70 -0
  19. data/lib/space_architect/research/renderer.rb +101 -0
  20. data/lib/space_architect/research/run.rb +7 -0
  21. data/lib/space_architect/research/supervisor.rb +108 -0
  22. data/lib/space_architect/research.rb +13 -0
  23. data/lib/space_architect/run_creator.rb +53 -0
  24. data/lib/space_architect/skill_installer.rb +81 -79
  25. data/lib/space_architect/templates/architect.md.erb +15 -4
  26. data/lib/space_architect/templates/brief.md.erb +5 -5
  27. data/lib/space_architect/templates/iteration.md.erb +17 -6
  28. data/lib/space_architect.rb +8 -21
  29. data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
  30. data/lib/space_core/cli/base_command.rb +19 -0
  31. data/lib/space_core/cli/build.rb +27 -0
  32. data/lib/space_core/cli/config.rb +49 -0
  33. data/lib/space_core/cli/current.rb +16 -0
  34. data/lib/space_core/cli/help.rb +123 -0
  35. data/lib/space_core/cli/helpers.rb +115 -0
  36. data/lib/space_core/cli/init.rb +29 -0
  37. data/lib/space_core/cli/list.rb +24 -0
  38. data/lib/space_core/cli/new.rb +38 -0
  39. data/lib/space_core/cli/pack.rb +29 -0
  40. data/lib/space_core/cli/path.rb +16 -0
  41. data/lib/space_core/cli/repeatable_options.rb +75 -0
  42. data/lib/space_core/cli/repo.rb +76 -0
  43. data/lib/space_core/cli/run.rb +29 -0
  44. data/lib/space_core/cli/shell.rb +125 -0
  45. data/lib/space_core/cli/show.rb +21 -0
  46. data/lib/space_core/cli/status.rb +33 -0
  47. data/lib/space_core/cli/use.rb +17 -0
  48. data/lib/space_core/cli.rb +177 -0
  49. data/lib/{space_architect → space_core}/config.rb +1 -1
  50. data/lib/{space_architect → space_core}/errors.rb +1 -1
  51. data/lib/{space_architect → space_core}/git_client.rb +1 -1
  52. data/lib/{space_architect → space_core}/mise_client.rb +1 -1
  53. data/lib/space_core/oci_builder.rb +56 -0
  54. data/lib/space_core/oci_packer.rb +99 -0
  55. data/lib/space_core/oci_runner.rb +73 -0
  56. data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
  57. data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
  58. data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
  59. data/lib/{space_architect → space_core}/slugger.rb +1 -1
  60. data/lib/{space_architect → space_core}/space.rb +11 -3
  61. data/lib/{space_architect → space_core}/space_store.rb +13 -13
  62. data/lib/{space_architect → space_core}/state.rb +1 -1
  63. data/lib/space_core/templates/oci/dockerfile.erb +63 -0
  64. data/lib/space_core/templates/oci/dockerignore.erb +17 -0
  65. data/lib/space_core/templates/oci/entrypoint.sh.erb +10 -0
  66. data/lib/{space_architect → space_core}/terminal.rb +1 -1
  67. data/lib/space_core/version.rb +7 -0
  68. data/lib/{space_architect → space_core}/warnings.rb +1 -1
  69. data/lib/{space_architect → space_core}/xdg.rb +1 -1
  70. data/lib/space_core.rb +24 -0
  71. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
  72. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
  73. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
  74. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
  75. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
  76. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
  77. data/lib/space_src/cli/shell.rb +122 -0
  78. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
  79. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
  80. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
  81. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
  82. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
  83. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
  84. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
  85. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
  86. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
  87. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
  88. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
  89. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
  90. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
  91. data/lib/space_src/migration.rb +43 -0
  92. data/lib/space_src/nav.rb +98 -0
  93. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
  94. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
  95. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
  96. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
  97. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
  98. data/lib/space_src/shell_integration.rb +321 -0
  99. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
  100. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
  101. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
  102. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
  103. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
  104. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
  105. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
  106. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
  107. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
  108. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
  109. data/lib/space_src.rb +37 -0
  110. data/skill/architect/SKILL.md +110 -54
  111. data/skill/architect/dispatch.md +147 -39
  112. data/skill/architect/research.md +46 -37
  113. data/skill/architect-research/SKILL.md +2 -2
  114. data/skill/architect-vocabulary/SKILL.md +24 -21
  115. metadata +127 -68
  116. data/lib/space_architect/cli/config.rb +0 -61
  117. data/lib/space_architect/cli/current.rb +0 -22
  118. data/lib/space_architect/cli/helpers.rb +0 -117
  119. data/lib/space_architect/cli/init.rb +0 -35
  120. data/lib/space_architect/cli/list.rb +0 -30
  121. data/lib/space_architect/cli/new.rb +0 -43
  122. data/lib/space_architect/cli/options.rb +0 -12
  123. data/lib/space_architect/cli/path.rb +0 -22
  124. data/lib/space_architect/cli/repo.rb +0 -88
  125. data/lib/space_architect/cli/shell.rb +0 -137
  126. data/lib/space_architect/cli/show.rb +0 -27
  127. data/lib/space_architect/cli/status.rb +0 -39
  128. data/lib/space_architect/cli/use.rb +0 -23
  129. data/lib/space_architect/version.rb +0 -5
  130. 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
- SpaceArchitect::CLI::Registry.register "space" do |prefix|
4
- prefix.register "init", SpaceArchitect::CLI::Init
5
- prefix.register "new", SpaceArchitect::CLI::New
6
- prefix.register "list", SpaceArchitect::CLI::List
7
- prefix.register "ls", SpaceArchitect::CLI::List
8
- prefix.register "show", SpaceArchitect::CLI::Show
9
- prefix.register "path", SpaceArchitect::CLI::Path
10
- prefix.register "use", SpaceArchitect::CLI::Use
11
- prefix.register "current", SpaceArchitect::CLI::Current
12
- prefix.register "status", SpaceArchitect::CLI::Status
13
- prefix.register "config" do |c|
14
- c.register "show", SpaceArchitect::CLI::Config::Show
15
- c.register "path", SpaceArchitect::CLI::Config::ConfigPath
16
- c.register "set", SpaceArchitect::CLI::Config::Set
17
- end
18
- prefix.register "repo" do |r|
19
- r.register "add", SpaceArchitect::CLI::Repo::Add
20
- r.register "list", SpaceArchitect::CLI::Repo::RepoList
21
- r.register "ls", SpaceArchitect::CLI::Repo::RepoList
22
- r.register "resolve", SpaceArchitect::CLI::Repo::Resolve
23
- end
24
- prefix.register "repos" do |r|
25
- r.register "add", SpaceArchitect::CLI::Repo::Add
26
- r.register "list", SpaceArchitect::CLI::Repo::RepoList
27
- r.register "ls", SpaceArchitect::CLI::Repo::RepoList
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 "space_architect/pristine/cli"
3
+ require "space_src/cli"
4
4
 
5
- module SpaceArchitect
5
+ module Space::Architect
6
6
  module CLI
7
- # Exit-code bridge to the vendored Pristine (repo-tender) CLI engine.
8
- # `architect src <args>` hands the raw remainder to Pristine's own dry-cli
9
- # registry and translates Pristine's recorded Outcome into the host exit code.
10
- # Pristine has its own Registry, Outcome, and :repo_tender_cli_* thread-locals;
11
- # this is the seam between the two registries (NOT a re-registration). Pristine's
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 Pristine::CLI::TOP_LEVEL_HELP.include?(rest)
19
- out.puts Dry::CLI::Usage.call(Pristine::CLI::Registry.get([]))
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 Pristine::CLI::VERSION_REQUEST.include?(rest)
23
- out.puts SpaceArchitect::Pristine::VERSION
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
- Thread.current[:repo_tender_cli_outcome] = nil
28
- Dry::CLI.new(Pristine::CLI::Registry).call(arguments: rest, out: out, err: err)
29
- Pristine::CLI.last_outcome&.exit_code || 0
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
@@ -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 SpaceArchitect
7
+ module Space::Architect
6
8
  module CLI
7
- Outcome = Data.define(:exit_code, :message) do
8
- def initialize(exit_code:, message: nil) = super
9
- end
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) = (Thread.current[:space_architect_outcome] = o)
12
- def self.last_outcome = Thread.current[:space_architect_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[:space_architect_outcome] = nil
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 SpaceArchitect::VERSION
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
- Dry::CLI.new(Registry).call(arguments: normalize_args(argv), out: out, err: err)
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 SpaceArchitect
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