space-architect 3.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -0
- data/README.md +25 -8
- data/lib/space_architect/architect_project.rb +245 -54
- data/lib/space_architect/cli/architect.rb +158 -47
- data/lib/space_architect/harness.rb +63 -9
- data/lib/space_architect/templates/iteration.md.erb +15 -1
- data/lib/space_core/cli/help.rb +88 -18
- data/lib/space_core/cli/loop_status.rb +47 -0
- data/lib/space_core/cli/repeatable_options.rb +52 -43
- data/lib/space_core/cli/status.rb +54 -13
- data/lib/space_core/shell_integration.rb +1 -1
- data/lib/space_core/space.rb +51 -1
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/version.rb +1 -1
- data/lib/space_src/cli/clone.rb +0 -1
- data/lib/space_src/cli/config.rb +0 -1
- data/lib/space_src/cli/daemon.rb +0 -1
- data/lib/space_src/cli/org.rb +0 -1
- data/lib/space_src/cli/repo.rb +0 -1
- data/lib/space_src/cli/shell.rb +0 -1
- data/lib/space_src/cli/status.rb +0 -1
- data/lib/space_src/cli/sync.rb +0 -1
- data/lib/space_src/cli.rb +0 -1
- data/skill/architect/SKILL.md +63 -20
- data/skill/architect/dispatch.md +54 -32
- metadata +2 -1
|
@@ -4,17 +4,62 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Space::Architect
|
|
6
6
|
module CLI
|
|
7
|
+
# Optional loop-phase DSL on the shared command base, read by
|
|
8
|
+
# Space::Core::CLI::Help to group the help listing. A command may declare
|
|
9
|
+
# `phase <order>, "<Label>"`; groups, and members within a group, sort by
|
|
10
|
+
# <order>. Only architect commands declare one — space commands leave it nil
|
|
11
|
+
# and render as the single ungrouped default listing (unchanged).
|
|
12
|
+
class BaseCommand
|
|
13
|
+
def self.phase(order = nil, label = nil)
|
|
14
|
+
return @phase if order.nil?
|
|
15
|
+
|
|
16
|
+
@phase = [order, label]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Declares -m/--message and --message-from on a committing command. Every
|
|
20
|
+
# loop command that commits takes both: the space's git log is the loop's
|
|
21
|
+
# durable memory, so detailed messages are encouraged everywhere.
|
|
22
|
+
def self.commit_message_options
|
|
23
|
+
option :message, aliases: ["-m"], default: nil,
|
|
24
|
+
desc: "Commit message: first line completes the subject after the canonical prefix, the rest becomes the body"
|
|
25
|
+
option :message_from, default: nil,
|
|
26
|
+
desc: "Read the commit message from this file (subject line + detailed body)"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Authored-content intake shared by section/verdict/brief: a file, an
|
|
32
|
+
# inline flag, or stdin — canonical files are only ever written by the CLI.
|
|
33
|
+
def read_body(from: nil, body: nil, stdin: false, what: "section body")
|
|
34
|
+
return File.read(from) if from
|
|
35
|
+
return body if body
|
|
36
|
+
return $stdin.read if stdin
|
|
37
|
+
|
|
38
|
+
raise Space::Core::Error, "provide the #{what} via --from <file>, --body <text>, or --stdin"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Commit-message intake for commit_message_options: --message-from wins
|
|
42
|
+
# over -m/--message; nil means the command's canonical default message.
|
|
43
|
+
def read_commit_message(message: nil, message_from: nil)
|
|
44
|
+
return File.read(message_from) if message_from
|
|
45
|
+
|
|
46
|
+
message
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
7
50
|
module Architect
|
|
8
51
|
class Init < BaseCommand
|
|
9
52
|
desc "Scaffold (or top up) the architect project: ARCHITECT.md, space.yaml project block, SessionStart hook"
|
|
53
|
+
phase 50, "Project"
|
|
10
54
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
55
|
+
commit_message_options
|
|
11
56
|
|
|
12
|
-
def call(space: nil, **opts)
|
|
57
|
+
def call(space: nil, message: nil, message_from: nil, **opts)
|
|
13
58
|
setup_terminal(**opts.slice(:color, :colors))
|
|
14
59
|
handle_errors do
|
|
15
60
|
render(store.find(space)) do |sp|
|
|
16
61
|
project = ArchitectProject.new(space: sp)
|
|
17
|
-
path = project.init!
|
|
62
|
+
path = project.init!(message: read_commit_message(message: message, message_from: message_from))
|
|
18
63
|
terminal.say "Project ready: #{terminal.path(path)}"
|
|
19
64
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
20
65
|
end
|
|
@@ -24,6 +69,7 @@ module Space::Architect
|
|
|
24
69
|
|
|
25
70
|
class Ground < BaseCommand
|
|
26
71
|
desc "Print grounding reads (ARCHITECT.md, BRIEF.md, in-flight iteration) to stdout"
|
|
72
|
+
phase 51, "Project"
|
|
27
73
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
28
74
|
|
|
29
75
|
def call(space: nil, **opts)
|
|
@@ -56,15 +102,18 @@ module Space::Architect
|
|
|
56
102
|
|
|
57
103
|
class New < BaseCommand
|
|
58
104
|
desc "Scaffold the next iteration file (architecture/I<NN>-<iteration>.md)"
|
|
105
|
+
phase 10, "Spec"
|
|
59
106
|
argument :iteration, required: true, desc: "Iteration name (kebab-case)"
|
|
60
107
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
108
|
+
commit_message_options
|
|
61
109
|
|
|
62
|
-
def call(iteration:, space: nil, **opts)
|
|
110
|
+
def call(iteration:, space: nil, message: nil, message_from: nil, **opts)
|
|
63
111
|
setup_terminal(**opts.slice(:color, :colors))
|
|
64
112
|
handle_errors do
|
|
65
113
|
render(store.find(space)) do |sp|
|
|
66
114
|
project = ArchitectProject.new(space: sp)
|
|
67
|
-
path = project.new_iteration!(iteration
|
|
115
|
+
path = project.new_iteration!(iteration,
|
|
116
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
68
117
|
terminal.say "Iteration scaffolded: #{terminal.path(path)}"
|
|
69
118
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
70
119
|
end
|
|
@@ -74,6 +123,7 @@ module Space::Architect
|
|
|
74
123
|
|
|
75
124
|
class Status < BaseCommand
|
|
76
125
|
desc "Show architect project state (read-only)"
|
|
126
|
+
phase 52, "Project"
|
|
77
127
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
78
128
|
|
|
79
129
|
def call(space: nil, **opts)
|
|
@@ -126,16 +176,19 @@ module Space::Architect
|
|
|
126
176
|
|
|
127
177
|
class Freeze < BaseCommand
|
|
128
178
|
desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
|
|
179
|
+
phase 12, "Spec"
|
|
129
180
|
argument :iteration, required: true, desc: "Iteration name"
|
|
130
181
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
182
|
+
commit_message_options
|
|
131
183
|
|
|
132
|
-
def call(iteration:, space: nil, **opts)
|
|
184
|
+
def call(iteration:, space: nil, message: nil, message_from: nil, **opts)
|
|
133
185
|
setup_terminal(**opts.slice(:color, :colors))
|
|
134
186
|
handle_errors do
|
|
135
187
|
render(store.find(space)) do |sp|
|
|
136
188
|
project = ArchitectProject.new(space: sp)
|
|
137
189
|
warnings = []
|
|
138
|
-
sha = project.freeze!(iteration, warnings: warnings
|
|
190
|
+
sha = project.freeze!(iteration, warnings: warnings,
|
|
191
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
139
192
|
terminal.say "Frozen #{iteration} at #{sha}"
|
|
140
193
|
warnings.each { |w| terminal.say "Warning: #{w}" }
|
|
141
194
|
ac = project.acceptance_criteria(iteration)
|
|
@@ -152,6 +205,7 @@ module Space::Architect
|
|
|
152
205
|
|
|
153
206
|
class Verify < BaseCommand
|
|
154
207
|
desc "Post-flight mechanical lane checks — frozen-untouched, no builder commits, report exists, in-bounds (reports only, no judgment)"
|
|
208
|
+
phase 30, "Judge"
|
|
155
209
|
argument :iteration, required: true, desc: "Iteration name"
|
|
156
210
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
157
211
|
|
|
@@ -199,9 +253,11 @@ module Space::Architect
|
|
|
199
253
|
|
|
200
254
|
class Dispatch < BaseCommand
|
|
201
255
|
desc "Dispatch a builder for a lane (streams to build/<id>-<lane>/run.jsonl)"
|
|
256
|
+
phase 21, "Build"
|
|
202
257
|
argument :iteration, required: true, desc: "Iteration name"
|
|
203
258
|
argument :lane, required: true, desc: "Lane name"
|
|
204
259
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
260
|
+
option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
|
|
205
261
|
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"
|
|
206
262
|
option :max_turns, default: "200", desc: "Max turns for the builder"
|
|
207
263
|
option :harness, default: nil, desc: "Harness override (claude-code, opencode)"
|
|
@@ -212,7 +268,7 @@ module Space::Architect
|
|
|
212
268
|
option :push_token, default: nil, desc: "Bearer token for push endpoint authorization"
|
|
213
269
|
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)"
|
|
214
270
|
|
|
215
|
-
def call(iteration:, lane:, space: nil, model: nil,
|
|
271
|
+
def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
|
|
216
272
|
max_turns: "200", harness: nil, effort: nil, detach: false,
|
|
217
273
|
timeout: "14400", push_url: nil, push_token: nil, push_host: nil, **opts)
|
|
218
274
|
setup_terminal(**opts.slice(:color, :colors))
|
|
@@ -220,6 +276,7 @@ module Space::Architect
|
|
|
220
276
|
render(store.find(space)) do |sp|
|
|
221
277
|
project = ArchitectProject.new(space: sp)
|
|
222
278
|
kwargs = { max_turns: max_turns.to_i, detach: detach }
|
|
279
|
+
kwargs[:prompt] = prompt if prompt
|
|
223
280
|
kwargs[:model] = model if model
|
|
224
281
|
kwargs[:harness] = harness if harness
|
|
225
282
|
kwargs[:effort] = effort if effort
|
|
@@ -228,6 +285,7 @@ module Space::Architect
|
|
|
228
285
|
kwargs[:push_token] = push_token if push_token
|
|
229
286
|
kwargs[:push_host] = push_host if push_host
|
|
230
287
|
res = project.dispatch(iteration, lane, **kwargs)
|
|
288
|
+
terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
|
|
231
289
|
if detach
|
|
232
290
|
terminal.say "PID: #{res[:pid]}"
|
|
233
291
|
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
@@ -251,8 +309,37 @@ module Space::Architect
|
|
|
251
309
|
end
|
|
252
310
|
end
|
|
253
311
|
|
|
312
|
+
class Provision < BaseCommand
|
|
313
|
+
desc "Materialize declared lanes (worktree + lane/<id>-<lane> branch) from the frozen lane plan"
|
|
314
|
+
phase 20, "Build"
|
|
315
|
+
argument :iteration, required: true, desc: "Iteration name"
|
|
316
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
317
|
+
option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
|
|
318
|
+
option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
|
|
319
|
+
|
|
320
|
+
def call(iteration:, space: nil, base: nil, lane: nil, **opts)
|
|
321
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
322
|
+
handle_errors do
|
|
323
|
+
render(store.find(space)) do |sp|
|
|
324
|
+
project = ArchitectProject.new(space: sp)
|
|
325
|
+
results = project.provision(iteration, base: base, lane: lane)
|
|
326
|
+
if results.empty?
|
|
327
|
+
terminal.say "No declared lanes to provision for '#{iteration}'"
|
|
328
|
+
else
|
|
329
|
+
results.each do |r|
|
|
330
|
+
state = r[:created] ? "created" : "already present"
|
|
331
|
+
terminal.say "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
254
340
|
class Section < BaseCommand
|
|
255
341
|
desc "Write a section of the iteration file and commit it (one call)"
|
|
342
|
+
phase 11, "Spec"
|
|
256
343
|
argument :iteration, required: true, desc: "Iteration name"
|
|
257
344
|
argument :section, required: true, desc: "Section: grounds, specification, prompt, verdict"
|
|
258
345
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
@@ -261,14 +348,17 @@ module Space::Architect
|
|
|
261
348
|
option :stdin, type: :boolean, default: false, desc: "Read the section body from stdin"
|
|
262
349
|
option :append, type: :boolean, default: false, desc: "Append a ### <lane> subsection instead of replacing"
|
|
263
350
|
option :lane, default: nil, desc: "Lane name for an appended ### subsection"
|
|
351
|
+
commit_message_options
|
|
264
352
|
|
|
265
|
-
def call(iteration:, section:, space: nil, from: nil, body: nil, stdin: false, append: false, lane: nil,
|
|
353
|
+
def call(iteration:, section:, space: nil, from: nil, body: nil, stdin: false, append: false, lane: nil,
|
|
354
|
+
message: nil, message_from: nil, **opts)
|
|
266
355
|
setup_terminal(**opts.slice(:color, :colors))
|
|
267
356
|
handle_errors do
|
|
268
|
-
content =
|
|
357
|
+
content = read_body(from: from, body: body, stdin: stdin, what: "section body")
|
|
269
358
|
render(store.find(space)) do |sp|
|
|
270
359
|
project = ArchitectProject.new(space: sp)
|
|
271
|
-
res = project.write_section!(iteration, section, body: content, append: append, lane: lane
|
|
360
|
+
res = project.write_section!(iteration, section, body: content, append: append, lane: lane,
|
|
361
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
272
362
|
if res[:committed]
|
|
273
363
|
terminal.say "Committed #{res[:heading]} → #{res[:sha][0, 8]}"
|
|
274
364
|
terminal.say res[:diffstat] unless res[:diffstat].empty?
|
|
@@ -279,61 +369,50 @@ module Space::Architect
|
|
|
279
369
|
end
|
|
280
370
|
end
|
|
281
371
|
end
|
|
282
|
-
|
|
283
|
-
private
|
|
284
|
-
|
|
285
|
-
def read_section_body(from:, body:, stdin:)
|
|
286
|
-
return File.read(from) if from
|
|
287
|
-
return body if body
|
|
288
|
-
return $stdin.read if stdin
|
|
289
|
-
raise Space::Core::Error, "provide the section body via --from <file>, --body <text>, or --stdin"
|
|
290
|
-
end
|
|
291
372
|
end
|
|
292
373
|
|
|
293
374
|
class Verdict < BaseCommand
|
|
294
375
|
desc "Record the architect's verdict decision (continue or kill) and write ## Verdict prose"
|
|
376
|
+
phase 33, "Judge"
|
|
295
377
|
argument :iteration, required: true, desc: "Iteration name"
|
|
296
378
|
argument :decision, required: true, desc: "Decision: continue or kill"
|
|
297
379
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
298
380
|
option :from, default: nil, desc: "Read the verdict body from this file"
|
|
299
381
|
option :body, default: nil, desc: "Inline verdict body"
|
|
300
382
|
option :stdin, type: :boolean, default: false, desc: "Read the verdict body from stdin"
|
|
383
|
+
commit_message_options
|
|
301
384
|
|
|
302
|
-
def call(iteration:, decision:, space: nil, from: nil, body: nil, stdin: false,
|
|
385
|
+
def call(iteration:, decision:, space: nil, from: nil, body: nil, stdin: false,
|
|
386
|
+
message: nil, message_from: nil, **opts)
|
|
303
387
|
setup_terminal(**opts.slice(:color, :colors))
|
|
304
388
|
handle_errors do
|
|
305
|
-
content =
|
|
389
|
+
content = read_body(from: from, body: body, stdin: stdin, what: "verdict body")
|
|
306
390
|
render(store.find(space)) do |sp|
|
|
307
391
|
project = ArchitectProject.new(space: sp)
|
|
308
|
-
res = project.record_verdict!(iteration, decision: decision, body: content
|
|
392
|
+
res = project.record_verdict!(iteration, decision: decision, body: content,
|
|
393
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
309
394
|
terminal.say "Verdict '#{res[:decision]}' recorded → #{res[:sha][0, 8]}"
|
|
310
395
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
311
396
|
end
|
|
312
397
|
end
|
|
313
398
|
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
399
|
end
|
|
324
400
|
|
|
325
401
|
class Evidence < BaseCommand
|
|
326
402
|
desc "Transcribe a lane's scratch report VERBATIM into Builder Report and commit"
|
|
403
|
+
phase 31, "Judge"
|
|
327
404
|
argument :iteration, required: true, desc: "Iteration name"
|
|
328
405
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
329
406
|
option :lane, default: nil, desc: "Lane name (per-lane subsection; omit for a single-lane iteration)"
|
|
407
|
+
commit_message_options
|
|
330
408
|
|
|
331
|
-
def call(iteration:, space: nil, lane: nil, **opts)
|
|
409
|
+
def call(iteration:, space: nil, lane: nil, message: nil, message_from: nil, **opts)
|
|
332
410
|
setup_terminal(**opts.slice(:color, :colors))
|
|
333
411
|
handle_errors do
|
|
334
412
|
render(store.find(space)) do |sp|
|
|
335
413
|
project = ArchitectProject.new(space: sp)
|
|
336
|
-
res = project.transcribe_evidence!(iteration, lane: lane
|
|
414
|
+
res = project.transcribe_evidence!(iteration, lane: lane,
|
|
415
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
337
416
|
terminal.say "Transcribed #{res[:lines]} lines → #{res[:sha][0, 8]}"
|
|
338
417
|
terminal.say "Builder STATUS: #{res[:status_line]}" if res[:status_line]
|
|
339
418
|
terminal.say "Now rule on the builder's PHASE 0 disagreements in the Verdict (a later session)."
|
|
@@ -345,17 +424,19 @@ module Space::Architect
|
|
|
345
424
|
|
|
346
425
|
class Merge < BaseCommand
|
|
347
426
|
desc "Integrate ONE judged-passing lane (merges --no-ff; runs no gates, makes no verdict)"
|
|
427
|
+
phase 41, "Land"
|
|
348
428
|
argument :iteration, required: true, desc: "Iteration name"
|
|
349
429
|
argument :lane, required: true, desc: "Lane name (architect-judged passing)"
|
|
350
430
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
351
|
-
|
|
431
|
+
commit_message_options
|
|
352
432
|
|
|
353
|
-
def call(iteration:, lane:, space: nil, message: nil, **opts)
|
|
433
|
+
def call(iteration:, lane:, space: nil, message: nil, message_from: nil, **opts)
|
|
354
434
|
setup_terminal(**opts.slice(:color, :colors))
|
|
355
435
|
handle_errors do
|
|
356
436
|
render(store.find(space)) do |sp|
|
|
357
437
|
project = ArchitectProject.new(space: sp)
|
|
358
|
-
r = project.merge_lane!(iteration, lane,
|
|
438
|
+
r = project.merge_lane!(iteration, lane,
|
|
439
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
359
440
|
terminal.say "Merged #{lane} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
|
|
360
441
|
terminal.say r[:diffstat] unless r[:diffstat].empty?
|
|
361
442
|
terminal.say "Gates NOT run — run `architect gate #{iteration}` against the integration branch."
|
|
@@ -367,22 +448,38 @@ module Space::Architect
|
|
|
367
448
|
|
|
368
449
|
class Integrate < BaseCommand
|
|
369
450
|
desc "Integrate the architect-supplied set of passing lanes, in order (stops on conflict)"
|
|
451
|
+
phase 40, "Land"
|
|
370
452
|
argument :iteration, required: true, desc: "Iteration name"
|
|
371
453
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
372
|
-
option :lanes, required:
|
|
454
|
+
option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
|
|
373
455
|
option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
|
|
456
|
+
commit_message_options
|
|
374
457
|
|
|
375
|
-
def call(iteration:, space: nil, lanes
|
|
458
|
+
def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, **opts)
|
|
376
459
|
setup_terminal(**opts.slice(:color, :colors))
|
|
377
460
|
handle_errors do
|
|
461
|
+
lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
462
|
+
raise Space::Core::Error, "integrate needs --lanes <set>, or --teardown for teardown-only" \
|
|
463
|
+
if lane_names.empty? && !teardown
|
|
464
|
+
|
|
378
465
|
render(store.find(space)) do |sp|
|
|
379
466
|
project = ArchitectProject.new(space: sp)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
467
|
+
results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
|
|
468
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
469
|
+
if lane_names.empty?
|
|
470
|
+
if results.empty?
|
|
471
|
+
terminal.say "Nothing to tear down for #{iteration}"
|
|
472
|
+
else
|
|
473
|
+
results.each do |r|
|
|
474
|
+
terminal.say "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
else
|
|
478
|
+
results.each do |r|
|
|
479
|
+
terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
|
|
480
|
+
end
|
|
481
|
+
terminal.say "Gates NOT run — run `architect gate #{iteration}`; the verdict is the next session's."
|
|
384
482
|
end
|
|
385
|
-
terminal.say "Gates NOT run — run `architect gate #{iteration}`; the verdict is the next session's."
|
|
386
483
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
387
484
|
end
|
|
388
485
|
end
|
|
@@ -391,6 +488,7 @@ module Space::Architect
|
|
|
391
488
|
|
|
392
489
|
class Gate < BaseCommand
|
|
393
490
|
desc "Run the frozen Acceptance Criteria gate commands and report PASS/FAIL"
|
|
491
|
+
phase 32, "Judge"
|
|
394
492
|
argument :iteration, required: true, desc: "Iteration name"
|
|
395
493
|
argument :lane, required: false, desc: "Run in a lane worktree (default: the integration repo)"
|
|
396
494
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
@@ -420,6 +518,7 @@ module Space::Architect
|
|
|
420
518
|
|
|
421
519
|
class BugReport < BaseCommand
|
|
422
520
|
desc "Generate a prefilled GitHub issue template for filing bugs against space-architect"
|
|
521
|
+
phase 54, "Project"
|
|
423
522
|
|
|
424
523
|
def call(**opts)
|
|
425
524
|
setup_terminal(**opts.slice(:color, :colors))
|
|
@@ -443,6 +542,7 @@ module Space::Architect
|
|
|
443
542
|
|
|
444
543
|
class InstallSkills < BaseCommand
|
|
445
544
|
desc "Install bundled skills (architect, architect-research, architect-vocabulary) for a harness"
|
|
545
|
+
phase 53, "Project"
|
|
446
546
|
option :provider, default: "claude", desc: "Harness: claude, codex, opencode, pi"
|
|
447
547
|
option :project, type: :boolean, default: false, desc: "Install to CWD instead of global"
|
|
448
548
|
option :force, type: :boolean, default: false, desc: "Overwrite existing skills that differ"
|
|
@@ -621,17 +721,23 @@ module Space::Architect
|
|
|
621
721
|
|
|
622
722
|
module Brief
|
|
623
723
|
class New < BaseCommand
|
|
624
|
-
desc "
|
|
724
|
+
desc "Write the durable project brief (architecture/BRIEF.md) — authored via --from/--stdin, or a placeholder template"
|
|
625
725
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
626
726
|
option :force, type: :boolean, default: false, desc: "Overwrite an existing BRIEF.md"
|
|
727
|
+
option :from, default: nil, desc: "Read the authored brief body from this file"
|
|
728
|
+
option :stdin, type: :boolean, default: false, desc: "Read the authored brief body from stdin"
|
|
729
|
+
commit_message_options
|
|
627
730
|
|
|
628
|
-
def call(space: nil, force: false, **opts)
|
|
731
|
+
def call(space: nil, force: false, from: nil, stdin: false, message: nil, message_from: nil, **opts)
|
|
629
732
|
setup_terminal(**opts.slice(:color, :colors))
|
|
630
733
|
handle_errors do
|
|
734
|
+
content = (from || stdin) ? read_body(from: from, stdin: stdin, what: "brief body") : nil
|
|
631
735
|
render(store.find(space)) do |sp|
|
|
632
736
|
project = ArchitectProject.new(space: sp)
|
|
633
|
-
path = project.brief_new!(force: force
|
|
634
|
-
|
|
737
|
+
path = project.brief_new!(force: force, content: content,
|
|
738
|
+
message: read_commit_message(message: message, message_from: message_from))
|
|
739
|
+
note = content ? "" : " (template — Read it before editing)"
|
|
740
|
+
terminal.say "Brief ready: #{terminal.path(path)}#{note}"
|
|
635
741
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
636
742
|
end
|
|
637
743
|
end
|
|
@@ -642,12 +748,17 @@ module Space::Architect
|
|
|
642
748
|
end
|
|
643
749
|
end
|
|
644
750
|
|
|
751
|
+
# Loop-phase declarations above sort the architect help listing; its namespaces
|
|
752
|
+
# (brief/worktree/variant/research) declare no phase and list under this header.
|
|
753
|
+
Space::Core::CLI::Help.trailing_group_label = "Groups"
|
|
754
|
+
|
|
645
755
|
Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Architect::Init
|
|
646
756
|
Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
|
|
647
757
|
Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
|
|
648
758
|
Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
|
|
649
759
|
Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
|
|
650
760
|
Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
|
|
761
|
+
Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
|
|
651
762
|
Space::Architect::CLI::Registry.register "dispatch", Space::Architect::CLI::Architect::Dispatch
|
|
652
763
|
Space::Architect::CLI::Registry.register "section", Space::Architect::CLI::Architect::Section
|
|
653
764
|
Space::Architect::CLI::Registry.register "verdict", Space::Architect::CLI::Architect::Verdict
|
|
@@ -56,7 +56,12 @@ module Space::Architect
|
|
|
56
56
|
|
|
57
57
|
TIMEOUT_EXIT_CODE = 124
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
# How long the liveness fiber waits before reading the run log's stream-json init
|
|
60
|
+
# event. Injectable via the run(liveness_delay:) kwarg so tests need not sleep seconds.
|
|
61
|
+
LIVENESS_DELAY_SECONDS = 5.0
|
|
62
|
+
|
|
63
|
+
def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil,
|
|
64
|
+
liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
|
|
60
65
|
prompt_path = Pathname.new(prompt_path)
|
|
61
66
|
run_log_path = Pathname.new(run_log_path)
|
|
62
67
|
|
|
@@ -66,7 +71,7 @@ module Space::Architect
|
|
|
66
71
|
Sync do
|
|
67
72
|
child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: w, err: log)
|
|
68
73
|
w.close
|
|
69
|
-
tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client)
|
|
74
|
+
tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client, err: err)
|
|
70
75
|
timed_out = false
|
|
71
76
|
timeout_task = nil
|
|
72
77
|
|
|
@@ -84,8 +89,21 @@ module Space::Architect
|
|
|
84
89
|
end
|
|
85
90
|
end
|
|
86
91
|
|
|
92
|
+
# Liveness self-check: after a bounded delay, read the run log's stream-json
|
|
93
|
+
# init event and print ONE line naming the streamed model + confirming growth.
|
|
94
|
+
# transient: true so it never keeps the reactor alive; best-effort so it never
|
|
95
|
+
# raises into the run path. run_detached gets no such fiber.
|
|
96
|
+
liveness_task = nil
|
|
97
|
+
if liveness_delay && liveness_delay > 0
|
|
98
|
+
liveness_task = Async(transient: true) do
|
|
99
|
+
sleep liveness_delay
|
|
100
|
+
emit_liveness(run_log_path, liveness_delay, err)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
87
104
|
status = child.wait
|
|
88
105
|
timeout_task&.stop
|
|
106
|
+
liveness_task&.stop
|
|
89
107
|
|
|
90
108
|
tasks.each(&:wait)
|
|
91
109
|
timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
|
|
@@ -113,6 +131,42 @@ module Space::Architect
|
|
|
113
131
|
|
|
114
132
|
private
|
|
115
133
|
|
|
134
|
+
# Read the run log's stream-json init event and print exactly one bounded liveness
|
|
135
|
+
# line to err. Best-effort: swallows any read/parse error so it never raises into run.
|
|
136
|
+
def emit_liveness(run_log_path, delay, err)
|
|
137
|
+
bytes = run_log_path.exist? ? run_log_path.size : 0
|
|
138
|
+
if bytes.zero?
|
|
139
|
+
err.puts "liveness: WARN no growth — run log still empty #{delay}s after dispatch"
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
streamed = streamed_init_model(run_log_path)
|
|
144
|
+
if streamed.nil?
|
|
145
|
+
err.puts "liveness: WARN model unverified — no stream-json init event after #{delay}s (run log #{bytes} bytes)"
|
|
146
|
+
elsif streamed == @model
|
|
147
|
+
err.puts "liveness: OK streaming model=#{streamed} (run log growing, #{bytes} bytes)"
|
|
148
|
+
else
|
|
149
|
+
err.puts "liveness: WARN model mismatch — pinned=#{@model} streamed=#{streamed} (run log growing, #{bytes} bytes)"
|
|
150
|
+
end
|
|
151
|
+
rescue StandardError
|
|
152
|
+
# Best-effort: an internal read/parse failure must never break the run.
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The model named by the stream-json init event ({"type":"system","subtype":"init",...}),
|
|
156
|
+
# or nil if no such event has been logged yet.
|
|
157
|
+
def streamed_init_model(run_log_path)
|
|
158
|
+
run_log_path.each_line do |line|
|
|
159
|
+
ev = begin
|
|
160
|
+
JSON.parse(line)
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
|
|
165
|
+
return ev["model"]
|
|
166
|
+
end
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
116
170
|
def argv
|
|
117
171
|
args = [
|
|
118
172
|
@bin, "-p",
|
|
@@ -128,17 +182,17 @@ module Space::Architect
|
|
|
128
182
|
args
|
|
129
183
|
end
|
|
130
184
|
|
|
131
|
-
def start_tee(r, log, push_url:, push_token:, push_client:)
|
|
185
|
+
def start_tee(r, log, push_url:, push_token:, push_client:, err: $stderr)
|
|
132
186
|
if push_url || push_client
|
|
133
187
|
body = Protocol::HTTP::Body::Writable.new(queue: Thread::SizedQueue.new(32))
|
|
134
|
-
push = Async { push_body(body, push_url: push_url, push_token: push_token, push_client: push_client) }
|
|
135
|
-
[Async { tee_pipe(r, log, body) }, push]
|
|
188
|
+
push = Async { push_body(body, push_url: push_url, push_token: push_token, push_client: push_client, err: err) }
|
|
189
|
+
[Async { tee_pipe(r, log, body, err: err) }, push]
|
|
136
190
|
else
|
|
137
191
|
[Async { drain_pipe(r, log) }]
|
|
138
192
|
end
|
|
139
193
|
end
|
|
140
194
|
|
|
141
|
-
def push_body(body, push_url:, push_token:, push_client:)
|
|
195
|
+
def push_body(body, push_url:, push_token:, push_client:, err: $stderr)
|
|
142
196
|
path = push_url ? URI.parse(push_url).path : "/"
|
|
143
197
|
headers = [["content-type", "application/x-ndjson"]]
|
|
144
198
|
headers << ["authorization", "Bearer #{push_token}"] if push_token
|
|
@@ -150,10 +204,10 @@ module Space::Architect
|
|
|
150
204
|
end
|
|
151
205
|
end
|
|
152
206
|
rescue StandardError => e
|
|
153
|
-
|
|
207
|
+
err.puts "push_body: transport error (best-effort, run log intact): #{e.class}: #{e.message}"
|
|
154
208
|
end
|
|
155
209
|
|
|
156
|
-
def tee_pipe(r, log, body)
|
|
210
|
+
def tee_pipe(r, log, body, err: $stderr)
|
|
157
211
|
pushing = true
|
|
158
212
|
while (chunk = r.gets)
|
|
159
213
|
log.write(chunk)
|
|
@@ -162,7 +216,7 @@ module Space::Architect
|
|
|
162
216
|
begin
|
|
163
217
|
body.write(chunk)
|
|
164
218
|
rescue StandardError => e
|
|
165
|
-
|
|
219
|
+
err.puts "tee_pipe: push write failed (best-effort, continuing log): #{e.class}: #{e.message}"
|
|
166
220
|
pushing = false
|
|
167
221
|
end
|
|
168
222
|
end
|
|
@@ -27,9 +27,23 @@ Write + commit: `architect section <%= @_name %> specification --from <file>`. -
|
|
|
27
27
|
- **Boundaries** — may-touch / must-not-touch / out-of-scope; no placeholders;
|
|
28
28
|
no refactors beyond the task. (Frozen stack: cite `BRIEF §2`.)
|
|
29
29
|
- **Lane plan** — 1–4 lanes, each declaring: target repo `repos/<repo>`,
|
|
30
|
-
file-touch set (overlap-checked), objective, output format, boundaries.
|
|
30
|
+
file-touch set (overlap-checked), objective, output format, boundaries. The
|
|
31
|
+
machine-readable declaration lives in the fenced ```lanes block below — the single
|
|
32
|
+
frozen source of truth `architect freeze` records and `architect provision`
|
|
33
|
+
materializes.
|
|
31
34
|
- **Effort** — `think hard` … `ultrathink` per lane, with one line of why.
|
|
32
35
|
|
|
36
|
+
```lanes
|
|
37
|
+
# One entry per lane (1–4). The frozen out-of-bounds contract: `architect freeze`
|
|
38
|
+
# writes each into space.yaml (name, repo, touch_set); `architect provision <%= @_name %>`
|
|
39
|
+
# materializes the worktrees + lane branches. Remove the comment markers to activate.
|
|
40
|
+
# - name: lane-a # lane name (required)
|
|
41
|
+
# repo: my-repo # target repo under repos/ (required)
|
|
42
|
+
# touch: # file globs this lane may write (required, non-empty)
|
|
43
|
+
# - lib/my_repo/**
|
|
44
|
+
# - test/my_repo_test.rb
|
|
45
|
+
```
|
|
46
|
+
|
|
33
47
|
## Acceptance Criteria
|
|
34
48
|
|
|
35
49
|
<!-- PROOF. Write the prose conditions of correctness (AC1, AC2, …) that the
|