space-architect 2.0.0.rc1 → 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.
@@ -1,23 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Space::Architect
4
6
  module CLI
5
7
  module Architect
6
8
  class Init < BaseCommand
7
- desc "Scaffold architect mission memory in the current space"
9
+ desc "Scaffold (or top up) the architect project: ARCHITECT.md, space.yaml project block, SessionStart hook"
10
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
11
+
12
+ def call(space: nil, **opts)
13
+ setup_terminal(**opts.slice(:color, :colors))
14
+ handle_errors do
15
+ render(store.find(space)) do |sp|
16
+ project = ArchitectProject.new(space: sp)
17
+ path = project.init!
18
+ terminal.say "Project ready: #{terminal.path(path)}"
19
+ CLI.record_outcome(Outcome.new(exit_code: 0))
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ class Ground < BaseCommand
26
+ desc "Print grounding reads (ARCHITECT.md, BRIEF.md, in-flight iteration) to stdout"
8
27
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
9
28
 
10
29
  def call(space: nil, **opts)
11
30
  setup_terminal(**opts.slice(:color, :colors))
12
31
  handle_errors do
32
+ session_cwd = parse_session_cwd_from_stdin
13
33
  render(store.find(space)) do |sp|
14
- mission = ArchitectMission.new(space: sp)
15
- path = mission.init!
16
- terminal.say "Mission ready: #{terminal.path(path)}"
34
+ project = ArchitectProject.new(space: sp)
35
+ content = project.ground(session_cwd: session_cwd)
36
+ terminal.say content unless content.empty?
17
37
  CLI.record_outcome(Outcome.new(exit_code: 0))
18
38
  end
19
39
  end
20
40
  end
41
+
42
+ private
43
+
44
+ # Read the session's working directory from the Claude Code hook JSON on stdin,
45
+ # falling back to Dir.pwd when stdin is a tty or returns nothing (e.g. direct
46
+ # terminal invocation, CI with /dev/null stdin, or in-process test invocation).
47
+ def parse_session_cwd_from_stdin
48
+ return Dir.pwd if $stdin.tty?
49
+ line = $stdin.gets
50
+ return Dir.pwd unless line
51
+ JSON.parse(line.strip)["cwd"] || Dir.pwd
52
+ rescue JSON::ParserError, TypeError
53
+ Dir.pwd
54
+ end
21
55
  end
22
56
 
23
57
  class New < BaseCommand
@@ -29,8 +63,8 @@ module Space::Architect
29
63
  setup_terminal(**opts.slice(:color, :colors))
30
64
  handle_errors do
31
65
  render(store.find(space)) do |sp|
32
- mission = ArchitectMission.new(space: sp)
33
- path = mission.new_iteration!(iteration)
66
+ project = ArchitectProject.new(space: sp)
67
+ path = project.new_iteration!(iteration)
34
68
  terminal.say "Iteration scaffolded: #{terminal.path(path)}"
35
69
  CLI.record_outcome(Outcome.new(exit_code: 0))
36
70
  end
@@ -39,18 +73,18 @@ module Space::Architect
39
73
  end
40
74
 
41
75
  class Status < BaseCommand
42
- desc "Show architect mission state (read-only)"
76
+ desc "Show architect project state (read-only)"
43
77
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
44
78
 
45
79
  def call(space: nil, **opts)
46
80
  setup_terminal(**opts.slice(:color, :colors))
47
81
  handle_errors do
48
82
  render(store.find(space)) do |sp|
49
- mission = ArchitectMission.new(space: sp)
50
- info = mission.status
83
+ project = ArchitectProject.new(space: sp)
84
+ info = project.status
51
85
  block = info[:block]
52
86
 
53
- terminal.say "Mission status: #{block['status'] || '(none)'}"
87
+ terminal.say "Project status: #{block['status'] || '(none)'}"
54
88
  terminal.say "Current iteration: #{block['current_iteration'] || '(none)'}"
55
89
 
56
90
  iterations = block["iterations"] || []
@@ -68,7 +102,14 @@ module Space::Architect
68
102
  end.join(", ")
69
103
  lanes = lane_list.any? { |l| l["variant"] } ? "variant: #{lanes_str}" : lanes_str
