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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec439ebdc7e044a032f6a6736166d38dcc38b965ee26c03a2a27b1f95c9f5b2e
4
- data.tar.gz: 3b242e89b02bd5e1aa69389aad16eb3e221da0fd000b3e7587ccce02cb565d32
3
+ metadata.gz: db24aa20a68fdf981176f7e51a46794a4fc2ec5cef9765c33de5832f0a314a9e
4
+ data.tar.gz: 8912085239c76c1228e01d8986d789ba0fde42b4517459830c1f08cdb6e0fcfc
5
5
  SHA512:
6
- metadata.gz: 58deb069cc61cf59161122547407699f03072cea024608e9954b568f409c2f11afe241aa585378010dfd5fe9df53aeb83f68945418482d45ba9d312b76297a85
7
- data.tar.gz: 59c12540afc15984bc74d67051f4efc59a360e0fb93acd00bae252fcc2d7b139e2c3f63e692cd5980e0cbc1b3f378d141520b985a41a57c22a2ef08894be914d
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
- 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