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.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +284 -0
  4. data/exe/architect +13 -0
  5. data/exe/space +13 -0
  6. data/lib/space_architect/architect_mission.rb +436 -0
  7. data/lib/space_architect/atomic_write.rb +21 -0
  8. data/lib/space_architect/cli/architect.rb +388 -0
  9. data/lib/space_architect/cli/config.rb +61 -0
  10. data/lib/space_architect/cli/current.rb +22 -0
  11. data/lib/space_architect/cli/helpers.rb +117 -0
  12. data/lib/space_architect/cli/init.rb +35 -0
  13. data/lib/space_architect/cli/list.rb +30 -0
  14. data/lib/space_architect/cli/new.rb +43 -0
  15. data/lib/space_architect/cli/options.rb +12 -0
  16. data/lib/space_architect/cli/path.rb +22 -0
  17. data/lib/space_architect/cli/repo.rb +88 -0
  18. data/lib/space_architect/cli/shell.rb +137 -0
  19. data/lib/space_architect/cli/show.rb +27 -0
  20. data/lib/space_architect/cli/space.rb +35 -0
  21. data/lib/space_architect/cli/src.rb +32 -0
  22. data/lib/space_architect/cli/status.rb +39 -0
  23. data/lib/space_architect/cli/use.rb +23 -0
  24. data/lib/space_architect/cli.rb +102 -0
  25. data/lib/space_architect/config.rb +152 -0
  26. data/lib/space_architect/dispatcher.rb +21 -0
  27. data/lib/space_architect/errors.rb +14 -0
  28. data/lib/space_architect/git_client.rb +49 -0
  29. data/lib/space_architect/harness.rb +168 -0
  30. data/lib/space_architect/mise_client.rb +37 -0
  31. data/lib/space_architect/repo_reference.rb +19 -0
  32. data/lib/space_architect/repo_resolver.rb +167 -0
  33. data/lib/space_architect/shell_integration.rb +438 -0
  34. data/lib/space_architect/slugger.rb +16 -0
  35. data/lib/space_architect/space.rb +110 -0
  36. data/lib/space_architect/space_store.rb +319 -0
  37. data/lib/space_architect/state.rb +86 -0
  38. data/lib/space_architect/templates/architect.md.erb +48 -0
  39. data/lib/space_architect/templates/iteration.md.erb +66 -0
  40. data/lib/space_architect/terminal.rb +163 -0
  41. data/lib/space_architect/version.rb +5 -0
  42. data/lib/space_architect/warnings.rb +13 -0
  43. data/lib/space_architect/xdg.rb +33 -0
  44. data/lib/space_architect.rb +26 -0
  45. data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
  46. data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
  47. data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
  48. data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
  49. data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
  50. data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
  51. data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
  52. data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
  53. data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
  54. data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
  55. data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
  56. data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
  57. data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
  58. data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
  59. data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
  60. data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
  61. data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
  62. data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
  63. data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
  64. data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
  65. data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
  66. data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
  67. data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
  68. data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
  69. data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
  70. data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
  71. data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
  72. data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
  73. data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
  74. data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
  75. data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
  76. data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
  77. data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
  78. data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
  79. data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
  80. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ VERSION = "1.1.0"
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect
4
+ module Warnings
5
+ module_function
6
+
7
+ def disable_experimental!
8
+ return unless Warning.respond_to?(:[]) && Warning.respond_to?(:[]=)
9
+
10
+ Warning[:experimental] = false
11
+ end
12
+ end
13
+ end