70
104
  lanes = "#{lanes} → winner: #{s['winner']}" if s["winner"]
71
- [nn, s["name"], s["freeze_sha"]&.[](0, 8) || "-", lanes, s["verdict"] || "-"]
105
+ verdict_str = if s["verdict"] && s["verdict"] != "pending"
106
+ s["verdict"]
107
+ elsif (s["lanes"] || []).any? { |l| l["integration_branch"] }
108
+ "awaiting-verdict"
109
+ else
110
+ s["verdict"] || "-"
111
+ end
112
+ [nn, s["name"], s["freeze_sha"]&.[](0, 8) || "-", lanes, verdict_str]
72
113
  end
73
114
  terminal.say terminal.table(%w[II Iteration FreezeSHA Lanes Verdict], rows)
74
115
  end
@@ -84,7 +125,7 @@ module Space::Architect
84
125
  end
85
126
 
86
127
  class Freeze < BaseCommand
87
- desc "Freeze gates for an iteration"
128
+ desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
88
129
  argument :iteration, required: true, desc: "Iteration name"
89
130
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
90
131
 
@@ -92,10 +133,12 @@ module Space::Architect
92
133
  setup_terminal(**opts.slice(:color, :colors))
93
134
  handle_errors do
94
135
  render(store.find(space)) do |sp|
95
- mission = ArchitectMission.new(space: sp)
96
- sha = mission.freeze!(iteration)
136
+ project = ArchitectProject.new(space: sp)
137
+ warnings = []
138
+ sha = project.freeze!(iteration, warnings: warnings)
97
139
  terminal.say "Frozen #{iteration} at #{sha}"
98
- ac = mission.acceptance_criteria(iteration)
140
+ warnings.each { |w| terminal.say "Warning: #{w}" }
141
+ ac = project.acceptance_criteria(iteration)
99
142
  unless ac.to_s.strip.empty?
100
143
  terminal.say ""
101
144
  terminal.say "Frozen Acceptance Criteria (quote these verbatim when judging):"
@@ -108,7 +151,7 @@ module Space::Architect
108
151
  end
109
152
 
110
153
  class Verify < BaseCommand
111
- desc "Post-flight mechanical checks for an iteration (reports only, no judgment)"
154
+ desc "Post-flight mechanical lane checks frozen-untouched, no builder commits, report exists, in-bounds (reports only, no judgment)"
112
155
  argument :iteration, required: true, desc: "Iteration name"
113
156
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
114
157
 
@@ -116,8 +159,8 @@ module Space::Architect
116
159
  setup_terminal(**opts.slice(:color, :colors))
117
160
  handle_errors do
118
161
  render(store.find(space)) do |sp|
119
- mission = ArchitectMission.new(space: sp)
120
- results = mission.verify(iteration)
162
+ project = ArchitectProject.new(space: sp)
163
+ results = project.verify(iteration)
121
164
 
122
165
  if results.empty?
123
166
  terminal.say "No lanes recorded for iteration '#{iteration}'"
@@ -146,9 +189,10 @@ module Space::Architect
146
189
 
147
190
  def pass_fail(val)
148
191
  case val
149
- when true then "PASS"
150
- when false then "FAIL"
151
- else "N/A"
192
+ when true then "PASS"
193
+ when false then "FAIL"
194
+ when :no_touch_set then "WARN — no touch_set recorded"
195
+ else "N/A"
152
196
  end
153
197
  end
154
198
  end
@@ -158,36 +202,43 @@ module Space::Architect
158
202
  argument :iteration, required: true, desc: "Iteration name"
159
203
  argument :lane, required: true, desc: "Lane name"
160
204
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
161
- option :model, default: nil, desc: "Model to use (default: lane entry or claude-sonnet-4-6)"
205
+ option :model, default: nil, desc: "Builder model to pin (default: the lane's model, else the reference default claude-sonnet-4-6). Any provider/tier; pin a full id, not a floating alias"
162
206
  option :max_turns, default: "200", desc: "Max turns for the builder"
163
207
  option :harness, default: nil, desc: "Harness override (claude-code, opencode)"
164
208
  option :effort, default: nil, desc: "Reasoning effort override (opencode only; sets reasoningEffort in the model config)"
