space-architect 5.5.0 → 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.
@@ -7,6 +7,8 @@ require "fileutils"
7
7
  require "pathname"
8
8
  require "tempfile"
9
9
  require "time"
10
+ require "digest"
11
+ require "shellwords"
10
12
 
11
13
  module Space::Architect
12
14
  # Manages an architect-loop project inside a space: one self-contained file per
@@ -42,6 +44,23 @@ module Space::Architect
42
44
  # Hard per-gate timeout. Generous relative to the full suite (~55s).
43
45
  DEFAULT_GATE_TIMEOUT = 900
44
46
 
47
+ # The scaffold's untouched placeholder AC1 line — templates/iteration.md.erb's
48
+ # Acceptance Criteria section (named, not line-numbered: a pinned line number
49
+ # here has already drifted twice). Pinned by contract with the authoring
50
+ # lane, which is forbidden from changing it, precisely so freeze!'s #73
51
+ # hard-refuse (below) can key on it.
52
+ AC1_PLACEHOLDER = "**AC1.** ..."
53
+
54
+ # Rehearsal's BROKEN heuristic (I09/AC5): a command-not-found exit code or a
55
+ # shell parse failure, distinct from a clean non-zero (RED — the gate
56
+ # discriminates). Advisory, not authoritative — see #classify_rehearsal.
57
+ BROKEN_STDERR_PATTERN = /\bsyntax error\b|unexpected end of file|unexpected eof/i
58
+
59
+ # I09/AC9(b): a gate `cmd` that carries a literal 'repos/<name>/' prefix
60
+ # with no `cwd` — legal, occasionally correct, but usually a leftover
61
+ # space-root-relative path since `cmd` already resolves against the repo tree.
62
+ BARE_REPO_PREFIX = %r{(?<![\w./-])repos/[^/\s'"]+/}
63
+
45
64
  # Legacy sentinel: worktree_add used to seed prompt.md with this placeholder
46
65
  # (dropped — the blind-overwrite tripped harness read-before-write guards, #48).
47
66
  # dispatch still refuses to launch on this content, so stubs in old spaces
@@ -146,6 +165,7 @@ module Space::Architect
146
165
  block = space.data["project"] || {}
147
166
  architecture_dir = space.path.join("architecture")
148
167
  iteration_files = if architecture_dir.exist?
168
+ # paths:exempt - the /\AI\d+-.+\.md\z/ filter structurally cannot match a dotfile-prefixed name, so raw enumeration is already dotfile-safe here
149
169
  architecture_dir.children
150
170
  .select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
151
171
  .map { |f| f.basename.to_s }.sort
@@ -159,7 +179,7 @@ module Space::Architect
159
179
  # any pending changes to the iteration file and records HEAD as freeze_sha. If
160
180
  # already frozen, refuses when the frozen region has changed since.
161
181
  # With force: true, re-freezes a changed frozen region if no lane is dispatched yet.
162
- def freeze!(iteration, warnings: nil, message: nil, force: false)
182
+ def freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil)
163
183
  entry = slice_entry(iteration)
164
184
  rel = entry["file"]
165
185
  path = space.path.join(rel)
@@ -172,6 +192,19 @@ module Space::Architect
172
192
  lint_gates!(text, warnings: warnings)
173
193
  lint_lanes!(text)
174
194
 
195
+ if untouched_ac_placeholder?(text)
196
+ raise Space::Core::Error,
197
+ "#{rel}'s Acceptance Criteria still carries the scaffold placeholder '#{AC1_PLACEHOLDER}' with no " \
198
+ "active gate — write the real Acceptance Criteria (a hand-authored prose-only AC without the " \
199
+ "placeholder still freezes) before freezing."
200
+ end
201
+
202
+ if skip_rehearse_reason
203
+ raise Space::Core::Error, "--skip-rehearse requires a non-empty REASON" if skip_rehearse_reason.to_s.strip.empty?
204
+ else
205
+ ensure_rehearsed!(iteration, entry, text)
206
+ end
207
+
175
208
  if entry["freeze_sha"]
176
209
  sha = entry["freeze_sha"]
177
210
  if frozen_region_changed?(sha, rel)
@@ -204,6 +237,7 @@ module Space::Architect
204
237
  next unless s["name"] == iteration
205
238
  s["freeze_sha"] = sha
206
239
  s["verdict"] ||= "pending"
240
+ s["rehearsal_skip_reason"] = skip_rehearse_reason.strip if skip_rehearse_reason
207
241
  lanes = s["lanes"] || []
208
242
  declared.each do |d|
209
243
  fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
@@ -314,6 +348,9 @@ module Space::Architect
314
348
 
315
349
  # Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
316
350
  # the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
351
+ # Re-transcribing a lane replaces its existing "### <lane>" subsection in place
352
+ # (preserving the order lanes were already transcribed in) instead of appending a
353
+ # duplicate; a lane not yet present still appends.
317
354
  def transcribe_evidence!(iteration, lane: nil, message: nil)
318
355
  entry = slice_entry(iteration)
319
356
  rel = entry["file"]
@@ -326,8 +363,15 @@ module Space::Architect
326
363
  raw = report.read
327
364
  raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty?
328
365
 
329
- block = lane ? "### #{lane}\n\n#{raw.rstrip}" : raw.rstrip
330
- path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
366
+ text = path.read
367
+ new_body =
368
+ if lane
369
+ lane_names = (entry["lanes"] || []).map { |l| l["name"] }
370
+ replace_lane_report(section_body(text, "## Builder Report").to_s, lane, raw.rstrip, lane_names)
371
+ else
372
+ raw.rstrip
373
+ end
374
+ path.write(replace_section_body(text, "## Builder Report", new_body, append: false))
331
375
 
332
376
  nn = format("%02d", entry["ordinal"] || 0)
333
377
  git_capture("-C", space.path.to_s, "commit", "-m",
@@ -359,7 +403,20 @@ module Space::Architect
359
403
  # the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
360
404
  # Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
361
405
  # (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
362
- def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil)
406
+ #
407
+ # accept_bounds_reason overrides ONLY the in-bounds check — never no_builder_commits,
408
+ # which stays an unconditional refusal (a builder commit is tampering, not an authoring
409
+ # defect the architect can rule on). Modeled on freeze!'s --skip-rehearse: a non-empty
410
+ # REASON is required whenever passed, recorded in space.yaml beside the lane, and
411
+ # returned for the caller to echo — the override can never be silent. Recorded only
412
+ # for THIS lane, and only when its in-bounds check actually failed — integrate! passes
413
+ # the same reason to every lane in the set, but a lane that was already in bounds gets
414
+ # neither the record nor the echo.
415
+ def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
416
+ if accept_bounds_reason
417
+ raise Space::Core::Error, "--accept-bounds requires a non-empty REASON" if accept_bounds_reason.to_s.strip.empty?
418
+ end
419
+
363
420
  entry = slice_entry(iteration)
364
421
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
365
422
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
@@ -369,8 +426,10 @@ module Space::Architect
369
426
  if checks[:no_builder_commits] == false
370
427
  raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
371
428
  end
372
- if checks[:in_bounds] == false
373
- raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
429
+ if checks[:in_bounds] == false && !accept_bounds_reason
430
+ raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset " \
431
+ "and re-dispatch, or `architect integrate #{iteration} --lanes #{lane} --accept-bounds REASON` to override when " \
432
+ "the touch-set declaration itself is the defect."
374
433
  end
375
434
 
376
435
  repo = lane_entry["repo"]
@@ -404,10 +463,7 @@ module Space::Architect
404
463
  git_capture("-C", repo_path.to_s, "merge", "--abort")
405
464
  conflict_files = conflicts.split
406
465
  lane_touch_set = lane_entry["touch_set"] || []
407
- fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
408
- outside = conflict_files.reject do |f|
409
- lane_touch_set.any? { |g| File.fnmatch(g, f, fnm) || (g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm)) }
410
- end
466
+ outside = conflict_files.reject { |f| in_touch_set?(f, lane_touch_set) }
411
467
  if !lane_touch_set.empty? && outside.empty?
