space-architect 8.0.0 → 9.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +39 -464
  4. data/lib/space_architect/architect_project.rb +90 -39
  5. data/lib/space_architect/bug_report.rb +2 -1
  6. data/lib/space_architect/cli/architect.rb +189 -11
  7. data/lib/space_architect/cli/research.rb +1 -1
  8. data/lib/space_architect/cli.rb +9 -14
  9. data/lib/space_architect/loop_cost.rb +592 -0
  10. data/lib/space_architect/research/supervisor.rb +9 -2
  11. data/lib/space_architect/routing.rb +149 -0
  12. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  13. data/lib/space_architect/session_sync/cursor.rb +1 -1
  14. data/lib/space_architect/session_sync/plist.rb +1 -1
  15. data/lib/{space_core → space_architect}/version.rb +2 -2
  16. data/lib/space_architect.rb +2 -1
  17. data/skill/architect/SKILL.md +3 -2
  18. data/skill/architect-vocabulary/SKILL.md +2 -2
  19. metadata +20 -177
  20. data/exe/space +0 -13
  21. data/exe/src +0 -13
  22. data/lib/space_architect/cli/space.rb +0 -29
  23. data/lib/space_architect/cli/src.rb +0 -38
  24. data/lib/space_core/atomic_write.rb +0 -21
  25. data/lib/space_core/cli/base_command.rb +0 -19
  26. data/lib/space_core/cli/build.rb +0 -27
  27. data/lib/space_core/cli/config.rb +0 -49
  28. data/lib/space_core/cli/current.rb +0 -16
  29. data/lib/space_core/cli/help.rb +0 -195
  30. data/lib/space_core/cli/helpers.rb +0 -115
  31. data/lib/space_core/cli/init.rb +0 -29
  32. data/lib/space_core/cli/list.rb +0 -24
  33. data/lib/space_core/cli/loop_status.rb +0 -47
  34. data/lib/space_core/cli/new.rb +0 -38
  35. data/lib/space_core/cli/pack.rb +0 -29
  36. data/lib/space_core/cli/path.rb +0 -16
  37. data/lib/space_core/cli/repeatable_options.rb +0 -84
  38. data/lib/space_core/cli/repo.rb +0 -76
  39. data/lib/space_core/cli/run.rb +0 -44
  40. data/lib/space_core/cli/shell.rb +0 -125
  41. data/lib/space_core/cli/show.rb +0 -21
  42. data/lib/space_core/cli/status.rb +0 -74
  43. data/lib/space_core/cli/use.rb +0 -17
  44. data/lib/space_core/cli.rb +0 -177
  45. data/lib/space_core/commands.rb +0 -51
  46. data/lib/space_core/config.rb +0 -152
  47. data/lib/space_core/errors.rb +0 -14
  48. data/lib/space_core/git_client.rb +0 -49
  49. data/lib/space_core/mise_client.rb +0 -37
  50. data/lib/space_core/oci_builder.rb +0 -56
  51. data/lib/space_core/oci_packer.rb +0 -103
  52. data/lib/space_core/oci_runner.rb +0 -89
  53. data/lib/space_core/paths.rb +0 -58
  54. data/lib/space_core/repo_reference.rb +0 -19
  55. data/lib/space_core/repo_resolver.rb +0 -167
  56. data/lib/space_core/shell_integration.rb +0 -438
  57. data/lib/space_core/slugger.rb +0 -16
  58. data/lib/space_core/space.rb +0 -174
  59. data/lib/space_core/space_store.rb +0 -319
  60. data/lib/space_core/state.rb +0 -86
  61. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  62. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  63. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  64. data/lib/space_core/terminal.rb +0 -161
  65. data/lib/space_core/warnings.rb +0 -13
  66. data/lib/space_core/xdg.rb +0 -33
  67. data/lib/space_core.rb +0 -26
  68. data/lib/space_src/cli/clone.rb +0 -54
  69. data/lib/space_src/cli/config.rb +0 -65
  70. data/lib/space_src/cli/daemon.rb +0 -362
  71. data/lib/space_src/cli/options.rb +0 -21
  72. data/lib/space_src/cli/org.rb +0 -199
  73. data/lib/space_src/cli/repo.rb +0 -169
  74. data/lib/space_src/cli/shell.rb +0 -121
  75. data/lib/space_src/cli/status.rb +0 -75
  76. data/lib/space_src/cli/sync.rb +0 -161
  77. data/lib/space_src/cli.rb +0 -159
  78. data/lib/space_src/cloner.rb +0 -77
  79. data/lib/space_src/config/contract.rb +0 -54
  80. data/lib/space_src/config/duration.rb +0 -79
  81. data/lib/space_src/config/model.rb +0 -49
  82. data/lib/space_src/config/store.rb +0 -156
  83. data/lib/space_src/forge/client.rb +0 -31
  84. data/lib/space_src/forge/github.rb +0 -98
  85. data/lib/space_src/launchd/agent.rb +0 -195
  86. data/lib/space_src/launchd/plist.rb +0 -129
  87. data/lib/space_src/log_rotator.rb +0 -46
  88. data/lib/space_src/migration.rb +0 -43
  89. data/lib/space_src/nav.rb +0 -99
  90. data/lib/space_src/paths.rb +0 -72
  91. data/lib/space_src/scm/client.rb +0 -87
  92. data/lib/space_src/scm/git.rb +0 -232
  93. data/lib/space_src/scm/status.rb +0 -24
  94. data/lib/space_src/shell.rb +0 -90
  95. data/lib/space_src/shell_integration.rb +0 -321
  96. data/lib/space_src/state/lock.rb +0 -59
  97. data/lib/space_src/state/store.rb +0 -140
  98. data/lib/space_src/sync/engine.rb +0 -503
  99. data/lib/space_src/sync/repo_plan.rb +0 -215
  100. data/lib/space_src/sync/report.rb +0 -18
  101. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  102. data/lib/space_src/ui/json_reporter.rb +0 -39
  103. data/lib/space_src/ui/mode.rb +0 -68
  104. data/lib/space_src/ui/plain_reporter.rb +0 -53
  105. data/lib/space_src/ui/reporter.rb +0 -48
  106. data/lib/space_src/version.rb +0 -7
  107. data/lib/space_src.rb +0 -38
