space-architect 6.0.0 → 7.1.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.
@@ -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"]
@@ -254,30 +262,81 @@ module Space::Architect
254
262
 
255
263
  private
256
264
 
265
+ # I01: inline output is bounded — one identity line per gate, ≤ 20
266
+ # lines of tail output — because the full report always lands in the
267
+ # transcript #render_gate points at. The summary block is emitted last
268
+ # (after the disclaimer) so a `tail` of stdout captures it whole.
257
269
  def render_rehearsal(result)
258
270
  if result[:empty]
259
271
  reason = result[:placeholder] ? "the scaffold placeholder '#{ArchitectProject::AC1_PLACEHOLDER}' with no active gate" : "no gates drafted yet"
260
272
  terminal.say "EMPTY — #{reason}. Nothing to rehearse; the pre-freeze look is still stamped."
261
- else
262
- terminal.say "Rehearsing #{result[:iteration]} against #{terminal.path(result[:base_dir])} (repo: #{result[:repo]})"
263
- result[:gates].each { |g| render_gate(g) }
264
- render_scope_asymmetry(result[:scope_asymmetry])
273
+ terminal.say ""
274
+ terminal.say discrimination_note
275
+ return
265
276
  end
277
+
278
+ terminal.say rehearsal_header(result)
279
+ result[:gates].each { |g| render_gate(g, result[:transcript]) }
280
+ render_scope_asymmetry(result[:scope_asymmetry])
266
281
  terminal.say ""
267
- terminal.say "Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
282
+ terminal.say discrimination_note
283
+ render_summary(result)
284
+ end
285
+
286
+ def discrimination_note
287
+ "Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
288
+ end
289
+
290
+ # #90/AC7: with a single defaultable repo, name it as before; a cross-repo
291
+ # run (or one where every gate declares its own `cwd`) has no one repo to
292
+ # name — each gate's own "dir:" line (below) already says where it ran.
293
+ def rehearsal_header(result)
294
+ if result[:repo]
295
+ "Rehearsing #{result[:iteration]} against #{terminal.path(result[:base_dir])} (repo: #{result[:repo]})"
296
+ else
297
+ "Rehearsing #{result[:iteration]} — #{result[:gates].size} gate(s), each in its own declared cwd"
298
+ end
268
299
  end
269
300
 
270
- def render_gate(g)
301
+ # I01/AC "bounded terminal report": identity (AC label, gate id, verdict,
302
+ # exit code) always lands on one stdout line, however many lines `cmd`
303
+ # spans — the command itself is shown as at most its first line,
304
+ # truncated; the full command lives in the transcript.
305
+ CMD_LINE_MAX = 100
306
+ OUTPUT_LINE_CAP = 20
307
+
308
+ def render_gate(g, transcript_path)
271
309
  terminal.say ""
272
- terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]}: #{g[:cmd]} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
310
+ terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]}: #{truncated_cmd(g[:cmd])} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
273
311
  terminal.say " dir: #{terminal.path(g[:dir])}"
274
312
  terminal.say " reason: #{g[:reason]}" unless g[:reason].to_s.empty?
313
+ if g[:shared_with]&.any?
314
+ terminal.say " shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)"
315
+ end
275
316
  if g[:rehearsal] == :broken
276
317
  terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
277
318
  "hasn't written yet). Confirm before treating it as a defect."
278
319
  end
