space-architect 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec439ebdc7e044a032f6a6736166d38dcc38b965ee26c03a2a27b1f95c9f5b2e
4
- data.tar.gz: 3b242e89b02bd5e1aa69389aad16eb3e221da0fd000b3e7587ccce02cb565d32
3
+ metadata.gz: 62235ba54045a8883eaaaced2fef7c231d45ad6cb363dda9256ec65692b6023b
4
+ data.tar.gz: 7d90b91f3a247c20cc273c332807c65c8e500dc62bffaec1a35076263eb969a2
5
5
  SHA512:
6
- metadata.gz: 58deb069cc61cf59161122547407699f03072cea024608e9954b568f409c2f11afe241aa585378010dfd5fe9df53aeb83f68945418482d45ba9d312b76297a85
7
- data.tar.gz: 59c12540afc15984bc74d67051f4efc59a360e0fb93acd00bae252fcc2d7b139e2c3f63e692cd5980e0cbc1b3f378d141520b985a41a57c22a2ef08894be914d
6
+ metadata.gz: 042c966a4dbf2075abaeaffb23ad9e426a4421ffcd9621ff238d2f074771e5d65bc7030775fe1733dde40030dc0aff37b84c35e13a035ac29705e3e42667c96e
7
+ data.tar.gz: f633823970cefde990131c1cce4a415bd8f767030120438ae7bd76220653ab0c42580352de48ab2c34b82f5fd4cc597c6ba14410603e66de6a3dd86f89e197ee
@@ -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
- def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil)
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
- 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 }
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
@@ -107,6 +107,12 @@ module SpaceArchitect
107
107
  mission = ArchitectMission.new(space: sp)
108
108
  sha = mission.freeze!(iteration)
109
109
  terminal.say "Frozen #{iteration} at #{sha}"
110
+ ac = mission.acceptance_criteria(iteration)
111
+ unless ac.to_s.strip.empty?
112
+ terminal.say ""
113
+ terminal.say "Frozen Acceptance Criteria (quote these verbatim when judging):"
114
+ terminal.say ac
115
+ end
110
116
  CLI.record_outcome(Outcome.new(exit_code: 0))
111
117
  end
112
118
  end
@@ -195,6 +201,176 @@ module SpaceArchitect
195
201
  end
196
202
  end
197
203
 