165
209
  option :detach, type: :boolean, default: false, desc: "Detach the builder process (returns immediately with PID; poll report for completion)"
210
+ option :timeout, default: "14400", desc: "Wall-clock timeout in seconds (0 disables; default 4h); foreground only"
166
211
  option :push_url, default: nil, desc: "HTTP endpoint for streaming push (POST body to this URL)"
167
212
  option :push_token, default: nil, desc: "Bearer token for push endpoint authorization"
168
213
  option :push_host, default: nil, desc: "Base URL of the ingest server; the CLI creates a run via POST <host>/runs and streams to /runs/<id>/ingest (requires --push-token)"
169
214
 
170
215
  def call(iteration:, lane:, space: nil, model: nil,
171
216
  max_turns: "200", harness: nil, effort: nil, detach: false,
172
- push_url: nil, push_token: nil, push_host: nil, **opts)
217
+ timeout: "14400", push_url: nil, push_token: nil, push_host: nil, **opts)
173
218
  setup_terminal(**opts.slice(:color, :colors))
174
219
  handle_errors do
175
220
  render(store.find(space)) do |sp|
176
- mission = ArchitectMission.new(space: sp)
221
+ project = ArchitectProject.new(space: sp)
177
222
  kwargs = { max_turns: max_turns.to_i, detach: detach }
178
- kwargs[:model] = model if model
179
- kwargs[:harness] = harness if harness
180
- kwargs[:effort] = effort if effort
181
- kwargs[:push_url] = push_url if push_url
182
- kwargs[:push_token] = push_token if push_token
183
- kwargs[:push_host] = push_host if push_host
184
- res = mission.dispatch(iteration, lane, **kwargs)
223
+ kwargs[:model] = model if model
224
+ kwargs[:harness] = harness if harness
225
+ kwargs[:effort] = effort if effort
226
+ kwargs[:timeout] = timeout.to_i unless detach
227
+ kwargs[:push_url] = push_url if push_url
228
+ kwargs[:push_token] = push_token if push_token
229
+ kwargs[:push_host] = push_host if push_host
230
+ res = project.dispatch(iteration, lane, **kwargs)
185
231
  if detach
186
232
  terminal.say "PID: #{res[:pid]}"
187
233
  terminal.say "Run log: #{terminal.path(res[:run_log])}"
188
234
  terminal.say "Report: #{terminal.path(res[:report])}"
189
235
  terminal.say "Dispatched detached — poll #{terminal.path(res[:report])} for completion"
190
236
  CLI.record_outcome(Outcome.new(exit_code: 0))
237
+ elsif res[:timed_out]
238
+ terminal.say "Run log: #{terminal.path(res[:run_log])}"
239
+ terminal.say "Report: #{terminal.path(res[:report])}"
240
+ terminal.say "Builder TIMED OUT after #{timeout}s — process group killed. Re-dispatch (lanes are cheap)."
241
+ CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
191
242
  else
192
243
  terminal.say "Run log: #{terminal.path(res[:run_log])}"
193
244
  terminal.say "Report: #{terminal.path(res[:report])}"
@@ -216,8 +267,8 @@ module Space::Architect
216
267
  handle_errors do
217
268
  content = read_section_body(from: from, body: body, stdin: stdin)
218
269
  render(store.find(space)) do |sp|
219
- mission = ArchitectMission.new(space: sp)
220
- res = mission.write_section!(iteration, section, body: content, append: append, lane: lane)
270
+ project = ArchitectProject.new(space: sp)
271
+ res = project.write_section!(iteration, section, body: content, append: append, lane: lane)
221
272
  if res[:committed]
222
273
  terminal.say "Committed #{res[:heading]} → #{res[:sha][0, 8]}"
223
274
  terminal.say res[:diffstat] unless res[:diffstat].empty?
@@ -239,6 +290,38 @@ module Space::Architect
239
290
  end
240
291
  end
241
292
 