@@ -1,174 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "pathname"
5
- require "time"
6
-
7
- module Space::Core
8
- class Space
9
- METADATA_FILE = "space.yaml"
10
- VALID_STATUSES = %w[active paused done archived].freeze
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
-
17
- attr_reader :path, :data
18
-
19
- def self.load(path)
20
- metadata_path = Pathname.new(path).join(METADATA_FILE)
21
- raise NotFoundError, "No space metadata found at #{metadata_path}" unless metadata_path.exist?
22
-
23
- parsed = YAML.safe_load(metadata_path.read, aliases: false) || {}
24
- raise Error, "Space metadata must contain a YAML mapping: #{metadata_path}" unless parsed.is_a?(Hash)
25
-
26
- data = stringify_keys(parsed)
27
- normalize_schema!(data, metadata_path)
28
- new(Pathname.new(path), data)
29
- end
30
-
31
- def self.stringify_keys(hash)
32
- hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
33
- end
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
-
78
- def initialize(path, data)
79
- @path = Pathname.new(path)
80
- @data = data
81
- end
82
-
83
- def id
84
- data.fetch("id")
85
- end
86
-
87
- def title
88
- data.fetch("title")
89
- end
90
-
91
- def status
92
- data.fetch("status", "active")
93
- end
94
-
95
- def repos
96
- Array(data["repos"]).map do |repo|
97
- repo.is_a?(Hash) ? self.class.stringify_keys(repo) : { "name" => repo.to_s }
98
- end
99
- end
100
-
101
- def provision_scripts
102
- Array(data.dig("pack", "provision")).map(&:to_s)
103
- end
104
-
105
- def persist_paths
106
- Array(data.dig("pack", "persist")).map(&:to_s)
107
- end
108
-
109
- # Host env vars to forward into the container at `space run` (bare passthrough).
110
- # Declared per-space so payloads needing credentials run without a wall of flags.
111
- def run_env
112
- Array(data.dig("run", "env")).map(&:to_s)
113
- end
114
-
115
- def architect
116
- data["project"]
117
- end
118
-
119
- def architect=(val)
120
- data["project"] = val
121
- end
122
-
123
- def metadata_path
124
- path.join(METADATA_FILE)
125
- end
126
-
127
- def save
128
- AtomicWrite.write(metadata_path, YAML.dump(data))
129
- self
130
- end
131
-
132
- def update_status(status, now: Time.now)
133
- normalized = status.to_s.downcase
134
- unless VALID_STATUSES.include?(normalized)
135
- raise InvalidStatusError, "Invalid status '#{status}'. Expected one of: #{VALID_STATUSES.join(', ')}"
136
- end
137
-
138
- data["status"] = normalized
139
- data["updated_at"] = now.iso8601
140
- save
141
- end
142
-
143
- def add_repo(reference, relative_path:, now: Time.now)
144
- repo_data = repo_data_for(reference, relative_path:, now:)
145
- existing = repos.find do |repo|
146
- repo["full_name"] == repo_data["full_name"] ||
147
- repo["path"] == repo_data["path"] ||
148
- repo["name"] == repo_data["name"]
149
- end
150
- if existing
151
- raise RepoExistsError, "Repo '#{repo_data['full_name']}' already exists in #{id}"
152
- end
153
-
154
- data["repos"] = repos + [repo_data]
155
- data["updated_at"] = now.iso8601
156
- save
157
- repo_data
158
- end
159
-
160
- private
161
-
162
- def repo_data_for(reference, relative_path:, now:)
163
- {
164
- "provider" => reference.provider,
165
- "organization" => reference.owner,
166
- "name" => reference.name,
167
- "full_name" => reference.full_name,
168
- "clone_url" => reference.clone_url,
169
- "path" => relative_path.to_s,
170
- "added_at" => now.iso8601
171
- }
172
- end
173
- end
174
- end
@@ -1,319 +0,0 @@
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_src/scm/git"
10
- require "space_src/cloner"
11
-
12
- module Space::Core
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 Error => e
50
- Failure(e)
51
- end
52
-
53
- def list
54
- return [] unless spaces_dir.directory?
55
-
56
- Paths.layout_children(spaces_dir).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 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 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 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: ::Space::Src::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: ::Space::Src::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: ::Space::Src::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 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 || ::Space::Src::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" => 2,
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 project 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
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "pathname"
5
-
6
- module Space::Core
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
@@ -1,73 +0,0 @@
1
- # Generated by `space pack` for space: <%= space_id %>
2
- # Build (run from the space root):
3
- # docker build -f <output_dir>/Dockerfile -t <%= space_id %>:latest .
4
- #
5
- # Run (auth injected at runtime — never baked into layers):
6
- # container run --rm -it \
7
- # -e ANTHROPIC_API_KEY=<key> \
8
- <% persist_paths.each do |guest_path| -%>
9
- # -v .state<%= guest_path %>:<%= guest_path %> \
10
- <% end -%>
11
- # <%= space_id %>:latest
12
- <% if persist_paths.any? -%>
13
- # (Persist: host paths are relative to space root; create them before first run.)
14
- <% end -%>
15
- #
16
- # Available env vars at runtime:
17
- # ANTHROPIC_API_KEY — API key
18
- # CLAUDE_CODE_OAUTH_TOKEN — alternative to API key
19
- # ANTHROPIC_BASE_URL — optional proxy endpoint
20
-
21
- FROM ruby:4.0.5
22
-
23
- RUN apt-get update -qq \
24
- && apt-get install -y --no-install-recommends \
25
- git \
26
- curl \
27
- ca-certificates \
28
- && rm -rf /var/lib/apt/lists/*
29
-
30
- # Claude Code CLI (https://claude.ai/install.sh)
31
- RUN curl -fsSL https://claude.ai/install.sh | bash
32
-
33
- # Write entrypoint: sets git safe.directory, execs requested command or a login shell
34
- RUN echo '<%= entrypoint_b64 %>' | base64 -d > /entrypoint.sh \
35
- && chmod +x /entrypoint.sh
36
-
37
- # Prepend gem-exec and payload bin dirs so login shells (bash --login) resolve architect/space/claude
38
- RUN printf 'export PATH="/usr/local/bundle/bin:/root/.local/bin:$PATH"\n' \
39
- > /etc/profile.d/space-architect.sh
40
- <% if provision_scripts.any? -%>
41
-
42
- # Space-declared build-time provisioning (pack.provision)
43
- # Each script is COPY'd individually — a space-content edit elsewhere does not bust its cache layer.
44
- <% provision_scripts.each do |script| -%>
45
- COPY <%= script %> /space/<%= script %>
46
- RUN /space/<%= script %>
47
- <% end -%>
48
- <% end -%>
49
-
50
- # Install space-architect gem
51
- <% if local_space_architect? -%>
52
- COPY repos/space-architect /space/repos/space-architect
53
- RUN cd /space/repos/space-architect \
54
- && rake build \
55
- && gem install --no-document pkg/*.gem
56
- <% else -%>
57
- RUN gem install --no-document space-architect
58
- <% end -%>
59
-
60
- # Copy the space tree into the image (filtered by Dockerfile.dockerignore alongside this Dockerfile)
61
- COPY . /space
62
- <% if persist_paths.any? -%>
63
-
64
- # Seed snapshot: capture baked content of persisted dirs so a first-run (empty) mount is seeded from it
65
- <% persist_paths.each do |guest_path| -%>
66
- RUN mkdir -p "/opt/space-seed<%= guest_path %>" && cp -a "<%= guest_path %>/." "/opt/space-seed<%= guest_path %>/" 2>/dev/null || true
67
- <% end -%>
68
- <% end -%>
69
-
70
- WORKDIR /space
71
-
72
- ENTRYPOINT ["/entrypoint.sh"]
73
- CMD ["bash", "--login"]
@@ -1,17 +0,0 @@
1
- # Build scratch: worktrees, harness output, builder artefacts
2
- build/
3
-
4
- # Temp workspace
5
- tmp/
6
- notes/tmp/
7
-
8
- # Secrets — NEVER bake credentials in image layers
9
- .env
10
- **/.env
11
- *.key
12
- *.pem
13
- *.p12
14
- *.pfx
15
- id_rsa
16
- id_ecdsa
17
- id_ed25519
@@ -1,16 +0,0 @@
1
- #!/bin/bash
2
- set -e
3
- git config --global --add safe.directory '*'
4
- git config --global --get user.name >/dev/null 2>&1 || git config --global user.name 'space-architect'
5
- git config --global --get user.email >/dev/null 2>&1 || git config --global user.email 'architect@localhost'
6
- <% persist_paths.each do |guest_path| -%>
7
- if [ -d "/opt/space-seed<%= guest_path %>" ] && [ -z "$(ls -A "<%= guest_path %>" 2>/dev/null)" ]; then
8
- mkdir -p "<%= guest_path %>"
9
- cp -a "/opt/space-seed<%= guest_path %>/." "<%= guest_path %>/" 2>/dev/null || true
10
- fi
11
- <% end -%>
12
- if [ "$#" -eq 0 ]; then
13
- exec bash --login
14
- else
15
- exec "$@"
16
- fi