space-architect 5.5.1 → 6.0.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.
@@ -9,20 +9,31 @@ module Space
9
9
  REPO = "jetpks/space-architect"
10
10
 
11
11
  class << self
12
- def generate(space: nil, env: ENV, cwd: Dir.pwd, now: Time.now)
12
+ def generate(space: nil, env: ENV, cwd: Dir.pwd, now: Time.now, title: nil)
13
13
  body_path = resolve_body_path(space, cwd, now)
14
14
  FileUtils.mkdir_p(body_path.dirname)
15
- body = build_body(space)
15
+ body = build_body(space, title)
16
16
  body_path.write(body)
17
17
  contracted = Space::Core::Paths.contract(body_path, env: env)
18
+ title_flag = blank_title?(title) ? "" : " --title #{quote_title(title)}"
18
19
  command = Space::Core::Commands.wrap(
19
- %(gh issue create -R #{REPO} --title "<one-line summary>" --body-file #{contracted})
20
+ %(gh issue create -R #{REPO}#{title_flag} --body-file #{contracted})
20
21
  )
21
22
  { body_path: body_path, command: command, body: body }
22
23
  end
23
24
 
24
25
  private
25
26
 
27
+ # Double-quote a title for a POSIX shell: quotes preserve spaces
28
+ # literally, only the characters special inside double quotes are escaped.
29
+ def quote_title(title)
30
+ %("#{title.gsub(/["\\$`]/) { |m| "\\#{m}" }}")
31
+ end
32
+
33
+ def blank_title?(title)
34
+ title.to_s.strip.empty?
35
+ end
36
+
26
37
  def resolve_body_path(space, cwd, now)
27
38
  filename = "architect-bug-report-#{now.strftime('%Y%m%d-%H%M%S')}.md"
28
39
  if space
@@ -32,8 +43,10 @@ module Space
32
43
  end
33
44
  end
34
45
 
35
- def build_body(space)
36
- body = +template_header
46
+ def build_body(space, title)
47
+ body = +""
48
+ body << "# #{title}\n\n" unless blank_title?(title)
49
+ body << template_header
37
50
  body << diagnostics_section
38
51
  body << space_section(space) if space
39
52
  body
@@ -41,8 +54,6 @@ module Space
41
54
 
42
55
  def template_header
43
56
  <<~MD
44
- <!-- Title: <one-line summary> -->
45
-
46
57
  **Kind:** <!-- process / tooling / both -->
47
58
 
48
59
  ## Summary
@@ -226,23 +226,127 @@ module Space::Architect
226
226
  end
227
227
  end
228
228
 
229
+ class Rehearse < BaseCommand
230
+ desc "Rehearse the DRAFTED (unfrozen, working-tree) gates against the repo checkout and report RED/GREEN/BROKEN/EMPTY — runs and reports, never judges"
231
+ phase 12, "Spec"
232
+ argument :iteration, required: true, desc: "Iteration name"
233
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
234
+ # type: :flag (not :boolean): dry-cli renders a :boolean option as
235
+ # "--[no-]record" in --help, which does not contain the literal
236
+ # substring "--record" that a presence-check on the flag would look
237
+ # for; :flag renders unbracketed ("--record") and is presence-only,
238
+ # which is all this needs.
239
+ option :record, type: :flag, default: false, desc: "Emit a paste-able provenance block summarizing the run, shaped to drop into an Acceptance Criteria preamble"
240
+
241
+ def call(iteration:, space: nil, record: false, **opts)
242
+ setup_terminal(**opts.slice(:color, :colors))
243
+ handle_errors do
244
+ render(store.find(space)) do |sp|
245
+ project = ArchitectProject.new(space: sp)
246
+ result = project.rehearse(iteration)
247
+ render_rehearsal(result)
248
+ terminal.say ""
249
+ terminal.say render_record(result) if record
250
+ CLI.record_outcome(Outcome.new(exit_code: 0))
251
+ end
252
+ end
253
+ end
254
+
255
+ private
256
+
257
+ def render_rehearsal(result)
258
+ if result[:empty]
259
+ reason = result[:placeholder] ? "the scaffold placeholder '#{ArchitectProject::AC1_PLACEHOLDER}' with no active gate" : "no gates drafted yet"
260
+ terminal.say "EMPTY — #{reason}. Nothing to rehearse; the pre-freeze look is still stamped."
261
+ else
262
+ terminal.say "Rehearsing #{result[:iteration]} against #{terminal.path(result[:base_dir])} (repo: #{result[:repo]})"
263
+ result[:gates].each { |g| render_gate(g) }
264
+ render_scope_asymmetry(result[:scope_asymmetry])
265
+ end
266
+ terminal.say ""
267
+ terminal.say "Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
268
+ end
269
+
270
+ def render_gate(g)
271
+ terminal.say ""
272
+ terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]}: #{g[:cmd]} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
273
+ terminal.say " dir: #{terminal.path(g[:dir])}"
274
+ terminal.say " reason: #{g[:reason]}" unless g[:reason].to_s.empty?
275
+ if g[:rehearsal] == :broken
276
+ terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
277
+ "hasn't written yet). Confirm before treating it as a defect."
278
+ end
279
+ terminal.say g[:stdout].rstrip unless g[:stdout].strip.empty?
280
+ terminal.say g[:stderr].rstrip unless g[:stderr].strip.empty?
281
+ end
282
+
283
+ # I12/AC3-AC4: reports scope asymmetry only — never affects RED/GREEN/
284
+ # BROKEN, rehearse's exit code, or the stamp. A file outside every
285
+ # declared lane's touch set is flagged loudest: it's the one no lane
286
+ # may legally fix. not_analyzable is rendered too, and counted, so a
287
+ # reader can tell "nothing matched" from "nothing was examined".
288
+ def render_scope_asymmetry(report)
289
+ return if report.nil? || (report[:findings].empty? && report[:not_analyzable].empty?)
290
+
291
+ terminal.say ""
292
+ terminal.say "Scope-asymmetry check (grep-family gates only):"
293
+ report[:findings].each do |f|
294
+ terminal.say "── #{f[:id]}: pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
295
+ if f[:outside_lanes].any?
296
+ terminal.say " OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
297
+ f[:outside_lanes].each { |file| terminal.say " #{file}" }
298
+ end
299
+ terminal.say " also matches (within a declared lane's touch set): #{f[:within_lanes].join(', ')}" if f[:within_lanes].any?
300
+ terminal.say " 0 files elsewhere match" if f[:outside_lanes].empty? && f[:within_lanes].empty?
301
+ end
302
+
303
+ return if report[:not_analyzable].empty?
304
+ terminal.say ""
305
+ terminal.say "Not analyzed (#{report[:not_analyzable].size} grep invocation(s)) — counted, not silently skipped:"
306
+ report[:not_analyzable].each { |na| terminal.say " #{na[:id]}: #{na[:reason]}" }
307
+ end
308
+
309
+ def render_record(result)
310
+ return "> Rehearsed #{result[:iteration]} — no gates drafted; nothing to record." if result[:empty]
311
+
312
+ by = result[:gates].group_by { |g| g[:rehearsal] }
313
+ ids = ->(sym) { (by[sym] || []).map { |g| g[:id] }.join(", ") }
314
+ [
315
+ "> **Dry-run at rehearsal time, recorded for transparency.** All #{result[:gates].size} gate command(s) " \
316
+ "were executed as written against `#{result[:repo]}` under `/bin/sh` — the shell `architect gate` uses.",
317
+ ">",
318
+ "> - **RED (#{(by[:red] || []).size} — discriminate):** #{ids.call(:red).empty? ? "(none)" : ids.call(:red)}",
319
+ "> - **GREEN (#{(by[:green] || []).size} — regression guard or non-discriminating):** #{ids.call(:green).empty? ? "(none)" : ids.call(:green)}",
320
+ "> - **BROKEN (#{(by[:broken] || []).size} — advisory, confirm each):** #{ids.call(:broken).empty? ? "(none)" : ids.call(:broken)}"
321
+ ].join("\n")
322
+ end
323
+ end
324
+
229
325
  class Freeze < BaseCommand
230
326
  desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
231
- phase 12, "Spec"
327
+ phase 13, "Spec"
232
328
  argument :iteration, required: true, desc: "Iteration name"
233
329
  argument :space, required: false, desc: "Space identifier (default: $PWD)"
234
330
  option :force, type: :boolean, default: false, desc: "Re-freeze even if the frozen region changed (pre-dispatch only)"
331
+ # Named --skip-rehearse, not --no-rehearse: Ruby's OptionParser (dry-cli's
332
+ # underlying parser, verified against the live dry-cli 1.4.1 in this repo)
333
+ # treats ANY switch literally named --no-<word> as a boolean negation and
334
+ # silently discards its value, regardless of declared type — so a REASON
335
+ # cannot bind to a flag spelled --no-rehearse. --skip-rehearse is the
336
+ # working escape valve; --no-rehearse is the name this design uses for it.
337
+ option :skip_rehearse, default: nil, desc: "Escape valve (design name: --no-rehearse): skip the fresh-rehearsal requirement, recording REASON in space.yaml (bare freeze refuses without a matching `architect rehearse` stamp)"
235
338
  commit_message_options
236
339
 
237
- def call(iteration:, space: nil, message: nil, message_from: nil, force: false, **opts)
340
+ def call(iteration:, space: nil, message: nil, message_from: nil, force: false, skip_rehearse: nil, **opts)
238
341
  setup_terminal(**opts.slice(:color, :colors))
239
342
  handle_errors do
240
343
  render(store.find(space)) do |sp|
241
344
  project = ArchitectProject.new(space: sp)
242
345
  warnings = []
243
- sha = project.freeze!(iteration, warnings: warnings, force: force,
346
+ sha = project.freeze!(iteration, warnings: warnings, force: force, skip_rehearse_reason: skip_rehearse,
244
347
  message: read_commit_message(message: message, message_from: message_from))
245
348
  terminal.say "Frozen #{iteration} at #{sha}"
349
+ terminal.say "Rehearsal requirement skipped — #{skip_rehearse}" if skip_rehearse
246
350
  warnings.each { |w| terminal.say "Warning: #{w}" }
247
351
  ac = project.acceptance_criteria(iteration)
248
352
  unless ac.to_s.strip.empty?
@@ -402,6 +506,9 @@ module Space::Architect
402
506
  terminal.say "Report: #{terminal.path(res[:report])}"
403
507
  terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
404
508
  terminal.say "Builder exited with status #{res[:exit_code]}"
509
+ unless res[:report].exist? && !res[:report].read.strip.empty?
510
+ terminal.say "WARNING: no report at #{terminal.path(res[:report])} — the lane produced no deliverable."
511
+ end
405
512
  CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
406
513
  end
407
514
  end
@@ -560,9 +667,10 @@ module Space::Architect
560
667
  option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
561
668
  option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
562
669
  option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
670
+ option :accept_bounds, default: nil, desc: "Escape valve: override the in-bounds check for these lanes when the frozen touch-set glob is itself the defect, recording REASON in space.yaml (never overrides the no-builder-commits check)"
563
671
  commit_message_options
564
672
 
565
- def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, **opts)
673
+ def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, accept_bounds: nil, **opts)
566
674
  setup_terminal(**opts.slice(:color, :colors))
567
675
  handle_errors do
568
676
  lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
@@ -573,7 +681,7 @@ module Space::Architect
573
681
  project = ArchitectProject.new(space: sp)
574
682
  results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
575
683
  message: read_commit_message(message: message, message_from: message_from),
576
- commit_mode: commit_mode, into: into)
684
+ commit_mode: commit_mode, into: into, accept_bounds_reason: accept_bounds)
577
685
  if lane_names.empty?