279
- terminal.say g[:stdout].rstrip unless g[:stdout].strip.empty?
280
- terminal.say g[:stderr].rstrip unless g[:stderr].strip.empty?
320
+ render_gate_output(g, transcript_path)
321
+ end
322
+
323
+ def truncated_cmd(cmd)
324
+ first, *rest = cmd.to_s.each_line.map(&:chomp)
325
+ clipped = first.length > CMD_LINE_MAX ? "#{first[0, CMD_LINE_MAX]}…" : first
326
+ rest.any? || clipped != first ? "#{clipped} …" : clipped
327
+ end
328
+
329
+ def render_gate_output(g, transcript_path)
330
+ combined = [g[:stdout], g[:stderr]].map { |s| s.to_s.rstrip }.reject(&:empty?).join("\n")
331
+ return if combined.empty?
332
+
333
+ lines = combined.lines(chomp: true)
334
+ if lines.size > OUTPUT_LINE_CAP
335
+ terminal.say lines.last(OUTPUT_LINE_CAP).join("\n")
336
+ terminal.say " … #{lines.size - OUTPUT_LINE_CAP} more line(s) elided — full output in #{terminal.path(transcript_path)}"
337
+ else
338
+ terminal.say lines.join("\n")
339
+ end
281
340
  end
282
341
 
283
342
  # I12/AC3-AC4: reports scope asymmetry only — never affects RED/GREEN/
@@ -291,7 +350,7 @@ module Space::Architect
291
350
  terminal.say ""
292
351
  terminal.say "Scope-asymmetry check (grep-family gates only):"
293
352
  report[:findings].each do |f|
294
- terminal.say "── #{f[:id]}: pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
353
+ terminal.say "── #{f[:id]} (#{f[:repo]}): pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
295
354
  if f[:outside_lanes].any?
296
355
  terminal.say " OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
297
356
  f[:outside_lanes].each { |file| terminal.say " #{file}" }
@@ -306,14 +365,31 @@ module Space::Architect
306
365
  report[:not_analyzable].each { |na| terminal.say " #{na[:id]}: #{na[:reason]}" }
307
366
  end
308
367
 
368
+ # I01: always-on, positioned last so a `tail` of stdout captures it
369
+ # whole — one line per gate (AC label, gate id, verdict; reason
370
+ # appended for RED/BROKEN), a tally by verdict, and the transcript path.
371
+ def render_summary(result)
372
+ terminal.say ""
373
+ terminal.say "Summary:"
374
+ result[:gates].each do |g|
375
+ line = " #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} [#{g[:rehearsal].to_s.upcase}]"
376
+ line += " — #{g[:reason]}" if %i[red broken].include?(g[:rehearsal]) && !g[:reason].to_s.empty?
377
+ terminal.say line
378
+ end
379
+ tally = result[:gates].group_by { |g| g[:rehearsal] }.transform_values(&:size)
380
+ terminal.say " #{%i[green red broken].filter_map { |v| "#{v.to_s.upcase}: #{tally[v]}" if tally[v] }.join(', ')}"
381
+ terminal.say " transcript: #{terminal.path(result[:transcript])}"
382
+ end
383
+
309
384
  def render_record(result)
310
385
  return "> Rehearsed #{result[:iteration]} — no gates drafted; nothing to record." if result[:empty]
311
386
 
312
387
  by = result[:gates].group_by { |g| g[:rehearsal] }
313
388
  ids = ->(sym) { (by[sym] || []).map { |g| g[:id] }.join(", ") }
389
+ ran_against = result[:repo] ? "against `#{result[:repo]}`" : "each in its own declared `cwd`"
314
390
  [
315
391
  "> **Dry-run at rehearsal time, recorded for transparency.** All #{result[:gates].size} gate command(s) " \
316
- "were executed as written against `#{result[:repo]}` under `/bin/sh` — the shell `architect gate` uses.",
392
+ "were executed as written #{ran_against} under `/bin/sh` — the shell `architect gate` uses.",
317
393
  ">",
318
394
  "> - **RED (#{(by[:red] || []).size} — discriminate):** #{ids.call(:red).empty? ? "(none)" : ids.call(:red)}",
319
395
  "> - **GREEN (#{(by[:green] || []).size} — regression guard or non-discriminating):** #{ids.call(:green).empty? ? "(none)" : ids.call(:green)}",
@@ -420,6 +496,8 @@ module Space::Architect
420
496
  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"
421
497
  option :max_turns, default: "200", desc: "Max turns for the builder"
422
498
  option :harness, default: nil, desc: "Harness override (claude-code, opencode, pi)"
499
+ 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"
500
+ 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"
423
501
  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"
424
502
  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"
425
503
  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"
@@ -441,6 +519,7 @@ module Space::Architect
441
519
 
442
520
  def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
443
521
  max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
522
+ allowed_tools: nil, append_allowed_tools: nil,
444
523
  force_effort: nil, force_thinking: nil, force_reasoning: nil, quiet: false, detach: false,
445
524
  timeout: "14400", push_url: nil, push_token: nil, push_host: nil,
446
525
  as_job: false, host: nil, token: nil, backend_url: nil, job_model: nil, api_key_ref: nil, **opts)
