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,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "erb"
5
+ require "open3"
6
+ require "fileutils"
7
+ require "pathname"
8
+
9
+ module SpaceArchitect
10
+ # Manages an architect-loop mission inside a space: one self-contained file per
11
+ # iteration at architecture/I<NN>-<iteration>.md (Grounds / Specification / Acceptance Criteria / Builder
12
+ # Prompt / Builder Report / Verdict), grown one commit per section. The freeze
13
+ # is the commit that establishes the Acceptance Criteria; the frozen region (everything
14
+ # above "## Builder Prompt") is read-only afterward.
15
+ class ArchitectMission
16
+ # The heading that separates the frozen sections (Grounds/Specification/Acceptance Criteria)
17
+ # from the appended-after-freeze sections (Builder Prompt/Report/Verdict).
18
+ FROZEN_BOUNDARY = /^## Builder Prompt/
19
+
20
+ def initialize(space:)
21
+ @space = space
22
+ end
23
+
24
+ def init!
25
+ handoff_path = space.path.join("architecture", "ARCHITECT.md")
26
+ if handoff_path.exist?
27
+ raise Error, "architecture/ARCHITECT.md already exists — remove it first or edit it directly (idempotent guard)"
28
+ end
29
+
30
+ FileUtils.mkdir_p(handoff_path.dirname)
31
+ handoff_path.write(render_handoff)
32
+
33
+ update_architect_block do |b|
34
+ b.merge("status" => "active", "current_iteration" => nil, "iterations" => [])
35
+ end
36
+
37
+ git_run("-C", space.path.to_s, "add", "architecture/ARCHITECT.md", Space::METADATA_FILE)
38
+ git_run("-C", space.path.to_s, "commit", "-m", "Initialize architect mission")
39
+
40
+ handoff_path
41
+ end
42
+
43
+ # Allocate the next ordinal and scaffold architecture/I<NN>-<iteration>.md.
44
+ def new_iteration!(name)
45
+ block = space.data["architect"] || {}
46
+ iterations = block["iterations"] || []
47
+ if iterations.any? { |s| s["name"] == name }
48
+ raise Error, "iteration '#{name}' already exists in space.yaml"
49
+ end
50
+
51
+ ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
52
+ nn = format("%02d", ordinal)
53
+ rel = "architecture/I#{nn}-#{name}.md"
54
+ path = space.path.join(rel)
55
+ raise Error, "#{rel} already exists" if path.exist?
56
+
57
+ FileUtils.mkdir_p(path.dirname)
58
+ path.write(render_iteration(nn, name))
59
+
60
+ update_architect_block do |b|
61
+ b["current_iteration"] = name
62
+ list = b["iterations"] || []
63
+ list << {
64
+ "name" => name, "ordinal" => ordinal, "file" => rel,
65
+ "freeze_sha" => nil, "verdict" => "pending", "lanes" => []
66
+ }
67
+ b["iterations"] = list
68
+ b
69
+ end
70
+
71
+ git_run("-C", space.path.to_s, "add", rel, Space::METADATA_FILE)
72
+ git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: scaffold #{name}")
73
+
74
+ path
75
+ end
76
+
77
+ def status
78
+ block = space.data["architect"] || {}
79
+ architecture_dir = space.path.join("architecture")
80
+ iteration_files = if architecture_dir.exist?
81
+ architecture_dir.children
82
+ .select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
83
+ .map { |f| f.basename.to_s }.sort
84
+ else
85
+ []
86
+ end
87
+ { block: block, iteration_files: iteration_files }
88
+ end
89
+
90
+ # Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits
91
+ # any pending changes to the iteration file and records HEAD as freeze_sha. If
92
+ # already frozen, refuses when the frozen region has changed since.
93
+ def freeze!(iteration)
94
+ entry = slice_entry(iteration)
95
+ rel = entry["file"]
96
+ path = space.path.join(rel)
97
+ raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
98
+ unless path.read.match?(/^## Acceptance Criteria/)
99
+ raise Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing"
100
+ end
101
+
102
+ if entry["freeze_sha"]
103
+ sha = entry["freeze_sha"]
104
+ if frozen_region_changed?(sha, rel)
105
+ raise Error,
106
+ "Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
107
+ "refusing to re-freeze. Restore them to their frozen state or use a new iteration."
108
+ end
109
+ return sha
110
+ end
111
+
112
+ files = [rel]
113
+ files << "architecture/ARCHITECT.md" if space.path.join("architecture", "ARCHITECT.md").exist?
114
+ git_run("-C", space.path.to_s, "add", *files)
115
+ if staged_changes?
116
+ nn = format("%02d", entry["ordinal"] || 0)
117
+ git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: acceptance criteria (freeze)")
118
+ end
119
+
120
+ sha, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
121
+ sha = sha.strip
122
+
123
+ update_architect_block do |b|
124
+ b["current_iteration"] = iteration
125
+ (b["iterations"] || []).each do |s|
126
+ next unless s["name"] == iteration
127
+ s["freeze_sha"] = sha
128
+ s["verdict"] ||= "pending"
129
+ end
130
+ b
131
+ end
132
+
133
+ sha
134
+ end
135
+
136
+ def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil)
137
+ if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
138
+ raise Error,
139
+ "Pass --model when using --harness opencode " \
140
+ "(#{Harness::CLAUDE_DEFAULT_MODEL} is a Claude model ID, not valid for opencode — " \
141
+ "try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
142
+ end
143
+ if effort && harness.to_s != "opencode"
144
+ raise Error,
145
+ "effort is opencode-only (sets opencode reasoningEffort) — " \
146
+ "set effort only on opencode lanes (harness: opencode)"
147
+ end
148
+
149
+ entry = slice_entry(iteration)
150
+ repo_path = space.path.join("repos", repo)
151
+ raise Error, "repos/#{repo} does not exist" unless repo_path.exist?
152
+
153
+ id = iteration_id(entry)
154
+ wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
155
+ FileUtils.mkdir_p(wt_path.dirname)
156
+
157
+ base_ref = base || "HEAD"
158
+ base_sha, _, wt_status = git_capture("-C", repo_path.to_s, "rev-parse", base_ref)
159
+ raise Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success?
160
+ base_sha = base_sha.strip
161
+
162
+ branch = "lane/#{id}-#{lane}"
163
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
164
+
165
+ update_architect_block do |b|
166
+ (b["iterations"] || []).each do |s|
167
+ next unless s["name"] == iteration
168
+ lanes = s["lanes"] || []
169
+ lane_entry = {
170
+ "name" => lane,
171
+ "repo" => repo,
172
+ "base_sha" => base_sha,
173
+ "worktree" => "build/#{id}-#{lane}/wt",
174
+ "integration_branch" => nil,
175
+ "harness" => harness.to_s,
176
+ "model" => model,
177
+ "variant" => variant
178
+ }
179
+ lane_entry["effort"] = effort if effort
180
+ lanes << lane_entry
181
+ s["lanes"] = lanes
182
+ end
183
+ b
184
+ end
185
+
186
+ { worktree: wt_path, base_sha: base_sha }
187
+ end
188
+
189
+ # Declare a variant set for an iteration: one competing lane per (harness, model) pair,
190
+ # all sharing a byte-identical prompt. Returns descriptors for each created variant.
191
+ def variant_add(repo, iteration, pairs, base: nil, prompt: nil)
192
+ prompt_bytes = prompt ? File.binread(prompt) : nil
193
+ entry = slice_entry(iteration)
194
+ id = iteration_id(entry)
195
+ existing_count = (entry["lanes"] || []).count { |l| l["name"].match?(/\Av\d+\z/) }
196
+
197
+ pairs.each_with_index.map do |(harness, model), i|
198
+ v_name = "v#{format('%02d', existing_count + i + 1)}"
199
+ result = worktree_add(repo, iteration, v_name, base: base,
200
+ harness: harness, model: model, variant: true)
201
+
202
+ if prompt_bytes
203
+ build_dir = space.path.join("build", "#{id}-#{v_name}")
204
+ File.open(build_dir.join("prompt.md"), "wb") { |f| f.write(prompt_bytes) }
205
+ end
206
+
207
+ { name: v_name, repo: repo, harness: harness, model: model,
208
+ worktree: result[:worktree], base_sha: result[:base_sha] }
209
+ end
210
+ end
211
+
212
+ # Promote one variant of an iteration's variant set as the winner: records
213
+ # the decision durably onto the iteration entry (additive — no existing keys
214
+ # are removed or renamed). Re-promotable: a second call reassigns "winner"
215
+ # and recomputes every variant lane's "discarded" flag.
216
+ def variant_promote(iteration, winner)
217
+ entry = slice_entry(iteration)
218
+ variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
219
+ raise Error, "Iteration '#{iteration}' has no variant set — nothing to promote" if variant_lanes.empty?
220
+
221
+ names = variant_lanes.map { |l| l["name"] }
222
+ raise Error, "Cannot promote '#{winner}' — not a variant lane of iteration '#{iteration}'" unless names.include?(winner)
223
+ discarded_names = names - [winner]
224
+
225
+ update_architect_block do |b|
226
+ (b["iterations"] || []).each do |s|
227
+ next unless s["name"] == iteration
228
+ s["winner"] = winner
229
+ (s["lanes"] || []).each do |l|
230
+ next unless l["variant"]
231
+ l["discarded"] = (l["name"] != winner)
232
+ end
233
+ end
234
+ b
235
+ end
236
+
237
+ { winner: winner, discarded: discarded_names }
238
+ end
239
+
240
+ # Read-only side-by-side view of an iteration's variant set, reading ONLY the
241
+ # durable records in space.yaml. Returns a structured hash; the CLI renders it.
242
+ def variant_compare(iteration)
243
+ entry = slice_entry(iteration)
244
+ variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
245
+ raise Error, "Iteration '#{iteration}' has no variant set — nothing to compare" if variant_lanes.empty?
246
+
247
+ winner = entry["winner"]
248
+ {
249
+ winner: winner,
250
+ freeze_sha: entry["freeze_sha"],
251
+ variants: variant_lanes.map do |l|
252
+ {
253
+ name: l["name"],
254
+ harness: l["harness"] || "claude-code",
255
+ model: l["model"],
256
+ effort: l["effort"],
257
+ base_sha: l["base_sha"],
258
+ integration_branch: l["integration_branch"],
259
+ status: winner.nil? ? "pending" : (l["name"] == winner ? "winner" : "discarded")
260
+ }
261
+ end
262
+ }
263
+ end
264
+
265
+ def worktree_remove(iteration, lane)
266
+ entry = slice_entry(iteration)
267
+ lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
268
+ raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
269
+
270
+ repo = lane_entry["repo"]
271
+ repo_path = space.path.join("repos", repo)
272
+ wt_path = if lane_entry["worktree"]
273
+ space.path.join(lane_entry["worktree"])
274
+ else
275
+ space.path.join("build", "#{iteration_id(entry)}-#{lane}", "wt")
276
+ end
277
+
278
+ git_run("-C", repo_path.to_s, "worktree", "remove", "--force", wt_path.to_s)
279
+ git_run("-C", repo_path.to_s, "worktree", "prune")
280
+
281
+ update_architect_block do |b|
282
+ (b["iterations"] || []).each do |s|
283
+ next unless s["name"] == iteration
284
+ (s["lanes"] || []).each { |l| l["worktree"] = nil if l["name"] == lane }
285
+ end
286
+ b
287
+ end
288
+ end
289
+
290
+ def worktree_list
291
+ wt_base = space.path.join("build")
292
+ return [] unless wt_base.exist?
293
+ wt_base.children.select(&:directory?).map { |p| p.basename.to_s }.sort
294
+ end
295
+
296
+ def verify(iteration)
297
+ entry = slice_entry(iteration)
298
+ freeze_sha = entry["freeze_sha"]
299
+ rel = entry["file"]
300
+ lanes = entry["lanes"] || []
301
+
302
+ lanes.map do |lane|
303
+ lane_name = lane["name"]
304
+ base_sha = lane["base_sha"]
305
+ wt_path = space.path.join(lane["worktree"] || "build/#{iteration_id(entry)}-#{lane_name}/wt")
306
+ touch_set = lane["touch_set"] || []
307
+
308
+ checks = {}
309
+
310
+ # (a) frozen sections of the iteration file untouched since freeze
311
+ checks[:frozen_untouched] = if freeze_sha && rel
312
+ !frozen_region_changed?(freeze_sha, rel)
313
+ end
314
+
315
+ # (b) no builder commits in the worktree
316
+ log_out, = git_capture("-C", wt_path.to_s, "log", "#{base_sha}..")
317
+ checks[:no_builder_commits] = log_out.strip.empty?
318
+
319
+ # (c) builder's scratch report exists and is non-empty
320
+ report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
321
+ checks[:report_exists] = report.exist? && !report.read.strip.empty?
322
+
323
+ # (d) in-bounds: changed paths ⊆ touch_set (nil if no touch_set recorded)
324
+ checks[:in_bounds] = if touch_set.empty?
325
+ nil
326
+ else
327
+ status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
328
+ changed = status_out.lines.map { |l| l[3..].strip }
329
+ changed.all? { |f| touch_set.any? { |g| File.fnmatch(g, f) } }
330
+ end
331
+
332
+ { lane: lane_name, repo: lane["repo"], checks: checks }
333
+ end
334
+ end
335
+
336
+ def dispatch(iteration, lane, model: nil, max_turns: 200,
337
+ claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil)
338
+ entry = slice_entry(iteration)
339
+ lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
340
+ raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
341
+
342
+ resolved_harness = harness || lane_entry["harness"] || "claude-code"
343
+ resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
344
+ resolved_effort = effort || lane_entry["effort"]
345
+
346
+ id = iteration_id(entry)
347
+ wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
348
+ raise Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
349
+
350
+ build_dir = space.path.join("build", "#{id}-#{lane}")
351
+ prompt_path = build_dir.join("prompt.md")
352
+ run_log_path = build_dir.join("run.jsonl")
353
+ report_path = build_dir.join("report.md")
354
+ raise Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
355
+
356
+ bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
357
+ harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
358
+ bin: bin, config_dir: build_dir, effort: resolved_effort)
359
+
360
+ exit_code = harness_obj.run(
361
+ prompt_path: prompt_path,
362
+ run_log_path: run_log_path,
363
+ chdir: wt_path
364
+ )
365
+
366
+ { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
367
+ end
368
+
369
+ private
370
+
371
+ attr_reader :space
372
+
373
+ def iteration_id(entry)
374
+ "I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
375
+ end
376
+
377
+ def slice_entry(iteration)
378
+ block = space.data["architect"] || {}
379
+ entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
380
+ raise Error, "Iteration '#{iteration}' not recorded in space.yaml — run `architect new #{iteration}` first" unless entry
381
+ entry
382
+ end
383
+
384
+ # Everything above the "## Builder Prompt" heading is frozen at freeze time.
385
+ def frozen_region(text)
386
+ idx = text =~ FROZEN_BOUNDARY
387
+ idx ? text[0...idx] : text
388
+ end
389
+
390
+ def frozen_region_changed?(freeze_sha, rel)
391
+ old, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}")
392
+ return true unless st.success?
393
+ current = space.path.join(rel).read
394
+ frozen_region(old) != frozen_region(current)
395
+ end
396
+
397
+ def staged_changes?
398
+ _o, _e, st = git_capture("-C", space.path.to_s, "diff", "--cached", "--quiet")
399
+ !st.success? # --quiet exits non-zero when there are staged differences
400
+ end
401
+
402
+ def render_handoff
403
+ @_title = space.data["title"] || space.id
404
+ @_repos = space.repos
405
+ render_template("architect.md.erb")
406
+ end
407
+
408
+ def render_iteration(ordinal_nn, name)
409
+ @_ordinal = "I#{ordinal_nn}"
410
+ @_name = name
411
+ render_template("iteration.md.erb")
412
+ end
413
+
414
+ def render_template(filename)
415
+ template_path = Pathname.new(__dir__).join("templates", filename)
416
+ ERB.new(template_path.read, trim_mode: "-").result(binding)
417
+ end
418
+
419
+ def update_architect_block
420
+ block = space.data["architect"] || { "status" => "active", "current_iteration" => nil, "iterations" => [] }
421
+ space.data["architect"] = yield(block)
422
+ space.save
423
+ end
424
+
425
+ def git_run(*args)
426
+ out, err, status = Open3.capture3("git", *args)
427
+ return if status.success?
428
+ output = [out, err].map(&:strip).reject(&:empty?).join(" ")
429
+ raise Error, "git #{args.join(' ')} failed: #{output}"
430
+ end
431
+
432
+ def git_capture(*args)
433
+ Open3.capture3("git", *args)
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module SpaceArchitect
6
+ module AtomicWrite
7
+ module_function
8
+
9
+ def write(path, content)
10
+ path = path.to_s
11
+ dir = File.dirname(path)
12
+ FileUtils.mkdir_p(dir)
13
+ tmp_path = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
14
+
15
+ File.write(tmp_path, content)
16
+ File.rename(tmp_path, path)
17
+ ensure
18
+ FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path && File.exist?(tmp_path)
19
+ end
20
+ end
21
+ end