578
686
  if results.empty?
579
687
  terminal.say "Nothing to tear down for #{iteration}"
@@ -585,6 +693,7 @@ module Space::Architect
585
693
  else
586
694
  results.each do |r|
587
695
  terminal.say "Merged #{r[:lane]} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
696
+ terminal.say "In-bounds check overridden for #{r[:lane]}: #{r[:bounds_override_reason]}" if r[:bounds_override_reason]
588
697
  end
589
698
  terminal.say "Gates NOT run — run gates: `architect gate #{iteration}`"
590
699
  end
@@ -611,6 +720,7 @@ module Space::Architect
611
720
  marker = r[:status] == :pass ? "PASS" : "FAIL"
612
721
  terminal.say ""
613
722
  terminal.say "── #{r[:ac].empty? ? "(gate)" : r[:ac]}: #{r[:cmd]} (exit #{r[:exit_code]}) [#{marker}]"
723
+ terminal.say " dir: #{terminal.path(r[:dir])}"
614
724
  terminal.say " reason: #{r[:reason]}" if r[:status] == :fail && !r[:reason].to_s.empty?
615
725
  terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
616
726
  terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
@@ -627,15 +737,20 @@ module Space::Architect
627
737
  class BugReport < BaseCommand
628
738
  desc "Generate a prefilled GitHub issue template for filing bugs against space-architect"
