space-architect 1.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +284 -0
- data/exe/architect +13 -0
- data/exe/space +13 -0
- data/lib/space_architect/architect_mission.rb +436 -0
- data/lib/space_architect/atomic_write.rb +21 -0
- data/lib/space_architect/cli/architect.rb +388 -0
- data/lib/space_architect/cli/config.rb +61 -0
- data/lib/space_architect/cli/current.rb +22 -0
- data/lib/space_architect/cli/helpers.rb +117 -0
- data/lib/space_architect/cli/init.rb +35 -0
- data/lib/space_architect/cli/list.rb +30 -0
- data/lib/space_architect/cli/new.rb +43 -0
- data/lib/space_architect/cli/options.rb +12 -0
- data/lib/space_architect/cli/path.rb +22 -0
- data/lib/space_architect/cli/repo.rb +88 -0
- data/lib/space_architect/cli/shell.rb +137 -0
- data/lib/space_architect/cli/show.rb +27 -0
- data/lib/space_architect/cli/space.rb +35 -0
- data/lib/space_architect/cli/src.rb +32 -0
- data/lib/space_architect/cli/status.rb +39 -0
- data/lib/space_architect/cli/use.rb +23 -0
- data/lib/space_architect/cli.rb +102 -0
- data/lib/space_architect/config.rb +152 -0
- data/lib/space_architect/dispatcher.rb +21 -0
- data/lib/space_architect/errors.rb +14 -0
- data/lib/space_architect/git_client.rb +49 -0
- data/lib/space_architect/harness.rb +168 -0
- data/lib/space_architect/mise_client.rb +37 -0
- data/lib/space_architect/repo_reference.rb +19 -0
- data/lib/space_architect/repo_resolver.rb +167 -0
- data/lib/space_architect/shell_integration.rb +438 -0
- data/lib/space_architect/slugger.rb +16 -0
- data/lib/space_architect/space.rb +110 -0
- data/lib/space_architect/space_store.rb +319 -0
- data/lib/space_architect/state.rb +86 -0
- data/lib/space_architect/templates/architect.md.erb +48 -0
- data/lib/space_architect/templates/iteration.md.erb +66 -0
- data/lib/space_architect/terminal.rb +163 -0
- data/lib/space_architect/version.rb +5 -0
- data/lib/space_architect/warnings.rb +13 -0
- data/lib/space_architect/xdg.rb +33 -0
- data/lib/space_architect.rb +26 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
- data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
- data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
- data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
- data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
- data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
- metadata +307 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/semaphore"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "time"
|
|
8
|
+
require "dry/monads"
|
|
9
|
+
require "space_architect/pristine/scm/git"
|
|
10
|
+
require "space_architect/pristine/cloner"
|
|
11
|
+
|
|
12
|
+
module SpaceArchitect
|
|
13
|
+
class SpaceStore
|
|
14
|
+
include Dry::Monads[:result, :maybe]
|
|
15
|
+
|
|
16
|
+
MAX_CONCURRENT_CLONES = 5
|
|
17
|
+
|
|
18
|
+
attr_reader :config, :state, :now
|
|
19
|
+
|
|
20
|
+
def initialize(config:, state:, now: -> { Time.now })
|
|
21
|
+
@config = config
|
|
22
|
+
@state = state
|
|
23
|
+
@now = now
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def spaces_dir
|
|
27
|
+
config.spaces_dir
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create(title, git: true, git_client: GitClient.new)
|
|
31
|
+
FileUtils.mkdir_p(spaces_dir)
|
|
32
|
+
timestamp = now.call
|
|
33
|
+
id = unique_id("#{timestamp.strftime('%Y%m%d')}-#{Slugger.slug(title)}")
|
|
34
|
+
path = spaces_dir.join(id)
|
|
35
|
+
|
|
36
|
+
FileUtils.mkdir_p(path.join("repos"))
|
|
37
|
+
FileUtils.mkdir_p(path.join("notes"))
|
|
38
|
+
FileUtils.mkdir_p(path.join("architecture"))
|
|
39
|
+
FileUtils.mkdir_p(path.join("tmp"))
|
|
40
|
+
FileUtils.mkdir_p(path.join("build"))
|
|
41
|
+
File.write(path.join("build", ".keep"), "")
|
|
42
|
+
|
|
43
|
+
space = Space.new(path, metadata_for(id:, title:, timestamp:))
|
|
44
|
+
space.save
|
|
45
|
+
write_readme(path:, title:, id:, timestamp:)
|
|
46
|
+
init_git(path:, id:, git_client:) if git
|
|
47
|
+
state.touch_recent(id)
|
|
48
|
+
Success(space)
|
|
49
|
+
rescue SpaceArchitect::Error => e
|
|
50
|
+
Failure(e)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def list
|
|
54
|
+
return [] unless spaces_dir.directory?
|
|
55
|
+
|
|
56
|
+
spaces_dir.children.select(&:directory?).filter_map do |child|
|
|
57
|
+
Space.load(child)
|
|
58
|
+
rescue NotFoundError, Error
|
|
59
|
+
nil
|
|
60
|
+
end.sort_by(&:id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find(identifier = nil, from: Dir.pwd)
|
|
64
|
+
value = identifier.to_s.strip
|
|
65
|
+
return current(from:) if value.empty?
|
|
66
|
+
|
|
67
|
+
if looks_like_path?(value)
|
|
68
|
+
begin
|
|
69
|
+
return Success(Space.load(File.expand_path(value)))
|
|
70
|
+
rescue SpaceArchitect::Error => e
|
|
71
|
+
return Failure(e)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
matches = matching_spaces(value)
|
|
76
|
+
return Success(matches.first) if matches.length == 1
|
|
77
|
+
|
|
78
|
+
if matches.empty?
|
|
79
|
+
return Failure(NotFoundError.new("Could not find space matching '#{value}' in #{spaces_dir}"))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Failure(AmbiguousSpaceError.new("Space '#{value}' is ambiguous: #{matches.map(&:id).join(', ')}"))
|
|
83
|
+
rescue SpaceArchitect::Error => e
|
|
84
|
+
Failure(e)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def current(from: Dir.pwd)
|
|
88
|
+
current_from_pwd(from:).to_result(CurrentSpaceMissingError.new("No current space found from #{from}. Run this inside a space or pass a space id."))
|
|
89
|
+
rescue SpaceArchitect::Error => e
|
|
90
|
+
Failure(e)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def current_from_pwd(from: Dir.pwd)
|
|
94
|
+
path = Pathname.new(File.expand_path(from.to_s))
|
|
95
|
+
path = path.dirname if path.file?
|
|
96
|
+
|
|
97
|
+
loop do
|
|
98
|
+
return Some(Space.load(path)) if path.join(Space::METADATA_FILE).exist?
|
|
99
|
+
break if path.root?
|
|
100
|
+
|
|
101
|
+
path = path.parent
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
None()
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def path_for(identifier = nil)
|
|
108
|
+
find(identifier).fmap(&:path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def use(identifier)
|
|
112
|
+
find(identifier).fmap { |space| state.touch_recent(space.id); space }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def add_repo(spec, from: Dir.pwd, scm: Pristine::SCM::Git.new, cloner: nil, mise_client: MiseClient.new)
|
|
116
|
+
add_repos([spec], from:, scm:, cloner:, mise_client:).fmap(&:first)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def add_repos(specs, from: Dir.pwd, scm: Pristine::SCM::Git.new, cloner: nil, mise_client: MiseClient.new, reporter: nil)
|
|
120
|
+
current(from:).bind { |space| add_repos_to(space, specs, scm:, cloner:, mise_client:, reporter:) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_repos_to(space, specs, scm: Pristine::SCM::Git.new, cloner: nil, mise_client: MiseClient.new, reporter: nil)
|
|
124
|
+
additions = prepare_repo_additions(space, specs)
|
|
125
|
+
first_error = nil
|
|
126
|
+
|
|
127
|
+
Async do |task|
|
|
128
|
+
semaphore = Async::Semaphore.new(MAX_CONCURRENT_CLONES, parent: task)
|
|
129
|
+
|
|
130
|
+
clone_tasks = additions.map do |addition|
|
|
131
|
+
semaphore.async(finished: false) do
|
|
132
|
+
clone_addition(addition, scm:, cloner:, mise_client:, reporter:)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Collect results without raising inside the reactor so the outer task
|
|
137
|
+
# succeeds and async does not log "Task may have ended" for our errors.
|
|
138
|
+
clone_tasks.each do |ct|
|
|
139
|
+
ct.wait
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
first_error ||= e
|
|
142
|
+
end
|
|
143
|
+
end.wait
|
|
144
|
+
|
|
145
|
+
return Failure(first_error) if first_error
|
|
146
|
+
|
|
147
|
+
Success(additions.map do |addition|
|
|
148
|
+
repo_data = space.add_repo(addition.fetch(:reference), relative_path: addition.fetch(:relative_path), now: now.call)
|
|
149
|
+
{ space: space, repo: repo_data, reference: addition.fetch(:reference), path: addition.fetch(:path) }
|
|
150
|
+
end)
|
|
151
|
+
rescue SpaceArchitect::Error => e
|
|
152
|
+
Failure(e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def repos(from: Dir.pwd)
|
|
156
|
+
current(from:).fmap(&:repos)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def clone_addition(addition, scm:, cloner:, mise_client:, reporter: nil)
|
|
162
|
+
reporter&.start(addition)
|
|
163
|
+
fetch_addition(addition, scm:, cloner:)
|
|
164
|
+
reporter&.trust(addition)
|
|
165
|
+
mise_client.trust(addition.fetch(:path))
|
|
166
|
+
reporter&.finish(addition)
|
|
167
|
+
addition
|
|
168
|
+
rescue StandardError
|
|
169
|
+
reporter&.fail(addition)
|
|
170
|
+
raise
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Prefer a fast local src copy; fall back to a network clone only when no
|
|
174
|
+
# src copy is available.
|
|
175
|
+
def fetch_addition(addition, scm:, cloner:)
|
|
176
|
+
reference = addition.fetch(:reference)
|
|
177
|
+
destination = addition.fetch(:path)
|
|
178
|
+
source = addition.fetch(:src_source)
|
|
179
|
+
|
|
180
|
+
if source&.directory?
|
|
181
|
+
actual_cloner = cloner || Pristine::Cloner.new(base_dir: config.src_dir)
|
|
182
|
+
result = actual_cloner.call(name: reference.full_name, into: destination.dirname.to_s)
|
|
183
|
+
raise GitError, "clone failed (copy): #{result.failure}" if result.failure?
|
|
184
|
+
else
|
|
185
|
+
result = scm.clone(reference.clone_url, destination.to_s)
|
|
186
|
+
raise GitError, "clone failed: #{result.failure[:stderr]}" if result.failure?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def prepare_repo_additions(space, specs)
|
|
191
|
+
src_dir = config.src_dir
|
|
192
|
+
additions = specs.map do |spec|
|
|
193
|
+
reference = RepoResolver.new(config).resolve(spec)
|
|
194
|
+
relative_path = Pathname.new("repos").join(reference.directory_name)
|
|
195
|
+
destination = space.path.join(relative_path)
|
|
196
|
+
|
|
197
|
+
ensure_repo_can_be_added!(space, reference, relative_path, destination)
|
|
198
|
+
|
|
199
|
+
{
|
|
200
|
+
reference: reference,
|
|
201
|
+
relative_path: relative_path,
|
|
202
|
+
path: destination,
|
|
203
|
+
src_source: src_dir && reference.src_path(src_dir)
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
duplicate_paths = additions
|
|
208
|
+
.map { |addition| addition.fetch(:path).to_s }
|
|
209
|
+
.tally
|
|
210
|
+
.select { |_path, count| count > 1 }
|
|
211
|
+
.keys
|
|
212
|
+
unless duplicate_paths.empty?
|
|
213
|
+
raise RepoExistsError, "Multiple repos resolve to the same destination: #{duplicate_paths.join(', ')}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
additions
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def ensure_repo_can_be_added!(space, reference, relative_path, destination)
|
|
220
|
+
raise RepoExistsError, "Repo destination already exists: #{destination}" if destination.exist?
|
|
221
|
+
|
|
222
|
+
existing = space.repos.find do |repo|
|
|
223
|
+
repo["full_name"] == reference.full_name ||
|
|
224
|
+
repo["path"] == relative_path.to_s ||
|
|
225
|
+
repo["name"] == reference.name
|
|
226
|
+
end
|
|
227
|
+
return unless existing
|
|
228
|
+
|
|
229
|
+
raise RepoExistsError, "Repo '#{reference.full_name}' already exists in #{space.id}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def metadata_for(id:, title:, timestamp:)
|
|
233
|
+
iso_timestamp = timestamp.iso8601
|
|
234
|
+
{
|
|
235
|
+
"version" => 1,
|
|
236
|
+
"id" => id,
|
|
237
|
+
"title" => title,
|
|
238
|
+
"status" => "active",
|
|
239
|
+
"created_at" => iso_timestamp,
|
|
240
|
+
"updated_at" => iso_timestamp,
|
|
241
|
+
"repos" => [],
|
|
242
|
+
"notes" => [],
|
|
243
|
+
"tickets" => [],
|
|
244
|
+
"tags" => []
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def write_readme(path:, title:, id:, timestamp:)
|
|
249
|
+
AtomicWrite.write(path.join("README.md"), <<~README)
|
|
250
|
+
# #{title}
|
|
251
|
+
|
|
252
|
+
Space: `#{id}`
|
|
253
|
+
Created: #{timestamp.iso8601}
|
|
254
|
+
|
|
255
|
+
## Organization
|
|
256
|
+
|
|
257
|
+
- `space.yaml` tracks the space identity, status, and associated metadata.
|
|
258
|
+
- `repos/` contains cloned Git repositories for this work.
|
|
259
|
+
- `notes/` is for task notes, scratch docs, and thinking-in-progress.
|
|
260
|
+
- `architecture/` holds the architect mission memory (ARCHITECT.md and the per-iteration files).
|
|
261
|
+
- `tmp/` is the workspace-local scratch directory. Use it instead of `/tmp` or
|
|
262
|
+
`/var/tmp`; when using `mktemp`, use `tmp/` as the base directory.
|
|
263
|
+
- `build/` holds the architect loop's per-lane worktrees and scratch
|
|
264
|
+
(gitignored except `.keep`).
|
|
265
|
+
- The space is a Git repository so notes and architecture are versioned.
|
|
266
|
+
`repos/`, `tmp/`, and `build/` are gitignored, keeping the cloned repos and scratch
|
|
267
|
+
out of the space's history (each clone keeps its own Git repo).
|
|
268
|
+
README
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Make the space itself a Git repo so its notes/architecture are versioned.
|
|
272
|
+
# `repos/` and `tmp/` are ignored: the clones keep their own `.git`, and a
|
|
273
|
+
# space-level `git add` must never pull them in as embedded-repo gitlinks.
|
|
274
|
+
def init_git(path:, id:, git_client:)
|
|
275
|
+
write_gitignore(path)
|
|
276
|
+
git_client.init(path)
|
|
277
|
+
git_client.commit_all(path, "Initialize space #{id}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def write_gitignore(path)
|
|
281
|
+
AtomicWrite.write(path.join(".gitignore"), <<~GITIGNORE)
|
|
282
|
+
repos/
|
|
283
|
+
tmp/
|
|
284
|
+
build/
|
|
285
|
+
!build/.keep
|
|
286
|
+
GITIGNORE
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def unique_id(base_id)
|
|
290
|
+
candidate = base_id
|
|
291
|
+
counter = 2
|
|
292
|
+
|
|
293
|
+
while spaces_dir.join(candidate).exist?
|
|
294
|
+
candidate = "#{base_id}-#{counter}"
|
|
295
|
+
counter += 1
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
candidate
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def looks_like_path?(value)
|
|
302
|
+
value.include?(File::SEPARATOR) || value.start_with?("~") || value.start_with?(".")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def matching_spaces(value)
|
|
306
|
+
all = list
|
|
307
|
+
exact = all.select { |space| space.id == value }
|
|
308
|
+
return exact unless exact.empty?
|
|
309
|
+
|
|
310
|
+
suffix = all.select { |space| space.id.end_with?("-#{value}") }
|
|
311
|
+
return suffix unless suffix.empty?
|
|
312
|
+
|
|
313
|
+
prefix = all.select { |space| space.id.start_with?(value) }
|
|
314
|
+
return prefix unless prefix.empty?
|
|
315
|
+
|
|
316
|
+
all.select { |space| space.id.include?(value) }
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module SpaceArchitect
|
|
7
|
+
class State
|
|
8
|
+
DEFAULT_DATA = {
|
|
9
|
+
"version" => 1,
|
|
10
|
+
"current_space" => nil,
|
|
11
|
+
"recent" => []
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :path, :data, :env
|
|
15
|
+
|
|
16
|
+
def self.default_path(env: ENV)
|
|
17
|
+
XDG.state_home(env: env).join("space-architect", "state.yml")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.load(env: ENV, path: default_path(env: env))
|
|
21
|
+
new(env:, path:).load
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(env: ENV, path: self.class.default_path(env: env), data: nil)
|
|
25
|
+
@path = Pathname.new(path)
|
|
26
|
+
@env = env
|
|
27
|
+
@data = data ? default_data.merge(stringify_keys(data)) : default_data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def load
|
|
31
|
+
@data = if path.exist?
|
|
32
|
+
parsed = YAML.safe_load(path.read, aliases: false) || {}
|
|
33
|
+
unless parsed.is_a?(Hash)
|
|
34
|
+
raise Error, "State file must contain a YAML mapping: #{path}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
default_data.merge(stringify_keys(parsed))
|
|
38
|
+
else
|
|
39
|
+
default_data
|
|
40
|
+
end
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ensure_exists!
|
|
45
|
+
save unless path.exist?
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def save
|
|
50
|
+
AtomicWrite.write(path, YAML.dump(data))
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def current_space
|
|
55
|
+
data["current_space"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def current_space=(space_id)
|
|
59
|
+
data["current_space"] = space_id
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def recent
|
|
63
|
+
Array(data["recent"])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def touch_current(space_id)
|
|
67
|
+
self.current_space = space_id
|
|
68
|
+
touch_recent(space_id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def touch_recent(space_id)
|
|
72
|
+
data["recent"] = ([space_id] + recent).compact.uniq.first(20)
|
|
73
|
+
save
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def default_data
|
|
79
|
+
DEFAULT_DATA.merge("recent" => [])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stringify_keys(hash)
|
|
83
|
+
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ARCHITECT — <%= @_title %>
|
|
2
|
+
|
|
3
|
+
> Cross-iteration table of contents for the Architect Loop. Per-iteration detail lives in
|
|
4
|
+
> architecture/I<NN>-<iteration>.md — this file only indexes the iterations and carries
|
|
5
|
+
> mission-wide state. Keep it short (~150 lines): the next session must grok it
|
|
6
|
+
> in under a minute. Not in the committed architecture = didn't happen.
|
|
7
|
+
|
|
8
|
+
## TL;DR (keep current)
|
|
9
|
+
|
|
10
|
+
- Goal: _[one sentence]_
|
|
11
|
+
- Last iteration: _[I<NN>-name — CONTINUE / KILL / awaiting verdict]_
|
|
12
|
+
- Next action: _[exact command or decision needed]_
|
|
13
|
+
|
|
14
|
+
## Repos in scope
|
|
15
|
+
|
|
16
|
+
| Repo | Path |
|
|
17
|
+
|------|------|
|
|
18
|
+
<% @_repos.each do |repo| -%>
|
|
19
|
+
| <%= repo["full_name"] || repo["name"] %> | <%= repo["path"] || "" %> |
|
|
20
|
+
<% end -%>
|
|
21
|
+
<% if @_repos.empty? -%>
|
|
22
|
+
| _(none yet — add repos with `space repo add`)_ | |
|
|
23
|
+
<% end -%>
|
|
24
|
+
|
|
25
|
+
## Verification gate (exact commands, per repo)
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
[install / test / lint / typecheck / build commands for each repo in scope]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Iteration index
|
|
32
|
+
|
|
33
|
+
<!-- Add a row per iteration. Create iterations with `architect new <name>`. -->
|
|
34
|
+
|
|
35
|
+
| I# | Iteration | Status | freeze_sha | Integration branch | Verdict | File |
|
|
36
|
+
|----|-----------|--------|-----------|--------------------|---------|------|
|
|
37
|
+
|
|
38
|
+
Status values: speccing → frozen → dispatched → in-flight → awaiting-verdict → done.
|
|
39
|
+
|
|
40
|
+
## Open items for the human / architect
|
|
41
|
+
|
|
42
|
+
<!-- Blocking items: unresolved disagreements (which iteration), scope questions,
|
|
43
|
+
stop-condition checkpoints. Detail lives in the iteration file; link it. -->
|
|
44
|
+
|
|
45
|
+
## Decisions log (architect + human)
|
|
46
|
+
|
|
47
|
+
| Date | Decision | Why |
|
|
48
|
+
|------|----------|-----|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# <%= @_ordinal %>: <%= @_name %>
|
|
2
|
+
|
|
3
|
+
> One self-contained iteration of the Architect Loop. Grown section by section, one
|
|
4
|
+
> commit per section. The builder NEVER edits this file — the architect writes
|
|
5
|
+
> every section, and transcribes the Builder Report verbatim from the builder's
|
|
6
|
+
> scratch report in build/. Frozen sections (Grounds, Specification, Acceptance Criteria)
|
|
7
|
+
> are read-only after the freeze commit; only Builder Prompt, Builder Report, and
|
|
8
|
+
> Verdict are appended afterward.
|
|
9
|
+
|
|
10
|
+
## Grounds
|
|
11
|
+
|
|
12
|
+
<!-- WHY. Research/PRD distilled: problem, decision + why, requirements,
|
|
13
|
+
non-goals, verified facts WITH citation URLs, open questions. Optional — delete
|
|
14
|
+
this section for iterations that needed no research. Commit: "<%= @_ordinal %>: grounds". -->
|
|
15
|
+
|
|
16
|
+
## Specification
|
|
17
|
+
|
|
18
|
+
<!-- WHAT / HOW — the full, self-contained delegation contract.
|
|
19
|
+
Commit: "<%= @_ordinal %>: specification". -->
|
|
20
|
+
|
|
21
|
+
- **Objective** — what to build and why (cite Grounds if present).
|
|
22
|
+
- **Output format** — raw tables, numbers, commit SHAs, test output paths.
|
|
23
|
+
- **Tool guidance** — exact verification commands for the target repo; the
|
|
24
|
+
APIs/formats/versions to verify against live dependencies before writing code.
|
|
25
|
+
- **Boundaries** — may-touch / must-not-touch / out-of-scope; no placeholders;
|
|
26
|
+
no refactors beyond the task.
|
|
27
|
+
- **Lane plan** — 1–4 lanes, each declaring: target repo `repos/<repo>`,
|
|
28
|
+
file-touch set (overlap-checked), objective, output format, boundaries.
|
|
29
|
+
- **Effort** — `think hard` … `ultrathink` per lane, with one line of why.
|
|
30
|
+
|
|
31
|
+
## Acceptance Criteria
|
|
32
|
+
|
|
33
|
+
<!-- PROOF. Exact gate commands + thresholds. `architect freeze <%= @_name %>`
|
|
34
|
+
commits this file and records its SHA as freeze_sha. Read-only afterward — any
|
|
35
|
+
change to Grounds/Specification/Acceptance Criteria = automatic iteration FAIL. -->
|
|
36
|
+
|
|
37
|
+
| AC# | Command | Threshold |
|
|
38
|
+
|-----|---------|-----------|
|
|
39
|
+
| | | |
|
|
40
|
+
|
|
41
|
+
## Builder Prompt
|
|
42
|
+
|
|
43
|
+
<!-- The exact lane-prompt(s) dispatched, recorded as provenance. One ###
|
|
44
|
+
subsection per lane. A copy is written to build/<%= @_ordinal %>-<%= @_name %>-<lane>/prompt.md
|
|
45
|
+
for stdin dispatch. Commit: "<%= @_ordinal %>: dispatched". -->
|
|
46
|
+
|
|
47
|
+
## Builder Report
|
|
48
|
+
|
|
49
|
+
<!-- RAW EVIDENCE ONLY — tables, numbers, command output. The builder writes this
|
|
50
|
+
to build/<%= @_ordinal %>-<%= @_name %>-<lane>/report.md; the architect transcribes it here
|
|
51
|
+
VERBATIM (no interpretation). One ### subsection per lane; include the builder's
|
|
52
|
+
PHASE 0 disagreements and its STATUS line. Commit: "<%= @_ordinal %>: evidence". -->
|
|
53
|
+
|
|
54
|
+
## Verdict
|
|
55
|
+
|
|
56
|
+
<!-- ARCHITECT JUDGMENT, written in a LATER session than the dispatch.
|
|
57
|
+
Commit: "<%= @_ordinal %>: verdict". -->
|
|
58
|
+
|
|
59
|
+
- **Disagreement rulings** — each PHASE 0 disagreement: ACCEPT / REJECT / MODIFY + why.
|
|
60
|
+
- **Acceptance Criteria integrity** — frozen sections unchanged since freeze (`architect verify <%= @_name %>`)?
|
|
61
|
+
|
|
62
|
+
| AC# | Raw result | Verdict |
|
|
63
|
+
|-----|------------|---------|
|
|
64
|
+
| | | PASS/FAIL/INVALID |
|
|
65
|
+
|
|
66
|
+
- **Iteration** — KILL / CONTINUE + the single decisive reason.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "pastel"
|
|
5
|
+
|
|
6
|
+
module SpaceArchitect
|
|
7
|
+
class Terminal
|
|
8
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :stdout, :stderr, :config
|
|
11
|
+
|
|
12
|
+
def initialize(config:, stdout: $stdout, stderr: $stderr, color_mode: "auto")
|
|
13
|
+
@config = config
|
|
14
|
+
@stdout = stdout
|
|
15
|
+
@stderr = stderr
|
|
16
|
+
@color_mode = color_mode.to_s.downcase
|
|
17
|
+
color_mode
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def interactive?
|
|
21
|
+
stderr.tty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def pastel
|
|
25
|
+
@pastel ||= Pastel.new(enabled: colors_enabled?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def say(message = "")
|
|
29
|
+
stdout.puts(message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def error(message)
|
|
33
|
+
stderr.puts(colors_enabled? ? pastel.red(message) : message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def success(message)
|
|
37
|
+
say pastel.green(message)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def table(headers, rows)
|
|
41
|
+
column_widths = headers.each_index.map do |index|
|
|
42
|
+
([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
([headers] + rows).each_with_index.map do |row, row_index|
|
|
46
|
+
table_row(headers, row, column_widths, header: row_index.zero?)
|
|
47
|
+
end.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def path(path)
|
|
51
|
+
value = path.to_s
|
|
52
|
+
homes.each do |home|
|
|
53
|
+
return "~" if value == home
|
|
54
|
+
return "~#{value.delete_prefix(home)}" if value.start_with?("#{home}/")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def with_spinner(message)
|
|
61
|
+
return yield unless interactive?
|
|
62
|
+
|
|
63
|
+
Async do |task|
|
|
64
|
+
spinner_task = start_spinner(task, message)
|
|
65
|
+
yield
|
|
66
|
+
ensure
|
|
67
|
+
spinner_task&.stop
|
|
68
|
+
begin
|
|
69
|
+
spinner_task&.wait
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
clear_spinner
|
|
74
|
+
end.wait
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def color_mode
|
|
80
|
+
return @color_mode if %w[auto always never].include?(@color_mode)
|
|
81
|
+
|
|
82
|
+
raise Error, "Invalid color mode '#{@color_mode}'. Expected one of: auto, always, never"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def colors_enabled?
|
|
86
|
+
case color_mode
|
|
87
|
+
when "always"
|
|
88
|
+
true
|
|
89
|
+
when "never"
|
|
90
|
+
false
|
|
91
|
+
else
|
|
92
|
+
stdout.tty?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def homes
|
|
97
|
+
home = XDG.home(env: config.env)
|
|
98
|
+
[home, realpath_or_nil(home)].compact.uniq
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def realpath_or_nil(path)
|
|
102
|
+
File.realpath(path)
|
|
103
|
+
rescue SystemCallError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def table_row(headers, row, column_widths, header: false)
|
|
108
|
+
row.each_with_index.map do |cell, index|
|
|
109
|
+
raw = cell.to_s
|
|
110
|
+
styled = header ? pastel.bold(raw) : style_table_cell(headers[index], raw)
|
|
111
|
+
"#{styled}#{' ' * (column_widths[index] - raw.length)}"
|
|
112
|
+
end.join(" ").rstrip
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def style_table_cell(header, value)
|
|
116
|
+
case header
|
|
117
|
+
when "Status"
|
|
118
|
+
style_status(value)
|
|
119
|
+
when "Date"
|
|
120
|
+
pastel.dim(value)
|
|
121
|
+
when "Path"
|
|
122
|
+
pastel.cyan(value)
|
|
123
|
+
else
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def style_status(status)
|
|
129
|
+
case status
|
|
130
|
+
when "active"
|
|
131
|
+
pastel.green(status)
|
|
132
|
+
when "paused"
|
|
133
|
+
pastel.yellow(status)
|
|
134
|
+
when "done"
|
|
135
|
+
pastel.blue(status)
|
|
136
|
+
when "archived"
|
|
137
|
+
pastel.bright_black(status)
|
|
138
|
+
else
|
|
139
|
+
status
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def start_spinner(task, message)
|
|
144
|
+
task.async do |spinner|
|
|
145
|
+
frame_index = 0
|
|
146
|
+
|
|
147
|
+
loop do
|
|
148
|
+
text = message.respond_to?(:call) ? message.call : message.to_s
|
|
149
|
+
frame = SPINNER_FRAMES[frame_index % SPINNER_FRAMES.length]
|
|
150
|
+
stderr.print "\r\e[2K#{pastel.cyan(frame)} #{text}"
|
|
151
|
+
stderr.flush
|
|
152
|
+
frame_index += 1
|
|
153
|
+
spinner.sleep(0.1)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def clear_spinner
|
|
159
|
+
stderr.print "\r\e[2K"
|
|
160
|
+
stderr.flush
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|