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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +117 -0
- data/lib/space_architect/architect_project.rb +417 -86
- data/lib/space_architect/cli/architect.rb +128 -24
- data/lib/space_architect/harness.rb +14 -2
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +22 -9
- data/skill/architect/dispatch.md +84 -2
- metadata +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
262
|
-
terminal.say
|
|
263
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
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[:
|
|
540
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/space_core/version.rb
CHANGED
data/skill/architect/SKILL.md
CHANGED
|
@@ -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
|
|
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).
|
|
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`)
|
|
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.
|
|
380
|
-
|
|
381
|
-
|
|
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
|
data/skill/architect/dispatch.md
CHANGED
|
@@ -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.
|
|
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
|