412
468
  raise Space::Core::Error,
413
469
  "Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — the lane plan was " \
@@ -423,6 +479,8 @@ module Space::Architect
423
479
  merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
424
480
  diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")
425
481
 
482
+ bounds_override_reason = accept_bounds_reason.strip if accept_bounds_reason && checks[:in_bounds] == false
483
+
426
484
  update_architect_block do |b|
427
485
  b["integration_branch"] = integration_branch
428
486
  (b["iterations"] || []).each do |s|
@@ -431,27 +489,36 @@ module Space::Architect
431
489
  next unless l["name"] == lane
432
490
  l["integration_branch"] = integration_branch
433
491
  l["integrate_sha"] = integrate_sha
492
+ l["bounds_override_reason"] = bounds_override_reason if bounds_override_reason
434
493
  end
435
494
  end
436
495
  b
437
496
  end
438
497
 
439
- { lane: lane, repo: repo, integration_branch: integration_branch,
440
- merge_sha: merge_sha.strip, base_sha: base_sha, diffstat: diffstat.strip, gates_run: false }
498
+ { lane: lane, repo: repo, integration_branch: integration_branch, merge_sha: merge_sha.strip,
499
+ base_sha: base_sha, diffstat: diffstat.strip, gates_run: false,
500
+ bounds_override_reason: bounds_override_reason }
441
501
  end
442
502
 
443
503
  # Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
444
504
  # first conflict (a disjointness defect). Never decides which lanes pass. With no
445
505
  # lanes and teardown: true, tears down every lane recorded for the iteration instead
446
506
  # (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
447
- def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil)
507
+ #
508
+ # merge_lane!/teardown_lanes! only save integration_branch/integrate_sha/worktree to
509
+ # space.yaml on disk (update_architect_block never commits) — one commit per call,
510
+ # here, by pathspec, so which lanes merged survives independently of whether a
511
+ # Verdict follows (I13/A3). Always attempted, success or raise, so a conflict that
512
+ # stops the loop midway doesn't lose the lanes already merged.
513
+ def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
448
514
  lanes = Array(lanes)
449
515
  return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
450
516
  raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
451
517
 
452
518
  merged = []
453
519
  lanes.each do |lane|
454
- merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into)
520
+ merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into,
521
+ accept_bounds_reason: accept_bounds_reason)
455
522
  rescue Space::Core::Error => e
456
523
  done = merged.map { |m| m[:lane] }.join(", ")
457
524
  raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
@@ -459,6 +526,8 @@ module Space::Architect
459
526
 