629
739
  phase 54, "Project"
740
+ option :title, default: nil, desc: "Issue title, written into --title and the body's leading H1 (omit and gh will prompt for one interactively — the body file does not set it)"
630
741
 
631
- def call(**opts)
742
+ def call(title: nil, **opts)
632
743
  setup_terminal(**opts.slice(:color, :colors))
633
744
  handle_errors do
634
745
  space = store.find.value_or(nil)
635
746
  result = Space::Architect::BugReport.generate(
636
747
  space: space,
637
- env: project_config.env
748
+ env: project_config.env,
749
+ title: title
638
750
  )
751
+ unless title
752
+ terminal.say "No --title given — the body file does not set the issue title; re-run as architect bug-report --title \"...\" to set it in the command (or gh will prompt you for one)."
753
+ end
639
754
  terminal.say "Fill the placeholders in #{terminal.path(result[:body_path].to_s)}, then run:"
640
755
  terminal.say result[:command]
641
756
  terminal.say ""
@@ -1090,6 +1205,7 @@ Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Archit
1090
1205
  Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
1091
1206
  Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
1092
1207
  Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
1208
+ Space::Architect::CLI::Registry.register "rehearse", Space::Architect::CLI::Architect::Rehearse
1093
1209
  Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
1094
1210
  Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
1095
1211
  Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
