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.
- checksums.yaml +4 -4
- data/README.md +14 -1
- data/lib/space_architect/architect_project.rb +186 -28
- data/lib/space_architect/cli/architect.rb +80 -6
- data/lib/space_architect/harness.rb +63 -9
- data/lib/space_architect/templates/iteration.md.erb +15 -1
- 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/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/version.rb +1 -1
- 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 +37 -15
- data/skill/architect/dispatch.md +32 -20
- metadata +2 -1
|
@@ -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_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
|
|
@@ -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
|
|
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)
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
opts.on_tail("-h", "--help") do
|
|
43
|
+
return Result.help
|
|
44
|
+
end
|
|
45
|
+
end.parse!(arguments)
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 "
|
|
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"
|
data/lib/space_core/space.rb
CHANGED
|
@@ -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
|
-
|
|
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
|