460
527
  teardown_lanes!(iteration, merged) if teardown
461
528
  merged
529
+ ensure
530
+ commit_metadata_mutation!(iteration, message: message)
462
531
  end
463
532
 
464
533
  # Run the iteration's frozen Acceptance Criteria gate commands. Each gate is
@@ -466,7 +535,10 @@ module Space::Architect
466
535
  # a hard timeout, and evaluated against its `expect` block. Returns an array
467
536
  # of result hashes with :status (:pass/:fail) and :reason in addition to the
468
537
  # raw :stdout/:stderr/:exit_code. The mechanical verdict belongs here; the AC
469
- # verdict remains the architect's.
538
+ # verdict remains the architect's. WHERE the gate text comes from (the frozen
539
+ # commit) is resolved here; HOW gates execute is #execute_gates, shared
540
+ # byte-for-byte with #rehearse so the two can never run gates through
541
+ # different instruments (I09/AC3).
470
542
  def run_gates(iteration, lane: nil)
471
543
  entry = slice_entry(iteration)
472
544
  freeze_sha = entry["freeze_sha"]
@@ -494,37 +566,50 @@ module Space::Architect
494
566
  end
495
567
  raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
496
568
 
497
- gates.map do |gate|
498
- g = gate.transform_keys(&:to_s)
499
- dir =
500
- if (cwd = g["cwd"])
501
- gate_cwd = space.path.join(cwd)
502
- if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
503
- base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
504
- else
505
- gate_cwd
506
- end
507
- else
508
- base_dir
509
- end
510
- raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
569
+ execute_gates(gates, base_dir: base_dir, lane: lane, repo_root: repo_root)
570
+ end
511
571
 
512
- effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
513
- captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
572
+ # Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the
573
+ # freeze, while they can still be fixed — through the identical execution
574
+ # path #run_gates uses at judge time (#execute_gates). space.yaml records no
575
+ # lanes until freeze! writes them (#freeze!, :~209), so the run directory is
576
+ # resolved from the DRAFTED ```lanes``` block instead of the recorded one;
577
+ # rehearsal always runs in the repo checkout (repos/<repo>), never a lane
578
+ # worktree, because lane worktrees are not provisioned until after the
579
+ # freeze. Classifies each result RED/GREEN/BROKEN (I09/AC5) and stamps the
580
+ # iteration as rehearsed, keyed to the gates block's content (I09/AC7) — the
581
+ # stamp records that the architect looked, never that gates passed.
582
+ def rehearse(iteration, now: Time.now)
583
+ entry = slice_entry(iteration)
584
+ rel = entry["file"]
585
+ path = space.path.join(rel)
586
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
587
+ text = path.read
514
588
 
515
- if captured[:timed_out]
516
- status = :fail
517
- reason = "timed out after #{effective}s"
589
+ gates = parse_gates(text)
590
+ repo, base_dir, gate_results, scope_report =
591
+ if gates.empty?
592
+ [nil, nil, [], nil]
518
593
  else
519
- ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
520
- status = ev.pass? ? :pass : :fail
521
- reason = ev.reason
594
+ r = resolve_rehearsal_repo(iteration, text)
595
+ dir = space.path.join("repos", r)
596
+ raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
597
+ results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil)
598
+ .map { |g| g.merge(rehearsal: classify_rehearsal(g)) }
599
+ [r, dir, results, scope_asymmetry_report(gates, text, dir)]
522
600
  end
523
601
 
524
- { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
525
- stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
526
- dir: dir, status: status, reason: reason }
602
+ digest = gates_digest(text)
603
+ update_architect_block do |b|
604
+ (b["iterations"] || []).each do |s|
605
+ next unless s["name"] == iteration
606
+ s["rehearsal"] = { "gates_digest" => digest, "at" => now.iso8601 }
607
+ end
608
+ b
527
609
  end
610
+
611
+ { iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results,
612
+ empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report }
528
613
  end
529
614
 
530
615
  # Emit grounding reads for the architect's SessionStart hook.
@@ -785,7 +870,7 @@ module Space::Architect
785
870
  def worktree_list
786
871
  wt_base = space.path.join("build")
787
872
  return [] unless wt_base.exist?
788
- wt_base.children.select(&:directory?).map { |p| p.basename.to_s }.sort
873
+ Space::Core::Paths.layout_children(wt_base).select(&:directory?).map { |p| p.basename.to_s }.sort
789
874
  end
790
875
 
791
876
  # Materialize the iteration's declared lanes: for each lane (or the one named via
@@ -998,6 +1083,21 @@ module Space::Architect
998
1083
  body.empty? ? composed : "#{composed}\n\n#{body}"
999
1084
  end
1000
1085
 
1086
+ # integrate!'s own space.yaml commit, by pathspec — like record_verdict!'s sweep, so
1087
+ # other uncommitted work in the space isn't pulled in. A call that mutated nothing
1088
+ # (teardown-only over lanes with no worktree, or a call that raised before touching
1089
+ # anything) leaves git commit with nothing staged for that path; tolerate that exit
1090
+ # via git_capture the way write_section! tolerates a no-op frozen-section commit,
1091
+ # never git_run it.
1092
+ def commit_metadata_mutation!(iteration, message:)
1093
+ entries = (space.data["project"] || {})["iterations"] || []
1094
+ ordinal = entries.find { |s| s["name"] == iteration }&.dig("ordinal") || 0
1095
+ nn = format("%02d", ordinal)
1096
+ git_capture("-C", space.path.to_s, "commit", "-m",
1097
+ compose_message("I#{nn} integrate:", "I#{nn}: record integration", message),
1098
+ "--", Space::Core::Space::METADATA_FILE)
1099
+ end
1100
+
1001
1101
  # Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