@@ -467,6 +546,8 @@ module Space::Architect
467
546
  kwargs[:model] = model if model
468
547
  kwargs[:harness] = harness if harness
469
548
  kwargs[:effort] = forced_level || level if forced_level || level
549
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
550
+ kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
470
551
  kwargs[:force] = true if forced_level
471
552
  kwargs[:quiet] = true if quiet
472
553
  kwargs[:job_model] = job_model if job_model
@@ -482,6 +563,8 @@ module Space::Architect
482
563
  kwargs[:model] = model if model
483
564
  kwargs[:harness] = harness if harness
484
565
  kwargs[:effort] = forced_level || level if forced_level || level
566
+ kwargs[:allowed_tools] = allowed_tools if allowed_tools
567
+ kwargs[:append_allowed_tools] = append_allowed_tools if append_allowed_tools
485
568
  kwargs[:force] = true if forced_level
486
569
  kwargs[:quiet] = true if quiet
487
570
  kwargs[:timeout] = timeout.to_i unless detach
@@ -536,8 +619,14 @@ module Space::Architect
536
619
  terminal.say "No declared lanes to provision for '#{iteration}'"
537
620
  else
538
621
  results.each do |r|
539
- state = r[:created] ? "created" : "already present"
540
- terminal.say "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
622
+ state = case r[:outcome]
623
+ when :created then "created"
624
+ when :repointed then "re-pointed"
625
+ else "already present"
626
+ end
627
+ line = "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
628
+ line += " — discarded #{r[:discarded]} uncommitted change(s), --force was given" if r[:discarded]
629
+ terminal.say line
541
630
  end
542
631
  end
543
632
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -668,9 +757,10 @@ module Space::Architect
668
757
  option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
669
758
  option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
670
759
  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)"
760
+ 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)"
671
761
  commit_message_options
672
762
 
673
- def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, accept_bounds: nil, **opts)
763
+ 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)
674
764
  setup_terminal(**opts.slice(:color, :colors))
675
765
  handle_errors do
676
766
  lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
@@ -681,13 +771,15 @@ module Space::Architect
681
771
  project = ArchitectProject.new(space: sp)
682
772
  results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
683
773
  message: read_commit_message(message: message, message_from: message_from),
684
- commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds)
774
+ commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds, force: force)
685
775
  if lane_names.empty?
686
776
  if results.empty?
687
777
  terminal.say "Nothing to tear down for #{iteration}"
688
778
  else
689
779
  results.each do |r|
690
- terminal.say "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
780
+ line = "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
781
+ line += " — discarded #{r[:discarded]} uncommitted change(s), --force was given" if r[:discarded]
782
+ terminal.say line
691
783
  end
692
784
  end
693
785
  else
@@ -792,6 +884,7 @@ module Space::Architect
792
884
  argument :repo, required: true, desc: "Repo name (under repos/)"
793
885
  argument :iteration, required: true, desc: "Iteration name"
794
886
  argument :lane, required: true, desc: "Lane name"
887
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
795
888
  option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
796
889
  option :harness, default: nil, desc: "Harness (claude-code, opencode, pi; default: space.yaml project.harness, else claude-code)"
797
890
  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)"
@@ -802,12 +895,12 @@ module Space::Architect
802
895
  option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
803
896
  option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
804
897
 