@@ -6,8 +6,8 @@ module Space::Architect
6
6
  module Research
7
7
  class Dispatch < BaseCommand
8
8
  desc "Dispatch detached read-only research lanes (one per prompt file)"
9
- argument :prompts, required: true,
10
- desc: "Prompt file(s) to dispatch (space-separated paths)"
9
+ argument :prompts, type: :array, required: true,
10
+ desc: "Prompt file(s) to dispatch (space-separated paths)"
11
11
  option :model, default: nil, desc: "Researcher model override (default: the reference default claude-sonnet-4-6)"
12
12
  option :max_turns, default: "40", desc: "Max turns per researcher"
13
13
 
@@ -15,11 +15,10 @@ module Space::Architect
15
15
  setup_terminal(**opts.slice(:color, :colors))
16
16
  handle_errors do
17
17
  render(store.find(opts[:space])) do |sp|
18
- paths = Array(prompts)
19
18
  supervisor = Space::Architect::Research::Supervisor.new(space: sp)
20
19
  kwargs = { max_turns: max_turns.to_i }
21
20
  kwargs[:model] = model if model
22
- runs = supervisor.dispatch(paths, **kwargs)
21
+ runs = supervisor.dispatch(prompts, **kwargs)
23
22
  runs.each do |run|
24
23
  terminal.say "dispatched #{run.id} (pid #{run.pid}) → #{terminal.path(run.run_log_path)}"
25
24
  end
@@ -115,10 +115,19 @@ module Space::Architect
115
115
 
116
116
  TIMEOUT_EXIT_CODE = 124
117
117
 
118
- # How long the liveness fiber waits before reading the run log's stream-json init
119
- # event. Injectable via the run(liveness_delay:) kwarg so tests need not sleep seconds.
118
+ # The liveness fiber's total wait budget before it gives up on the run log's
119
+ # stream-json init event. Injectable via the run(liveness_delay:) kwarg so tests
120
+ # need not sleep seconds.
120
121
  LIVENESS_DELAY_SECONDS = 5.0
121
122
 
123
+ # The liveness fiber's actual deadline is liveness_delay * LIVENESS_BUDGET_FACTOR —
124
+ # a healthy child whose first write lands just after one delay window is still
125
+ # alive, not dead, so the budget spans several delay-lengths, not one.
126
+ LIVENESS_BUDGET_FACTOR = 3
127
+
128
+ # How often the liveness fiber re-checks the run log for growth while waiting.
129
+ LIVENESS_POLL_INTERVAL = 0.05
130
+
122
131
  def run(prompt_path:, run_log_path:, chdir:, push_url: nil, push_token: nil, push_client: nil, timeout: nil,
123
132
  liveness_delay: LIVENESS_DELAY_SECONDS, err: $stderr)
124
133
  prompt_path = Pathname.new(prompt_path)
@@ -148,15 +157,24 @@ module Space::Architect
148
157
  end