204
+ class Section < Dry::CLI::Command
205
+ include GlobalOptions
206
+ include Helpers
207
+
208
+ desc "Write a section of the iteration file and commit it (one call)"
209
+ argument :iteration, required: true, desc: "Iteration name"
210
+ argument :section, required: true, desc: "Section: grounds, specification, prompt, verdict"
211
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
212
+ option :from, default: nil, desc: "Read the section body from this file"
213
+ option :body, default: nil, desc: "Inline section body (one-liners)"
214
+ option :stdin, type: :boolean, default: false, desc: "Read the section body from stdin"
215
+ option :append, type: :boolean, default: false, desc: "Append a ### <lane> subsection instead of replacing"
216
+ option :lane, default: nil, desc: "Lane name for an appended ### subsection"
217
+
218
+ def call(iteration:, section:, space: nil, from: nil, body: nil, stdin: false, append: false, lane: nil, **opts)
219
+ setup_terminal(**opts.slice(:color, :colors))
220
+ handle_errors do
221
+ content = read_section_body(from: from, body: body, stdin: stdin)
222
+ render(store.find(space)) do |sp|
223
+ mission = ArchitectMission.new(space: sp)
224
+ res = mission.write_section!(iteration, section, body: content, append: append, lane: lane)
225
+ if res[:committed]
226
+ terminal.say "Committed #{res[:heading]} → #{res[:sha][0, 8]}"
227
+ terminal.say res[:diffstat] unless res[:diffstat].empty?
228
+ else
229
+ terminal.say "#{res[:heading]} written — no change to commit"
230
+ end
231
+ CLI.record_outcome(Outcome.new(exit_code: 0))
232
+ end
233
+ end
234
+ end
235
+
236
+ private
237
+
238
+ def read_section_body(from:, body:, stdin:)
239
+ return File.read(from) if from
240
+ return body if body
241
+ return $stdin.read if stdin
242
+ raise SpaceArchitect::Error, "provide the section body via --from <file>, --body <text>, or --stdin"
243
+ end
244
+ end
245
+
246
+ class Evidence < Dry::CLI::Command
247
+ include GlobalOptions
248
+ include Helpers
249
+
250
+ desc "Transcribe a lane's scratch report VERBATIM into Builder Report and commit"
251
+ argument :iteration, required: true, desc: "Iteration name"
252
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
253
+ option :lane, default: nil, desc: "Lane name (per-lane subsection; omit for a single-lane iteration)"
254
+
255
+ def call(iteration:, space: nil, lane: nil, **opts)
256
+ setup_terminal(**opts.slice(:color, :colors))
257
+ handle_errors do
258
+ render(store.find(space)) do |sp|
259
+ mission = ArchitectMission.new(space: sp)
260
+ res = mission.transcribe_evidence!(iteration, lane: lane)
261
+ terminal.say "Transcribed #{res[:lines]} lines → #{res[:sha][0, 8]}"
262
+ terminal.say "Builder STATUS: #{res[:status_line]}" if res[:status_line]
263
+ terminal.say "Now rule on the builder's PHASE 0 disagreements in the Verdict (a later session)."
264
+ CLI.record_outcome(Outcome.new(exit_code: 0))
265
+ end
266
+ end
267
+ end
268
+ end
269
+
270
+ class Merge < Dry::CLI::Command
271
+ include GlobalOptions
272
+ include Helpers
273
+
274
+ desc "Integrate ONE judged-passing lane (merges --no-ff; runs no gates, makes no verdict)"
275
+ argument :iteration, required: true, desc: "Iteration name"
276
+ argument :lane, required: true, desc: "Lane name (architect-judged passing)"
277
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
278
+ option :message, default: nil, desc: "Commit message for the lane's working-tree changes"
279
+
280
+ def call(iteration:, lane:, space: nil, message: nil, **opts)
281
+ setup_terminal(**opts.slice(:color, :colors))
282
+ handle_errors do
283
+ render(store.find(space)) do |sp|
284
+ mission = ArchitectMission.new(space: sp)
285
+ r = mission.merge_lane!(iteration, lane, message: message)
286
+ terminal.say "Merged #{lane} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
287
+ terminal.say r[:diffstat] unless r[:diffstat].empty?
288
+ terminal.say "Gates NOT run — run `architect gate #{iteration}` against the integration branch."
289
+ CLI.record_outcome(Outcome.new(exit_code: 0))
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ class Integrate < Dry::CLI::Command
296
+ include GlobalOptions
297
+ include Helpers
298
+
299
+ desc "Integrate the architect-supplied set of passing lanes, in order (stops on conflict)"
300
+ argument :iteration, required: true, desc: "Iteration name"
301
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
302
+ option :lanes, required: true, desc: "Comma-separated passing lane names (you decide the set)"
303
+ option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
304
+
305
+ def call(iteration:, space: nil, lanes:, teardown: false, **opts)
306
+ setup_terminal(**opts.slice(:color, :colors))
307
+ handle_errors do
308
+ render(store.find(space)) do |sp|
309
+ mission = ArchitectMission.new(space: sp)
310
+ lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
311
+ results = mission.integrate!(iteration, lanes: lane_names, teardown: teardown)
312
+ results.each do |r|
313
+ terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
314
+ end
315
+ terminal.say "Gates NOT run — run `architect gate #{iteration}`; the verdict is the next session's."
316
+ CLI.record_outcome(Outcome.new(exit_code: 0))
317
+ end
318
+ end
319
+ end
320
+ end
321
+
322
+ class Gate < Dry::CLI::Command
323
+ include GlobalOptions
324
+ include Helpers
325
+
326
+ desc "Run the frozen Acceptance Criteria gate commands and stream raw output (no PASS/FAIL)"
327
+ argument :iteration, required: true, desc: "Iteration name"
328
+ argument :lane, required: false, desc: "Run in a lane worktree (default: the integration repo)"
329
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
330
+
331
+ def call(iteration:, lane: nil, space: nil, **opts)
332
+ setup_terminal(**opts.slice(:color, :colors))
333
+ handle_errors do
334
+ render(store.find(space)) do |sp|
335
+ mission = ArchitectMission.new(space: sp)
336
+ results = mission.run_gates(iteration, lane: lane)
337
+ results.each do |r|
338
+ terminal.say ""
339
+ terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:command]} (exit #{r[:exit_code]})"
340
+ terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
341
+ terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
342
+ end
343
+ terminal.say ""
344
+ terminal.say "Raw gate output above — the PASS/FAIL/INVALID verdict is yours, read against the frozen thresholds."
345
+ CLI.record_outcome(Outcome.new(exit_code: 0))
346
+ end
347
+ end
348
+ end
349
+ end
350
+
351
+ module Brief
352
+ class New < Dry::CLI::Command
353
+ include GlobalOptions
354
+ include Helpers
355
+
356
+ desc "Scaffold the durable mission brief (architecture/BRIEF.md)"
357
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
358
+ option :force, type: :boolean, default: false, desc: "Overwrite an existing BRIEF.md"
359
+
360
+ def call(space: nil, force: false, **opts)
361
+ setup_terminal(**opts.slice(:color, :colors))
362
+ handle_errors do
363
+ render(store.find(space)) do |sp|
364
+ mission = ArchitectMission.new(space: sp)
365
+ path = mission.brief_new!(force: force)
366
+ terminal.say "Brief ready: #{terminal.path(path)}"
367
+ CLI.record_outcome(Outcome.new(exit_code: 0))
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
373
+
198
374
  module Worktree
199
375
  class Add < Dry::CLI::Command
200
376
  include GlobalOptions
@@ -208,14 +384,16 @@ module SpaceArchitect
208
384
  option :harness, default: "claude-code", desc: "Harness (claude-code, opencode)"
209
385
  option :model, default: nil, desc: "Model (required for opencode)"
210
386
  option :effort, default: nil, desc: "Reasoning effort (opencode only; sets reasoningEffort in the model config)"
387
+ option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
211
388
 
212
- def call(repo:, iteration:, lane:, base: nil, harness: "claude-code", model: nil, effort: nil, **opts)
389
+ def call(repo:, iteration:, lane:, base: nil, harness: "claude-code", model: nil, effort: nil, touch: nil, **opts)
213
390
  setup_terminal(**opts.slice(:color, :colors))
214
391
  handle_errors do
215
392
  render(store.find) do |sp|
216
393
  mission = ArchitectMission.new(space: sp)
394
+ touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
217
395
  result = mission.worktree_add(repo, iteration, lane, base: base,
218
- harness: harness, model: model, effort: effort)
396
+ harness: harness, model: model, effort: effort, touch: touch_set)
219
397
  terminal.say "Worktree: #{terminal.path(result[:worktree])}"
