space-architect 5.5.1 → 7.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.
@@ -9,20 +9,31 @@ module Space
9
9
  REPO = "jetpks/space-architect"
10
10
 
11
11
  class << self
12
- def generate(space: nil, env: ENV, cwd: Dir.pwd, now: Time.now)
12
+ def generate(space: nil, env: ENV, cwd: Dir.pwd, now: Time.now, title: nil)
13
13
  body_path = resolve_body_path(space, cwd, now)
14
14
  FileUtils.mkdir_p(body_path.dirname)
15
- body = build_body(space)
15
+ body = build_body(space, title)
16
16
  body_path.write(body)
17
17
  contracted = Space::Core::Paths.contract(body_path, env: env)
18
+ title_flag = blank_title?(title) ? "" : " --title #{quote_title(title)}"
18
19
  command = Space::Core::Commands.wrap(
19
- %(gh issue create -R #{REPO} --title "<one-line summary>" --body-file #{contracted})
20
+ %(gh issue create -R #{REPO}#{title_flag} --body-file #{contracted})
20
21
  )
21
22
  { body_path: body_path, command: command, body: body }
22
23
  end
23
24
 
24
25
  private
25
26
 
27
+ # Double-quote a title for a POSIX shell: quotes preserve spaces
28
+ # literally, only the characters special inside double quotes are escaped.
29
+ def quote_title(title)
30
+ %("#{title.gsub(/["\\$`]/) { |m| "\\#{m}" }}")
31
+ end
32
+
33
+ def blank_title?(title)
34
+ title.to_s.strip.empty?
35
+ end
36
+
26
37
  def resolve_body_path(space, cwd, now)
27
38
  filename = "architect-bug-report-#{now.strftime('%Y%m%d-%H%M%S')}.md"
28
39
  if space
@@ -32,8 +43,10 @@ module Space
32
43
  end
33
44
  end
34
45
 
35
- def build_body(space)
36
- body = +template_header
46
+ def build_body(space, title)
47
+ body = +""
48
+ body << "# #{title}\n\n" unless blank_title?(title)
49
+ body << template_header
37
50
  body << diagnostics_section
38
51
  body << space_section(space) if space
39
52
  body
@@ -41,8 +54,6 @@ module Space
41
54
 
42
55
  def template_header
43
56
  <<~MD
44
- <!-- Title: <one-line summary> -->
45
-
46
57
  **Kind:** <!-- process / tooling / both -->
47
58
 
48
59
  ## Summary
@@ -181,7 +181,15 @@ module Space::Architect
181
181
  h = l["harness"] || "claude-code"
182
182
  m = l["model"] || Harness.default_model_for(h)
183
183
  eff = l["effort"] ? "·#{l['effort']}" : ""
184
- "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
184
+ # #89/AC8: the resolved tool grant, visible next to the other resolved
185
+ # values — only when it diverges from the harness default, to keep the
186
+ # common case (no lane touches it) uncluttered. Meaningless outside
187
+ # claude-code (no equivalent grant mechanism), so shown only there.
188
+ tools = if h == "claude-code"
189
+ resolved = Harness::ClaudeCodeHarness.resolve_tools(replace: l["allowed_tools"], append: l["append_allowed_tools"])
190
+ resolved == Harness::ClaudeCodeHarness::ALLOWED_TOOLS ? "" : "·tools:#{resolved}"
191
+ end
192
+ "#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff}#{tools})"
185
193
  end.join(", ")
186
194
  lanes = lane_list.any? { |l| l["variant"] } ? "variant: #{lanes_str}" : lanes_str
187
195
  lanes = "#{lanes} → winner: #{s['winner']}" if s["winner"]
@@ -226,23 +234,139 @@ module Space::Architect
226
234
  end
227
235
  end
228
236
 
237
+ class Rehearse < BaseCommand
238
+ desc "Rehearse the DRAFTED (unfrozen, working-tree) gates against the repo checkout and report RED/GREEN/BROKEN/EMPTY — runs and reports, never judges"
239
+ phase 12, "Spec"
240
+ argument :iteration, required: true, desc: "Iteration name"
241
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
242
+ # type: :flag (not :boolean): dry-cli renders a :boolean option as
243
+ # "--[no-]record" in --help, which does not contain the literal
244
+ # substring "--record" that a presence-check on the flag would look
245
+ # for; :flag renders unbracketed ("--record") and is presence-only,
246
+ # which is all this needs.
247
+ option :record, type: :flag, default: false, desc: "Emit a paste-able provenance block summarizing the run, shaped to drop into an Acceptance Criteria preamble"
248
+
249
+ def call(iteration:, space: nil, record: false, **opts)
250
+ setup_terminal(**opts.slice(:color, :colors))
251
+ handle_errors do
252
+ render(store.find(space)) do |sp|
253
+ project = ArchitectProject.new(space: sp)
254
+ result = project.rehearse(iteration)
255
+ render_rehearsal(result)
256
+ terminal.say ""
257
+ terminal.say render_record(result) if record
258
+ CLI.record_outcome(Outcome.new(exit_code: 0))
259
+ end
260
+ end
261
+ end
262
+
263
+ private
264
+
265
+ def render_rehearsal(result)
266
+ if result[:empty]
267
+ reason = result[:placeholder] ? "the scaffold placeholder '#{ArchitectProject::AC1_PLACEHOLDER}' with no active gate" : "no gates drafted yet"
268
+ terminal.say "EMPTY — #{reason}. Nothing to rehearse; the pre-freeze look is still stamped."
269
+ else
270
+ terminal.say rehearsal_header(result)
271
+ result[:gates].each { |g| render_gate(g) }
272
+ render_scope_asymmetry(result[:scope_asymmetry])
273
+ end
274
+ terminal.say ""
275
+ terminal.say "Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
276
+ end
277
+
278
+ # #90/AC7: with a single defaultable repo, name it as before; a cross-repo
279
+ # run (or one where every gate declares its own `cwd`) has no one repo to
280
+ # name — each gate's own "dir:" line (below) already says where it ran.
281
+ def rehearsal_header(result)
282
+ if result[:repo]
283
+ "Rehearsing #{result[:iteration]} against #{terminal.path(result[:base_dir])} (repo: #{result[:repo]})"
284
+ else
285
+ "Rehearsing #{result[:iteration]} — #{result[:gates].size} gate(s), each in its own declared cwd"
286
+ end
287
+ end
288
+
289
+ def render_gate(g)
290
+ terminal.say ""
291
+ terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]}: #{g[:cmd]} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
292
+ terminal.say " dir: #{terminal.path(g[:dir])}"
293
+ terminal.say " reason: #{g[:reason]}" unless g[:reason].to_s.empty?
294
+ if g[:rehearsal] == :broken
295
+ terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
296
+ "hasn't written yet). Confirm before treating it as a defect."
297
+ end
298
+ terminal.say g[:stdout].rstrip unless g[:stdout].strip.empty?
299
+ terminal.say g[:stderr].rstrip unless g[:stderr].strip.empty?
300
+ end
301
+
302
+ # I12/AC3-AC4: reports scope asymmetry only — never affects RED/GREEN/
303
+ # BROKEN, rehearse's exit code, or the stamp. A file outside every
304
+ # declared lane's touch set is flagged loudest: it's the one no lane
305
+ # may legally fix. not_analyzable is rendered too, and counted, so a
306
+ # reader can tell "nothing matched" from "nothing was examined".
307
+ def render_scope_asymmetry(report)
308
+ return if report.nil? || (report[:findings].empty? && report[:not_analyzable].empty?)
309
+
310
+ terminal.say ""
311
+ terminal.say "Scope-asymmetry check (grep-family gates only):"
312
+ report[:findings].each do |f|
313
+ terminal.say "── #{f[:id]} (#{f[:repo]}): pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
314
+ if f[:outside_lanes].any?
315
+ terminal.say " OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
316
+ f[:outside_lanes].each { |file| terminal.say " #{file}" }
317
+ end
318
+ terminal.say " also matches (within a declared lane's touch set): #{f[:within_lanes].join(', ')}" if f[:within_lanes].any?
319
+ terminal.say " 0 files elsewhere match" if f[:outside_lanes].empty? && f[:within_lanes].empty?
320
+ end
321
+
322
+ return if report[:not_analyzable].empty?
323
+ terminal.say ""
324
+ terminal.say "Not analyzed (#{report[:not_analyzable].size} grep invocation(s)) — counted, not silently skipped:"
325
+ report[:not_analyzable].each { |na| terminal.say " #{na[:id]}: #{na[:reason]}" }
326
+ end
327
+
328
+ def render_record(result)
329
+ return "> Rehearsed #{result[:iteration]} — no gates drafted; nothing to record." if result[:empty]
330
+
331
+ by = result[:gates].group_by { |g| g[:rehearsal] }
332
+ ids = ->(sym) { (by[sym] || []).map { |g| g[:id] }.join(", ") }
333
+ ran_against = result[:repo] ? "against `#{result[:repo]}`" : "each in its own declared `cwd`"
334
+ [
335
+ "> **Dry-run at rehearsal time, recorded for transparency.** All #{result[:gates].size} gate command(s) " \
336
+ "were executed as written #{ran_against} under `/bin/sh` — the shell `architect gate` uses.",
337
+ ">",
338
+ "> - **RED (#{(by[:red] || []).size} — discriminate):** #{ids.call(:red).empty? ? "(none)" : ids.call(:red)}",
339
+ "> - **GREEN (#{(by[:green] || []).size} — regression guard or non-discriminating):** #{ids.call(:green).empty? ? "(none)" : ids.call(:green)}",
340
+ "> - **BROKEN (#{(by[:broken] || []).size} — advisory, confirm each):** #{ids.call(:broken).empty? ? "(none)" : ids.call(:broken)}"
341
+ ].join("\n")
342
+ end
343
+ end
344
+
229
345
  class Freeze < BaseCommand
230
346
  desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
231
- phase 12, "Spec"
347
+ phase 13, "Spec"
232
348
  argument :iteration, required: true, desc: "Iteration name"
233
349
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
234
350
  option :force, type: :boolean, default: false, desc: "Re-freeze even if the frozen region changed (pre-dispatch only)"
351
+ # Named --skip-rehearse, not --no-rehearse: Ruby's OptionParser (dry-cli's
352
+ # underlying parser, verified against the live dry-cli 1.4.1 in this repo)
353
+ # treats ANY switch literally named --no-<word> as a boolean negation and
354
+ # silently discards its value, regardless of declared type — so a REASON
355
+ # cannot bind to a flag spelled --no-rehearse. --skip-rehearse is the
356
+ # working escape valve; --no-rehearse is the name this design uses for it.
357
+ option :skip_rehearse, default: nil, desc: "Escape valve (design name: --no-rehearse): skip the fresh-rehearsal requirement, recording REASON in space.yaml (bare freeze refuses without a matching `architect rehearse` stamp)"
235
358
  commit_message_options
236
359
 
237
- def call(iteration:, space: nil, message: nil, message_from: nil, force: false, **opts)
360
+ def call(iteration:, space: nil, message: nil, message_from: nil, force: false, skip_rehearse: nil, **opts)
238
361
  setup_terminal(**opts.slice(:color, :colors))
239
362
  handle_errors do
240
363
  render(store.find(space)) do |sp|
241
364
  project = ArchitectProject.new(space: sp)
242
365
  warnings = []
243
- sha = project.freeze!(iteration, warnings: warnings, force: force,
366
+ sha = project.freeze!(iteration, warnings: warnings, force: force, skip_rehearse_reason: skip_rehearse,
244
367
  message: read_commit_message(message: message, message_from: message_from))
245
368
  terminal.say "Frozen #{iteration} at #{sha}"
369
+ terminal.say "Rehearsal requirement skipped — #{skip_rehearse}" if skip_rehearse
246
370
  warnings.each { |w| terminal.say "Warning: #{w}" }
247
371
  ac = project.acceptance_criteria(iteration)
248
372
  unless ac.to_s.strip.empty?
@@ -316,6 +440,8 @@ module Space::Architect
316
440
  option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else space.yaml project.model, else the per-harness sensible default). Any provider/tier; pin a full id, not a floating alias"
317
441
  option :max_turns, default: "200", desc: "Max turns for the builder"
318
442
  option :harness, default: nil, desc: "Harness override (claude-code, opencode, pi)"
443
+ option :allowed_tools, default: nil, desc: "Comma-separated tool list — replaces the claude-code --allowedTools grant for this dispatch (default: Read,Edit,Write,Grep,Glob,Bash,WebSearch,WebFetch). The lane's frozen allowed_tools: does the same with no flag; this flag wins over that key. Meaningless for opencode/pi"
444
+ option :append_allowed_tools, default: nil, desc: "Comma-separated tool list — appends to the claude-code --allowedTools grant for this dispatch (to the flag/lane replace value, or the default). The lane's frozen append_allowed_tools: does the same with no flag; this flag wins over that key. Meaningless for opencode/pi"
319
445
  option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
320
446
  option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
321
447
  option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
@@ -337,6 +463,7 @@ module Space::Architect
337
463
 
338
464
  def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
339
465
  max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
466
+ allowed_tools: nil, append_allowed_tools: nil,
340
467
  force_effort: nil, force_thinking: nil, force_reasoning: nil, quiet: false, detach: false,
341
468
  timeout: "14400", push_url: nil, push_token: nil, push_host: nil,
342
469
  as_job: false, host: nil, token: nil, backend_url: nil, job_model: nil, api_key_ref: nil, **opts)
@@ -363,6 +490,8 @@ module Space::Architect
363
490
  kwargs[:model] = model if model
364
491
  kwargs[:harness] = harness if harness
365
492
  kwargs[:effort] = forced_level || level if forced_level || level
493
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
494
+ kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
366
495
  kwargs[:force] = true if forced_level
367
496
  kwargs[:quiet] = true if quiet
368
497
  kwargs[:job_model] = job_model if job_model
@@ -378,6 +507,8 @@ module Space::Architect
378
507
  kwargs[:model] = model if model
379
508
  kwargs[:harness] = harness if harness
380
509
  kwargs[:effort] = forced_level || level if forced_level || level
510
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
511
+ kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
381
512
  kwargs[:force] = true if forced_level
382
513
  kwargs[:quiet] = true if quiet
383
514
  kwargs[:timeout] = timeout.to_i unless detach
@@ -402,6 +533,9 @@ module Space::Architect
402
533
  terminal.say "Report: #{terminal.path(res[:report])}"
403
534
  terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
404
535
  terminal.say "Builder exited with status #{res[:exit_code]}"
536
+ unless res[:report].exist? && !res[:report].read.strip.empty?
537
+ terminal.say "WARNING: no report at #{terminal.path(res[:report])} — the lane produced no deliverable."
538
+ end
405
539
  CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
406
540
  end
407
541
  end
@@ -429,8 +563,14 @@ module Space::Architect
429
563
  terminal.say "No declared lanes to provision for '#{iteration}'"
430
564
  else
431
565
  results.each do |r|
432
- state = r[:created] ? "created" : "already present"
433
- terminal.say "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
566
+ state = case r[:outcome]
567
+ when :created then "created"
568
+ when :repointed then "re-pointed"
569
+ else "already present"
570
+ end
571
+ line = "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
572
+ line += " — discarded #{r[:discarded]} uncommitted change(s), --force was given" if r[:discarded]
573
+ terminal.say line
434
574
  end
435
575
  end
436
576
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -560,9 +700,11 @@ module Space::Architect
560
700
  option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
561
701
  option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
562
702
  option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
703
+ option :accept_bounds, default: nil, desc: "Escape valve: override the in-bounds check for these lanes when the frozen touch-set glob is itself the defect, recording REASON in space.yaml (never overrides the no-builder-commits check)"
704
+ option :force, type: :boolean, default: false, desc: "Teardown-only: discard uncommitted work in a lane worktree instead of refusing (never overrides the mechanical merge checks)"
563
705
  commit_message_options
564
706
 
565
- def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, **opts)
707
+ def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, accept_bounds: nil, force: false, **opts)
566
708
  setup_terminal(**opts.slice(:color, :colors))