149
158
  end
150
159
 
151
- # Liveness self-check: after a bounded delay, read the run log's stream-json
152
- # init event and print ONE line naming the streamed model + confirming growth.
153
- # transient: true so it never keeps the reactor alive; best-effort so it never
154
- # raises into the run path. run_detached gets no such fiber.
160
+ # Liveness self-check: read the run log's stream-json init event and print ONE
161
+ # line naming the streamed model + true elapsed time. A single point-sample right
162
+ # at liveness_delay would report a healthy child dead if its first write landed a
163
+ # moment later, so this polls (like Research::Mux#wait_for_file) to a deadline of
164
+ # several delay-lengths, emitting as soon as the log holds a parseable init event —
165
+ # a bounded wait, not an unbounded one, and not satisfied by mere non-emptiness (the
166
+ # child's stderr is teed into the same run log, so one early stderr byte must not
167
+ # count). transient: true so it never keeps the reactor alive; best-effort so it
168
+ # never raises into the run path. run_detached gets no such fiber.
155
169
  liveness_task = nil
156
170
  if liveness_delay && liveness_delay > 0
157
171
  liveness_task = Async(transient: true) do
158
- sleep liveness_delay
159
- emit_liveness(run_log_path, liveness_delay, err)
172
+ start = Time.now
173
+ deadline = start + (liveness_delay * LIVENESS_BUDGET_FACTOR)
174
+ until init_event_ready?(run_log_path) || Time.now >= deadline
175
+ sleep LIVENESS_POLL_INTERVAL
176
+ end
177
+ emit_liveness(run_log_path, Time.now - start, err)
160
178
  end
161
179
  end
162
180
 
@@ -207,18 +225,60 @@ module Space::Architect
207
225
 
208
226
  private
209
227
 
228
+ # The liveness fiber's wait predicate: ready once the run log holds a parseable
229
+ # stream-json init event — not merely once it holds any bytes, since the child's
230
+ # stderr is teed into the same run log and one early stderr byte must not satisfy it.
231
+ #
232
+ # Scans only the bytes appended since the previous poll (remembering the offset
233
+ # in @liveness_offset) instead of re-parsing the whole log from the top on every
234
+ # 50ms tick — the child that never emits an init event would otherwise cost
235
+ # O(polls x log size) inside a fiber sharing the reactor with the dispatch itself.
236
+ def init_event_ready?(run_log_path)
237
+ return true if @liveness_model
238
+
239
+ @liveness_offset ||= 0
240
+ File.open(run_log_path, "r") do |f|
241
+ f.seek(@liveness_offset)
242
+ f.each_line do |line|
243
+ break unless line.end_with?("\n")
244
+ @liveness_offset = f.pos
245
+ ev = begin
246
+ JSON.parse(line)
247
+ rescue JSON::ParserError
248
+ next
249
+ end
250
+ next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
251
+ @liveness_model = ev["model"]
252
+ break
253
+ end
254
+ end
255
+ !@liveness_model.nil?
256
+ rescue StandardError
257
+ false
258
+ end
259
+
210
260
  # Read the run log's stream-json init event and print exactly one bounded liveness
211
- # line to err. Best-effort: swallows any read/parse error so it never raises into run.
212
- def emit_liveness(run_log_path, delay, err)
261
+ # line to err, naming the true elapsed wall-clock time since dispatch. Best-effort:
262
+ # swallows any read/parse error so it never raises into run.
263
+ #
264
+ # Reuses init_event_ready?'s own incremental scan rather than re-parsing the
265
+ # whole log from the top: when the poll loop already found the init event,
266
+ # @liveness_model is set and this is a no-op re-check; when it ended on the
267
+ # deadline without one, this performs exactly one more forward scan from
268
+ # @liveness_offset, so bytes appended between the last poll and this call are
269
+ # still observed — a scan forward from the cached offset, never a skipped one.
270
+ def emit_liveness(run_log_path, elapsed, err)
213
271
  bytes = run_log_path.exist? ? run_log_path.size : 0
272
+ elapsed = elapsed.round(1)
214
273
  if bytes.zero?
215
- err.puts "liveness: WARN no growth — run log still empty #{delay}s after dispatch"
274
+ err.puts "liveness: WARN no growth — run log still empty #{elapsed}s after dispatch"
216
275
  return
217
276
  end
218
277
 
