space-architect 3.0.0 → 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.
@@ -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
@@ -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
@@ -6,7 +6,7 @@ require "dry/cli"
6
6
  # on each occurrence, so `-r a -r b` yields ["b"]. We want repeated flags to
7
7
  # accumulate (`-r a -r b -r c` => ["a", "b", "c"]) the way git/docker-style CLIs
8
8
  # do, while still accepting the comma form. dry-cli exposes no hook for this, so
9
- # we reopen two private seams, each mirroring dry-cli 1.4.1 with a single change:
9
+ # we prepend two private seams, each mirroring dry-cli 1.4.1 with a single change:
10
10
  #
11
11
  # * Parser.call — concat instead of assign for array options.
12
12
  # * Banner.extended_command_options — drop the "=VALUE1,VALUE2,.." hint that
@@ -14,62 +14,71 @@ require "dry/cli"
14
14
  # matching how you actually type it (-r VALUE).
15
15
  #
16
16
  # These mirror the released 1.4.1 source EXACTLY (not the dry-rb main branch,
17
- # which already differs). Pinned via `~> 1.4`; if a future dry-cli reworks these
18
- # methods, repeatable_options_test goes red and we re-sync. Rationale:
19
- # notes/ruby-cli-gems-report.md.
17
+ # which already differs), prepended onto the module's singleton class so `super`
18
+ # reaches dry-cli's own implementation instead of discarding it outright. Pinned
19
+ # via `~> 1.4`; if a future dry-cli reworks these methods, repeatable_options_test
20
+ # goes red and we re-sync. Rationale: notes/ruby-cli-gems-report.md.
20
21
  module Dry
21
22
  class CLI
22
23
  module Parser
23
- def self.call(command, arguments, prog_name)
24
- original_arguments = arguments.dup
25
- parsed_options = {}
24
+ # Reopened on Parser's own nesting (not CLI's) so `Result` resolves
25
+ # lexically, exactly as it does in dry-cli's own `Parser.call`.
26
+ module RepeatableOptionsPatch
27
+ def call(command, arguments, prog_name)
28
+ original_arguments = arguments.dup
29
+ parsed_options = {}
26
30
 
27
- OptionParser.new do |opts|
28
- command.options.each do |option|
29
- opts.on(*option.parser_options) do |value|
30
- if option.array?
31
- (parsed_options[option.name.to_sym] ||= []).concat(value)
32
- else
33
- parsed_options[option.name.to_sym] = value
31
+ OptionParser.new do |opts|
32
+ command.options.each do |option|
33
+ opts.on(*option.parser_options) do |value|
34
+ if option.array?
35
+ (parsed_options[option.name.to_sym] ||= []).concat(value)
36
+ else
37
+ parsed_options[option.name.to_sym] = value
38
+ end
34
39
  end
35
40
  end
36
- end
37
41
 
38
- opts.on_tail("-h", "--help") do
39
- return Result.help
40
- end
41
- end.parse!(arguments)
42
+ opts.on_tail("-h", "--help") do
43
+ return Result.help
44
+ end
45
+ end.parse!(arguments)
42
46
 
43
- parsed_options = command.default_params.merge(parsed_options)
44
- parse_required_params(command, arguments, prog_name, parsed_options)
45
- rescue ::OptionParser::ParseError, ValueError
46
- Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
47
+ parsed_options = command.default_params.merge(parsed_options)
48
+ parse_required_params(command, arguments, prog_name, parsed_options)
49
+ rescue ::OptionParser::ParseError, ValueError
50
+ Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
51
+ end
47
52
  end
53
+ singleton_class.prepend(RepeatableOptionsPatch)
48
54
  end
49
55
 
50
56
  module Banner
51
- def self.extended_command_options(command)
52
- result = command.options.map do |option|
53
- name = Inflector.dasherize(option.name)
54
- name = if option.boolean?
55
- "[no-]#{name}"
56
- elsif option.flag?
57
- name
58
- else
59
- # array options included: repeated flags accumulate, so show
60
- # the single repeatable form rather than "=VALUE1,VALUE2,..".
61
- "#{name}=VALUE"
62
- end
63
- name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
64
- name = " --#{name.ljust(30)}"
65
- name = "#{name} # #{option.desc}"
66
- name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
67
- name
68
- end
57
+ module RepeatableOptionsPatch
58
+ def extended_command_options(command)
59
+ result = command.options.map do |option|
60
+ name = Inflector.dasherize(option.name)
61
+ name = if option.boolean?
62
+ "[no-]#{name}"
63
+ elsif option.flag?
64
+ name
65
+ else
66
+ # array options included: repeated flags accumulate, so show
67
+ # the single repeatable form rather than "=VALUE1,VALUE2,..".
68
+ "#{name}=VALUE"
69
+ end
70
+ name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
71
+ name = " --#{name.ljust(30)}"
72
+ name = "#{name} # #{option.desc}"
73
+ name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
74
+ name
75
+ end
69
76
 