567
709
  handle_errors do
568
710
  lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
@@ -573,18 +715,21 @@ module Space::Architect
573
715
  project = ArchitectProject.new(space: sp)
574
716
  results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
575
717
  message: read_commit_message(message: message, message_from: message_from),
576
- commit_mode: commit_mode, into: into)
718
+ commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds, force: force)
577
719
  if lane_names.empty?
578
720
  if results.empty?
579
721
  terminal.say "Nothing to tear down for #{iteration}"
580
722
  else
581
723
  results.each do |r|
582
- terminal.say "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
724
+ line = "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
725
+ line += " — discarded #{r[:discarded]} uncommitted change(s), --force was given" if r[:discarded]
726
+ terminal.say line
583
727
  end
584
728
  end
585
729
  else
586
730
  results.each do |r|
587
731
  terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
732
+ terminal.say "In-bounds check overridden for #{r[:lane]}: #{r[:bounds_override_reason]}" if r[:bounds_override_reason]
588
733
  end
589
734
  terminal.say "Gates NOT run — run gates: `architect gate #{iteration}`"
590
735
  end
@@ -611,6 +756,7 @@ module Space::Architect
611
756
  marker = r[:status] == :pass ? "PASS" : "FAIL"
612
757
  terminal.say ""
613
758
  terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:cmd]} (exit #{r[:exit_code]}) [#{marker}]"
759
+ terminal.say " dir: #{terminal.path(r[:dir])}"
614
760
  terminal.say " reason: #{r[:reason]}" if r[:status] == :fail && !r[:reason].to_s.empty?
615
761
  terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
616
762
  terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
@@ -627,15 +773,20 @@ module Space::Architect
627
773
  class BugReport < BaseCommand
628
774
  desc "Generate a prefilled GitHub issue template for filing bugs against space-architect"
629
775
  phase 54, "Project"
776
+ option :title, default: nil, desc: "Issue title, written into --title and the body's leading H1 (omit and gh will prompt for one interactively — the body file does not set it)"
630
777
 
631
- def call(**opts)
778
+ def call(title: nil, **opts)
632
779
  setup_terminal(**opts.slice(:color, :colors))
633
780
  handle_errors do
634
781
  space = store.find.value_or(nil)
635
782
  result = Space::Architect::BugReport.generate(
636
783
  space: space,
637
- env: project_config.env
784
+ env: project_config.env,
785
+ title: title
638
786
  )
787
+ unless title
788
+ terminal.say "No --title given — the body file does not set the issue title; re-run as architect bug-report --title \"...\" to set it in the command (or gh will prompt you for one)."
789
+ end
639
790
  terminal.say "Fill the placeholders in #{terminal.path(result[:body_path].to_s)}, then run:"
640
791
  terminal.say result[:command]
641
792
  terminal.say ""
@@ -677,6 +828,7 @@ module Space::Architect
677
828
  argument :repo, required: true, desc: "Repo name (under repos/)"
678
829
  argument :iteration, required: true, desc: "Iteration name"
679
830
  argument :lane, required: true, desc: "Lane name"
831
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
680
832
  option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
681
833
  option :harness, default: nil, desc: "Harness (claude-code, opencode, pi; default: space.yaml project.harness, else claude-code)"
682
834
  option :model, default: nil, desc: "Model; a trailing :<level> suffix (e.g. foo:high) is parsed into --effort (default: space.yaml project.model, else the per-harness sensible default)"
@@ -687,12 +839,12 @@ module Space::Architect
687
839
  option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
688
840
  option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
689
841
 
690
- def call(repo:, iteration:, lane:, base: nil, harness: nil, model: nil,
842
+ def call(repo:, iteration:, lane:, space: nil, base: nil, harness: nil, model: nil,
691
843
  effort: nil, thinking: nil, reasoning: nil, quiet: false, touch: nil, force: false, **opts)
692
844
  setup_terminal(**opts.slice(:color, :colors))
693
845
  handle_errors do
694
846
  level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
695
- render(store.find) do |sp|
847
+ render(store.find(space)) do |sp|
696
848
  project = ArchitectProject.new(space: sp)
697
849
  touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
698
850
  err = quiet ? File.open(File::NULL, "w") : $stderr
@@ -700,6 +852,7 @@ module Space::Architect
700
852
  effort: level, touch: touch_set, force: force, err: err)
701
853
  terminal.say "Worktree: #{terminal.path(result[:worktree])}"
702
854
  terminal.say "Base SHA: #{result[:base_sha]}"
855
+ terminal.say "Discarded #{result[:discarded]} uncommitted change(s), --force was given" if result[:discarded]
703
856
  CLI.record_outcome(Outcome.new(exit_code: 0))
704
857
  end
705
858
  end
@@ -710,14 +863,23 @@ module Space::Architect
710
863
  desc "Remove a lane worktree"
711
864
  argument :iteration, required: true, desc: "Iteration name"
712
865
  argument :lane, required: true, desc: "Lane name"
866
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
867
+ option :force, type: :boolean, default: false, desc: "Discard uncommitted work in the worktree (untracked files included) instead of refusing"
713
868
 
714
- def call(iteration:, lane:, **opts)
869
+ def call(iteration:, lane:, space: nil, force: false, **opts)
715
870
  setup_terminal(**opts.slice(:color, :colors))
716
871
  handle_errors do
717
- render(store.find) do |sp|
872
+ render(store.find(space)) do |sp|
718
873
  project = ArchitectProject.new(space: sp)
719
- project.worktree_remove(iteration, lane)
874
+ result = project.worktree_remove(iteration, lane, force: force)
720
875
  terminal.say "Removed worktree for #{iteration}/#{lane}"
876
+ if result[:discarded]
877
+ terminal.say "Discarded #{result[:discarded]} uncommitted change(s), --force was given"
878
+ end
879
+ if result[:branch_survives]
880
+ terminal.say "Lane branch '#{result[:branch]}' survives — this is not a reset; " \
881
+ "re-provisioning re-points or refuses it, it does not start it over."
882
+ end
721
883
  CLI.record_outcome(Outcome.new(exit_code: 0))
722
884
  end
723
885
  end
@@ -726,11 +888,12 @@ module Space::Architect
726
888
 
727
889
  class List < BaseCommand
728
890
  desc "List active architect worktrees"
891
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
729
892
 
730
- def call(**opts)
893
+ def call(space: nil, **opts)
731
894
  setup_terminal(**opts.slice(:color, :colors))
732
895
  handle_errors do
733
- render(store.find) do |sp|
896
+ render(store.find(space)) do |sp|
734
897
  project = ArchitectProject.new(space: sp)
735
898
  worktrees = project.worktree_list
736
899
  if worktrees.empty?
@@ -1090,6 +1253,7 @@ Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Archit
1090
1253
  Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
1091
1254
  Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
1092
1255
  Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
1256
+ Space::Architect::CLI::Registry.register "rehearse", Space::Architect::CLI::Architect::Rehearse
1093
1257
  Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
1094
1258
  Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
1095
1259
  Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
@@ -6,8 +6,8 @@ module Space::Architect
6
6
  module Research
7
7
  class Dispatch < BaseCommand
8
8
  desc "Dispatch detached read-only research lanes (one per prompt file)"
9
- argument :prompts, required: true,
10
- desc: "Prompt file(s) to dispatch (space-separated paths)"
9
+ argument :prompts, type: :array, required: true,
10
+ desc: "Prompt file(s) to dispatch (space-separated paths)"
11
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
 
@@ -15,11 +15,10 @@ module Space::Architect
15
15
  setup_terminal(**opts.slice(:color, :colors))
16
16
  handle_errors do
17
17
  render(store.find(opts[:space])) do |sp|
18
- paths = Array(prompts)
19
18
  supervisor = Space::Architect::Research::Supervisor.new(space: sp)
20
19
  kwargs = { max_turns: max_turns.to_i }
21
20
  kwargs[:model] = model if model
22
- runs = supervisor.dispatch(paths, **kwargs)
21
+ runs = supervisor.dispatch(prompts, **kwargs)
23
22
  runs.each do |run|
24
23
  terminal.say "dispatched #{run.id} (pid #{run.pid}) → #{terminal.path(run.run_log_path)}"
25
24
  end
@@ -62,13 +62,16 @@ module Space::Architect
62
62
  # (default $stderr; thread a null writer to suppress) when it clamps/strips.
63
63
  # With force: true, the literal `effort` value is passed through unmodified.
64
64
  # For opencode: config_dir is required (build/<id>-<lane> dir outside the worktree).
65
- def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil, force: false, err: $stderr)
65
+ def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil, allowed_tools: nil,
66
+ force: false, err: $stderr)
66
67
  translated, inform = translate_thinking(name, effort, force: force)
