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.
@@ -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: true, desc: "Comma-separated passing lane names (you decide the set)"
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:, teardown: false, **opts)
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
- render(store.find(space)) do |sp|
379
- project = ArchitectProject.new(space: sp)
380
- lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
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.land
402
- results.each do |r|
403
- terminal.say r[:context]
404
- terminal.say r[:command]
405
- terminal.say "Body: #{terminal.path(r[:body_file])}"
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
- def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil)
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
- $stderr.puts "push_body: transport error (best-effort, run log intact): #{e.class}: #{e.message}"
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
- $stderr.puts "tee_pipe: push write failed (best-effort, continuing log): #{e.class}: #{e.message}"
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
@@ -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"
@@ -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
- def call(result, pastel: CLI.help_pastel)
31
- rows = listing(result)
32
- width = rows.map { |label, _| label.length }.max || 0
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
- lines = rows.map do |label, description|
35
- painted = pastel.cyan(label.ljust(width))
36
- description ? " #{painted} #{pastel.dim("# #{description}")}" : " #{painted}"
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:"), *lines, footer(result, pastel)]
40
- .compact.join("\n")
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
- # [[label_with_banner, description_or_nil], ...] sorted by command name.
73
- def listing(result)
74
- result.children.sort_by { |name, _| name }.filter_map do |name, node|
75
- next if node.hidden
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
- [label(result, name, node), description(node)]
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 replace Usage.call wholesale and depend only on the LookupResult/Node API
113
- # (children, command, leaf?/children?/hidden, names) rather than copying Usage's
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
- def self.call(result)
119
- Space::Core::CLI::Help.call(result)
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