805
- def call(repo:, iteration:, lane:, base: nil, harness: nil, model: nil,
898
+ def call(repo:, iteration:, lane:, space: nil, base: nil, harness: nil, model: nil,
806
899
  effort: nil, thinking: nil, reasoning: nil, quiet: false, touch: nil, force: false, **opts)
807
900
  setup_terminal(**opts.slice(:color, :colors))
808
901
  handle_errors do
809
902
  level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
810
- render(store.find) do |sp|
903
+ render(store.find(space)) do |sp|
811
904
  project = ArchitectProject.new(space: sp)
812
905
  touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
813
906
  err = quiet ? File.open(File::NULL, "w") : $stderr
@@ -815,6 +908,7 @@ module Space::Architect
815
908
  effort: level, touch: touch_set, force: force, err: err)
816
909
  terminal.say "Worktree: #{terminal.path(result[:worktree])}"
817
910
  terminal.say "Base SHA: #{result[:base_sha]}"
911
+ terminal.say "Discarded #{result[:discarded]} uncommitted change(s), --force was given" if result[:discarded]
818
912
  CLI.record_outcome(Outcome.new(exit_code: 0))
819
913
  end
820
914
  end
@@ -825,14 +919,23 @@ module Space::Architect
825
919
  desc "Remove a lane worktree"
826
920
  argument :iteration, required: true, desc: "Iteration name"
827
921
  argument :lane, required: true, desc: "Lane name"
922
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
923
+ option :force, type: :boolean, default: false, desc: "Discard uncommitted work in the worktree (untracked files included) instead of refusing"
828
924
 
829
- def call(iteration:, lane:, **opts)
925
+ def call(iteration:, lane:, space: nil, force: false, **opts)
830
926
  setup_terminal(**opts.slice(:color, :colors))
831
927
  handle_errors do
832
- render(store.find) do |sp|
928
+ render(store.find(space)) do |sp|
833
929
  project = ArchitectProject.new(space: sp)
834
- project.worktree_remove(iteration, lane)
930
+ result = project.worktree_remove(iteration, lane, force: force)
835
931
  terminal.say "Removed worktree for #{iteration}/#{lane}"
932
+ if result[:discarded]
933
+ terminal.say "Discarded #{result[:discarded]} uncommitted change(s), --force was given"
934
+ end
935
+ if result[:branch_survives]
936
+ terminal.say "Lane branch '#{result[:branch]}' survives — this is not a reset; " \
937
+ "re-provisioning re-points or refuses it, it does not start it over."
938
+ end
836
939
  CLI.record_outcome(Outcome.new(exit_code: 0))
837
940
  end
838
941
  end
@@ -841,11 +944,12 @@ module Space::Architect
841
944
 
842
945
  class List < BaseCommand
843
946
  desc "List active architect worktrees"
947
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
844
948
 
845
- def call(**opts)
949
+ def call(space: nil, **opts)
846
950
  setup_terminal(**opts.slice(:color, :colors))
847
951
  handle_errors do
848
- render(store.find) do |sp|
952
+ render(store.find(space)) do |sp|
849
953
  project = ArchitectProject.new(space: sp)
850
954
  worktrees = project.worktree_list
851
955
  if worktrees.empty?
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "6.0.0"
5
+ VERSION = "7.1.0"
6
6
  end
7
7
  end
@@ -277,7 +277,9 @@ contract, self-contained:
277
277
  its **target repo + file-touch set, checked for overlap**: name the repo
278
278
  (`repos/<repo>`) and every file each lane may touch. The machine-readable