67
68
  err.puts(inform) if inform
68
69
 
69
70
  case name.to_s
70
71
  when "claude-code"
71
- ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin, effort: translated)
72
+ kwargs = { model: model, max_turns: max_turns, bin: bin, effort: translated }
73
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
74
+ ClaudeCodeHarness.new(**kwargs)
72
75
  when "opencode"
73
76
  raise Space::Core::Error, "config_dir is required for opencode harness" unless config_dir
74
77
  OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
@@ -93,6 +96,15 @@ module Space::Architect
93
96
  ACCEPTED_LEVELS = %w[low medium high xhigh max].freeze
94
97
  CLAMP_MAP = { "minimal" => "low" }.freeze
95
98
 
99
+ # #89: compose the --allowedTools value from an optional replace + append pair —
100
+ # resolved once here regardless of which surface (--allowed-tools/--append-allowed-tools
101
+ # flag, or the lane's frozen allowed_tools:/append_allowed_tools: declaration) supplied
102
+ # them, since replace/append combine identically either way.
103
+ def self.resolve_tools(replace: nil, append: nil)
104
+ base = replace || ALLOWED_TOOLS
105
+ append ? "#{base},#{append}" : base
106
+ end
107
+
96
108
  def self.translate_thinking(level, force: false)
97
109
  return [nil, nil] if level.nil?