1002
1102
  # either merge_lane! results (symbol keys) or recorded lane entries (string
1003
1103
  # keys) — both carry a lane name and a repo.
@@ -1031,6 +1131,7 @@ module Space::Architect
1031
1131
  end
1032
1132
  end
1033
1133
 
1134
+ # paths:exempt - the /\AI\d+-.+\.md\z/ filter structurally cannot match a dotfile-prefixed name, so raw enumeration is already dotfile-safe here
1034
1135
  candidates = arch_dir.children.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
1035
1136
  return nil if candidates.empty?
1036
1137
  candidates.max_by { |f| f.basename.to_s[/\AI(\d+)/, 1].to_i }
@@ -1042,7 +1143,7 @@ module Space::Architect
1042
1143
  def capture_with_timeout(cmd, dir:, timeout:)
1043
1144
  out_f = Tempfile.new(["gate-stdout", ".log"])
1044
1145
  err_f = Tempfile.new(["gate-stderr", ".log"])
1045
- pid = Process.spawn(cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
1146
+ pid = Process.spawn("/bin/sh", "-c", cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
1046
1147
 
1047
1148
  deadline = Time.now + timeout
1048
1149
  status = nil
@@ -1072,6 +1173,311 @@ module Space::Architect
1072
1173
  err_f&.close!
1073
1174
  end
1074
1175
 
1176
+ # HOW gates execute — the one instrument #run_gates (judge time) and
1177
+ # #rehearse (pre-freeze) both call, byte-for-byte: same cwd-remap semantics,
1178
+ # same shell (capture_with_timeout's Process.spawn), same GateEvaluator,
1179
+ # same timeout handling (I09/AC3). repo_root/lane are nil outside a lane
1180
+ # context (rehearsal never has one — it always runs in the repo checkout).
1181
+ def execute_gates(gates, base_dir:, lane:, repo_root:)
1182
+ gates.map do |gate|
1183
+ g = gate.transform_keys(&:to_s)
1184
+ dir =
1185
+ if (cwd = g["cwd"])
1186
+ gate_cwd = space.path.join(cwd)
1187
+ if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
1188
+ base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
1189
+ else
1190
+ gate_cwd
1191
+ end
1192
+ else
1193
+ base_dir
1194
+ end
1195
+ raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
1196
+
1197
+ effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
1198
+ captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
1199
+
1200
+ if captured[:timed_out]
1201
+ status = :fail
1202
+ reason = "timed out after #{effective}s"
1203
+ else
1204
+ ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
1205
+ status = ev.pass? ? :pass : :fail
1206
+ reason = ev.reason
1207
+ end
1208
+
1209
+ { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
1210
+ stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
1211
+ dir: dir, status: status, reason: reason, timed_out: captured[:timed_out] }
1212
+ end
1213
+ end
1214
+
1215
+ # I09/AC4: resolve rehearsal's run repo from the DRAFTED ```lanes``` block —
1216
+ # one repo, unambiguously declared or inferable, else a message naming what
1217
+ # to do. Never from space.yaml (no lanes are recorded there pre-freeze).
1218
+ def resolve_rehearsal_repo(iteration, text)
1219
+ declared = parse_lanes(text).filter_map { |l| l["repo"] }.uniq
1220
+ return declared.first if declared.size == 1
1221
+
1222
+ if declared.empty?
1223
+ tracked = space.repos.map { |r| r["name"] }
1224
+ return tracked.first if tracked.size == 1
1225
+ raise Space::Core::Error,
1226
+ "Cannot resolve a repo to rehearse '#{iteration}' against — no lane declares a repo and the space " \
1227
+ "tracks #{tracked.size} repos (#{tracked.join(', ')}). Declare a ```lanes``` block in the " \
1228
+ "Specification, naming the repo to rehearse against."
1229
+ end
1230
+
1231
+ raise Space::Core::Error,
1232
+ "Cannot resolve a single repo to rehearse '#{iteration}' against — the drafted lanes block names " \
1233
+ "multiple repos (#{declared.join(', ')}). Rehearsal runs once, against one repo checkout; narrow the " \
1234
+ "```lanes``` block before rehearsing."
1235
+ end
1236
+
1237
+ # I09/AC5: RED (clean non-zero — discriminates) vs GREEN (passes on base) vs
1238
+ # BROKEN (127, timeout, or a shell parse failure). BROKEN is advisory, not
1239
+ # authoritative: a correct RED can look broken (e.g. `grep -q x new_file`
1240
+ # exits 2 — "No such file" — when the file is one the lane will write); the
1241
+ # CLI names this suspicion, the architect confirms it.
1242
+ def classify_rehearsal(result)
1243
+ return :broken if result[:timed_out] || result[:exit_code] == 127
1244
+ return :broken if BROKEN_STDERR_PATTERN.match?(result[:stderr].to_s)
1245
+ result[:status] == :pass ? :green : :red
1246
+ end
1247
+
1248
+ # grep-family binaries this check recognizes in a gate's shell command.
1249
+ SCOPE_GREP_BINARIES = %w[grep egrep fgrep].freeze
1250
+
1251
+ # Shellwords treats shell syntax — pipes, `&&`/`||`/`;`, `$(...)`, control
1252
+ # keywords — as ordinary word characters, not structure (its own docs say
1253
+ # plainly this isn't a command-line parser). These are the markers
1254
+ # #shell_segments splits a token stream on, once #pad_shell_operators has
1255
+ # made sure none of them can arrive glued to an adjacent word.
1256
+ SCOPE_BOUNDARY_TOKENS = %w[| || && ; $( ( ) if elif then else fi do done while until case esac].freeze
1257
+
1258
+ # Flags that change what a pattern matches and this check actually replays
1259
+ # verbatim against the real grep binary — mode (E/F/G/P) and w, both
1260
+ # threaded through to #whole_repo_grep_matches below — vs flags that only
1261
+ # change what's printed (irrelevant to a files-with-matches re-run). A flag
1262
+ # belongs here only once something below replays it.
1263
+ SCOPE_MATCH_FLAGS = %w[E F G P w].freeze
1264
+ SCOPE_NOOP_FLAGS = %w[c i n o q r l z H h].freeze
1265
+
1266
+ # Recognized flags git grep has no equivalent for (-w and -x are not
1267
+ # symmetric) — declined as not-analyzable, reason naming the flag, rather
1268
+ # than replayed wrong or silently dropped.
1269
+ SCOPE_DECLINED_FLAGS = { "x" => "-x (whole-line match) has no git-grep equivalent" }.freeze
1270
+
1271
+ # git grep, not a raw recursive grep: it skips .git's own object store (a
1272
+ # raw `grep -r .` would trawl it) and matches "the tree" the way the rest
1273
+ # of this corpus's git-based gates already do (diff-scope's own gates are
1274
+ # git diff, not find/grep). Small fixed budget — runs once per recognized
1275
+ # invocation, not per gate.
1276
+ SCOPE_SEARCH_TIMEOUT = 30
1277
+
1278
+ # I12/AC3: rehearse's scope-asymmetry check. I11 shipped a bug its own new
1279
+ # boundary discipline could not catch: a gate's grep-family search covered
1280
+ # only `lib`, while the identifier it renamed also lived in a `test/` file
1281
+ # no lane declared — three consistent statements, all drawn from the same
1282
+ # too-narrow grep. For each gate whose command is a recognizable
1283
+ # file-scoped grep invocation, re-run its own pattern across the whole repo
1284
+ # and report anything that matches outside the gate's own declared paths,
1285
+ # flagging loudest whatever also lies outside every declared lane's touch
1286
+ # set (I12/AC4) — the file no lane may legally fix. touch_globs is that
1287
+ # union; parse_lanes returns [] with no ```lanes``` block, which still
1288
+ # yields the outside-every-lane half of the report. Reports only: never
1289
+ # touches rehearse's exit code, RED/GREEN/BROKEN, or the rehearsal stamp.
1290
+ def scope_asymmetry_report(gates, text, base_dir)
1291
+ touch_globs = parse_lanes(text).flat_map { |l| l["touch"] || [] }
1292
+ findings = []
1293
+ not_analyzable = []
1294
+
1295
+ gates.each do |gate|
1296
+ g = gate.transform_keys(&:to_s)
1297
+ grep_invocations(g["cmd"].to_s).each do |inv|
1298
+ if inv[:status] != :recognized
1299
+ not_analyzable << { id: g["id"], reason: inv[:reason] }
1300
+ next
1301
+ end
1302
+
1303
+ finding = scope_finding(inv, base_dir, touch_globs)
1304
+ if finding
1305
+ findings << finding.merge(id: g["id"])
1306
+ else
1307
+ not_analyzable << { id: g["id"], reason: "whole-repo re-run failed" }
1308
+ end
1309
+ end
1310
+ end
1311
+
1312
+ { findings: findings, not_analyzable: not_analyzable }
1313
+ end
1314
+
1315
+ # Re-runs a recognized invocation's own pattern across the whole repo and
1316
+ # splits what it finds into: the gate's own declared scope (expected —
1317
+ # that's what the gate already searches, so it's not reported), elsewhere
1318
+ # but inside some lane's touch set, and elsewhere outside every lane's
1319
+ # touch set. nil signals the re-run itself failed (caller counts it as
1320
+ # not-analyzable rather than reporting a guess).
1321
+ def scope_finding(inv, base_dir, touch_globs)
1322
+ matched = whole_repo_grep_matches(inv, base_dir)
1323
+ return nil unless matched
1324
+
1325
+ elsewhere = matched.reject { |f| within_declared_scope?(f, inv[:paths], base_dir) }
1326
+ within_lanes, outside_lanes = elsewhere.partition { |f| in_touch_set?(f, touch_globs) }
1327
+ { pattern: inv[:pattern], paths: inv[:paths], outside_lanes: outside_lanes.sort, within_lanes: within_lanes.sort }
1328
+ end
1329
+
1330
+ # The files (relative paths) an invocation's own pattern matches anywhere
1331
+ # in the repo, via the real git-grep binary with the same
1332
+ # matching-relevant flags — never a Ruby reimplementation of grep's
1333
+ # pattern semantics. nil on anything other than a clean "matched"/"no
1334
+ # matches" outcome (exit 0/1); [] is a real, examined zero.
1335
+ def whole_repo_grep_matches(inv, base_dir)
1336
+ flags = ["-l", "-I"]
1337
+ flags << "-i" if inv[:case_insensitive]
1338
+ flags << "-w" if inv[:word_boundary]
1339
+ flags << "-#{inv[:mode]}" unless inv[:mode] == "G"
1340
+ cmd = "git grep #{flags.join(' ')} -- #{Shellwords.escape(inv[:pattern])}"
1341
+ captured = capture_with_timeout(cmd, dir: base_dir, timeout: SCOPE_SEARCH_TIMEOUT)
1342
+ return [] if captured[:exit_code] == 1
1343
+ return nil if captured[:timed_out] || captured[:exit_code] != 0
1344
+
1345
+ captured[:stdout].each_line.map(&:chomp).reject(&:empty?)
1346
+ end
1347
+
1348
+ # Is relative_path inside one of an invocation's own declared search
1349
+ # paths? A declared path that's a real directory at rehearsal time covers
1350
+ # anything under it; a file (or a path a lane hasn't written yet, same
1351
+ # BROKEN-adjacent case #classify_rehearsal already names) covers only
1352
+ # itself.
1353
+ def within_declared_scope?(relative_path, declared_paths, base_dir)
1354
+ declared_paths.any? do |p|
1355
+ p = p.sub(%r{/\z}, "")
1356
+ relative_path == p || (base_dir.join(p).directory? && relative_path.start_with?("#{p}/"))
1357
+ end
1358
+ end
1359
+
1360
+ # Every grep-family invocation recognized in one gate's shell command. A
1361
+ # command that never mentions grep/egrep/fgrep isn't this check's concern
1362
+ # at all (silently absent, same as e.g. `bundle exec rake test`) — only a
1363
+ # command that DOES attempt one and can't be cleanly recognized is
1364
+ # reported not-analyzable (I12/AC4's "counted and identifiable").
1365
+ def grep_invocations(cmd)
1366
+ return [] unless cmd.match?(/\b(?:#{SCOPE_GREP_BINARIES.join('|')})\b/)
1367
+
1368
+ segments = shell_segments(cmd)
1369
+ return [{ status: :not_analyzable, reason: "unparseable command" }] unless segments
1370
+
1371
+ found = segments.filter_map { |seg| grep_invocation(seg) }
1372
+ return [{ status: :not_analyzable, reason: "could not locate a recognizable grep invocation" }] if found.empty?
1373
+
1374
+ found
1375
+ end
1376
+
1377
+ # Splits a gate's shell command into simple-command segments at pipes,
1378
+ # `&&`/`||`/`;`, command substitution, and control keywords (see
1379
+ # SCOPE_BOUNDARY_TOKENS). Returns nil on anything Shellwords itself can't
1380
+ # tokenize (an unmatched quote), so the caller counts it instead of
1381
+ # guessing at it.
1382
+ def shell_segments(cmd)
1383
+ tokens = expand_quoted_substitutions(shell_tokenize(cmd))
1384
+ segments = [[]]
1385
+ tokens.each do |tok|
1386
+ if SCOPE_BOUNDARY_TOKENS.include?(tok)
1387
+ segments << []
1388
+ else
1389
+ segments.last << tok
1390
+ end
1391
+ end
1392
+ segments.reject(&:empty?)
1393
+ rescue ArgumentError
1394
+ nil
1395
+ end
1396
+
1397
+ def shell_tokenize(cmd)
1398
+ Shellwords.split(pad_shell_operators(cmd))
1399
+ end
1400
+
1401
+ # Shellwords treats shell operator/substitution punctuation as ordinary
1402
+ # word characters whenever it isn't whitespace-separated (its own docs
1403
+ # warn this isn't a command-line parser) — pads whitespace around
1404
+ # `$(` `&&` `||` `;` `|` `(` `)` and folds a bare newline to `;` (its own
1405
+ # statement terminator, otherwise invisible to Shellwords as anything but
1406
+ # whitespace) so each always surfaces as its own token. Quoted spans and
1407
+ # backslash-escapes are passed through untouched, so a pattern's own `|`
1408
+ # or `$` — inside a quote — is never mistaken for shell syntax.
1409
+ def pad_shell_operators(text)
1410
+ out = String.new
1411
+ text.scan(/'[^']*'|"(?:[^"\\]|\\.)*"|\\.|[^'"\\]+/m) do |chunk|
1412
+ if chunk.start_with?("'", '"', "\\")
1413
+ out << chunk
1414
+ else
1415
+ out << chunk.gsub(/\$\(|&&|\|\||[();|\n]/) { |op| op == "\n" ? " ; " : " #{op} " }
1416
+ end
1417
+ end
1418
+ out
1419
+ end
1420
+
1421
+ # A token that is ITSELF a whole `$(...)` survives #pad_shell_operators
1422
+ # intact only when the substitution sat inside a still-open quote
1423
+ # (protected, so nothing inside it got space-padded) — recurse into its
1424
+ # contents exactly as the top-level command was tokenized.
1425
+ def expand_quoted_substitutions(tokens)
1426
+ tokens.flat_map do |tok|
1427
+ m = tok.match(/\A\$\((.*)\)\z/m)
1428
+ next [tok] unless m
1429
+
1430
+ ["$("] + expand_quoted_substitutions(shell_tokenize(m[1])) + [")"]
1431
+ end
1432
+ end
1433
+
1434
+ # Recognizes one simple-command segment as a file-scoped grep-family
1435
+ # invocation, or declines with a specific reason. Never guesses: `-v`
1436
+ # inverts per-line matching (fine for filtering a computed list, wrong for
1437
+ # "does this pattern occur elsewhere") so it's declined rather than
1438
+ # silently reinterpreted; so is any unsupported flag or a path operand
1439
+ # that still carries an unresolved shell variable.
1440
+ def grep_invocation(segment)
1441
+ i = 0
1442
+ i += 1 while segment[i] == "!"
1443
+ return nil unless segment[i] && SCOPE_GREP_BINARIES.include?(segment[i])
1444
+ i += 1
1445
+
1446
+ flag_chars = []
1447
+ loop do
1448
+ tok = segment[i]
1449
+ break unless tok
1450
+ if tok == "--"
1451
+ i += 1
1452
+ break
1453
+ elsif tok.start_with?("-") && tok != "-"
1454
+ flag_chars.concat(tok.delete_prefix("-").chars)
1455
+ i += 1
1456
+ else
1457
+ break
1458
+ end
1459
+ end
1460
+
1461
+ unsupported = flag_chars - (SCOPE_MATCH_FLAGS + SCOPE_NOOP_FLAGS + SCOPE_DECLINED_FLAGS.keys)
1462
+ return { status: :not_analyzable, reason: "unsupported flag(s): #{unsupported.uniq.join(', ')}" } if unsupported.any?
1463
+ return { status: :not_analyzable, reason: "-v (inverted match) semantics not safely reinterpreted" } if flag_chars.include?("v")
1464
+
1465
+ declined = (flag_chars & SCOPE_DECLINED_FLAGS.keys).uniq
1466
+ return { status: :not_analyzable, reason: declined.map { |f| SCOPE_DECLINED_FLAGS[f] }.join("; ") } if declined.any?
1467
+
1468
+ modes = (flag_chars & %w[E F G P]).uniq
1469
+ return { status: :not_analyzable, reason: "ambiguous pattern flags: #{modes.join(', ')}" } if modes.size > 1
1470
+ return { status: :not_analyzable, reason: "no pattern operand" } unless segment[i]
1471
+
1472
+ pattern = segment[i]
1473
+ paths = segment[(i + 1)..] || []
1474
+ return { status: :not_analyzable, reason: "no path operand (pipeline/stdin search)" } if paths.empty?
1475
+ return { status: :not_analyzable, reason: "path operand contains an unresolved shell variable" } if paths.any? { |p| p.include?("$") }
1476
+
1477
+ { status: :recognized, pattern: pattern, paths: paths, case_insensitive: flag_chars.include?("i"),
1478
+ word_boundary: flag_chars.include?("w"), mode: modes.first || "G" }
1479
+ end
1480
+
1075
1481
  def iteration_id(entry)
1076
1482
  "I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
1077
1483
  end
@@ -1298,18 +1704,40 @@ module Space::Architect
1298
1704
  i += 1
1299
1705
  changed << orig if orig && !orig.empty?
1300
1706
  end
1301
- fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
1302
- changed.all? do |f|
1303
- touch_set.any? do |g|
1304
- File.fnmatch(g, f, fnm) ||
1305
- (g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm))
1306
- end
1307
- end
1707
+ changed.all? { |f| in_touch_set?(f, touch_set) }
1308
1708
  end
1309
1709
 
1310
1710
  checks
1311
1711
  end
1312
1712
 
1713
+ # Is a changed path inside a lane's declared touch set? Single-sourced so the
1714
+ # in-bounds check (d) and merge_lane!'s conflict classification can never drift.
1715
+ def in_touch_set?(path, globs)
1716
+ globs.any? { |g| Space::Core::Paths.touch_match?(g, path) }
1717
+ end
1718
+
1719
+ # Merge a lane's verbatim block into the current "## Builder Report" body for
1720
+ # #transcribe_evidence!: replace its own "### <lane>" subsection in place if
1721
+ # present (preserving the order lanes were already transcribed in), else append
1722
+ # a new one after the others. Subsection boundaries are matched against the
1723
+ # iteration's declared lane names (like KNOWN_HEADINGS for top-level sections),
1724
+ # not any "### " line, so a verbatim report containing its own "### " heading
1725
+ # can't fool the parser.
1726
+ def replace_lane_report(body, lane, raw, lane_names)
1727
+ block = "### #{lane}\n\n#{raw}"
1728
+ return block if placeholder_body?(body)
1729
+
1730
+ headings = lane_names.map { |n| "### #{n}" }
1731
+ lines = body.lines
1732
+ start = lines.index { |l| l.chomp == "### #{lane}" }
1733
+ return "#{body.strip}\n\n#{block}" unless start
1734
+
1735
+ finish = ((start + 1)...lines.length).find { |i| headings.include?(lines[i].chomp) } || lines.length
1736
+ before = lines[0...start].join.strip
1737
+ after = lines[finish..].join.strip
1738
+ [before, block, after].reject(&:empty?).join("\n\n")
1739
+ end
1740
+
1313
1741
  # Replace (or, with append:, extend) the body of a "## Heading" section, leaving
1314
1742
  # every other section byte-untouched. Append replaces a placeholder body (only a
1315
1743
  # template comment) the first time, then stacks subsections after it. A supplied
@@ -1356,18 +1784,58 @@ module Space::Architect
1356
1784
  lines[(start + 1)...finish].join.strip
1357
1785
  end
1358
1786
 
1787
+ # The raw (unparsed) text inside the fenced ```gates block, or nil when the
1788
+ # block itself is absent. Single-sourced so #parse_gates and the rehearsal
1789
+ # stamp's content digest (#gates_digest) can never read two different
1790
+ # slices of the same section.
1791
+ def gates_block_source(text)
1792
+ body = section_body(text, "## Acceptance Criteria")
1793
+ return nil unless body
1794
+ match = body.match(/^```gates\n(.*?)^```/m)
1795
+ match && match[1]
1796
+ end
1797
+
1359
1798
  # Extract and parse the fenced ```gates block from the Acceptance Criteria section.
1360
1799
  # Returns an array of gate hashes (string-keyed). Returns [] when the block is
1361
1800
  # absent, empty, or contains only YAML comments.
1362
1801
  def parse_gates(text)
1363
- body = section_body(text, "## Acceptance Criteria")
1364
- return [] unless body
1365
- match = body.match(/^```gates\n(.*?)^```/m)
1366
- return [] unless match
1367
- parsed = YAML.safe_load(match[1], aliases: false)
1802
+ raw = gates_block_source(text)
1803
+ return [] unless raw
1804
+ parsed = YAML.safe_load(raw, aliases: false)
1368
1805
  parsed.is_a?(Array) ? parsed : []
1369
1806
  end
1370
1807
 
1808
+ # A stable digest of the gates block's raw content — the key the I09/AC7
1809
+ # rehearsal stamp is validated against. Any byte edit to the fenced block
1810
+ # (add, remove, reorder, reword) changes this and invalidates the stamp.
1811
+ def gates_digest(text)
1812
+ Digest::SHA256.hexdigest(gates_block_source(text).to_s)
1813
+ end
1814
+
1815
+ # #73, narrowly (I09/AC8): true only when the AC section still carries the
1816
+ # scaffold's untouched placeholder AND there is no active gate — a
1817
+ # hand-authored prose-only AC (placeholder replaced, still no gates) is not
1818
+ # this. Call only after lint_gates! has validated the block parses cleanly.
1819
+ def untouched_ac_placeholder?(text)
1820
+ body = section_body(text, "## Acceptance Criteria")
1821
+ return false unless body
1822
+ body.include?(AC1_PLACEHOLDER) && parse_gates(text).empty?
1823
+ end
1824
+
1825
+ # I09/AC7: freeze! refuses without a fresh rehearsal stamp — one whose
1826
+ # gates_digest matches the CURRENT (about-to-be-frozen) gates block. Stale
1827
+ # (gates edited since) or absent (never rehearsed) both refuse identically;
1828
+ # the stamp records that the architect looked, never that gates passed.
1829
+ def ensure_rehearsed!(iteration, entry, text)
1830
+ stamp = entry["rehearsal"]
1831
+ return if stamp && stamp["gates_digest"] == gates_digest(text)
1832
+
1833
+ raise Space::Core::Error,
1834
+ "Iteration '#{iteration}' has not been rehearsed against its current gates — " \
1835
+ "run `architect rehearse #{iteration}` first, or `architect freeze #{iteration} " \
1836
+ "--skip-rehearse REASON` to skip deliberately."
1837
+ end
1838
+
1371
1839
  # Extract and parse the fenced ```lanes block from the Specification section.
1372
1840
  # Returns an array of lane declaration hashes (string-keyed). Returns [] when the
1373
1841
  # block is absent, empty, or contains only YAML comments (back-compat).
@@ -1420,8 +1888,26 @@ module Space::Architect
1420
1888
  return
1421
1889
  end
1422
1890
  result = GateLint.call(gates)
1423
- return if result.success?
1424
- raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
1891
+ raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}" unless result.success?
1892
+
1893
+ warn_bare_repo_prefix!(gates, warnings) if warnings
1894
+ end
1895
+
1896
+ # I09/AC9(b), #39: a gate whose `cmd` carries a literal `repos/<name>/` prefix
1897
+ # with no `cwd` set is warned about, not failed — `cmd` already resolves
1898
+ # against the repo tree (`cwd` is what's space-root-relative), so this
1899
+ # pattern is usually a leftover space-root-relative path, but is legal and
1900
+ # occasionally correct. Threaded through the same warnings: channel
1901
+ # lint_gates! already carries, never a second channel.
1902
+ def warn_bare_repo_prefix!(gates, warnings)
1903
+ gates.each do |g|
1904
+ g = g.transform_keys(&:to_s)
1905
+ next if g["cwd"]
1906
+ next unless g["cmd"].to_s.match?(BARE_REPO_PREFIX)
1907
+ warnings << "gate '#{g["id"]}': cmd contains a literal 'repos/<name>/' path with no cwd set — " \
1908
+ "cmd already resolves against the repo tree, so this is likely a leftover space-root-relative " \
1909
+ "path (legal, occasionally correct — verify it)"
1910
+ end
1425
1911
  end
1426
1912
 
1427
1913
  # Raises if any lane in the entry has been dispatched (dispatched_at or integrate_sha set).