279
279
  declaration lives in a fenced ` ```lanes ` block in the Specification — one
280
- entry per lane (`name`, `repo`, `touch` globs) the single frozen source of
280
+ entry per lane (`name`, `repo`, `touch` globs; optionally `allowed_tools`/
281
+ `append_allowed_tools` to replace/extend the lane's builder tool grant — see
282
+ `dispatch.md`) — the single frozen source of
281
283
  truth `architect freeze` records into `space.yaml` and `architect provision`
282
284
  materializes. The same boundary is stated twice more — the lane-prompt's
283
285
  may-touch list, and the scope gate that checks the lane's diff at judge
@@ -298,12 +300,15 @@ contract, self-contained:
298
300
  overlap run as one. Each lane gets its own objective, output format, and
299
301
  boundaries. Most
300
302
  iterations are one lane — fan out only when the work is genuinely parallel (a
301
- cross-repo project often is). Two first-class patterns — runnable recipes in `dispatch.md`: **parallel +
303
+ cross-repo project often is). Three first-class patterns — runnable recipes in `dispatch.md`: **parallel +
302
304
  fast-follow** (disjoint lanes integrate first; a fast-follow lane off
303
- `project/<slug>` carries the seam — see `### Parallel + fast-follow`) and
305
+ `project/<slug>` carries the seam — see `### Parallel + fast-follow`),
304
306
  **serial deferred judgment** (iterations run to gates-green with `architect
305
307
  verdict` withheld; one later batch session judges each against its own frozen
306
- AC — see `### Serial deferred judgment`).
308
+ AC — see `### Serial deferred judgment`), and **long-running sweep** (a lane
309
+ launches its own long detached process, ends its session at a sanctioned
310
+ pending status, and a later session in the same worktree audits it once it
311
+ exits — see `### Long-running sweep` in `dispatch.md`).
307
312
  - **Effort call** — thinking budget set per dispatch with `architect dispatch
308
313
  --effort <level>`, translated and clamped to the lane's harness (the
309
314
  escalation keywords `think hard` … `ultrathink` still work in-prompt);
@@ -376,9 +381,13 @@ architect-read.
376
381
  that measures nothing; **BROKEN**, a 127 / syntax error / timeout —
377
382
  advisory, because a correct RED can look broken (`grep -q x` on a file the
378
383
  lane will write exits 2): the tool names the suspicion, you confirm;
379
- **EMPTY**, no gates or an untouched placeholder. `--record` emits a
380
- paste-able provenance block for the AC preamble. It reports; which GREENs
381
- are guards and which are defects stays your call.
384
+ **EMPTY**, no gates or an untouched placeholder. Every non-EMPTY run writes
385
+ a full transcript (every gate's command, dir, exit code, verdict, reason,
386
+ complete stdout+stderr) under `build/rehearse/`, prints its path, and ends
387
+ with a bounded summary — one line per gate plus a tally — so a rerun is
388
+ never needed just to re-see output the terminal already trimmed. `--record`
389
+ emits a paste-able provenance block for the AC preamble. It reports; which
390
+ GREENs are guards and which are defects stays your call.
382
391
 
383
392
  Then run `architect freeze <name>`. Freeze requires a fresh rehearsal stamp —
384
393
  `rehearse` records one in `space.yaml`, keyed to the gates block's content, so
@@ -402,7 +411,8 @@ Per the mechanics in `dispatch.md`. The lane lifecycle is **declare → rehearse
402
411
  there is no dispatch-in-the-checkout path:
403
412
 
404
413
  - **Declare** — at spec time, each lane is one entry in the Specification's
405
- fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs.
414
+ fenced ` ```lanes ` block (§4): `name`, `repo`, `touch` globs, and optionally
415
+ `allowed_tools`/`append_allowed_tools`.
406
416
  - **Rehearse** — `architect rehearse <iteration>` dry-runs the drafted gates
407
417
  from the working tree (§4's pre-freeze check), resolving its run dir from the
408
418
  drafted ` ```lanes ` block, and stamps `space.yaml`; `freeze` refuses without
@@ -488,7 +498,10 @@ only the per-lane `lane/<iteration>-<lane>` branches and worktrees, never the
488
498
  project branch. Update the iteration index in `architecture/ARCHITECT.md`
489
499
  (recording the `project/<slug>` branch), remove the provisioned worktrees