98
110
  return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
@@ -115,10 +127,19 @@ module Space::Architect
115
127
 
116
128
  TIMEOUT_EXIT_CODE = 124
117
129
 
118
- # How long the liveness fiber waits before reading the run log's stream-json init
119
- # event. Injectable via the run(liveness_delay:) kwarg so tests need not sleep seconds.
130
+ # The liveness fiber's total wait budget before it gives up on the run log's
131
+ # stream-json init event. Injectable via the run(liveness_delay:) kwarg so tests
132
+ # need not sleep seconds.
120
133
  LIVENESS_DELAY_SECONDS = 5.0
121
134
 
135
+ # The liveness fiber's actual deadline is liveness_delay * LIVENESS_BUDGET_FACTOR —
136
+ # a healthy child whose first write lands just after one delay window is still
137
+ # alive, not dead, so the budget spans several delay-lengths, not one.
138
+ LIVENESS_BUDGET_FACTOR = 3
139
+
140
+ # How often the liveness fiber re-checks the run log for growth while waiting.
141
+ LIVENESS_POLL_INTERVAL = 0.05
142
+
122
143
  def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil,
123
144
  liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
124
145
  prompt_path = Pathname.new(prompt_path)
@@ -148,15 +169,24 @@ module Space::Architect
148
169
  end
