space-architect 1.1.0 โ 1.3.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 +4 -4
- data/README.md +9 -0
- data/lib/space_architect/architect_mission.rb +345 -36
- data/lib/space_architect/cli/architect.rb +214 -2
- data/lib/space_architect/skill_installer.rb +107 -0
- data/lib/space_architect/templates/architect.md.erb +4 -0
- data/lib/space_architect/templates/brief.md.erb +43 -0
- data/lib/space_architect/templates/iteration.md.erb +26 -18
- data/lib/space_architect/terminal.rb +15 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- data/skill/architect/SKILL.md +329 -0
- data/skill/architect/dispatch.md +308 -0
- data/skill/architect/research.md +89 -0
- data/skill/architect-research/SKILL.md +165 -0
- data/skill/architect-research/lanes.md +191 -0
- data/skill/architect-vocabulary/SKILL.md +141 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +1 -10
- data/vendor/repo-tender/lib/space_architect/pristine.rb +7 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db24aa20a68fdf981176f7e51a46794a4fc2ec5cef9765c33de5832f0a314a9e
|
|
4
|
+
data.tar.gz: 8912085239c76c1228e01d8986d789ba0fde42b4517459830c1f08cdb6e0fcfc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 06a4e3382403542421679272136d6c1f6a99ac5dece354b5175f5d895b1afb9cce902f9e11ea2cf64c940dd4a00f11d3dfa7390a0941f387ae4a28f47cdbc3fb
|
|
7
|
+
data.tar.gz: 73c71c080f640540c4cfb57f347f12eae5a2486c45a373a525c44dc35ea54edf7b43ee415d979352f6569406f0f261e8d4a601af869eae5bd9f9055df02d9807
|
data/README.md
CHANGED
|
@@ -69,6 +69,7 @@ architect space status done # mark the current mission comp
|
|
|
69
69
|
**Architect Loop (run from inside a space):**
|
|
70
70
|
|
|
71
71
|
```sh
|
|
72
|
+
architect install-skills # install skills for your harness (once per machine)
|
|
72
73
|
architect init # scaffold ARCHITECT.md + architecture/
|
|
73
74
|
architect new <iteration> # scaffold next iteration file
|
|
74
75
|
architect dispatch <iteration> <lane> # dispatch a builder for a lane
|
|
@@ -77,6 +78,14 @@ architect freeze <iteration> # freeze Acceptance Criteria
|
|
|
77
78
|
architect verify <iteration> # post-flight mechanical checks
|
|
78
79
|
```
|
|
79
80
|
|
|
81
|
+
`architect install-skills` installs the bundled `architect`, `architect-research`,
|
|
82
|
+
and `architect-vocabulary` skills for a harness. (`architect-vocabulary` loads the
|
|
83
|
+
system's terms and a short orientation when you're in a space but don't want to run
|
|
84
|
+
the loop โ see [The Architect Loop](#the-architect-loop-).) Default is `claude`
|
|
85
|
+
(`~/.claude/skills/`); use `--provider
|
|
86
|
+
opencode|codex|pi` for other harnesses, and `--project` to install into the current
|
|
87
|
+
directory instead of globally. See the [command reference](docs/reference.md) for details.
|
|
88
|
+
|
|
80
89
|
## Usage ๐ฐ๏ธ
|
|
81
90
|
|
|
82
91
|
```sh
|
|
@@ -17,6 +17,26 @@ module SpaceArchitect
|
|
|
17
17
|
# from the appended-after-freeze sections (Builder Prompt/Report/Verdict).
|
|
18
18
|
FROZEN_BOUNDARY = /^## Builder Prompt/
|
|
19
19
|
|
|
20
|
+
# Sections the architect writes (and the CLI commits) via `architect section`.
|
|
21
|
+
# Acceptance Criteria is intentionally absent โ it is set by `architect freeze`,
|
|
22
|
+
# the one code path that creates the freeze commit. Builder Report has its own
|
|
23
|
+
# command (`architect evidence`) because it is transcribed verbatim from scratch.
|
|
24
|
+
# `frozen: true` sections live above the freeze boundary and are refused once frozen.
|
|
25
|
+
SECTIONS = {
|
|
26
|
+
"grounds" => { heading: "## Grounds", message: "grounds", frozen: true },
|
|
27
|
+
"specification" => { heading: "## Specification", message: "specification", frozen: true },
|
|
28
|
+
"prompt" => { heading: "## Builder Prompt", message: "dispatched", frozen: false },
|
|
29
|
+
"verdict" => { heading: "## Verdict", message: "verdict", frozen: false }
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# The fixed top-level section headings. Section boundaries are detected against
|
|
33
|
+
# this set (not any "## " line), so a verbatim Builder Report containing its own
|
|
34
|
+
# "## " headings cannot fool the parser.
|
|
35
|
+
KNOWN_HEADINGS = [
|
|
36
|
+
"## Grounds", "## Specification", "## Acceptance Criteria",
|
|
37
|
+
"## Builder Prompt", "## Builder Report", "## Verdict"
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
20
40
|
def initialize(space:)
|
|
21
41
|
@space = space
|
|
22
42
|
end
|
|
@@ -133,7 +153,221 @@ module SpaceArchitect
|
|
|
133
153
|
sha
|
|
134
154
|
end
|
|
135
155
|
|
|
136
|
-
|
|
156
|
+
# Scaffold the durable, section-numbered mission brief at architecture/BRIEF.md
|
|
157
|
+
# and commit it. The brief is the stable cross-iteration address space iterations
|
|
158
|
+
# cite as "BRIEF ยงN"; it lives outside the per-iteration freeze region.
|
|
159
|
+
def brief_new!(force: false)
|
|
160
|
+
brief_path = space.path.join("architecture", "BRIEF.md")
|
|
161
|
+
if brief_path.exist? && !force
|
|
162
|
+
raise Error, "architecture/BRIEF.md already exists โ edit it directly (idempotent guard), or pass --force to overwrite"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
FileUtils.mkdir_p(brief_path.dirname)
|
|
166
|
+
brief_path.write(render_brief)
|
|
167
|
+
git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md")
|
|
168
|
+
git_run("-C", space.path.to_s, "commit", "-m", "Add mission brief") if staged_changes?
|
|
169
|
+
brief_path
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Write one section of the iteration file and commit it with the canonical
|
|
173
|
+
# per-section message, in one call. Refuses to write a frozen section
|
|
174
|
+
# (Grounds/Specification) once the iteration is frozen. Acceptance Criteria is
|
|
175
|
+
# NOT writable here (use freeze); Builder Report is not here (use evidence).
|
|
176
|
+
def write_section!(iteration, section, body:, append: false, lane: nil)
|
|
177
|
+
spec = SECTIONS[section]
|
|
178
|
+
unless spec
|
|
179
|
+
raise Error,
|
|
180
|
+
"Unknown section '#{section}' โ one of: #{SECTIONS.keys.join(', ')}. " \
|
|
181
|
+
"(Acceptance Criteria is set by `architect freeze`; Builder Report by `architect evidence`.)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
entry = slice_entry(iteration)
|
|
185
|
+
rel = entry["file"]
|
|
186
|
+
path = space.path.join(rel)
|
|
187
|
+
raise Error, "#{rel} does not exist โ run `architect new #{iteration}` first" unless path.exist?
|
|
188
|
+
|
|
189
|
+
if spec[:frozen] && entry["freeze_sha"]
|
|
190
|
+
raise Error,
|
|
191
|
+
"#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) โ " \
|
|
192
|
+
"frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
block = lane ? "### #{lane}\n\n#{body.strip}" : body.strip
|
|
196
|
+
path.write(replace_section_body(path.read, spec[:heading], block, append: append))
|
|
197
|
+
|
|
198
|
+
nn = format("%02d", entry["ordinal"] || 0)
|
|
199
|
+
git_run("-C", space.path.to_s, "add", rel)
|
|
200
|
+
committed = staged_changes?
|
|
201
|
+
git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: #{spec[:message]}") if committed
|
|
202
|
+
|
|
203
|
+
head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
|
|
204
|
+
diffstat, = committed ? git_capture("-C", space.path.to_s, "show", "--stat", "--format=", "HEAD") : [""]
|
|
205
|
+
{ section: section, heading: spec[:heading], sha: head.strip, committed: committed, diffstat: diffstat.strip }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
|
|
209
|
+
# the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
|
|
210
|
+
def transcribe_evidence!(iteration, lane: nil)
|
|
211
|
+
entry = slice_entry(iteration)
|
|
212
|
+
rel = entry["file"]
|
|
213
|
+
path = space.path.join(rel)
|
|
214
|
+
raise Error, "#{rel} does not exist โ run `architect new #{iteration}` first" unless path.exist?
|
|
215
|
+
|
|
216
|
+
id = iteration_id(entry)
|
|
217
|
+
report = space.path.join("build", lane ? "#{id}-#{lane}" : id, "report.md")
|
|
218
|
+
raise Error, "builder report not found: #{report}" unless report.exist?
|
|
219
|
+
raw = report.read
|
|
220
|
+
raise Error, "builder report is empty: #{report}" if raw.strip.empty?
|
|
221
|
+
|
|
222
|
+
block = lane ? "### #{lane}\n\n#{raw.rstrip}" : raw.rstrip
|
|
223
|
+
path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
|
|
224
|
+
|
|
225
|
+
nn = format("%02d", entry["ordinal"] || 0)
|
|
226
|
+
git_run("-C", space.path.to_s, "add", rel)
|
|
227
|
+
git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: evidence") if staged_changes?
|
|
228
|
+
head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
|
|
229
|
+
|
|
230
|
+
status_line = raw.lines.reverse_each.find { |l| l.strip.start_with?("STATUS:") }&.strip
|
|
231
|
+
{ sha: head.strip, lines: raw.lines.count, status_line: status_line, lane: lane }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Read the Acceptance Criteria section text, by default from the freeze commit
|
|
235
|
+
# (so the architect quotes the frozen gates, never a drifted working copy).
|
|
236
|
+
def acceptance_criteria(iteration, ref: :freeze)
|
|
237
|
+
entry = slice_entry(iteration)
|
|
238
|
+
rel = entry["file"]
|
|
239
|
+
ref = entry["freeze_sha"] if ref == :freeze
|
|
240
|
+
text =
|
|
241
|
+
if ref
|
|
242
|
+
out, _, st = git_capture("-C", space.path.to_s, "show", "#{ref}:#{rel}")
|
|
243
|
+
raise Error, "could not read #{rel} at #{ref}" unless st.success?
|
|
244
|
+
out
|
|
245
|
+
else
|
|
246
|
+
space.path.join(rel).read
|
|
247
|
+
end
|
|
248
|
+
section_body(text, "## Acceptance Criteria")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Integrate ONE architect-judged-passing lane: commit the builder's working tree on
|
|
252
|
+
# the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
|
|
253
|
+
# Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
|
|
254
|
+
# (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
|
|
255
|
+
def merge_lane!(iteration, lane, message: nil)
|
|
256
|
+
entry = slice_entry(iteration)
|
|
257
|
+
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
258
|
+
raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
259
|
+
|
|
260
|
+
checks = lane_mechanical_checks(entry, lane_entry)
|
|
261
|
+
if checks[:no_builder_commits] == false
|
|
262
|
+
raise Error, "Lane '#{lane}' has builder commits โ the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
|
|
263
|
+
end
|
|
264
|
+
if checks[:in_bounds] == false
|
|
265
|
+
raise Error, "Lane '#{lane}' wrote outside its declared touch set โ out-of-bounds fails the lane. Reset and re-dispatch."
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
repo = lane_entry["repo"]
|
|
269
|
+
repo_path = space.path.join("repos", repo)
|
|
270
|
+
id = iteration_id(entry)
|
|
271
|
+
wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
|
|
272
|
+
raise Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
|
|
273
|
+
base_sha = lane_entry["base_sha"]
|
|
274
|
+
lane_branch = "lane/#{id}-#{lane}"
|
|
275
|
+
integration_branch = "lane/#{id}"
|
|
276
|
+
|
|
277
|
+
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
|
|
278
|
+
raise Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
|
|
279
|
+
|
|
280
|
+
git_run("-C", wt_path.to_s, "add", "-A")
|
|
281
|
+
git_run("-C", wt_path.to_s, "commit", "-m", message || "lane #{lane}: integrate")
|
|
282
|
+
|
|
283
|
+
_o, _e, exists = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", "--quiet", integration_branch)
|
|
284
|
+
if exists.success?
|
|
285
|
+
git_run("-C", repo_path.to_s, "checkout", integration_branch)
|
|
286
|
+
else
|
|
287
|
+
git_run("-C", repo_path.to_s, "checkout", "-b", integration_branch, base_sha)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
_mo, merr, mst = git_capture("-C", repo_path.to_s, "merge", "--no-ff", lane_branch, "-m", "Merge #{lane_branch}")
|
|
291
|
+
unless mst.success?
|
|
292
|
+
conflicts, = git_capture("-C", repo_path.to_s, "diff", "--name-only", "--diff-filter=U")
|
|
293
|
+
git_capture("-C", repo_path.to_s, "merge", "--abort")
|
|
294
|
+
raise Error,
|
|
295
|
+
"Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) โ the lane plan was " \
|
|
296
|
+
"not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
|
|
300
|
+
diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")
|
|
301
|
+
|
|
302
|
+
update_architect_block do |b|
|
|
303
|
+
(b["iterations"] || []).each do |s|
|
|
304
|
+
next unless s["name"] == iteration
|
|
305
|
+
(s["lanes"] || []).each { |l| l["integration_branch"] = integration_branch if l["name"] == lane }
|
|
306
|
+
end
|
|
307
|
+
b
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
{ lane: lane, repo: repo, integration_branch: integration_branch,
|
|
311
|
+
merge_sha: merge_sha.strip, base_sha: base_sha, diffstat: diffstat.strip, gates_run: false }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
|
|
315
|
+
# first conflict (a disjointness defect). Never decides which lanes pass.
|
|
316
|
+
def integrate!(iteration, lanes:, teardown: false)
|
|
317
|
+
raise Error, "No lanes given to integrate" if lanes.nil? || lanes.empty?
|
|
318
|
+
|
|
319
|
+
merged = []
|
|
320
|
+
lanes.each do |lane|
|
|
321
|
+
merged << merge_lane!(iteration, lane)
|
|
322
|
+
rescue Error => e
|
|
323
|
+
done = merged.map { |m| m[:lane] }.join(", ")
|
|
324
|
+
raise Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if teardown
|
|
328
|
+
id = iteration_id(slice_entry(iteration))
|
|
329
|
+
merged.each do |m|
|
|
330
|
+
worktree_remove(iteration, m[:lane])
|
|
331
|
+
git_capture("-C", space.path.join("repos", m[:repo]).to_s, "branch", "-d", "lane/#{id}-#{m[:lane]}")
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
merged
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Run the iteration's frozen Acceptance Criteria gate commands and stream raw
|
|
338
|
+
# stdout/stderr + exit codes. A path-resolving RUNNER ONLY โ no threshold
|
|
339
|
+
# comparison, no PASS/FAIL. The verdict is the architect reading this output.
|
|
340
|
+
def run_gates(iteration, lane: nil)
|
|
341
|
+
entry = slice_entry(iteration)
|
|
342
|
+
freeze_sha = entry["freeze_sha"]
|
|
343
|
+
raise Error, "Iteration '#{iteration}' is not frozen โ freeze before running gates." unless freeze_sha
|
|
344
|
+
rel = entry["file"]
|
|
345
|
+
|
|
346
|
+
text, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}")
|
|
347
|
+
raise Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
|
|
348
|
+
commands = acceptance_criteria_commands(text)
|
|
349
|
+
raise Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if commands.empty?
|
|
350
|
+
|
|
351
|
+
lanes = entry["lanes"] || []
|
|
352
|
+
dir =
|
|
353
|
+
if lane
|
|
354
|
+
le = lanes.find { |l| l["name"] == lane }
|
|
355
|
+
raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
|
|
356
|
+
space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
|
|
357
|
+
else
|
|
358
|
+
repo = lanes.first&.dig("repo")
|
|
359
|
+
raise Error, "No lane/repo recorded for '#{iteration}' โ cannot resolve a directory to run gates in" unless repo
|
|
360
|
+
space.path.join("repos", repo)
|
|
361
|
+
end
|
|
362
|
+
raise Error, "directory does not exist: #{dir}" unless dir.exist?
|
|
363
|
+
|
|
364
|
+
commands.map do |row|
|
|
365
|
+
out, err, status = Open3.capture3(row[:command], chdir: dir.to_s)
|
|
366
|
+
{ ac: row[:ac], command: row[:command], stdout: out, stderr: err, exit_code: status.exitstatus, dir: dir }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil)
|
|
137
371
|
if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
|
|
138
372
|
raise Error,
|
|
139
373
|
"Pass --model when using --harness opencode " \
|
|
@@ -177,6 +411,7 @@ module SpaceArchitect
|
|
|
177
411
|
"variant" => variant
|
|
178
412
|
}
|
|
179
413
|
lane_entry["effort"] = effort if effort
|
|
414
|
+
lane_entry["touch_set"] = Array(touch) if touch && !Array(touch).empty?
|
|
180
415
|
lanes << lane_entry
|
|
181
416
|
s["lanes"] = lanes
|
|
182
417
|
end
|
|
@@ -295,41 +530,8 @@ module SpaceArchitect
|
|
|
295
530
|
|
|
296
531
|
def verify(iteration)
|
|
297
532
|
entry = slice_entry(iteration)
|
|
298
|
-
|
|
299
|
-
|
|
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 }
|
|
533
|
+
(entry["lanes"] || []).map do |lane|
|
|
534
|
+
{ lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane) }
|
|
333
535
|
end
|
|
334
536
|
end
|
|
335
537
|
|
|
@@ -394,6 +596,107 @@ module SpaceArchitect
|
|
|
394
596
|
frozen_region(old) != frozen_region(current)
|
|
395
597
|
end
|
|
396
598
|
|
|
599
|
+
# The four per-lane post-flight checks, shared by `verify` (reports) and
|
|
600
|
+
# `merge_lane!` (refuses on failure) so the two can never drift.
|
|
601
|
+
def lane_mechanical_checks(entry, lane)
|
|
602
|
+
freeze_sha = entry["freeze_sha"]
|
|
603
|
+
rel = entry["file"]
|
|
604
|
+
lane_name = lane["name"]
|
|
605
|
+
base_sha = lane["base_sha"]
|
|
606
|
+
wt_path = space.path.join(lane["worktree"] || "build/#{iteration_id(entry)}-#{lane_name}/wt")
|
|
607
|
+
touch_set = lane["touch_set"] || []
|
|
608
|
+
|
|
609
|
+
checks = {}
|
|
610
|
+
|
|
611
|
+
# (a) frozen sections of the iteration file untouched since freeze
|
|
612
|
+
checks[:frozen_untouched] = (!frozen_region_changed?(freeze_sha, rel) if freeze_sha && rel)
|
|
613
|
+
|
|
614
|
+
# (b) no builder commits in the worktree
|
|
615
|
+
log_out, = git_capture("-C", wt_path.to_s, "log", "#{base_sha}..")
|
|
616
|
+
checks[:no_builder_commits] = log_out.strip.empty?
|
|
617
|
+
|
|
618
|
+
# (c) builder's scratch report exists and is non-empty
|
|
619
|
+
report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
|
|
620
|
+
checks[:report_exists] = report.exist? && !report.read.strip.empty?
|
|
621
|
+
|
|
622
|
+
# (d) in-bounds: changed paths โ touch_set (nil if no touch_set recorded)
|
|
623
|
+
checks[:in_bounds] = if touch_set.empty?
|
|
624
|
+
nil
|
|
625
|
+
else
|
|
626
|
+
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
|
|
627
|
+
changed = status_out.lines.map { |l| l[3..].to_s.strip }
|
|
628
|
+
changed.all? { |f| touch_set.any? { |g| File.fnmatch(g, f) } }
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
checks
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Replace (or, with append:, extend) the body of a "## Heading" section, leaving
|
|
635
|
+
# every other section byte-untouched. Append replaces a placeholder body (only a
|
|
636
|
+
# template comment) the first time, then stacks subsections after it.
|
|
637
|
+
def replace_section_body(text, heading, new_block, append:)
|
|
638
|
+
lines = text.lines
|
|
639
|
+
start = lines.index { |l| l.chomp == heading }
|
|
640
|
+
raise Error, "section heading '#{heading}' not found in iteration file" unless start
|
|
641
|
+
|
|
642
|
+
finish = ((start + 1)...lines.length).find { |i| KNOWN_HEADINGS.include?(lines[i].chomp) } || lines.length
|
|
643
|
+
body = lines[(start + 1)...finish].join
|
|
644
|
+
|
|
645
|
+
new_body =
|
|
646
|
+
if append && !placeholder_body?(body)
|
|
647
|
+
"#{body.strip}\n\n#{new_block.strip}"
|
|
648
|
+
else
|
|
649
|
+
new_block.strip
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
prefix = lines[0..start].join.rstrip
|
|
653
|
+
suffix = lines[finish..].to_a.join.strip
|
|
654
|
+
parts = [prefix, "", new_body]
|
|
655
|
+
parts += ["", suffix] unless suffix.empty?
|
|
656
|
+
"#{parts.join("\n")}\n"
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# A section body is a placeholder when it holds nothing but a leading HTML
|
|
660
|
+
# comment (the scaffold's guidance) and whitespace.
|
|
661
|
+
def placeholder_body?(body)
|
|
662
|
+
body.strip.sub(/\A<!--.*?-->/m, "").strip.empty?
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# The text between "## Heading" and the next "## " heading (nil if absent).
|
|
666
|
+
def section_body(text, heading)
|
|
667
|
+
lines = text.lines
|
|
668
|
+
start = lines.index { |l| l.chomp == heading }
|
|
669
|
+
return nil unless start
|
|
670
|
+
finish = ((start + 1)...lines.length).find { |i| KNOWN_HEADINGS.include?(lines[i].chomp) } || lines.length
|
|
671
|
+
lines[(start + 1)...finish].join.strip
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Parse the Acceptance Criteria markdown table into [{ac:, command:}]. Reads the
|
|
675
|
+
# Command column by header name (so an added "Brief ยง" column doesn't shift it);
|
|
676
|
+
# strips surrounding backticks and unescapes \| inside a cell.
|
|
677
|
+
def acceptance_criteria_commands(text)
|
|
678
|
+
body = section_body(text, "## Acceptance Criteria")
|
|
679
|
+
return [] unless body
|
|
680
|
+
rows = body.lines.map(&:strip).select { |l| l.start_with?("|") }
|
|
681
|
+
return [] if rows.length < 2
|
|
682
|
+
|
|
683
|
+
header = split_md_row(rows[0])
|
|
684
|
+
cmd_idx = header.index { |c| c.downcase == "command" } || 1
|
|
685
|
+
ac_idx = header.index { |c| c.downcase.start_with?("ac") } || 0
|
|
686
|
+
|
|
687
|
+
rows[2..].to_a.filter_map do |line|
|
|
688
|
+
cells = split_md_row(line)
|
|
689
|
+
command = cells[cmd_idx].to_s.gsub(/\A`+|`+\z/, "").strip
|
|
690
|
+
next if command.empty?
|
|
691
|
+
{ ac: cells[ac_idx].to_s.strip, command: command }
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def split_md_row(line)
|
|
696
|
+
inner = line.strip.sub(/\A\|/, "").sub(/\|\z/, "")
|
|
697
|
+
inner.split(/(?<!\\)\|/).map { |c| c.strip.gsub('\\|', "|") }
|
|
698
|
+
end
|
|
699
|
+
|
|
397
700
|
def staged_changes?
|
|
398
701
|
_o, _e, st = git_capture("-C", space.path.to_s, "diff", "--cached", "--quiet")
|
|
399
702
|
!st.success? # --quiet exits non-zero when there are staged differences
|
|
@@ -405,6 +708,12 @@ module SpaceArchitect
|
|
|
405
708
|
render_template("architect.md.erb")
|
|
406
709
|
end
|
|
407
710
|
|
|
711
|
+
def render_brief
|
|
712
|
+
@_title = space.data["title"] || space.id
|
|
713
|
+
@_repos = space.repos
|
|
714
|
+
render_template("brief.md.erb")
|
|
715
|
+
end
|
|
716
|
+
|
|
408
717
|
def render_iteration(ordinal_nn, name)
|
|
409
718
|
@_ordinal = "I#{ordinal_nn}"
|
|
410
719
|
@_name = name
|