293
+ class Verdict < BaseCommand
294
+ desc "Record the architect's verdict decision (continue or kill) and write ## Verdict prose"
295
+ argument :iteration, required: true, desc: "Iteration name"
296
+ argument :decision, required: true, desc: "Decision: continue or kill"
297
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
298
+ option :from, default: nil, desc: "Read the verdict body from this file"
299
+ option :body, default: nil, desc: "Inline verdict body"
300
+ option :stdin, type: :boolean, default: false, desc: "Read the verdict body from stdin"
301
+
302
+ def call(iteration:, decision:, space: nil, from: nil, body: nil, stdin: false, **opts)
303
+ setup_terminal(**opts.slice(:color, :colors))
304
+ handle_errors do
305
+ content = read_section_body(from: from, body: body, stdin: stdin)
306
+ render(store.find(space)) do |sp|
307
+ project = ArchitectProject.new(space: sp)
308
+ res = project.record_verdict!(iteration, decision: decision, body: content)
309
+ terminal.say "Verdict '#{res[:decision]}' recorded → #{res[:sha][0, 8]}"
310
+ CLI.record_outcome(Outcome.new(exit_code: 0))
311
+ end
312
+ end
313
+ end
314
+
315
+ private
316
+
317
+ def read_section_body(from:, body:, stdin:)
318
+ return File.read(from) if from
319
+ return body if body
320
+ return $stdin.read if stdin
321
+ raise Space::Core::Error, "provide the verdict body via --from <file>, --body <text>, or --stdin"
322
+ end
323
+ end
324
+
242
325
  class Evidence < BaseCommand
243
326
  desc "Transcribe a lane's scratch report VERBATIM into Builder Report and commit"
244
327
  argument :iteration, required: true, desc: "Iteration name"
@@ -249,8 +332,8 @@ module Space::Architect
249
332
  setup_terminal(**opts.slice(:color, :colors))
250
333
  handle_errors do
251
334
  render(store.find(space)) do |sp|
252
- mission = ArchitectMission.new(space: sp)
253
- res = mission.transcribe_evidence!(iteration, lane: lane)
335
+ project = ArchitectProject.new(space: sp)
336
+ res = project.transcribe_evidence!(iteration, lane: lane)
254
337
  terminal.say "Transcribed #{res[:lines]} lines → #{res[:sha][0, 8]}"
255
338
  terminal.say "Builder STATUS: #{res[:status_line]}" if res[:status_line]
256
339
  terminal.say "Now rule on the builder's PHASE 0 disagreements in the Verdict (a later session)."
@@ -271,8 +354,8 @@ module Space::Architect
271
354
  setup_terminal(**opts.slice(:color, :colors))
272
355
  handle_errors do
273
356
  render(store.find(space)) do |sp|
274
- mission = ArchitectMission.new(space: sp)
275
- r = mission.merge_lane!(iteration, lane, message: message)
357
+ project = ArchitectProject.new(space: sp)
358
+ r = project.merge_lane!(iteration, lane, message: message)
276
359
  terminal.say "Merged #{lane} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
277
360
  terminal.say r[:diffstat] unless r[:diffstat].empty?
278
361
  terminal.say "Gates NOT run — run `architect gate #{iteration}` against the integration branch."
@@ -293,9 +376,9 @@ module Space::Architect
293
376
  setup_terminal(**opts.slice(:color, :colors))
294
377
  handle_errors do
295
378
  render(store.find(space)) do |sp|
296
- mission = ArchitectMission.new(space: sp)
379
+ project = ArchitectProject.new(space: sp)
297
380
  lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
298
- results = mission.integrate!(iteration, lanes: lane_names, teardown: teardown)
381
+ results = project.integrate!(iteration, lanes: lane_names, teardown: teardown)
299
382
  results.each do |r|
300
383
  terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
301
384
  end
@@ -306,8 +389,29 @@ module Space::Architect
306
389
  end
307
390
  end
308
391
 
392
+ class Land < BaseCommand
393
+ desc "Generate the end-of-project PR command (no push, no gh — prints gh pr create)"
394
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
395
+
396
+ def call(space: nil, **opts)
397
+ setup_terminal(**opts.slice(:color, :colors))
398
+ handle_errors do
399
+ render(store.find(space)) do |sp|
400
+ project = ArchitectProject.new(space: sp)
401
+ results = project.land
402
+ results.each do |r|
403
+ terminal.say r[:context]
404
+ terminal.say r[:command]
405
+ terminal.say "Body: #{terminal.path(r[:body_file])}"
406
+ end
407
+ CLI.record_outcome(Outcome.new(exit_code: 0))
408
+ end
409
+ end
410
+ end
411
+ end
412
+
309
413
  class Gate < BaseCommand