220
398
  terminal.say "Base SHA: #{result[:base_sha]}"
221
399
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -376,6 +554,14 @@ SpaceArchitect::CLI::Registry.register "status", SpaceArchitect::CLI::Architect:
376
554
  SpaceArchitect::CLI::Registry.register "freeze", SpaceArchitect::CLI::Architect::Freeze
377
555
  SpaceArchitect::CLI::Registry.register "verify", SpaceArchitect::CLI::Architect::Verify
378
556
  SpaceArchitect::CLI::Registry.register "dispatch", SpaceArchitect::CLI::Architect::Dispatch
557
+ SpaceArchitect::CLI::Registry.register "section", SpaceArchitect::CLI::Architect::Section
558
+ SpaceArchitect::CLI::Registry.register "evidence", SpaceArchitect::CLI::Architect::Evidence
559
+ SpaceArchitect::CLI::Registry.register "merge", SpaceArchitect::CLI::Architect::Merge
560
+ SpaceArchitect::CLI::Registry.register "integrate", SpaceArchitect::CLI::Architect::Integrate
561
+ SpaceArchitect::CLI::Registry.register "gate", SpaceArchitect::CLI::Architect::Gate
562
+ SpaceArchitect::CLI::Registry.register "brief" do |b|
563
+ b.register "new", SpaceArchitect::CLI::Architect::Brief::New
564
+ end
379
565
  SpaceArchitect::CLI::Registry.register "worktree" do |wt|
380
566
  wt.register "add", SpaceArchitect::CLI::Architect::Worktree::Add
381
567
  wt.register "remove", SpaceArchitect::CLI::Architect::Worktree::Remove
@@ -4,6 +4,10 @@
4
4
  > architecture/I<NN>-<iteration>.md — this file only indexes the iterations and carries
