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.
- checksums.yaml +4 -4
- data/README.md +34 -18
- data/lib/space_architect/{architect_mission.rb → architect_project.rb} +396 -77
- data/lib/space_architect/cli/architect.rb +170 -60
- data/lib/space_architect/cli/research.rb +1 -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 +24 -3
- 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 +3 -1
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/help.rb +15 -2
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/repo.rb +1 -1
- data/lib/space_core/cli/run.rb +29 -0
- data/lib/space_core/cli.rb +6 -0
- 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_core/space.rb +10 -2
- data/lib/space_core/space_store.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_core/version.rb +1 -1
- data/skill/architect/SKILL.md +109 -53
- data/skill/architect/dispatch.md +147 -39
- data/skill/architect/research.md +1 -1
- data/skill/architect-research/SKILL.md +2 -2
- data/skill/architect-vocabulary/SKILL.md +24 -21
- metadata +13 -2
|
@@ -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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
terminal.say
|
|
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
|
-
|
|
33
|
-
path =
|
|
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
|
|
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
|
-
|
|
50
|
-
info =
|
|
83
|
+
project = ArchitectProject.new(space: sp)
|
|
84
|
+
info = project.status
|
|
51
85
|
block = info[:block]
|
|
52
86
|
|
|
53
|
-
terminal.say "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
136
|
+
project = ArchitectProject.new(space: sp)
|
|
137
|
+
warnings = []
|
|
138
|
+
sha = project.freeze!(iteration, warnings: warnings)
|
|
97
139
|
terminal.say "Frozen #{iteration} at #{sha}"
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
results =
|
|
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
|
|
150
|
-
when false
|
|
151
|
-
|
|
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: "
|
|
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
|
-
|
|
221
|
+
project = ArchitectProject.new(space: sp)
|
|
177
222
|
kwargs = { max_turns: max_turns.to_i, detach: detach }
|
|
178
|
-
kwargs[:model] = model
|
|
179
|
-
kwargs[:harness] = harness
|
|
180
|
-
kwargs[:effort] = effort
|
|
181
|
-
kwargs[:
|
|
182
|
-
kwargs[:
|
|
183
|
-
kwargs[:
|
|
184
|
-
|
|
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
|
-
|
|
220
|
-
res =
|
|
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
|
-
|
|
253
|
-
res =
|
|
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
|
-
|
|
275
|
-
r =
|
|
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
|
-
|
|
379
|
+
project = ArchitectProject.new(space: sp)
|
|
297
380
|
lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
298
|
-
results =
|
|
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
|
|
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
|
-
|
|
320
|
-
results =
|
|
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[:
|
|
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 "
|
|
329
|
-
|
|
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
|
-
|
|
480
|
+
project = ArchitectProject.new(space: sp)
|
|
374
481
|
touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
|
|
375
|
-
result =
|
|
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
|
-
|
|
395
|
-
|
|
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
|
-
|
|
411
|
-
worktrees =
|
|
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
|
-
|
|
445
|
-
variants =
|
|
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
|
-
|
|
466
|
-
result =
|
|
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
|
-
|
|
488
|
-
info =
|
|
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
|
|
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
|
-
|
|
524
|
-
path =
|
|
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: "
|
|
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
|