310
- desc "Run the frozen Acceptance Criteria gate commands and stream raw output (no PASS/FAIL)"
414
+ desc "Run the frozen Acceptance Criteria gate commands and report PASS/FAIL"
311
415
  argument :iteration, required: true, desc: "Iteration name"
312
416
  argument :lane, required: false, desc: "Run in a lane worktree (default: the integration repo)"
313
417
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
@@ -316,17 +420,20 @@ module Space::Architect
316
420
  setup_terminal(**opts.slice(:color, :colors))
317
421
  handle_errors do
318
422
  render(store.find(space)) do |sp|
319
- mission = ArchitectMission.new(space: sp)
320
- results = mission.run_gates(iteration, lane: lane)
423
+ project = ArchitectProject.new(space: sp)
424
+ results = project.run_gates(iteration, lane: lane)
321
425
  results.each do |r|
426
+ marker = r[:status] == :pass ? "PASS" : "FAIL"
322
427
  terminal.say ""
323
- terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:command]} (exit #{r[:exit_code]})"
428
+ terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:cmd]} (exit #{r[:exit_code]}) [#{marker}]"
429
+ terminal.say " reason: #{r[:reason]}" if r[:status] == :fail && !r[:reason].to_s.empty?
324
430
  terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
325
431
  terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
326
432
  end
327
433
  terminal.say ""
328
- terminal.say "Raw gate output above the PASS/FAIL/INVALID verdict is yours, read against the frozen thresholds."
329
- CLI.record_outcome(Outcome.new(exit_code: 0))
434
+ terminal.say "Mechanical gate results above; the Acceptance-Criteria verdict necessary, not sufficient — remains the architect's."
435
+ any_fail = results.any? { |r| r[:status] == :fail }
436
+ CLI.record_outcome(Outcome.new(exit_code: any_fail ? 1 : 0))
330
437
  end
331
438
  end
332
439
  end
@@ -370,9 +477,9 @@ module Space::Architect
370
477
  setup_terminal(**opts.slice(:color, :colors))
371
478
  handle_errors do
372
479
  render(store.find) do |sp|
373
- mission = ArchitectMission.new(space: sp)
480
+ project = ArchitectProject.new(space: sp)
374
481
  touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
375
- result = mission.worktree_add(repo, iteration, lane, base: base,
482
+ result = project.worktree_add(repo, iteration, lane, base: base,
376
483
  harness: harness, model: model, effort: effort, touch: touch_set)
377
484
  terminal.say "Worktree: #{terminal.path(result[:worktree])}"
378
485
  terminal.say "Base SHA: #{result[:base_sha]}"
@@ -391,8 +498,8 @@ module Space::Architect
391
498
  setup_terminal(**opts.slice(:color, :colors))
392
499
  handle_errors do
393
500
  render(store.find) do |sp|
394
- mission = ArchitectMission.new(space: sp)
395
- mission.worktree_remove(iteration, lane)
501
+ project = ArchitectProject.new(space: sp)
502
+ project.worktree_remove(iteration, lane)
396
503
  terminal.say "Removed worktree for #{iteration}/#{lane}"
397
504
  CLI.record_outcome(Outcome.new(exit_code: 0))
398
505
  end
@@ -407,8 +514,8 @@ module Space::Architect
407
514
  setup_terminal(**opts.slice(:color, :colors))
408
515
  handle_errors do
409
516
  render(store.find) do |sp|
410
- mission = ArchitectMission.new(space: sp)
411
- worktrees = mission.worktree_list
517
+ project = ArchitectProject.new(space: sp)
518
+ worktrees = project.worktree_list
412
519
  if worktrees.empty?
413
520
  terminal.say "No active architect worktrees"
414
521
  else
@@ -441,8 +548,8 @@ module Space::Architect
441
548
  [harness, model]
442
549
  end
443
550
 
444
- mission = ArchitectMission.new(space: sp)
445
- variants = mission.variant_add(repo, iteration, parsed_pairs, base: base, prompt: prompt)
551
+ project = ArchitectProject.new(space: sp)
552
+ variants = project.variant_add(repo, iteration, parsed_pairs, base: base, prompt: prompt)
446
553
  variants.each do |v|
447
554
  terminal.say "#{v[:name]} · #{v[:harness]} · #{v[:model] || "(default)"} · #{terminal.path(v[:worktree])}"
448
555
  end
@@ -462,8 +569,8 @@ module Space::Architect
462
569
  setup_terminal(**opts.slice(:color, :colors))
463
570
  handle_errors do
464
571
  render(store.find(space)) do |sp|
465
- mission = ArchitectMission.new(space: sp)
466
- result = mission.variant_promote(iteration, winner)
572
+ project = ArchitectProject.new(space: sp)
573
+ result = project.variant_promote(iteration, winner)
467
574
  if result[:discarded].any?
468
575
  terminal.say "Promoted #{result[:winner]} (discarded: #{result[:discarded].join(', ')})"
469
576
  else
@@ -484,8 +591,8 @@ module Space::Architect
484
591
  setup_terminal(**opts.slice(:color, :colors))
485
592
  handle_errors do
486
593
  render(store.find(space)) do |sp|
487
- mission = ArchitectMission.new(space: sp)
488
- info = mission.variant_compare(iteration)
594
+ project = ArchitectProject.new(space: sp)
595
+ info = project.variant_compare(iteration)
489
596
 
490
597
  terminal.say "Variant comparison: #{iteration} (freeze #{info[:freeze_sha]&.[](0, 8) || "-"})"
491
598
  terminal.say "Winner: #{info[:winner] || '(none)'}"
@@ -512,7 +619,7 @@ module Space::Architect
512
619
 
513
620
  module Brief
514
621
  class New < BaseCommand
515
- desc "Scaffold the durable mission brief (architecture/BRIEF.md)"
622
+ desc "Scaffold the durable project brief (architecture/BRIEF.md)"
516
623
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
517
624
  option :force, type: :boolean, default: false, desc: "Overwrite an existing BRIEF.md"
518
625
 
@@ -520,8 +627,8 @@ module Space::Architect
520
627
  setup_terminal(**opts.slice(:color, :colors))
521
628
  handle_errors do
522
629
  render(store.find(space)) do |sp|
523
- mission = ArchitectMission.new(space: sp)
524
- path = mission.brief_new!(force: force)
630
+ project = ArchitectProject.new(space: sp)
631
+ path = project.brief_new!(force: force)
525
632
  terminal.say "Brief ready: #{terminal.path(path)}"
526
633
  CLI.record_outcome(Outcome.new(exit_code: 0))
527
634
  end
@@ -534,15 +641,18 @@ module Space::Architect
534
641
  end
535
642
 
536
643
  Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Architect::Init
644
+ Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
537
645
  Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
538
646
  Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
539
647
  Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
540
648
  Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
541
649
  Space::Architect::CLI::Registry.register "dispatch", Space::Architect::CLI::Architect::Dispatch
542
650
  Space::Architect::CLI::Registry.register "section", Space::Architect::CLI::Architect::Section
651
+ Space::Architect::CLI::Registry.register "verdict", Space::Architect::CLI::Architect::Verdict
543
652
  Space::Architect::CLI::Registry.register "evidence", Space::Architect::CLI::Architect::Evidence
544
653
  Space::Architect::CLI::Registry.register "merge", Space::Architect::CLI::Architect::Merge
545
654
  Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Architect::Integrate
655
+ Space::Architect::CLI::Registry.register "land", Space::Architect::CLI::Architect::Land
546
656
  Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
547
657
  Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
548
658
  Space::Architect::CLI::Registry.register "brief" do |b|
@@ -8,7 +8,7 @@ module Space::Architect
8
8
  desc "Dispatch detached read-only research lanes (one per prompt file)"
9
9
  argument :prompts, required: true,
10
10
  desc: "Prompt file(s) to dispatch (space-separated paths)"
11
- option :model, default: nil, desc: "Model override (default: claude-sonnet-4-6)"
11
+ option :model, default: nil, desc: "Researcher model override (default: the reference default claude-sonnet-4-6)"
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)
@@ -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