70
- result << " --#{"help, -h".ljust(30)} # Print this help"
71
- result.join("\n")
77
+ result << " --#{"help, -h".ljust(30)} # Print this help"
78
+ result.join("\n")
79
+ end
72
80
  end
81
+ singleton_class.prepend(RepeatableOptionsPatch)
73
82
  end
74
83
  end
75
84
  end
@@ -5,28 +5,69 @@ class Status < BaseCommand
5
5
  desc "Set a space status: active, paused, done, archived"
6
6
  argument :rest, type: :array, required: false, desc: "[SPACE] STATUS"
7
7
 
8
+ # One command, overloaded by its positional(s):
9
+ # help token (`help`, or dry-cli's -h/--help which never reach here) → help
10
+ # lone keyword / `<space> <keyword>` → set
11
+ # bare, or a lone non-keyword identifier → report
8
12
  def call(rest: [], **opts)
9
13
  setup_terminal(**opts.slice(:color, :colors))
14
+ args = Array(rest)
15
+ return show_help if help_token?(args)
16
+
10
17
  handle_errors do
11
- identifier, status_value = parse_status_args(Array(rest))
12
- render(store.find(identifier)) do |space|
13
- space.update_status(status_value)
14
- terminal.success "#{space.id} is #{space.status}"
15
- CLI.record_outcome(Outcome.new(exit_code: 0))
18
+ if set_args?(args)
19
+ set_status(args)
20
+ elsif args.length <= 1
21
+ report(args.first)
22
+ else
23
+ raise Space::Core::Error, "Usage: space status [SPACE] STATUS"
16
24
  end
17
25
  end
18
26
  end
19
27
 
20
28
  private
21
29
 
22
- def parse_status_args(args)
23
- case args.length
24
- when 1
25
- [nil, args.first]
26
- when 2
27
- args
28
- else
29
- raise Space::Core::Error, "Usage: space status [SPACE] STATUS"
30
+ # The bare word `help` shows the command help, like dry-cli's -h/--help (which
31
+ # it intercepts before dispatch). It must never set status to "help" or report.
32
+ def help_token?(args)
33
+ args == ["help"]
34
+ end
35
+
36
+ def show_help
37
+ out.puts Dry::CLI::Banner.call(self.class, Dry::CLI::ProgramName.call(["status"]))
38
+ CLI.record_outcome(Outcome.new(exit_code: 0))
39
+ end
40
+
41
+ # A set request: `<space> <keyword>` (two args), or a lone status keyword. A
42
+ # lone non-keyword arg is a space identifier to REPORT, not a malformed status.
43
+ def set_args?(args)
44
+ args.length == 2 ||
45
+ (args.length == 1 && Space::Core::Space::VALID_STATUSES.include?(args.first))
46
+ end
47
+
48
+ def set_status(args)
49
+ identifier, status_value = args.length == 2 ? args : [nil, args.first]
50
+ render(store.find(identifier)) do |space|
51
+ space.update_status(status_value)
52
+ terminal.success "#{space.id} is #{space.status}"
53
+ CLI.record_outcome(Outcome.new(exit_code: 0))
54
+ end
55
+ end
56
+
57
+ def report(identifier)
58
+ render(store.find(identifier)) do |space|
59
+ terminal.say "ID: #{space.id}"
60
+ terminal.say "Title: #{space.title}"
61
+ terminal.say "Status: #{space.status}"
62
+ terminal.say "Path: #{terminal.path(space.path)}"
63
+ terminal.say "Created: #{space.data['created_at']}"
64
+ terminal.say "Updated: #{space.data['updated_at']}"
65
+ lines = LoopStatus.lines(space.data["project"])
66
+ if lines
67
+ terminal.say ""
68
+ lines.each { |line| terminal.say line }
69
+ end
70
+ CLI.record_outcome(Outcome.new(exit_code: 0))
30
71
  end
31
72
  end
32
73
  end
@@ -295,7 +295,7 @@ module Space::Core
295
295
  complete -c space -f -n "__space_architect_complete_needs_command" -a path -d "Print a space path"