149
170
  end
150
171
 
151
- # Liveness self-check: after a bounded delay, read the run log's stream-json
152
- # init event and print ONE line naming the streamed model + confirming growth.
153
- # transient: true so it never keeps the reactor alive; best-effort so it never
154
- # raises into the run path. run_detached gets no such fiber.
172
+ # Liveness self-check: read the run log's stream-json init event and print ONE
173
+ # line naming the streamed model + true elapsed time. A single point-sample right
174
+ # at liveness_delay would report a healthy child dead if its first write landed a
175
+ # moment later, so this polls (like Research::Mux#wait_for_file) to a deadline of
176
+ # several delay-lengths, emitting as soon as the log holds a parseable init event —
177
+ # a bounded wait, not an unbounded one, and not satisfied by mere non-emptiness (the
178
+ # child's stderr is teed into the same run log, so one early stderr byte must not
179
+ # count). transient: true so it never keeps the reactor alive; best-effort so it
180
+ # never raises into the run path. run_detached gets no such fiber.
155
181
  liveness_task = nil
156
182
  if liveness_delay && liveness_delay > 0
157
183
  liveness_task = Async(transient: true) do
158
- sleep liveness_delay
159
- emit_liveness(run_log_path, liveness_delay, err)
184
+ start = Time.now
185
+ deadline = start + (liveness_delay * LIVENESS_BUDGET_FACTOR)
186
+ until init_event_ready?(run_log_path) || Time.now >= deadline
187
+ sleep LIVENESS_POLL_INTERVAL
188
+ end
189
+ emit_liveness(run_log_path, Time.now - start, err)
160
190
  end