5
5
  > mission-wide state. Keep it short (~150 lines): the next session must grok it
6
6
  > in under a minute. Not in the committed architecture = didn't happen.
7
+ >
8
+ > Durable mission contract: `architecture/BRIEF.md` (numbered §sections, cited as BRIEF §N).
9
+ > Create it with `architect brief new`. Edits to a §section are mission-scope decisions —
10
+ > log them in the Decisions log below, never as silent per-iteration drift.
7
11
 
8
12
  ## TL;DR (keep current)
9
13
 
@@ -0,0 +1,43 @@
1
+ # BRIEF — <%= @_title %>
2
+
3
+ > Durable mission contract for the Architect Loop. The numbered §sections below are the
4
+ > stable, cross-iteration address space: every iteration cites them as **BRIEF §N** in its
5
+ > Grounds, Specification, Acceptance Criteria, and Verdict (e.g. `(BRIEF §3.1)`), the way each
6
+ > gate addresses its intent back to one frozen reference. Frozen early; edits are mission-scope
7
+ > decisions logged in ARCHITECT.md's Decisions log, never silent per-iteration drift. Optional —
8
+ > a discovery mission may defer this and cite per-iteration Grounds until the shape stabilizes,
9
+ > then promote the consolidated picture here once.
10
+
11
+ ## 1. Goal & non-goals
12
+
13
+ <!-- One paragraph of goal; an explicit non-goals list. §1 is the cardinal-intent address —
14
+ iterations and verdicts cite it for the invariants that must never break. -->
15
+
16
+ ## 2. Constraints / frozen stack
17
+
18
+ <!-- Languages, gems/deps and pinned versions, platform constraints. "No new dependencies"
19
+ boundaries in iteration specs cite BRIEF §2. -->
20
+
21
+ ## 3. Domain model
22
+
23
+ <!-- The nouns and their relationships; the data/contract shapes that span iterations. -->
24
+
25
+ ### 3.1
26
+
27
+ ## 4. Layout
28
+
29
+ <!-- Where things live: directories, entry points, the seams iterations build against. -->
30
+
31
+ ## 5. Iteration map
32
+
33
+ <!-- The planned iterations, each independently shippable; the Acceptance Criteria are the
34
+ frozen proof. An iteration's Specification cites its row here (e.g. "BRIEF §5 Slice 3"). -->
35
+
36
+ ## 6. Cross-iteration risks / PHASE-0 challenge list
37
+
38
+ <!-- Known risks and the things a builder's PHASE 0 should challenge — cited from specs as
39
+ "BRIEF §6". -->
40
+
41
+ ## 7. Definition of done (whole mission)
42
+
43
+ <!-- The mission-level DoD. Iteration gates diff against "BRIEF §7 DoD". -->
@@ -9,21 +9,23 @@
9
9
 
10
10
  ## Grounds
11
11
 
12
- <!-- WHY. Research/PRD distilled: problem, decision + why, requirements,
13
- non-goals, verified facts WITH citation URLs, open questions. Optional — delete
14
- this section for iterations that needed no research. Commit: "<%= @_ordinal %>: grounds". -->
12
+ <!-- WHY. Research/spec distilled: problem, decision + why, requirements,
13
+ non-goals, verified facts WITH citation URLs, open questions. If
14
+ architecture/BRIEF.md exists, cite **BRIEF §N** instead of restating shrink this
15
+ to deltas + citations. Optional — delete this section for iterations that needed
16
+ no research. Write + commit: `architect section <%= @_name %> grounds --from <file>`. -->
15
17
 
16
18
  ## Specification
17
19
 
18
20
  <!-- WHAT / HOW — the full, self-contained delegation contract.
19
- Commit: "<%= @_ordinal %>: specification". -->
21
+ Write + commit: `architect section <%= @_name %> specification --from <file>`. -->
20
22
 
21
- - **Objective** — what to build and why (cite Grounds if present).
23
+ - **Objective** — what to build and why; cite **BRIEF §N** for durable context (e.g. `(BRIEF §3.1)`).
22
24
  - **Output format** — raw tables, numbers, commit SHAs, test output paths.
23
25
  - **Tool guidance** — exact verification commands for the target repo; the
24
26
  APIs/formats/versions to verify against live dependencies before writing code.