219
- streamed = streamed_init_model(run_log_path)
278
+ init_event_ready?(run_log_path)
279
+ streamed = @liveness_model
220
280
  if streamed.nil?
221
- err.puts "liveness: WARN model unverified — no stream-json init event after #{delay}s (run log #{bytes} bytes)"
281
+ err.puts "liveness: WARN model unverified — no stream-json init event after #{elapsed}s (run log #{bytes} bytes)"
222
282
  elsif streamed == @model
223
283
  err.puts "liveness: OK streaming model=#{streamed} (run log growing, #{bytes} bytes)"
224
284
  else
@@ -228,21 +288,6 @@ module Space::Architect
228
288
  # Best-effort: an internal read/parse failure must never break the run.
229
289
  end
230
290
 
231
- # The model named by the stream-json init event ({"type":"system","subtype":"init",...}),
232
- # or nil if no such event has been logged yet.
233
- def streamed_init_model(run_log_path)
234
- run_log_path.each_line do |line|
235
- ev = begin
236
- JSON.parse(line)
237
- rescue JSON::ParserError
238
- next
239
- end
240
- next unless ev.is_a?(Hash) && ev["type"] == "system" && ev["subtype"] == "init"
241
- return ev["model"]
242
- end
243
- nil
244
- end
245
-
246
291
  def argv
247
292
  [@bin, "-p", "--model", @model, "--output-format", "stream-json", "--verbose"] + builder_args
248
293
  end
@@ -53,8 +53,10 @@ module Space::Architect
53
53
  end
54
54
 
55
55
  def files
56
- pi = Dir.glob(File.join(@pi_root, "**", "*.jsonl")).map { |p| [p, SessionId.for_pi(p)] }
57
- claude = Dir.glob(File.join(@claude_root, "**", "*.jsonl")).map { |p| [p, SessionId.for_claude(p)] }
56
+ pi = Space::Core::Paths.content_tree(@pi_root).select { |p| p.end_with?(".jsonl") }
57
+ .map { |p| [p, SessionId.for_pi(p)] }
58
+ claude = Space::Core::Paths.content_tree(@claude_root).select { |p| p.end_with?(".jsonl") }
59
+ .map { |p| [p, SessionId.for_claude(p)] }
58
60
  pi + claude
59
61
  end
60
62
  end
@@ -46,7 +46,7 @@ module Space
46
46
  end
47
47
 
48
48
  def source_skills
49
- source_root.children.select(&:directory?)
49
+ Space::Core::Paths.layout_children(source_root).select(&:directory?)
50
50
  end
51
51
 
52
52
  private
@@ -93,8 +93,8 @@ module Space
93
93
  def same_content?(source, dest)
94
94
  return false unless dest.directory?
95
95
 
96
- source_files = Dir.glob("#{source}/**/*").reject { |f| File.directory?(f) }
97
- dest_files = Dir.glob("#{dest}/**/*").reject { |f| File.directory?(f) }
96
+ source_files = Space::Core::Paths.content_tree(source).reject { |f| File.directory?(f) }
97
+ dest_files = Space::Core::Paths.content_tree(dest).reject { |f| File.directory?(f) }
98
98
 
99
99
  return false if source_files.length != dest_files.length
100
100
 
@@ -31,7 +31,9 @@ Write + commit: `architect section <%= @_name %> specification --from <file>`. -
31
31
  machine-readable declaration lives in the fenced ```lanes block below — the single
32
32
  frozen source of truth `architect freeze` records and `architect provision`
33
33
  materializes.
34
- - **Effort** — `think hard` … `ultrathink` per lane, with one line of why.
34
+ - **Effort** — per lane, set at dispatch: `architect dispatch --effort <level>`,
35
+ translated + clamped to the lane's harness, with one line of why (the
36
+ escalation keywords `think hard` … `ultrathink` still work in-prompt).
35
37
 
36
38
  ```lanes
37
39
  # One entry per lane (1–4). The frozen out-of-bounds contract: `architect freeze`
@@ -39,17 +41,33 @@ Write + commit: `architect section <%= @_name %> specification --from <file>`. -
39
41
  # materializes the worktrees + lane branches. Remove the comment markers to activate.
40
42
  # - name: lane-a # lane name (required)
41
43
  # repo: my-repo # target repo under repos/ (required)