161
191
  end
162
192
 
@@ -207,18 +237,60 @@ module Space::Architect
207
237
 
208
238
  private
209
239
 
240
+ # The liveness fiber's wait predicate: ready once the run log holds a parseable
241
+ # stream-json init event — not merely once it holds any bytes, since the child's
242
+ # stderr is teed into the same run log and one early stderr byte must not satisfy it.
243
+ #
244
+ # Scans only the bytes appended since the previous poll (remembering the offset
245
+ # in @liveness_offset) instead of re-parsing the whole log from the top on every
246
+ # 50ms tick — the child that never emits an init event would otherwise cost
247
+ # O(polls x log size) inside a fiber sharing the reactor with the dispatch itself.
248
+ def init_event_ready?(run_log_path)
249
+ return true if @liveness_model
250
+
251
+ @liveness_offset ||= 0
252
+ File.open(run_log_path, "r") do |f|
253
+ f.seek(@liveness_offset)
254
+ f.each_line do |line|
255
+ break unless line.end_with?("\n")
256
+ @liveness_offset = f.pos
257
+ ev = begin
258
+ JSON.parse(line)
259
+ rescue JSON::ParserError
260
+ next
261
+ end
262
+ next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
263
+ @liveness_model = ev["model"]
264
+ break
265
+ end
266
+ end
267
+ !@liveness_model.nil?
268
+ rescue StandardError
269
+ false
270
+ end
271
+
210
272
  # Read the run log's stream-json init event and print exactly one bounded liveness
