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,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
|