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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -0
- data/lib/space_architect/architect_project.rb +843 -103
- data/lib/space_architect/bug_report.rb +18 -7
- data/lib/space_architect/cli/architect.rb +182 -18
- data/lib/space_architect/cli/research.rb +3 -4
- data/lib/space_architect/harness.rb +87 -30
- data/lib/space_architect/session_sync/runner.rb +4 -2
- data/lib/space_architect/skill_installer.rb +3 -3
- data/lib/space_architect/templates/iteration.md.erb +35 -7
- data/lib/space_core/cli/help.rb +2 -0
- data/lib/space_core/commands.rb +32 -1
- data/lib/space_core/paths.rb +35 -0
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/version.rb +1 -1
- data/lib/space_src/cli/sync.rb +23 -10
- data/lib/space_src/cli.rb +1 -0
- data/lib/space_src/cloner.rb +2 -0
- data/lib/space_src/nav.rb +1 -0
- data/lib/space_src/sync/engine.rb +46 -7
- data/lib/space_src/sync/report.rb +18 -0
- data/lib/space_src.rb +1 -0
- data/skill/architect/SKILL.md +111 -20
- data/skill/architect/dispatch.md +111 -14
- metadata +2 -1
|
@@ -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} --
|
|
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 = +
|
|
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
|
-
|
|
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
|
|
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[:
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
@@ -115,10 +127,19 @@ module Space::Architect
|
|
|
115
127
|
|
|
116
128
|
TIMEOUT_EXIT_CODE = 124
|
|
117
129
|
|
|
118
|
-
#
|
|
119
|
-
# event. Injectable via the run(liveness_delay:) kwarg so tests
|
|
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:
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
212
|
-
|
|
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 #{
|
|
286
|
+
err.puts "liveness: WARN no growth — run log still empty #{elapsed}s after dispatch"
|
|
216
287
|
return
|
|
217
288
|
end
|
|
218
289
|
|
|
219
|
-
|
|
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 #{
|
|
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 =
|
|
57
|
-
|
|
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
|