211
- # line to err. Best-effort: swallows any read/parse error so it never raises into run.
212
- def emit_liveness(run_log_path, delay, err)
273
+ # line to err, naming the true elapsed wall-clock time since dispatch. Best-effort:
274
+ # swallows any read/parse error so it never raises into run.
275
+ #
276
+ # Reuses init_event_ready?'s own incremental scan rather than re-parsing the
277
+ # whole log from the top: when the poll loop already found the init event,
278
+ # @liveness_model is set and this is a no-op re-check; when it ended on the
279
+ # deadline without one, this performs exactly one more forward scan from
280
+ # @liveness_offset, so bytes appended between the last poll and this call are
281
+ # still observed — a scan forward from the cached offset, never a skipped one.
282
+ def emit_liveness(run_log_path, elapsed, err)
213
283
  bytes = run_log_path.exist? ? run_log_path.size : 0
284
+ elapsed = elapsed.round(1)
214
285
  if bytes.zero?
215
- err.puts "liveness: WARN no growth — run log still empty #{delay}s after dispatch"
286
+ err.puts "liveness: WARN no growth — run log still empty #{elapsed}s after dispatch"
216
287
  return
217
288
  end
218
289
 
219
- streamed = streamed_init_model(run_log_path)
290
+ init_event_ready?(run_log_path)
291
+ streamed = @liveness_model
220
292
  if streamed.nil?
