space-architect 2.0.2 → 4.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 +31 -0
- data/README.md +16 -3
- data/lib/space_architect/architect_project.rb +186 -65
- data/lib/space_architect/bug_report.rb +93 -0
- data/lib/space_architect/cli/architect.rb +104 -28
- data/lib/space_architect/harness.rb +63 -9
- data/lib/space_architect/templates/iteration.md.erb +15 -1
- data/lib/space_architect.rb +1 -0
- data/lib/space_core/cli/help.rb +88 -18
- data/lib/space_core/cli/loop_status.rb +47 -0
- data/lib/space_core/cli/repeatable_options.rb +52 -43
- data/lib/space_core/cli/status.rb +54 -13
- data/lib/space_core/commands.rb +20 -0
- data/lib/space_core/paths.rb +23 -0
- data/lib/space_core/shell_integration.rb +1 -1
- data/lib/space_core/space.rb +51 -1
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/terminal.rb +1 -18
- data/lib/space_core/version.rb +1 -1
- data/lib/space_core.rb +2 -0
- data/lib/space_src/cli/clone.rb +0 -1
- data/lib/space_src/cli/config.rb +0 -1
- data/lib/space_src/cli/daemon.rb +0 -1
- data/lib/space_src/cli/org.rb +0 -1
- data/lib/space_src/cli/repo.rb +0 -1
- data/lib/space_src/cli/shell.rb +0 -1
- data/lib/space_src/cli/status.rb +0 -1
- data/lib/space_src/cli/sync.rb +0 -1
- data/lib/space_src/cli.rb +0 -1
- data/skill/architect/SKILL.md +47 -19
- data/skill/architect/dispatch.md +37 -23
- metadata +5 -1
|
@@ -4,9 +4,23 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Space::Architect
|
|
6
6
|
module CLI
|
|
7
|
+
# Optional loop-phase DSL on the shared command base, read by
|
|
8
|
+
# Space::Core::CLI::Help to group the help listing. A command may declare
|
|
9
|
+
# `phase <order>, "<Label>"`; groups, and members within a group, sort by
|
|
10
|
+
# <order>. Only architect commands declare one — space commands leave it nil
|
|
11
|
+
# and render as the single ungrouped default listing (unchanged).
|
|
12
|
+
class BaseCommand
|
|
13
|
+
def self.phase(order = nil, label = nil)
|
|
14
|
+
return @phase if order.nil?
|
|
15
|
+
|
|
16
|
+
@phase = [order, label]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
7
20
|
module Architect
|
|
8
21
|
class Init < BaseCommand
|
|
9
22
|
desc "Scaffold (or top up) the architect project: ARCHITECT.md, space.yaml project block, SessionStart hook"
|
|
23
|
+
phase 50, "Project"
|
|
10
24
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
11
25
|
|
|
12
26
|
def call(space: nil, **opts)
|
|
@@ -24,6 +38,7 @@ module Space::Architect
|
|
|
24
38
|
|
|
25
39
|
class Ground < BaseCommand
|
|
26
40
|
desc "Print grounding reads (ARCHITECT.md, BRIEF.md, in-flight iteration) to stdout"
|
|
41
|
+
phase 51, "Project"
|
|
27
42
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
28
43
|
|
|
29
44
|
def call(space: nil, **opts)
|
|
@@ -56,6 +71,7 @@ module Space::Architect
|
|
|
56
71
|
|
|
57
72
|
class New < BaseCommand
|
|
58
73
|
desc "Scaffold the next iteration file (architecture/I<NN>-<iteration>.md)"
|
|
74
|
+
phase 10, "Spec"
|
|
59
75
|
argument :iteration, required: true, desc: "Iteration name (kebab-case)"
|
|
60
76
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
61
77
|
|
|
@@ -74,6 +90,7 @@ module Space::Architect
|
|
|
74
90
|
|
|
75
91
|
class Status < BaseCommand
|
|
76
92
|
desc "Show architect project state (read-only)"
|
|
93
|
+
phase 52, "Project"
|
|
77
94
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
78
95
|
|
|
79
96
|
def call(space: nil, **opts)
|
|
@@ -126,6 +143,7 @@ module Space::Architect
|
|
|
126
143
|
|
|
127
144
|
class Freeze < BaseCommand
|
|
128
145
|
desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
|
|
146
|
+
phase 12, "Spec"
|
|
129
147
|
argument :iteration, required: true, desc: "Iteration name"
|
|
130
148
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
131
149
|
|
|
@@ -152,6 +170,7 @@ module Space::Architect
|
|
|
152
170
|
|
|
153
171
|
class Verify < BaseCommand
|
|
154
172
|
desc "Post-flight mechanical lane checks — frozen-untouched, no builder commits, report exists, in-bounds (reports only, no judgment)"
|
|
173
|
+
phase 30, "Judge"
|
|
155
174
|
argument :iteration, required: true, desc: "Iteration name"
|
|
156
175
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
157
176
|
|
|
@@ -199,6 +218,7 @@ module Space::Architect
|
|
|
199
218
|
|
|
200
219
|
class Dispatch < BaseCommand
|
|
201
220
|
desc "Dispatch a builder for a lane (streams to build/<id>-<lane>/run.jsonl)"
|
|
221
|
+
phase 21, "Build"
|
|
202
222
|
argument :iteration, required: true, desc: "Iteration name"
|
|
203
223
|
argument :lane, required: true, desc: "Lane name"
|
|
204
224
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
@@ -251,8 +271,37 @@ module Space::Architect
|
|
|
251
271
|
end
|
|
252
272
|
end
|
|
253
273
|
|
|
274
|
+
class Provision < BaseCommand
|
|
275
|
+
desc "Materialize declared lanes (worktree + lane/<id>-<lane> branch) from the frozen lane plan"
|
|
276
|
+
phase 20, "Build"
|
|
277
|
+
argument :iteration, required: true, desc: "Iteration name"
|
|
278
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
279
|
+
option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
|
|
280
|
+
option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
|
|
281
|
+
|
|
282
|
+
def call(iteration:, space: nil, base: nil, lane: nil, **opts)
|
|
283
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
284
|
+
handle_errors do
|
|
285
|
+
render(store.find(space)) do |sp|
|
|
286
|
+
project = ArchitectProject.new(space: sp)
|
|
287
|
+
results = project.provision(iteration, base: base, lane: lane)
|
|
288
|
+
if results.empty?
|
|
289
|
+
terminal.say "No declared lanes to provision for '#{iteration}'"
|
|
290
|
+
else
|
|
291
|
+
results.each do |r|
|
|
292
|
+
state = r[:created] ? "created" : "already present"
|
|
293
|
+
terminal.say "#{r[:lane]}: #{terminal.path(r[:worktree])} (#{state})"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
254
302
|
class Section < BaseCommand
|
|
255
303
|
desc "Write a section of the iteration file and commit it (one call)"
|
|
304
|
+
phase 11, "Spec"
|
|
256
305
|
argument :iteration, required: true, desc: "Iteration name"
|
|
257
306
|
argument :section, required: true, desc: "Section: grounds, specification, prompt, verdict"
|
|
258
307
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
@@ -292,6 +341,7 @@ module Space::Architect
|
|
|
292
341
|
|
|
293
342
|
class Verdict < BaseCommand
|
|
294
343
|
desc "Record the architect's verdict decision (continue or kill) and write ## Verdict prose"
|
|
344
|
+
phase 33, "Judge"
|
|
295
345
|
argument :iteration, required: true, desc: "Iteration name"
|
|
296
346
|
argument :decision, required: true, desc: "Decision: continue or kill"
|
|
297
347
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
@@ -324,6 +374,7 @@ module Space::Architect
|
|
|
324
374
|
|
|
325
375
|
class Evidence < BaseCommand
|
|
326
376
|
desc "Transcribe a lane's scratch report VERBATIM into Builder Report and commit"
|
|
377
|
+
phase 31, "Judge"
|
|
327
378
|
argument :iteration, required: true, desc: "Iteration name"
|
|
328
379
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
329
380
|
option :lane, default: nil, desc: "Lane name (per-lane subsection; omit for a single-lane iteration)"
|
|
@@ -345,6 +396,7 @@ module Space::Architect
|
|
|
345
396
|
|
|
346
397
|
class Merge < BaseCommand
|
|
347
398
|
desc "Integrate ONE judged-passing lane (merges --no-ff; runs no gates, makes no verdict)"
|
|
399
|
+
phase 41, "Land"
|
|
348
400
|
argument :iteration, required: true, desc: "Iteration name"
|
|
349
401
|
argument :lane, required: true, desc: "Lane name (architect-judged passing)"
|
|
350
402
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
@@ -367,42 +419,35 @@ module Space::Architect
|
|
|
367
419
|
|
|
368
420
|
class Integrate < BaseCommand
|
|
369
421
|
desc "Integrate the architect-supplied set of passing lanes, in order (stops on conflict)"
|
|
422
|
+
phase 40, "Land"
|
|
370
423
|
argument :iteration, required: true, desc: "Iteration name"
|
|
371
424
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
372
|
-
option :lanes, required:
|
|
425
|
+
option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
|
|
373
426
|
option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
|
|
374
427
|
|
|
375
|
-
def call(iteration:, space: nil, lanes
|
|
428
|
+
def call(iteration:, space: nil, lanes: nil, teardown: false, **opts)
|
|
376
429
|
setup_terminal(**opts.slice(:color, :colors))
|
|
377
430
|
handle_errors do
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
lane_names
|
|
381
|
-
results = project.integrate!(iteration, lanes: lane_names, teardown: teardown)
|
|
382
|
-
results.each do |r|
|
|
383
|
-
terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
|
|
384
|
-
end
|
|
385
|
-
terminal.say "Gates NOT run — run `architect gate #{iteration}`; the verdict is the next session's."
|
|
386
|
-
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
387
|
-
end
|
|
388
|
-
end
|
|
389
|
-
end
|
|
390
|
-
end
|
|
431
|
+
lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
432
|
+
raise Space::Core::Error, "integrate needs --lanes <set>, or --teardown for teardown-only" \
|
|
433
|
+
if lane_names.empty? && !teardown
|
|
391
434
|
|
|
392
|
-
class Land < BaseCommand
|
|
393
|
-
desc "Generate the end-of-project PR command (no push, no gh — prints gh pr create)"
|
|
394
|
-
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
395
|
-
|
|
396
|
-
def call(space: nil, **opts)
|
|
397
|
-
setup_terminal(**opts.slice(:color, :colors))
|
|
398
|
-
handle_errors do
|
|
399
435
|
render(store.find(space)) do |sp|
|
|
400
436
|
project = ArchitectProject.new(space: sp)
|
|
401
|
-
results = project.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
437
|
+
results = project.integrate!(iteration, lanes: lane_names, teardown: teardown)
|
|
438
|
+
if lane_names.empty?
|
|
439
|
+
if results.empty?
|
|
440
|
+
terminal.say "Nothing to tear down for #{iteration}"
|
|
441
|
+
else
|
|
442
|
+
results.each do |r|
|
|
443
|
+
terminal.say "Tore down #{r[:lane]} (removed worktree, deleted #{r[:lane_branch]})"
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
else
|
|
447
|
+
results.each do |r|
|
|
448
|
+
terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
|
|
449
|
+
end
|
|
450
|
+
terminal.say "Gates NOT run — run `architect gate #{iteration}`; the verdict is the next session's."
|
|
406
451
|
end
|
|
407
452
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
408
453
|
end
|
|
@@ -412,6 +457,7 @@ module Space::Architect
|
|
|
412
457
|
|
|
413
458
|
class Gate < BaseCommand
|
|
414
459
|
desc "Run the frozen Acceptance Criteria gate commands and report PASS/FAIL"
|
|
460
|
+
phase 32, "Judge"
|
|
415
461
|
argument :iteration, required: true, desc: "Iteration name"
|
|
416
462
|
argument :lane, required: false, desc: "Run in a lane worktree (default: the integration repo)"
|
|
417
463
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
@@ -439,8 +485,33 @@ module Space::Architect
|
|
|
439
485
|
end
|
|
440
486
|
end
|
|
441
487
|
|
|
488
|
+
class BugReport < BaseCommand
|
|
489
|
+
desc "Generate a prefilled GitHub issue template for filing bugs against space-architect"
|
|
490
|
+
phase 54, "Project"
|
|
491
|
+
|
|
492
|
+
def call(**opts)
|
|
493
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
494
|
+
handle_errors do
|
|
495
|
+
space = store.find.value_or(nil)
|
|
496
|
+
result = Space::Architect::BugReport.generate(
|
|
497
|
+
space: space,
|
|
498
|
+
env: project_config.env
|
|
499
|
+
)
|
|
500
|
+
terminal.say "Fill the placeholders in #{terminal.path(result[:body_path].to_s)}, then run:"
|
|
501
|
+
terminal.say result[:command]
|
|
502
|
+
terminal.say ""
|
|
503
|
+
terminal.say "Diagnostics:"
|
|
504
|
+
terminal.say " space-architect #{Space::Core::VERSION}"
|
|
505
|
+
terminal.say " ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
506
|
+
terminal.say " space: #{space.id} — #{space.title}" if space
|
|
507
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
442
512
|
class InstallSkills < BaseCommand
|
|
443
513
|
desc "Install bundled skills (architect, architect-research, architect-vocabulary) for a harness"
|
|
514
|
+
phase 53, "Project"
|
|
444
515
|
option :provider, default: "claude", desc: "Harness: claude, codex, opencode, pi"
|
|
445
516
|
option :project, type: :boolean, default: false, desc: "Install to CWD instead of global"
|
|
446
517
|
option :force, type: :boolean, default: false, desc: "Overwrite existing skills that differ"
|
|
@@ -640,21 +711,26 @@ module Space::Architect
|
|
|
640
711
|
end
|
|
641
712
|
end
|
|
642
713
|
|
|
714
|
+
# Loop-phase declarations above sort the architect help listing; its namespaces
|
|
715
|
+
# (brief/worktree/variant/research) declare no phase and list under this header.
|
|
716
|
+
Space::Core::CLI::Help.trailing_group_label = "Groups"
|
|
717
|
+
|
|
643
718
|
Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Architect::Init
|
|
644
719
|
Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
|
|
645
720
|
Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
|
|
646
721
|
Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
|
|
647
722
|
Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
|
|
648
723
|
Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
|
|
724
|
+
Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
|
|
649
725
|
Space::Architect::CLI::Registry.register "dispatch", Space::Architect::CLI::Architect::Dispatch
|
|
650
726
|
Space::Architect::CLI::Registry.register "section", Space::Architect::CLI::Architect::Section
|
|
651
727
|
Space::Architect::CLI::Registry.register "verdict", Space::Architect::CLI::Architect::Verdict
|
|
652
728
|
Space::Architect::CLI::Registry.register "evidence", Space::Architect::CLI::Architect::Evidence
|
|
653
729
|
Space::Architect::CLI::Registry.register "merge", Space::Architect::CLI::Architect::Merge
|
|
654
730
|
Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Architect::Integrate
|
|
655
|
-
Space::Architect::CLI::Registry.register "land", Space::Architect::CLI::Architect::Land
|
|
656
731
|
Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
|
|
657
732
|
Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
|
|
733
|
+
Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
|
|
658
734
|
Space::Architect::CLI::Registry.register "brief" do |b|
|
|
659
735
|
b.register "new", Space::Architect::CLI::Architect::Brief::New
|
|
660
736
|
end
|
|
@@ -56,7 +56,12 @@ module Space::Architect
|
|
|
56
56
|
|
|
57
57
|
TIMEOUT_EXIT_CODE = 124
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
# How long the liveness fiber waits before reading the run log's stream-json init
|
|
60
|
+
# event. Injectable via the run(liveness_delay:) kwarg so tests need not sleep seconds.
|
|
61
|
+
LIVENESS_DELAY_SECONDS = 5.0
|
|
62
|
+
|
|
63
|
+
def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil,
|
|
64
|
+
liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
|
|
60
65
|
prompt_path = Pathname.new(prompt_path)
|
|
61
66
|
run_log_path = Pathname.new(run_log_path)
|
|
62
67
|
|
|
@@ -66,7 +71,7 @@ module Space::Architect
|
|
|
66
71
|
Sync do
|
|
67
72
|
child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: w, err: log)
|
|
68
73
|
w.close
|
|
69
|
-
tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client)
|
|
74
|
+
tasks = start_tee(r, log, push_url: push_url, push_token: push_token, push_client: push_client, err: err)
|
|
70
75
|
timed_out = false
|
|
71
76
|
timeout_task = nil
|
|
72
77
|
|
|
@@ -84,8 +89,21 @@ module Space::Architect
|
|
|
84
89
|
end
|
|
85
90
|
end
|
|
86
91
|
|
|
92
|
+
# Liveness self-check: after a bounded delay, read the run log's stream-json
|
|
93
|
+
# init event and print ONE line naming the streamed model + confirming growth.
|
|
94
|
+
# transient: true so it never keeps the reactor alive; best-effort so it never
|
|
95
|
+
# raises into the run path. run_detached gets no such fiber.
|
|
96
|
+
liveness_task = nil
|
|
97
|
+
if liveness_delay && liveness_delay > 0
|
|
98
|
+
liveness_task = Async(transient: true) do
|
|
99
|
+
sleep liveness_delay
|
|
100
|
+
emit_liveness(run_log_path, liveness_delay, err)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
87
104
|
status = child.wait
|
|
88
105
|
timeout_task&.stop
|
|
106
|
+
liveness_task&.stop
|
|
89
107
|
|
|
90
108
|
tasks.each(&:wait)
|
|
91
109
|
timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
|
|
@@ -113,6 +131,42 @@ module Space::Architect
|
|
|
113
131
|
|
|
114
132
|
private
|
|
115
133
|
|
|
134
|
+
# Read the run log's stream-json init event and print exactly one bounded liveness
|
|
135
|
+
# line to err. Best-effort: swallows any read/parse error so it never raises into run.
|
|
136
|
+
def emit_liveness(run_log_path, delay, err)
|
|
137
|
+
bytes = run_log_path.exist? ? run_log_path.size : 0
|
|
138
|
+
if bytes.zero?
|
|
139
|
+
err.puts "liveness: WARN no growth — run log still empty #{delay}s after dispatch"
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
streamed = streamed_init_model(run_log_path)
|
|
144
|
+
if streamed.nil?
|
|
145
|
+
err.puts "liveness: WARN model unverified — no stream-json init event after #{delay}s (run log #{bytes} bytes)"
|
|
146
|
+
elsif streamed == @model
|
|
147
|
+
err.puts "liveness: OK streaming model=#{streamed} (run log growing, #{bytes} bytes)"
|
|
148
|
+
else
|
|
149
|
+
err.puts "liveness: WARN model mismatch — pinned=#{@model} streamed=#{streamed} (run log growing, #{bytes} bytes)"
|
|
150
|
+
end
|
|
151
|
+
rescue StandardError
|
|
152
|
+
# Best-effort: an internal read/parse failure must never break the run.
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The model named by the stream-json init event ({"type":"system","subtype":"init",...}),
|
|
156
|
+
# or nil if no such event has been logged yet.
|
|
157
|
+
def streamed_init_model(run_log_path)
|
|
158
|
+
run_log_path.each_line do |line|
|
|
159
|
+
ev = begin
|
|
160
|
+
JSON.parse(line)
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
|
|
165
|
+
return ev["model"]
|
|
166
|
+
end
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
116
170
|
def argv
|
|
117
171
|
args = [
|
|
118
172
|
@bin, "-p",
|
|
@@ -128,17 +182,17 @@ module Space::Architect
|
|
|
128
182
|
args
|
|
129
183
|
end
|
|
130
184
|
|
|
131
|
-
def start_tee(r, log, push_url:, push_token:, push_client:)
|
|
185
|
+
def start_tee(r, log, push_url:, push_token:, push_client:, err: $stderr)
|
|
132
186
|
if push_url || push_client
|
|
133
187
|
body = Protocol::HTTP::Body::Writable.new(queue: Thread::SizedQueue.new(32))
|
|
134
|
-
push = Async { push_body(body, push_url: push_url, push_token: push_token, push_client: push_client) }
|
|
135
|
-
[Async { tee_pipe(r, log, body) }, push]
|
|
188
|
+
push = Async { push_body(body, push_url: push_url, push_token: push_token, push_client: push_client, err: err) }
|
|
189
|
+
[Async { tee_pipe(r, log, body, err: err) }, push]
|
|
136
190
|
else
|
|
137
191
|
[Async { drain_pipe(r, log) }]
|
|
138
192
|
end
|
|
139
193
|
end
|
|
140
194
|
|
|
141
|
-
def push_body(body, push_url:, push_token:, push_client:)
|
|
195
|
+
def push_body(body, push_url:, push_token:, push_client:, err: $stderr)
|
|
142
196
|
path = push_url ? URI.parse(push_url).path : "/"
|
|
143
197
|
headers = [["content-type", "application/x-ndjson"]]
|
|
144
198
|
headers << ["authorization", "Bearer #{push_token}"] if push_token
|
|
@@ -150,10 +204,10 @@ module Space::Architect
|
|
|
150
204
|
end
|
|
151
205
|
end
|
|
152
206
|
rescue StandardError => e
|
|
153
|
-
|
|
207
|
+
err.puts "push_body: transport error (best-effort, run log intact): #{e.class}: #{e.message}"
|
|
154
208
|
end
|
|
155
209
|
|
|
156
|
-
def tee_pipe(r, log, body)
|
|
210
|
+
def tee_pipe(r, log, body, err: $stderr)
|
|
157
211
|
pushing = true
|
|
158
212
|
while (chunk = r.gets)
|
|
159
213
|
log.write(chunk)
|
|
@@ -162,7 +216,7 @@ module Space::Architect
|
|
|
162
216
|
begin
|
|
163
217
|
body.write(chunk)
|
|
164
218
|
rescue StandardError => e
|
|
165
|
-
|
|
219
|
+
err.puts "tee_pipe: push write failed (best-effort, continuing log): #{e.class}: #{e.message}"
|
|
166
220
|
pushing = false
|
|
167
221
|
end
|
|
168
222
|
end
|
|
@@ -27,9 +27,23 @@ Write + commit: `architect section <%= @_name %> specification --from <file>`. -
|
|
|
27
27
|
- **Boundaries** — may-touch / must-not-touch / out-of-scope; no placeholders;
|
|
28
28
|
no refactors beyond the task. (Frozen stack: cite `BRIEF §2`.)
|
|
29
29
|
- **Lane plan** — 1–4 lanes, each declaring: target repo `repos/<repo>`,
|
|
30
|
-
file-touch set (overlap-checked), objective, output format, boundaries.
|
|
30
|
+
file-touch set (overlap-checked), objective, output format, boundaries. The
|
|
31
|
+
machine-readable declaration lives in the fenced ```lanes block below — the single
|
|
32
|
+
frozen source of truth `architect freeze` records and `architect provision`
|
|
33
|
+
materializes.
|
|
31
34
|
- **Effort** — `think hard` … `ultrathink` per lane, with one line of why.
|
|
32
35
|
|
|
36
|
+
```lanes
|
|
37
|
+
# One entry per lane (1–4). The frozen out-of-bounds contract: `architect freeze`
|
|
38
|
+
# writes each into space.yaml (name, repo, touch_set); `architect provision <%= @_name %>`
|
|
39
|
+
# materializes the worktrees + lane branches. Remove the comment markers to activate.
|
|
40
|
+
# - name: lane-a # lane name (required)
|
|
41
|
+
# repo: my-repo # target repo under repos/ (required)
|
|
42
|
+
# touch: # file globs this lane may write (required, non-empty)
|
|
43
|
+
# - lib/my_repo/**
|
|
44
|
+
# - test/my_repo_test.rb
|
|
45
|
+
```
|
|
46
|
+
|
|
33
47
|
## Acceptance Criteria
|
|
34
48
|
|
|
35
49
|
<!-- PROOF. Write the prose conditions of correctness (AC1, AC2, …) that the
|
data/lib/space_architect.rb
CHANGED
|
@@ -10,5 +10,6 @@ require_relative "space_architect/gate_lint"
|
|
|
10
10
|
require_relative "space_architect/gate_evaluator"
|
|
11
11
|
require_relative "space_architect/architect_project"
|
|
12
12
|
require_relative "space_architect/skill_installer"
|
|
13
|
+
require_relative "space_architect/bug_report"
|
|
13
14
|
require_relative "space_architect/research"
|
|
14
15
|
require_relative "space_architect/cli"
|
data/lib/space_core/cli/help.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "dry/cli"
|
|
4
4
|
require "pastel"
|
|
5
|
+
require_relative "loop_status"
|
|
5
6
|
|
|
6
7
|
module Space::Core::CLI
|
|
7
8
|
# Colourful replacement for dry-cli's plain `Usage` listing — the "global
|
|
@@ -27,17 +28,30 @@ module Space::Core::CLI
|
|
|
27
28
|
|
|
28
29
|
module_function
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
# Header for the trailing group of children that declare no phase. Left nil
|
|
32
|
+
# for the `space` binary (whose commands are all phase-less → one ungrouped
|
|
33
|
+
# listing); the `architect` binary sets it so its namespaces list under a
|
|
34
|
+
# "Groups" header. Keeps phase vocabulary out of this generic renderer.
|
|
35
|
+
def self.trailing_group_label = @trailing_group_label
|
|
36
|
+
|
|
37
|
+
def self.trailing_group_label=(label)
|
|
38
|
+
@trailing_group_label = label
|
|
39
|
+
end
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
def call(result, pastel: CLI.help_pastel)
|
|
42
|
+
groups = grouped_listing(result)
|
|
43
|
+
width = groups.flat_map { |_h, rows| rows.map { |label, _| label.length } }.max || 0
|
|
44
|
+
|
|
45
|
+
body = groups.flat_map do |group_header, rows|
|
|
46
|
+
lines = rows.map do |label, description|
|
|
47
|
+
painted = pastel.cyan(label.ljust(width))
|
|
48
|
+
description ? " #{painted} #{pastel.dim("# #{description}")}" : " #{painted}"
|
|
49
|
+
end
|
|
50
|
+
group_header ? ["", pastel.bold(group_header), *lines] : lines
|
|
37
51
|
end
|
|
38
52
|
|
|
39
|
-
[header(result, pastel), pastel.bold("Commands:"), *
|
|
40
|
-
|
|
53
|
+
[header(result, pastel), pastel.bold("Commands:"), *body,
|
|
54
|
+
footer(result, pastel), loop_status_block(result, pastel)].compact.join("\n")
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
# The richer header only makes sense at the true root (`space` / `architect`),
|
|
@@ -69,13 +83,66 @@ module Space::Core::CLI
|
|
|
69
83
|
[prog, *names].join(" ")
|
|
70
84
|
end
|
|
71
85
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
# Group non-hidden children into an ordered list of [header_or_nil, rows].
|
|
87
|
+
# When no child declares a phase (e.g. the `space` binary), returns a single
|
|
88
|
+
# unlabelled group sorted by name — byte-identical to the pre-phase listing.
|
|
89
|
+
# Otherwise groups declared commands under their phase label (groups, and
|
|
90
|
+
# members within a group, ordered by the declared order), with undeclared
|
|
91
|
+
# children (namespaces) trailing in the default group.
|
|
92
|
+
def grouped_listing(result)
|
|
93
|
+
decorated = result.children.filter_map do |name, node|
|
|
94
|
+
[name, node, phase_of(node)] unless node.hidden
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if decorated.all? { |_name, _node, phase| phase.nil? }
|
|
98
|
+
rows = decorated.sort_by { |name, _node, _phase| name }
|
|
99
|
+
.map { |name, node, _phase| row(result, name, node) }
|
|
100
|
+
return [[nil, rows]]
|
|
101
|
+
end
|
|
76
102
|
|
|
77
|
-
|
|
103
|
+
phased, unphased = decorated.partition { |_name, _node, phase| phase }
|
|
104
|
+
groups = phased.group_by { |_name, _node, phase| phase.last }
|
|
105
|
+
listing = groups.sort_by { |_label, members| members.map { |_n, _node, phase| phase.first }.min }
|
|
106
|
+
.map do |label, members|
|
|
107
|
+
rows = members.sort_by { |_n, _node, phase| phase.first }
|
|
108
|
+
.map { |name, node, _phase| row(result, name, node) }
|
|
109
|
+
[label, rows]
|
|
78
110
|
end
|
|
111
|
+
listing << [trailing_group_label, unphased.map { |name, node, _phase| row(result, name, node) }] \
|
|
112
|
+
unless unphased.empty?
|
|
113
|
+
listing
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def row(result, name, node)
|
|
117
|
+
[label(result, name, node), description(node)]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def phase_of(node)
|
|
121
|
+
node.command.respond_to?(:phase) ? node.command.phase : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Opportunistic, TOTAL loop-status embed: only the architect binary's ROOT
|
|
125
|
+
# listing gets it. Resolves the current space best-effort and renders the
|
|
126
|
+
# shared block from that space's own space.yaml `project` data (no
|
|
127
|
+
# space_architect dependency). ANY failure — no space, store failure,
|
|
128
|
+
# malformed yaml, no `project` block — omits the block; help always renders.
|
|
129
|
+
def loop_status_block(result, pastel)
|
|
130
|
+
return unless result.names.empty? && File.basename($PROGRAM_NAME) == "architect"
|
|
131
|
+
|
|
132
|
+
project = current_space_project
|
|
133
|
+
lines = project && LoopStatus.lines(project)
|
|
134
|
+
return if lines.nil? || lines.empty?
|
|
135
|
+
|
|
136
|
+
["", pastel.bold("Loop:"), *lines.map { |l| " #{pastel.dim(l)}" }].join("\n")
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def current_space_project
|
|
142
|
+
store = Space::Core::SpaceStore.new(config: Space::Core::Config.load, state: Space::Core::State.load)
|
|
143
|
+
store.current.value_or(nil)&.data&.[]("project")
|
|
144
|
+
rescue StandardError
|
|
145
|
+
nil
|
|
79
146
|
end
|
|
80
147
|
|
|
81
148
|
def label(result, name, node)
|
|
@@ -109,15 +176,18 @@ module Space::Core::CLI
|
|
|
109
176
|
end
|
|
110
177
|
|
|
111
178
|
# Route dry-cli's plain namespace/root listing through our colourful renderer.
|
|
112
|
-
# We
|
|
113
|
-
# (children, command, leaf?/children?/hidden, names) rather than copying
|
|
114
|
-
# internals — see notes/ruby-cli-gems-report.md.
|
|
179
|
+
# We prepend over Usage.call wholesale and depend only on the LookupResult/Node
|
|
180
|
+
# API (children, command, leaf?/children?/hidden, names) rather than copying
|
|
181
|
+
# Usage's internals — see notes/ruby-cli-gems-report.md.
|
|
115
182
|
module Dry
|
|
116
183
|
class CLI
|
|
117
184
|
module Usage
|
|
118
|
-
|
|
119
|
-
|
|
185
|
+
module ColourPatch
|
|
186
|
+
def call(result)
|
|
187
|
+
Space::Core::CLI::Help.call(result)
|
|
188
|
+
end
|
|
120
189
|
end
|
|
190
|
+
singleton_class.prepend(ColourPatch)
|
|
121
191
|
end
|
|
122
192
|
end
|
|
123
193
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
# Compact Architect-Loop status block, derived from a space's own `project`
|
|
5
|
+
# block in space.yaml — so this carries NO dependency on space_architect. It
|
|
6
|
+
# is shared by the `architect --help` embed and the `space status` report.
|
|
7
|
+
# Returns plain lines (callers colourise) and never raises on malformed data.
|
|
8
|
+
module LoopStatus
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Lines for the compact block, or nil when there is no `project` block.
|
|
12
|
+
def lines(project)
|
|
13
|
+
return nil unless project.is_a?(Hash) && !project.empty?
|
|
14
|
+
|
|
15
|
+
rows = ["Project status: #{project["status"] || "(none)"}"]
|
|
16
|
+
iter = current_iteration(project)
|
|
17
|
+
rows << "Iteration: #{ordinal(iter)} #{iter["name"]} — #{state_of(iter)}" if iter
|
|
18
|
+
rows
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def current_iteration(project)
|
|
22
|
+
name = project["current_iteration"]
|
|
23
|
+
return nil unless name
|
|
24
|
+
|
|
25
|
+
Array(project["iterations"]).find { |s| s["name"] == name }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ordinal(iter)
|
|
29
|
+
iter["ordinal"] ? format("I%02d", iter["ordinal"]) : "I--"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Derived loop state for the current iteration. Mirrors the precedence in
|
|
33
|
+
# `architect status`: a decided verdict wins, then an integrated lane
|
|
34
|
+
# (awaiting-verdict), then dispatched lanes, then a bare freeze, else spec.
|
|
35
|
+
def state_of(iter)
|
|
36
|
+
verdict = iter["verdict"]
|
|
37
|
+
return verdict if verdict && verdict != "pending"
|
|
38
|
+
|
|
39
|
+
lanes = Array(iter["lanes"])
|
|
40
|
+
return "awaiting-verdict" if lanes.any? { |l| l["integration_branch"] }
|
|
41
|
+
return "dispatched" if lanes.any?
|
|
42
|
+
return "frozen #{iter["freeze_sha"][0, 8]}" if iter["freeze_sha"]
|
|
43
|
+
|
|
44
|
+
"speccing"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|