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.
@@ -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, **opts)
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 = read_section_body(from: from, body: body, stdin: stdin)
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, **opts)
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 = read_section_body(from: from, body: body, stdin: stdin)
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
- option :message, default: nil, desc: "Commit message for the lane's working-tree changes"
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, message: message)
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: true, desc: "Comma-separated passing lane names (you decide the set)"
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:, teardown: false, **opts)
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
- lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
381
- results = project.integrate!(iteration, lanes: lane_names, teardown: teardown)
382
- results.each do |r|
383
- terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
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 "Scaffold the durable project brief (architecture/BRIEF.md)"
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
- terminal.say "Brief ready: #{terminal.path(path)}"
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
- def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil)
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
- $stderr.puts "push_body: transport error (best-effort, run log intact): #{e.class}: #{e.message}"
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
- $stderr.puts "tee_pipe: push write failed (best-effort, continuing log): #{e.class}: #{e.message}"
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