296
296
  complete -c space -f -n "__space_architect_complete_needs_command" -a use -d "Select and cd to a space with fish integration"
297
297
  complete -c space -f -n "__space_architect_complete_needs_command" -a current -d "Show the current space"
298
- complete -c space -f -n "__space_architect_complete_needs_command" -a status -d "Set a space status"
298
+ complete -c space -f -n "__space_architect_complete_needs_command" -a status -d "Report or set a space status"
299
299
  complete -c space -f -n "__space_architect_complete_needs_command" -a config -d "Show or update config"
300
300
  complete -c space -f -n "__space_architect_complete_needs_command" -a repo -d "Manage repos in the current space"
301
301
  complete -c space -f -n "__space_architect_complete_needs_command" -a repos -d "Manage repos in the current space"
@@ -9,6 +9,11 @@ module Space::Core
9
9
  METADATA_FILE = "space.yaml"
10
10
  VALID_STATUSES = %w[active paused done archived].freeze
11
11
 
12
+ # Canonical space.yaml shape: registry under `project:` (renamed from the
13
+ # pre-2.0 `architect:` key). Bumped from 1 in the release that did the
14
+ # rename, since that release shipped no read-side alias for it.
15
+ SCHEMA_VERSION = 2
16
+
12
17
  attr_reader :path, :data
13
18
 
14
19
  def self.load(path)
@@ -18,13 +23,58 @@ module Space::Core
18
23
  parsed = YAML.safe_load(metadata_path.read, aliases: false) || {}
19
24
  raise Error, "Space metadata must contain a YAML mapping: #{metadata_path}" unless parsed.is_a?(Hash)
20
25
 
21
- new(Pathname.new(path), stringify_keys(parsed))
26
+ data = stringify_keys(parsed)
27
+ normalize_schema!(data, metadata_path)
28
+ new(Pathname.new(path), data)
22
29
  end
23
30
 
24
31
  def self.stringify_keys(hash)
25
32
  hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
26
33
  end
27
34
 
35
+ # Normalizes a parsed space.yaml hash to canonical schema v2, in place:
36
+ # - future version (> SCHEMA_VERSION) → raise, refuse to misread it.
37
+ # - `architect:` only (v1a) → becomes `project:`.
38
+ # - `project:` only (v1b or v2) → left as-is.
39
+ # - both present, `project:` empty and `architect:` non-empty → the
40
+ # corruption from the old silent-default read path; take `architect:`.
41
+ # - both present and non-empty: identical → keep; differing → raise
42
+ # rather than silently pick one and lose data.
43
+ # Idempotent, so a canonical v2 space is a no-op through this method.
44
+ def self.normalize_schema!(data, metadata_path)
45
+ version = data["version"]
46
+ if version.is_a?(Integer) && version > SCHEMA_VERSION
47
+ raise Error,
48
+ "space.yaml schema version #{version} is newer than this gem supports " \
49
+ "(#{SCHEMA_VERSION}); upgrade space-architect: #{metadata_path}"
50
+ end
51
+
52
+ legacy = data.delete("architect")
53
+ current = data["project"]
54
+
55
+ data["project"] =
56
+ if legacy.nil?
57
+ current
58
+ elsif current.nil? || empty_project_block?(current)
59
+ legacy
60
+ elsif legacy == current
61
+ current
62
+ else
63
+ raise Error,
64
+ "#{metadata_path} has both 'architect:' and 'project:' blocks with " \
65
+ "conflicting content; resolve manually.\narchitect: #{legacy.inspect}\n" \
66
+ "project: #{current.inspect}"
67
+ end
68
+
69
+ data["version"] = SCHEMA_VERSION
70
+ end
71
+ private_class_method :normalize_schema!
72
+
73
+ def self.empty_project_block?(block)
74
+ block.is_a?(Hash) && Array(block["iterations"]).empty? && block["current_iteration"].nil?
75
+ end
76
+ private_class_method :empty_project_block?
77
+
28
78
  def initialize(path, data)
29
79
  @path = Pathname.new(path)
30
80
  @data = data
@@ -232,7 +232,7 @@ module Space::Core
232
232
  def metadata_for(id:, title:, timestamp:)
233
233
  iso_timestamp = timestamp.iso8601
234
234
  {
235
- "version" => 1,
235
+ "version" => 2,
236
236
  "id" => id,
237
237
  "title" => title,
238
238
  "status" => "active",
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "3.0.0"
5
+ VERSION = "4.0.0"
6
6
  end
7
7
  end