42
- # touch: # file globs this lane may write (required, non-empty)
43
- # - lib/my_repo/**
44
+ # touch: # every file this lane may write, enumerated — no globs (required, non-empty)
45
+ # - lib/my_repo/foo.rb
46
+ # - lib/my_repo/bar.rb
44
47
  # - test/my_repo_test.rb
45
48
  ```
46
49
 
47
50
  ## Acceptance Criteria
48
51
 
49
52
  <!-- PROOF. Write the prose conditions of correctness (AC1, AC2, …) that the
50
- architect judges against. Runnable checks live in the fenced ```gates block below
53
+ architect judges against. Calibrate precision to the property (SKILL.md §4): a
54
+ property criterion is bounded from the defect's side only — freeze a floor, not
55
+ an equality; exactness is for criteria whose number is itself the deliverable.
56
+ A presence-grep gate on prose is a tripwire, never the proof — its criterion
57
+ should say which. Runnable checks live in the fenced ```gates block below
51
58
  (parsed at freeze time — absent or empty is allowed; malformed fails freeze).
52
- `architect freeze <%= @_name %>` commits this file and records its SHA as
59
+ Pre-freeze check snapshot: no count/name-set/byte-identity frozen at merely
60
+ today's value; control: every baseline actually run this session; mechanism: no
61
+ *how* where the *what* is the requirement; interface: every CLI surface the
62
+ Specification names (verb, flag spelling, subcommand) executed once before it
63
+ freezes; dry-run:
64
+ `architect rehearse <%= @_name %>` — RED (clean non-zero) discriminates; GREEN
65
+ is a declared regression guard or a gate that measures nothing; BROKEN is
66
+ advisory (a correct RED can look broken — confirm it); EMPTY is no gates or an
67
+ untouched placeholder.
68
+ `architect freeze <%= @_name %>` needs a fresh rehearsal stamp — editing gates
69
+ stales it; `--skip-rehearse REASON` records the skip. The stamp records that you
70
+ looked, never that gates passed. Freeze commits this file and records its SHA as
53
71
  freeze_sha. Read-only afterward — any change to Grounds/Specification/Acceptance
54
72
  Criteria = automatic iteration FAIL. -->
55
73
 
@@ -60,12 +78,22 @@ Criteria = automatic iteration FAIL. -->
60
78
 
61
79
  ```gates
62
80
  # Each gate backs one prose AC above. Remove the comment markers to activate.
81
+ # `cmd` paths resolve against the REPO TREE; `cwd` is relative to the SPACE
82
+ # ROOT. The judge-time remap is asymmetric: a `cwd` under repos/<repo> is
83
+ # remapped into the lane worktree, one outside it passes through unchanged.
84
+ # End a multi-step `cmd` with `echo SENTINEL` + `stdout_match`: /bin/sh (the
85
+ # gate runner's shell) has no `set -e`, so an early branch can exit 0 — the
86
+ # sentinel proves the command reached its end.
87
+ # Draft, then run `architect rehearse <%= @_name %>` — before the freeze, while
88
+ # a bad gate still costs nothing.
63
89
  # - id: suite-green # unique slug within the iteration (required)
64
90
  # ac: AC1 # which prose AC this gate backs (required)
65
- # cwd: repos/my-repo # run dir, relative to space root (optional; consumed by I03)
66
- # cmd: bundle exec rake test # shell command (required, non-empty)
91
+ # cwd: repos/my-repo # run dir, space-root-relative (optional)
92
+ # cmd: |- # block style by default a plain scalar breaks on `: `
93
+ # bundle exec rake test && echo SUITE_OK
67
94
  # expect: # at least one of: exit_code, stdout_match, threshold
68
95
  # exit_code: 0