221
- err.puts "liveness: WARN model unverified — no stream-json init event after #{delay}s (run log #{bytes} bytes)"
293
+ err.puts "liveness: WARN model unverified — no stream-json init event after #{elapsed}s (run log #{bytes} bytes)"
222
294
  elsif streamed == @model
223
295
  err.puts "liveness: OK streaming model=#{streamed} (run log growing, #{bytes} bytes)"
224
296
  else
@@ -228,21 +300,6 @@ module Space::Architect
228
300
  # Best-effort: an internal read/parse failure must never break the run.
229
301
  end
230
302
 
231
- # The model named by the stream-json init event ({"type":"system","subtype":"init",...}),
232
- # or nil if no such event has been logged yet.
233
- def streamed_init_model(run_log_path)
234
- run_log_path.each_line do |line|
235
- ev = begin
236
- JSON.parse(line)
237
- rescue JSON::ParserError
238
- next
239
- end
240
- next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
241
- return ev["model"]
242
- end
243
- nil
244
- end
245
-
246
303
  def argv
247
304
  [@bin, "-p", "--model", @model, "--output-format", "stream-json", "--verbose"] + builder_args
248
305
  end
@@ -53,8 +53,10 @@ module Space::Architect
53
53
  end
54
54
 
55
55
  def files
56
- pi = Dir.glob(File.join(@pi_root, "**", "*.jsonl")).map { |p| [p, SessionId.for_pi(p)] }
57
- claude = Dir.glob(File.join(@claude_root, "**", "*.jsonl")).map { |p| [p, SessionId.for_claude(p)] }
56
+ pi = Space::Core::Paths.content_tree(@pi_root).select { |p| p.end_with?(".jsonl") }
57
+ .map { |p| [p, SessionId.for_pi(p)] }
58
+ claude = Space::Core::Paths.content_tree(@claude_root).select { |p| p.end_with?(".jsonl") }
59
+ .map { |p| [p, SessionId.for_claude(p)] }
58
60
  pi + claude
59
61
  end
60
62
  end