490
500
  (`architect integrate … --teardown`, or `architect worktree remove <iteration>
491
- <lane>`), and commit the space.
501
+ <lane>`), and commit the space. Both refuse per lane when its worktree still
502
+ holds uncommitted work — untracked files included; `--force` overrides and
503
+ discards it. The `--lanes` integration path itself is unaffected, since
504
+ `integrate` commits each lane's work before teardown.
492
505
 
493
506
  **Run the frozen gates cold** — `architect gate <iteration>` runs the frozen
494
507
  gate commands against the integration tree and streams raw output (a runner, not
@@ -145,7 +145,12 @@ spec defect: kill the conflicting lane and re-spec; don't hand-resolve builder
145
145
  conflicts. It runs **no gates and makes no verdict** — `architect gate` streams
146
146
  the raw gate output for you to judge. `--teardown` deletes only the per-lane
147
147
  `lane/<iteration>-<lane>` branches and worktrees; it never deletes the
148
- `project/<slug>` branch.
148
+ `project/<slug>` branch. Every destructive lane operation — `worktree remove`,
149
+ `integrate --teardown`, and the re-point path of `provision --base` — refuses
150
+ per lane when that lane's worktree still holds uncommitted work, untracked
151
+ files included, because that's what a dispatched lane always holds; `--force`
152
+ overrides and discards it. The `--lanes <passing-set>` integration path itself
153
+ is unaffected — `integrate` commits each lane's work before tearing it down.
149
154
 
150
155
  Under the hood / manual fallback (one lane shown):
151
156
 
@@ -261,6 +266,66 @@ rests on a foundation that a later KILL at N would revert. Accept this coupling
261
266
  only consciously, and always judge oldest-first so a KILL stops you before you
262
267
  compound it.
263
268
 
269
+ ### Long-running sweep (detached, two-phase)
270
+
271
+ Use when a lane's own work *is* a long-running detached process — a 30–70
272
+ minute sweep, migration, or batch run the lane must launch, let run
273
+ unattended, and then audit once it exits. A metered builder session paying
274
+ tokens to sit in a poll loop is the wrong instrument for that wait: split the
275
+ lane across two builder sessions instead, one before the process runs and one
276
+ after.
277
+
278
+ **Recipe:**
279
+
280
+ 1. **Phase A — launch, prove liveness, stop deliberately.** The lane-prompt's
281
+ PHASE 2 tells the builder to launch the process detached (backgrounded and
282
+ redirected to a log file, survives the builder's own process exit), capture
283
+ its pid, prove it's alive with one command result (e.g. `ps -p <pid>` plus a
284
+ growing log tail — not an assertion), pre-structure the report with the
285
+ sections phase B will fill in, and end the session there — not mid-poll —
286
+ at:
287
+ ```
288
+ STATUS: SWEEP_RUNNING (pid <pid>, run dir <path>)
289
+ ```
290
+ `SWEEP_RUNNING` is the fixed token spec authors and architects grep for;
291
+ the pid and run dir ride in the same line because nothing else in the loop
292
+ captures them.
293
+
294
+ 2. **The wait belongs to the architect's harness, not the builder session.**
295
+ Between phase A and B, a process-exit monitor on the architect's side —
296
+ notification-driven, not a poll loop — watches for the pid to exit. A
297
+ builder session spends tokens to sit idle in a wait; the harness doesn't.
298
+ Do not dispatch a phase-B session, or resume one, until the process has
299
+ actually exited.
300
+
301
+ 3. **Phase B — resume and audit.** Once the process has exited, resume the
302
+ same lane worktree with `--continue` — the one sanctioned use of
303
+ `--continue` beyond same-lane follow-ups (see `## Operating guidance`
304
+ below) — and hand it the post-run audit as the new instruction:
305
+ ```bash
306
+ ( cd build/<id>-<lane>/wt && \
307
+ echo "The sweep at <run dir> (pid <pid>) has exited. Audit its output
308
+ against the spec's acceptance criteria, finish the report you
309
+ pre-structured in phase A, and end at STATUS: COMPLETE." \
310
+ | claude -p --continue --model <builder-model> \
311
+ --permission-mode acceptEdits \
312
+ --allowedTools 'Read,Edit,Write,Grep,Glob,Bash,WebSearch,WebFetch' \
313
+ --disallowedTools 'Bash(git commit:*),Bash(git push:*),Bash(git reset:*),Bash(git merge:*),Bash(git rebase:*),Bash(git checkout:*),Bash(git branch:*)' \
314
+ --output-format stream-json --verbose \
315
+ > build/<id>-<lane>/run-b.jsonl 2>&1 )
316
+ ```
317
+ Phase B runs the post-run audits the spec asked for, then finishes the
318
+ report at `STATUS: COMPLETE` (or `COMPLETE_WITH_CONCERNS`/`BLOCKED`, per the
319
+ template) exactly as any other lane would.
320
+
321
+ **Invariant:** phase A never ends mid-poll or at a pending status of its own
322
+ invention — it ends at `SWEEP_RUNNING` with the pid and run dir recorded, or
323
+ it's indistinguishable from a lane that gave up. Spec authors: an in-lane
324
+ "poll until it finishes" / "never end the session while it runs" instruction
325
+ loses to the template's no-busy-wait clause below every time a builder holds
326
+ both — write this two-phase shape into the lane spec instead of a poll
327
+ instruction.
328
+
264
329
  ## Operating guidance