25
27
  - **Boundaries** — may-touch / must-not-touch / out-of-scope; no placeholders;
26
- no refactors beyond the task.
28
+ no refactors beyond the task. (Frozen stack: cite `BRIEF §2`.)
27
29
  - **Lane plan** — 1–4 lanes, each declaring: target repo `repos/<repo>`,
28
30
  file-touch set (overlap-checked), objective, output format, boundaries.
29
31
  - **Effort** — `think hard` … `ultrathink` per lane, with one line of why.
@@ -34,33 +36,39 @@ Commit: "<%= @_ordinal %>: specification". -->
34
36
  commits this file and records its SHA as freeze_sha. Read-only afterward — any
35
37
  change to Grounds/Specification/Acceptance Criteria = automatic iteration FAIL. -->
36
38
 
37
- | AC# | Command | Threshold |
38
- |-----|---------|-----------|
39
- | | | |
39
+ > Gate-pass is necessary, not sufficient: the architect also reads the diff against the
40
+ > cited **BRIEF §sections** intent and the §1 cardinal invariant before the verdict.
41
+
42
+ | AC# | Command | Threshold | Brief § |
43
+ |-----|---------|-----------|---------|
44
+ | | | | |
40
45
 
41
46
  ## Builder Prompt
42
47
 
43
48
  <!-- The exact lane-prompt(s) dispatched, recorded as provenance. One ###
44
49
  subsection per lane. A copy is written to build/<%= @_ordinal %>-<%= @_name %>-<lane>/prompt.md
45
- for stdin dispatch. Commit: "<%= @_ordinal %>: dispatched". -->
50
+ for stdin dispatch. Record + commit:
51
+ `architect section <%= @_name %> prompt --append --lane <lane> --from build/<%= @_ordinal %>-<%= @_name %>-<lane>/prompt.md`. -->
46
52
 
47
53
  ## Builder Report
48
54
 
49
55
  <!-- RAW EVIDENCE ONLY — tables, numbers, command output. The builder writes this
50
56
  to build/<%= @_ordinal %>-<%= @_name %>-<lane>/report.md; the architect transcribes it here
51
- VERBATIM (no interpretation). One ### subsection per lane; include the builder's
52
- PHASE 0 disagreements and its STATUS line. Commit: "<%= @_ordinal %>: evidence". -->
57
+ VERBATIM with `architect evidence <%= @_name %> --lane <lane>` (byte-for-byte, no
58
+ interpretation). One ### subsection per lane; includes the builder's PHASE 0
59
+ disagreements and its STATUS line. -->
53
60
 
54
61
  ## Verdict
55
62
 
56
63
  <!-- ARCHITECT JUDGMENT, written in a LATER session than the dispatch.
57
- Commit: "<%= @_ordinal %>: verdict". -->
64
+ Write + commit: `architect section <%= @_name %> verdict --from <file>`. -->
58
65
 
59
- - **Disagreement rulings** — each PHASE 0 disagreement: ACCEPT / REJECT / MODIFY + why.
66
+ - **Disagreement rulings** — each PHASE 0 disagreement: ACCEPT / REJECT / MODIFY + why; cite the
67
+ BRIEF §section in tension beside file:line.
60
68
  - **Acceptance Criteria integrity** — frozen sections unchanged since freeze (`architect verify <%= @_name %>`)?
61
69
 
62
- | AC# | Raw result | Verdict |
63
- |-----|------------|---------|
64
- | | | PASS/FAIL/INVALID |
70
+ | AC# | Raw result | Brief § | Verdict |
71
+ |-----|------------|---------|---------|
72
+ | | | | PASS/FAIL/INVALID |
65
73
 
66
- - **Iteration** — KILL / CONTINUE + the single decisive reason.
74
+ - **Iteration** — KILL / CONTINUE + the single decisive reason (e.g. "diff vs BRIEF §1/§3.3 faithful — CONTINUE").
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpaceArchitect
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -241,6 +241,7 @@ files:
241
241
  - lib/space_architect/space_store.rb
242
242
  - lib/space_architect/state.rb
243
243
  - lib/space_architect/templates/architect.md.erb
244
+ - lib/space_architect/templates/brief.md.erb
244
245
  - lib/space_architect/templates/iteration.md.erb
245
246
  - lib/space_architect/terminal.rb
246
247
  - lib/space_architect/version.rb