96
+ # stdout_match: SUITE_OK
69
97
  ```
70
98
 
71
99
  ## Builder Prompt
@@ -90,6 +90,7 @@ module Space::Core::CLI
90
90
  # members within a group, ordered by the declared order), with undeclared
91
91
  # children (namespaces) trailing in the default group.
92
92
  def grouped_listing(result)
93
+ # paths:exempt - result.children is a dry-cli command-tree node, not a filesystem path
93
94
  decorated = result.children.filter_map do |name, node|
94
95
  [name, node, phase_of(node)] unless node.hidden
95
96
  end
@@ -150,6 +151,7 @@ module Space::Core::CLI
150
151
  end
151
152
 
152
153
  def banner(node)
154
+ # paths:exempt - node.children? is a dry-cli command-tree predicate, not a filesystem path
153
155
  if node.command && node.leaf? && node.children?
154
156
  " [ARGUMENT|SUBCOMMAND]"
155
157
  elsif node.leaf?
@@ -8,7 +8,7 @@ module Space::Core
8
8
  # "--flag" boundaries, continuation lines indented two spaces. Commands
9
9
  # without "--" flags are returned unchanged.
10
10
  def wrap(command)
11
- parts = command.split(/(?= --)/)
11
+ parts = split_at_flag_boundaries(command)
12
12
  return command if parts.size <= 1
13
13
 
14
14
  parts.each_with_index.map do |part, i|
@@ -16,5 +16,36 @@ module Space::Core
16
16
  i < parts.size - 1 ? "#{segment} \\" : segment
17
17
  end.join("\n")
18
18
  end
19
+
20
+ # Same split points as command.split(/(?= --)/), but blind to " --" that
21
+ # falls inside a single- or double-quoted flag value, so a --title
22
+ # containing " -- " isn't torn apart mid-argument.
23
+ def split_at_flag_boundaries(command)
24
+ parts = []
25
+ start = 0
26
+ in_squote = false
27
+ in_dquote = false
28
+ escaped = false
29
+ command.each_char.with_index do |ch, i|
30
+ if escaped
31
+ escaped = false
32
+ next
33
+ end
34
+ case ch
35
+ # A backslash escapes the next character outside quotes and inside
36
+ # double quotes, but is a literal character inside single quotes.
37
+ when "\\" then escaped = true unless in_squote
38
+ when "'" then in_squote = !in_squote unless in_dquote
39
+ when '"' then in_dquote = !in_dquote unless in_squote
40
+ when " "
41
+ if !in_squote && !in_dquote && command[i + 1, 2] == "--"
42
+ parts << command[start...i]
43
+ start = i
44
+ end
45
+ end
46
+ end
47
+ parts << command[start..]
48
+ parts
49
+ end
19
50
  end
20
51
  end
@@ -1,9 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  module Space::Core
4
6
  module Paths
7
+ # paths:exempt-file - the shared module itself — this is the one home the guard defends
5
8
  module_function
6
9
 
10
+ # Flags for matching a changed path against a lane's touch_set globs.
11
+ # PATHNAME keeps a single `*` from crossing `/`; EXTGLOB enables `{a,b}`;
12
+ # DOTMATCH lets a glob reach dotfile segments, so a `dir/**` touch set covers
13
+ # `dir/.github/workflows/ci.yml` — the standard deliverable for a lane preparing
14
+ # a directory to become a repo root.
15
+ TOUCH_FNM = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
16
+
17
+ # Every path beneath root, at every depth: files, directories, dotfiles, and
18
+ # dot-directory contents all included. `File::FNM_DOTMATCH` is what makes
19
+ # dotfiles visible to `Dir.glob`, but it also emits a bogus `root/.`
20
+ # self-entry — that trap is absorbed here so no callsite has to know about it.
21
+ # Returns Array<String> — Dir.glob's native return shape.
22
+ def content_tree(root)
23
+ Dir.glob(File.join(root.to_s, "**", "*"), File::FNM_DOTMATCH).reject { |p| File.basename(p) == "." }
24
+ end
25
+
26
+ # The direct children of a fixed-depth structured directory layout (one
27
+ # entry per iteration/skill/lane/space) where a leading dot never names a
28
+ # real layout member — only tooling junk (.git, .DS_Store). Excludes them.
29
+ # Returns Array<Pathname> — Pathname#children's native return shape.
30
+ def layout_children(dir)
31
+ Pathname.new(dir).children.reject { |c| c.basename.to_s.start_with?(".") }
32
+ end
33
+
34
+ # Does a single touch_set glob match path? A trailing `dir/**` is matched
35
+ # twice: bare (PATHNAME stops it at direct children) and as `dir/**/*`,
36
+ # whose whole-component `**/` does cross `/`.
37
+ def touch_match?(glob, path)
38
+ File.fnmatch(glob, path, TOUCH_FNM) ||
39
+ (glob.end_with?("/**") && File.fnmatch("#{glob}/*", path, TOUCH_FNM))
40
+ end
41
+
7
42
  def contract(path, env: ENV)
8
43
  value = path.to_s
9
44
  home = XDG.home(env: env)