265
330
 
266
331
  - Background each lane as its own harness task and let the **per-lane
@@ -282,6 +347,19 @@ compound it.
282
347
  `ultrathink`) and the `MAX_THINKING_TOKENS` env var still raise depth from
283
348
  inside the block. Default unattended builder work to a high budget; downgrade
284
349
  a routine, tightly-specified lane (record which and why in the spec).
350
+ - **Tool grant.** The default `--allowedTools` list is overridable per dispatch:
351
+ `--allowed-tools <list>` replaces it, `--append-allowed-tools <list>` appends
352
+ to it. The same is settable in the frozen lane declaration's ` ```lanes ` block
353
+ — `allowed_tools:` (replaces) / `append_allowed_tools:` (appends) — so a lane
354
+ that needs an MCP or other non-default tool is granted it reviewably, judged
355
+ like every other lane boundary, rather than buried in a shell invocation. The
356
+ CLI flag wins over the lane's yaml key when both are given, and dispatch
357
+ reports which source resolved. This exists because `claude -p` **denies** a
358
+ tool not on the allow list rather than prompting for it — a lane missing a
359
+ needed grant doesn't error, it silently can't use the tool and files a
360
+ confident, dataless report instead. `--disallowedTools`/`DISALLOWED_TOOLS`
361
+ (the builder-never-commits deny rules above) has no override route, by design
362
+ — hard rule 7 depends on it staying fixed.
285
363
  - **Builders never commit, and the architect verifies it.** Claude Code has no
286
364
  sandbox to make `.git` read-only, so this is enforced by the deny rules at
287
365
  dispatch *and* checked after the run: before integrating a lane, confirm
@@ -400,7 +478,11 @@ backgrounded work is SIGTERMed and its output lost while the run still exits
400
478
  potentially long command an explicit timeout; if a
401
479
  runtime will not start unattended (interactive prompt, server with no timeout),
402
480
  record the exact failure in your report and route around it — never busy-wait
403
- or retry in a loop. When done, write your report to the scratch file given to
481
+ or retry in a loop. The one sanctioned exception is a deliberate wait on a
482
+ detached process this lane itself launched: end that phase at
483
+ `STATUS: SWEEP_RUNNING` (pid + run dir) per `### Long-running sweep` in
484
+ dispatch.md, instead of polling for it to finish — this clause still forbids
485
+ retry loops and sitting on an unattended runtime with no timeout. When done, write your report to the scratch file given to
404
486
  you, build/<id>-<lane>/report.md (an absolute path outside your worktree),
405
487
  with RAW results only — tables, numbers, command output — no interpretation, no
406
488
  "promising". Every status claim must be backed by a command result from this
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 7.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs