space-architect 5.5.1 → 7.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,12 +44,22 @@ module Space::Architect
42
44
  # Hard per-gate timeout. Generous relative to the full suite (~55s).
43
45
  DEFAULT_GATE_TIMEOUT = 900
44
46
 
45
- # Flags for matching a changed path against a lane's touch_set globs.
46
- # PATHNAME keeps a single `*` from crossing `/`; EXTGLOB enables `{a,b}`;
47
- # DOTMATCH lets a glob reach dotfile segments, so a `dir/**` touch set covers
48
- # `dir/.github/workflows/ci.yml` the standard deliverable for a lane preparing
49
- # a directory to become a repo root.
50
- TOUCH_FNM = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
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'"]+/}
51
63
 
52
64
  # Legacy sentinel: worktree_add used to seed prompt.md with this placeholder
53
65
  # (dropped — the blind-overwrite tripped harness read-before-write guards, #48).
@@ -153,6 +165,7 @@ module Space::Architect
153
165
  block = space.data["project"] || {}
154
166
  architecture_dir = space.path.join("architecture")
155
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
156
169
  architecture_dir.children
157
170
  .select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
158
171
  .map { |f| f.basename.to_s }.sort
@@ -166,7 +179,7 @@ module Space::Architect
166
179
  # any pending changes to the iteration file and records HEAD as freeze_sha. If
167
180
  # already frozen, refuses when the frozen region has changed since.
168
181
  # With force: true, re-freezes a changed frozen region if no lane is dispatched yet.
169
- def freeze!(iteration, warnings: nil, message: nil, force: false)
182
+ def freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil)
170
183
  entry = slice_entry(iteration)
171
184
  rel = entry["file"]
172
185
  path = space.path.join(rel)
@@ -179,6 +192,19 @@ module Space::Architect
179
192
  lint_gates!(text, warnings: warnings)
180
193
  lint_lanes!(text)
181
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
+
182
208
  if entry["freeze_sha"]
183
209
  sha = entry["freeze_sha"]
184
210
  if frozen_region_changed?(sha, rel)
@@ -211,9 +237,17 @@ module Space::Architect
211
237
  next unless s["name"] == iteration
212
238
  s["freeze_sha"] = sha
213
239
  s["verdict"] ||= "pending"
240
+ s["rehearsal_skip_reason"] = skip_rehearse_reason.strip if skip_rehearse_reason
214
241
  lanes = s["lanes"] || []
215
242
  declared.each do |d|
216
243
  fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
244
+ # #89/AC3-AC4: the frozen lane's own tool grant, reviewable like every other
245
+ # boundary. Declared-only, like touch_set — worktree_add never touches these
246
+ # keys, so a re-materialize preserves them without threading them through
247
+ # recorded_lane_fields (unlike harness/model, which worktree_add resolves and
248
+ # overwrites on every call).
249
+ fields["allowed_tools"] = d["allowed_tools"] if d["allowed_tools"]
250
+ fields["append_allowed_tools"] = d["append_allowed_tools"] if d["append_allowed_tools"]
217
251
  existing = lanes.find { |l| l["name"] == d["name"] }
218
252
  existing ? existing.merge!(fields) : lanes << fields
219
253
  end
@@ -321,6 +355,9 @@ module Space::Architect
321
355
 
322
356
  # Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
323
357
  # the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
358
+ # Re-transcribing a lane replaces its existing "### <lane>" subsection in place
359
+ # (preserving the order lanes were already transcribed in) instead of appending a
360
+ # duplicate; a lane not yet present still appends.
324
361
  def transcribe_evidence!(iteration, lane: nil, message: nil)
325
362
  entry = slice_entry(iteration)
326
363
  rel = entry["file"]
@@ -333,8 +370,15 @@ module Space::Architect
333
370
  raw = report.read
334
371
  raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty?
335
372
 
336
- block = lane ? "### #{lane}\n\n#{raw.rstrip}" : raw.rstrip
337
- path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
373
+ text = path.read
374
+ new_body =
375
+ if lane
376
+ lane_names = (entry["lanes"] || []).map { |l| l["name"] }
377
+ replace_lane_report(section_body(text, "## Builder Report").to_s, lane, raw.rstrip, lane_names)
378
+ else
379
+ raw.rstrip
380
+ end
381
+ path.write(replace_section_body(text, "## Builder Report", new_body, append: false))
338
382
 
339
383
  nn = format("%02d", entry["ordinal"] || 0)
340
384
  git_capture("-C", space.path.to_s, "commit", "-m",
@@ -366,7 +410,20 @@ module Space::Architect
366
410
  # the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
367
411
  # Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
368
412
  # (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
369
- def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil)
413
+ #
414
+ # accept_bounds_reason overrides ONLY the in-bounds check — never no_builder_commits,
415
+ # which stays an unconditional refusal (a builder commit is tampering, not an authoring
416
+ # defect the architect can rule on). Modeled on freeze!'s --skip-rehearse: a non-empty
417
+ # REASON is required whenever passed, recorded in space.yaml beside the lane, and
418
+ # returned for the caller to echo — the override can never be silent. Recorded only
419
+ # for THIS lane, and only when its in-bounds check actually failed — integrate! passes
420
+ # the same reason to every lane in the set, but a lane that was already in bounds gets
421
+ # neither the record nor the echo.
422
+ def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
423
+ if accept_bounds_reason
424
+ raise Space::Core::Error, "--accept-bounds requires a non-empty REASON" if accept_bounds_reason.to_s.strip.empty?
425
+ end
426
+
370
427
  entry = slice_entry(iteration)
371
428
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
372
429
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
@@ -376,8 +433,10 @@ module Space::Architect
376
433
  if checks[:no_builder_commits] == false
377
434
  raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
378
435
  end
379
- if checks[:in_bounds] == false
380
- raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
436
+ if checks[:in_bounds] == false && !accept_bounds_reason
437
+ raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset " \
438
+ "and re-dispatch, or `architect integrate #{iteration} --lanes #{lane} --accept-bounds REASON` to override when " \
439
+ "the touch-set declaration itself is the defect."
381
440
  end
382
441
 
383
442
  repo = lane_entry["repo"]
@@ -427,6 +486,8 @@ module Space::Architect
427
486
  merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
428
487
  diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")
429
488
 
489
+ bounds_override_reason = accept_bounds_reason.strip if accept_bounds_reason && checks[:in_bounds] == false
490
+
430
491
  update_architect_block do |b|
431
492
  b["integration_branch"] = integration_branch
432
493
  (b["iterations"] || []).each do |s|
@@ -435,34 +496,46 @@ module Space::Architect
435
496
  next unless l["name"] == lane
436
497
  l["integration_branch"] = integration_branch
437
498
  l["integrate_sha"] = integrate_sha
499
+ l["bounds_override_reason"] = bounds_override_reason if bounds_override_reason
438
500
  end
439
501
  end
440
502
  b
441
503
  end
442
504
 
443
- { lane: lane, repo: repo, integration_branch: integration_branch,
444
- merge_sha: merge_sha.strip, base_sha: base_sha, diffstat: diffstat.strip, gates_run: false }
505
+ { lane: lane, repo: repo, integration_branch: integration_branch, merge_sha: merge_sha.strip,
506
+ base_sha: base_sha, diffstat: diffstat.strip, gates_run: false,
507
+ bounds_override_reason: bounds_override_reason }
445
508
  end
446
509
 
447
510
  # Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
448
511
  # first conflict (a disjointness defect). Never decides which lanes pass. With no
449
512
  # lanes and teardown: true, tears down every lane recorded for the iteration instead
450
513
  # (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
451
- def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil)
514
+ #
515
+ # merge_lane!/teardown_lanes! only save integration_branch/integrate_sha/worktree to
516
+ # space.yaml on disk (update_architect_block never commits) — one commit per call,
517
+ # here, by pathspec, so which lanes merged survives independently of whether a
518
+ # Verdict follows (I13/A3). Always attempted, success or raise, so a conflict that
519
+ # stops the loop midway doesn't lose the lanes already merged.
520
+ def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil,
521
+ accept_bounds_reason: nil, force: false)
452
522
  lanes = Array(lanes)
453
- return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
523
+ return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || [], force: force) if lanes.empty? && teardown
454
524
  raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
455
525
 
456
526
  merged = []
457
527
  lanes.each do |lane|
458
- merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into)
528
+ merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into,
529
+ accept_bounds_reason: accept_bounds_reason)
459
530
  rescue Space::Core::Error => e
460
531
  done = merged.map { |m| m[:lane] }.join(", ")
461
532
  raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
462
533
  end
463
534
 
464
- teardown_lanes!(iteration, merged) if teardown
535
+ teardown_lanes!(iteration, merged, force: force) if teardown
465
536
  merged
537
+ ensure
538
+ commit_metadata_mutation!(iteration, message: message)
466
539
  end
467
540
 
468
541
  # Run the iteration's frozen Acceptance Criteria gate commands. Each gate is
@@ -470,7 +543,10 @@ module Space::Architect
470
543
  # a hard timeout, and evaluated against its `expect` block. Returns an array
471
544
  # of result hashes with :status (:pass/:fail) and :reason in addition to the
472
545
  # raw :stdout/:stderr/:exit_code. The mechanical verdict belongs here; the AC
473
- # verdict remains the architect's.
546
+ # verdict remains the architect's. WHERE the gate text comes from (the frozen
547
+ # commit) is resolved here; HOW gates execute is #execute_gates, shared
548
+ # byte-for-byte with #rehearse so the two can never run gates through
549
+ # different instruments (I09/AC3).
474
550
  def run_gates(iteration, lane: nil)
475
551
  entry = slice_entry(iteration)
476
552
  freeze_sha = entry["freeze_sha"]
@@ -498,37 +574,50 @@ module Space::Architect
498
574
  end
499
575
  raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
500
576
 
501
- gates.map do |gate|
502
- g = gate.transform_keys(&:to_s)
503
- dir =
504
- if (cwd = g["cwd"])
505
- gate_cwd = space.path.join(cwd)
506
- if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
507
- base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
508
- else
509
- gate_cwd
510
- end
511
- else
512
- base_dir
513
- end
514
- raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
577
+ execute_gates(gates, base_dir: base_dir, lane: lane, repo_root: repo_root)
578
+ end
515
579
 
516
- effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
517
- captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
580
+ # Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the
581
+ # freeze, while they can still be fixed — through the identical execution
582
+ # path #run_gates uses at judge time (#execute_gates). space.yaml records no
583
+ # lanes until freeze! writes them (#freeze!, :~209), so the run directory is
584
+ # resolved from the DRAFTED ```lanes``` block instead of the recorded one;
585
+ # rehearsal always runs in the repo checkout (repos/<repo>), never a lane
586
+ # worktree, because lane worktrees are not provisioned until after the
587
+ # freeze. Classifies each result RED/GREEN/BROKEN (I09/AC5) and stamps the
588
+ # iteration as rehearsed, keyed to the gates block's content (I09/AC7) — the
589
+ # stamp records that the architect looked, never that gates passed.
590
+ def rehearse(iteration, now: Time.now)
591
+ entry = slice_entry(iteration)
592
+ rel = entry["file"]
593
+ path = space.path.join(rel)
594
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
595
+ text = path.read
518
596
 
519
- if captured[:timed_out]
520
- status = :fail
521
- reason = "timed out after #{effective}s"
597
+ gates = parse_gates(text)
598
+ repo, base_dir, gate_results, scope_report =
599
+ if gates.empty?
600
+ [nil, nil, [], nil]
522
601
  else
523
- ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
524
- status = ev.pass? ? :pass : :fail
525
- reason = ev.reason
602
+ r = resolve_rehearsal_repo(iteration, text, gates)
603
+ dir = r ? space.path.join("repos", r) : nil
604
+ raise Space::Core::Error, "directory does not exist: #{dir}" if dir && !dir.exist?
605
+ results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil)
606
+ .map { |g| g.merge(rehearsal: classify_rehearsal(g)) }
607
+ [r, dir, results, scope_asymmetry_report(results, text)]
526
608
  end
527
609
 
528
- { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
529
- stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
530
- dir: dir, status: status, reason: reason }
610
+ digest = gates_digest(text)
611
+ update_architect_block do |b|
612
+ (b["iterations"] || []).each do |s|
613
+ next unless s["name"] == iteration
614
+ s["rehearsal"] = { "gates_digest" => digest, "at" => now.iso8601 }
615
+ end
616
+ b
531
617
  end
618
+
619
+ { iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results,
620
+ empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report }
532
621
  end
533
622
 
534
623
  # Emit grounding reads for the architect's SessionStart hook.
@@ -607,8 +696,15 @@ module Space::Architect
607
696
  repos.map { |r| sync_one_repo(r["name"]) }
608
697
  end
609
698
 
610
- def worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false,
611
- effort: nil, touch: nil, force: false, err: $stderr)
699
+ # base_explicit distinguishes an operator-given --base (enforce it refuse or
700
+ # re-point per #88's three cases) from an auto-materializing call (just attach
701
+ # whatever's there, never refuse, never move a branch nobody asked to move).
702
+ # Defaults from whether base: itself was given, which is exactly right for a
703
+ # direct `worktree_add`/CLI `worktree add --base` call; ensure_lane_materialized
704
+ # always resolves SOME base internally (it has to, to create a lane from
705
+ # scratch), so it overrides this explicitly to false.
706
+ def worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?, harness: nil, model: nil,
707
+ variant: false, effort: nil, touch: nil, force: false, err: $stderr)
612
708
  model, suffix_level = Harness.parse_model_suffix(model)
613
709
  harness, model = resolve_harness_model(harness, model)
614
710
  resolved_level = effort || suffix_level || project_defaults["effort"]
@@ -631,6 +727,7 @@ module Space::Architect
631
727
  base_sha = base_sha.strip
632
728
 
633
729
  branch = "lane/#{id}-#{lane}"
730
+ recorded_base = (entry["lanes"] || []).find { |l| l["name"] == lane }&.dig("base_sha")
634
731
 
635
732
  # Guard: an existing directory that is not a registered worktree is ambiguous.
636
733
  if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
@@ -643,16 +740,8 @@ module Space::Architect
643
740
  end
644
741
  end
645
742
 
646
- # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
647
- unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
648
- if branch_exists?(repo_path, branch)
649
- # The worktree was removed but its lane branch survived — re-attach the branch
650
- # (carrying its own tip) instead of `-b`, which would fail "branch already exists".
651
- git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
652
- else
653
- git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
654
- end
655
- end
743
+ outcome, base_sha, discarded = attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base,
744
+ base_explicit: base_explicit, force: force)
656
745
 
657
746
  new_fields = {
658
747
  "name" => lane,
@@ -682,7 +771,7 @@ module Space::Architect
682
771
  b
683
772
  end
684
773
 
685
- { worktree: wt_path, base_sha: base_sha }
774
+ { worktree: wt_path, base_sha: base_sha, outcome: outcome, discarded: discarded }
686
775
  end
687
776
 
688
777
  # Declare a variant set for an iteration: one competing lane per (harness, model) pair,
@@ -761,7 +850,13 @@ module Space::Architect
761
850
  }
762
851
  end
763
852
 
764
- def worktree_remove(iteration, lane)
853
+ # #88 (smaller, same area): a removed worktree does not touch its lane branch —
854
+ # remove-then-provision re-points that surviving branch (or refuses if it has
855
+ # its own commits), it is not a reset. Names it in the return so a human
856
+ # reading `worktree remove`'s output isn't misled into thinking it is.
857
+ # Never deletes the branch itself: that would discard lane work with no
858
+ # confirmation.
859
+ def worktree_remove(iteration, lane, force: false)
765
860
  entry = slice_entry(iteration)
766
861
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
767
862
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
@@ -774,6 +869,16 @@ module Space::Architect
774
869
  space.path.join("build", "#{iteration_id(entry)}-#{lane}", "wt")
775
870
  end
776
871
 
872
+ branch = worktree_branch(repo_path, wt_path) || "lane/#{iteration_id(entry)}-#{lane}"
873
+
874
+ dirty = dirty_file_count(repo_path, wt_path)
875
+ if dirty && !force
876
+ raise Space::Core::Error,
877
+ "Refusing to remove lane '#{lane}' worktree — #{dirty} uncommitted change(s) (untracked files " \
878
+ "included) would be discarded. Integrate the lane first, or re-run with --force to discard them " \
879
+ "and remove it."
880
+ end
881
+
777
882
  git_run("-C", repo_path.to_s, "worktree", "remove", "--force", wt_path.to_s)
778
883
  git_run("-C", repo_path.to_s, "worktree", "prune")
779
884
 
@@ -784,19 +889,25 @@ module Space::Architect
784
889
  end
785
890
  b
786
891
  end
892
+
893
+ { lane: lane, branch: branch, branch_survives: branch_exists?(repo_path, branch), discarded: dirty }
787
894
  end
788
895
 
789
896
  def worktree_list
790
897
  wt_base = space.path.join("build")
791
898
  return [] unless wt_base.exist?
792
- wt_base.children.select(&:directory?).map { |p| p.basename.to_s }.sort
899
+ Space::Core::Paths.layout_children(wt_base).select(&:directory?).map { |p| p.basename.to_s }.sort
793
900
  end
794
901
 
795
902
  # Materialize the iteration's declared lanes: for each lane (or the one named via
796
903
  # `lane:`), create its worktree + lane/<id>-<lane> branch from the resolved base and
797
- # record worktree/base_sha/integration_branch. Idempotent — an already-materialized
798
- # lane is skipped, not re-created. Refuses until the iteration is frozen, because
799
- # declarations are not authoritative until then.
904
+ # record worktree/base_sha/integration_branch. Idempotent — a bare re-run (no
905
+ # explicit --base) over an already-materialized lane changes nothing. An explicit
906
+ # --base is always enforced, even against an already-materialized lane (#88's
907
+ # "blind spot" — a short-circuit that trusted the recorded state without checking
908
+ # it against what was actually asked for). Refuses until the iteration is frozen,
909
+ # because declarations are not authoritative until then. outcome on each result is
910
+ # :created / :unchanged / :repointed (#88/AC4 — tellable apart by a human reader).
800
911
  def provision(iteration, base: nil, lane: nil, force: false)
801
912
  entry = slice_entry(iteration)
802
913
  raise Space::Core::Error,
@@ -807,17 +918,15 @@ module Space::Architect
807
918
  lanes = lanes.select { |l| l["name"] == lane } if lane
808
919
  raise Space::Core::Error, "No lane '#{lane}' declared for iteration '#{iteration}'" if lane && lanes.empty?
809
920
 
921
+ base_explicit = !base.nil?
922
+ refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force
923
+
810
924
  lanes.map do |l|
811
925
  name = l["name"]
812
- repo_path = space.path.join("repos", l["repo"])
813
- wt_path = space.path.join(l["worktree"] || "build/#{iteration_id(entry)}-#{name}/wt")
814
- if wt_path.exist? && worktree_registered?(repo_path, wt_path)
815
- { lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
816
- else
817
- result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
818
- force: force, **recorded_lane_fields(l))
819
- { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
820
- end
926
+ result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
927
+ base_explicit: base_explicit, force: force, **recorded_lane_fields(l))
928
+ { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome],
929
+ discarded: result[:discarded] }
821
930
  end
822
931
  end
823
932
 
@@ -830,7 +939,8 @@ module Space::Architect
830
939
  end
831
940
 
832
941
  def dispatch(iteration, lane, model: nil, max_turns: 200,
833
- claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, force: false, quiet: false,
942
+ claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
943
+ allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false,
834
944
  detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
835
945
  push_client: nil, timeout: nil, prompt: nil, now: Time.now)
836
946
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
@@ -847,6 +957,9 @@ module Space::Architect
847
957
 
848
958
  resolved_harness, resolved_model, resolved_effort =
849
959
  resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
960
+ resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
961
+ resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
962
+ err.puts(tools_provenance) if replace_tools || append_tools
850
963
 
851
964
  raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
852
965
  if push_host && resolved_harness != "claude-code"
@@ -866,22 +979,29 @@ module Space::Architect
866
979
 
867
980
  bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
868
981
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin,
869
- config_dir: build_dir, effort: resolved_effort, force: force, err: err)
870
-
871
- # Stamp launch time and the resolved harness/model/effort onto the lane entry:
872
- # after every preflight validation has passed (a dispatch that raises above
873
- # records nothing) and before the blocking run or a detached dispatch returns.
874
- # A re-dispatch overwrites the prior values, so `architect status` always reads
875
- # what actually ran on the last dispatch.
982
+ config_dir: build_dir, effort: resolved_effort,
983
+ allowed_tools: resolved_allowed_tools, force: force, err: err)
984
+
985
+ # Stamp launch time and the resolved harness/model/effort/allowed_tools onto the
986
+ # lane entry: after every preflight validation has passed (a dispatch that raises
987
+ # above records nothing) and before the blocking run or a detached dispatch
988
+ # returns. A re-dispatch overwrites the prior values, so `architect status`
989
+ # always reads what actually ran on the last dispatch. #89/AC8: the resolved
990
+ # tool grant is recorded the same way, so a denial traces back to a grant a
991
+ # human can read — replace_tools/append_tools are the pre-composition components
992
+ # (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later
993
+ # bare re-dispatch re-resolves from the same components instead of compounding.
876
994
  update_architect_block do |b|
877
995
  (b["iterations"] || []).each do |s|
878
996
  next unless s["name"] == iteration
879
997
  (s["lanes"] || []).each do |l|
880
998
  next unless l["name"] == lane
881
- l["dispatched_at"] = now.iso8601
882
- l["harness"] = resolved_harness
883
- l["model"] = resolved_model
884
- l["effort"] = resolved_effort if resolved_effort
999
+ l["dispatched_at"] = now.iso8601
1000
+ l["harness"] = resolved_harness
1001
+ l["model"] = resolved_model
1002
+ l["effort"] = resolved_effort if resolved_effort
1003
+ l["allowed_tools"] = replace_tools if replace_tools
1004
+ l["append_allowed_tools"] = append_tools if append_tools
885
1005
  end
886
1006
  end
887
1007
  b
@@ -930,7 +1050,8 @@ module Space::Architect
930
1050
  # transcript lives server-side. jobs_client: is the injectable seam (mirrors
931
1051
  # dispatch's run_creator:).
932
1052
  def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
933
- max_turns: 200, effort: nil, force: false, quiet: false,
1053
+ max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil,
1054
+ force: false, quiet: false,
934
1055
  job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now)
935
1056
  err = quiet ? File.open(File::NULL, "w") : $stderr
936
1057
 
@@ -945,6 +1066,12 @@ module Space::Architect
945
1066
  unless resolved_harness == "claude-code"
946
1067
  raise Space::Core::Error, "--job-model is required with --as-job" unless job_model
947
1068
 
1069
+ # #89: same replace/append resolution as the local dispatch path — the sandboxed
1070
+ # executor still runs `claude -p` with harness_args server-side.
1071
+ resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
1072
+ resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
1073
+ err.puts(tools_provenance) if replace_tools || append_tools
1074
+
948
1075
  id = iteration_id(entry)
949
1076
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
950
1077
  raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
@@ -955,7 +1082,8 @@ module Space::Architect
955
1082
 
956
1083
  repo_path = space.path.join("repos", lane_entry["repo"])
957
1084
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
958
- effort: resolved_effort, force: force, err: err)
1085
+ effort: resolved_effort, allowed_tools: resolved_allowed_tools,
1086
+ force: force, err: err)
959
1087
 
960
1088
  spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir,
961
1089
  repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url,
@@ -1002,19 +1130,61 @@ module Space::Architect
1002
1130
  body.empty? ? composed : "#{composed}\n\n#{body}"
1003
1131
  end
1004
1132
 
1133
+ # integrate!'s own space.yaml commit, by pathspec — like record_verdict!'s sweep, so
1134
+ # other uncommitted work in the space isn't pulled in. A call that mutated nothing
1135
+ # (teardown-only over lanes with no worktree, or a call that raised before touching
1136
+ # anything) leaves git commit with nothing staged for that path; tolerate that exit
1137
+ # via git_capture the way write_section! tolerates a no-op frozen-section commit,
1138
+ # never git_run it.
1139
+ def commit_metadata_mutation!(iteration, message:)
1140
+ entries = (space.data["project"] || {})["iterations"] || []
1141
+ ordinal = entries.find { |s| s["name"] == iteration }&.dig("ordinal") || 0
1142
+ nn = format("%02d", ordinal)
1143
+ git_capture("-C", space.path.to_s, "commit", "-m",
1144
+ compose_message("I#{nn} integrate:", "I#{nn}: record integration", message),
1145
+ "--", Space::Core::Space::METADATA_FILE)
1146
+ end
1147
+
1005
1148
  # Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
1006
1149
  # either merge_lane! results (symbol keys) or recorded lane entries (string
1007
1150
  # keys) — both carry a lane name and a repo.
1008
- def teardown_lanes!(iteration, lane_entries)
1151
+ def teardown_lanes!(iteration, lane_entries, force: false)
1009
1152
  id = iteration_id(slice_entry(iteration))
1153
+ refuse_teardown_if_dirty!(iteration, lane_entries, id) unless force
1154
+
1010
1155
  lane_entries.map do |l|
1011
1156
  lane = l[:lane] || l["name"]
1012
1157
  repo = l[:repo] || l["repo"]
1013
- worktree_remove(iteration, lane)
1158
+ removal = worktree_remove(iteration, lane, force: true)
1014
1159
  lane_branch = "lane/#{id}-#{lane}"
1015
1160
  git_capture("-C", space.path.join("repos", repo).to_s, "branch", "-d", lane_branch)
1016
- { lane: lane, repo: repo, lane_branch: lane_branch }
1161
+ { lane: lane, repo: repo, lane_branch: lane_branch, discarded: removal[:discarded] }
1162
+ end
1163
+ end
1164
+
1165
+ # Every lane a teardown-only call would touch is checked for
1166
+ # uncommitted work BEFORE any lane is removed — a dirty lane discovered late
1167
+ # in the recorded set can never leave an earlier lane already torn down.
1168
+ # Lanes reached via a merge-then-teardown call are always clean here
1169
+ # (merge_lane! commits the worktree first), so this never fires on the
1170
+ # normal integrate-then-teardown rhythm.
1171
+ def refuse_teardown_if_dirty!(iteration, lane_entries, id)
1172
+ entry = slice_entry(iteration)
1173
+ dirty = lane_entries.filter_map do |l|
1174
+ lane = l[:lane] || l["name"]
1175
+ repo = l[:repo] || l["repo"]
1176
+ recorded = (entry["lanes"] || []).find { |le| le["name"] == lane }
1177
+ wt_path = space.path.join(recorded&.dig("worktree") || "build/#{id}-#{lane}/wt")
1178
+ count = dirty_file_count(space.path.join("repos", repo), wt_path)
1179
+ count ? [lane, count] : nil
1017
1180
  end
1181
+ return if dirty.empty?
1182
+
1183
+ names = dirty.map { |n, c| "#{n} (#{c})" }.join(", ")
1184
+ raise Space::Core::Error,
1185
+ "Refusing to tear down '#{iteration}' — lane(s) #{names} hold uncommitted work (untracked files " \
1186
+ "included) that would be discarded (worktree removed, branch deleted). Integrate them first, or " \
1187
+ "re-run with --force to discard it."
1018
1188
  end
1019
1189
 
1020
1190
  # Resolve the in-flight iteration file for ground output.
@@ -1035,6 +1205,7 @@ module Space::Architect
1035
1205
  end
1036
1206
  end
1037
1207
 
1208
+ # paths:exempt - the /\AI\d+-.+\.md\z/ filter structurally cannot match a dotfile-prefixed name, so raw enumeration is already dotfile-safe here
1038
1209
  candidates = arch_dir.children.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
1039
1210
  return nil if candidates.empty?
1040
1211
  candidates.max_by { |f| f.basename.to_s[/\AI(\d+)/, 1].to_i }
@@ -1046,7 +1217,7 @@ module Space::Architect
1046
1217
  def capture_with_timeout(cmd, dir:, timeout:)
1047
1218
  out_f = Tempfile.new(["gate-stdout", ".log"])
1048
1219
  err_f = Tempfile.new(["gate-stderr", ".log"])
1049
- pid = Process.spawn(cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
1220
+ pid = Process.spawn("/bin/sh", "-c", cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
1050
1221
 
1051
1222
  deadline = Time.now + timeout
1052
1223
  status = nil
@@ -1076,6 +1247,332 @@ module Space::Architect
1076
1247
  err_f&.close!
1077
1248
  end
1078
1249
 
1250
+ # HOW gates execute — the one instrument #run_gates (judge time) and
1251
+ # #rehearse (pre-freeze) both call, byte-for-byte: same cwd-remap semantics,
1252
+ # same shell (capture_with_timeout's Process.spawn), same GateEvaluator,
1253
+ # same timeout handling (I09/AC3). repo_root/lane are nil outside a lane
1254
+ # context (rehearsal never has one — it always runs in the repo checkout).
1255
+ def execute_gates(gates, base_dir:, lane:, repo_root:)
1256
+ gates.map do |gate|
1257
+ g = gate.transform_keys(&:to_s)
1258
+ dir =
1259
+ if (cwd = g["cwd"])
1260
+ gate_cwd = space.path.join(cwd)
1261
+ if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
1262
+ base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
1263
+ else
1264
+ gate_cwd
1265
+ end
1266
+ else
1267
+ base_dir
1268
+ end
1269
+ raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
1270
+
1271
+ effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
1272
+ captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
1273
+
1274
+ if captured[:timed_out]
1275
+ status = :fail
1276
+ reason = "timed out after #{effective}s"
1277
+ else
1278
+ ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
1279
+ status = ev.pass? ? :pass : :fail
1280
+ reason = ev.reason
1281
+ end
1282
+
1283
+ { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
1284
+ stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
1285
+ dir: dir, status: status, reason: reason, timed_out: captured[:timed_out] }
1286
+ end
1287
+ end
1288
+
1289
+ # I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
1290
+ # `cwd` of their own — only those gates need one, and only they are ambiguous
1291
+ # when the drafted ```lanes``` block names more than one repo. A gate with its
1292
+ # own `cwd` rehearses in its own declared directory regardless of how many
1293
+ # repos are declared, so an iteration whose gates all declare `cwd` never
1294
+ # needs a default and never reaches this method's refusals. Never resolves
1295
+ # from space.yaml (no lanes are recorded there pre-freeze).
1296
+ def resolve_rehearsal_repo(iteration, text, gates)
1297
+ needing_default = gates.reject { |g| g.transform_keys(&:to_s)["cwd"] }
1298
+ return nil if needing_default.empty?
1299
+
1300
+ ids = needing_default.map { |g| g.transform_keys(&:to_s)["id"] }
1301
+ declared = parse_lanes(text).filter_map { |l| l["repo"] }.uniq
1302
+ return declared.first if declared.size == 1
1303
+
1304
+ if declared.empty?
1305
+ tracked = space.repos.map { |r| r["name"] }
1306
+ return tracked.first if tracked.size == 1
1307
+ raise Space::Core::Error,
1308
+ "Cannot resolve a repo to rehearse '#{iteration}' against — no lane declares a repo and the space " \
1309
+ "tracks #{tracked.size} repos (#{tracked.join(', ')}). Declare a ```lanes``` block in the " \
1310
+ "Specification naming the repo, or give gate(s) #{ids.join(', ')} an explicit `cwd`."
1311
+ end
1312
+
1313
+ raise Space::Core::Error,
1314
+ "Cannot default a repo for gate(s) #{ids.join(', ')} — the drafted lanes block names multiple repos " \
1315
+ "(#{declared.join(', ')}) and #{ids.size == 1 ? "it declares" : "they declare"} no `cwd`. Give " \
1316
+ "#{ids.size == 1 ? "it" : "them"} an explicit `cwd`, or narrow the ```lanes``` block to one repo."
1317
+ end
1318
+
1319
+ # I09/AC5: RED (clean non-zero — discriminates) vs GREEN (passes on base) vs
1320
+ # BROKEN (127, timeout, or a shell parse failure). BROKEN is advisory, not
1321
+ # authoritative: a correct RED can look broken (e.g. `grep -q x new_file`
1322
+ # exits 2 — "No such file" — when the file is one the lane will write); the
1323
+ # CLI names this suspicion, the architect confirms it.
1324
+ def classify_rehearsal(result)
1325
+ return :broken if result[:timed_out] || result[:exit_code] == 127
1326
+ return :broken if BROKEN_STDERR_PATTERN.match?(result[:stderr].to_s)
1327
+ result[:status] == :pass ? :green : :red
1328
+ end
1329
+
1330
+ # grep-family binaries this check recognizes in a gate's shell command.
1331
+ SCOPE_GREP_BINARIES = %w[grep egrep fgrep].freeze
1332
+
1333
+ # Shellwords treats shell syntax — pipes, `&&`/`||`/`;`, `$(...)`, control
1334
+ # keywords — as ordinary word characters, not structure (its own docs say
1335
+ # plainly this isn't a command-line parser). These are the markers
1336
+ # #shell_segments splits a token stream on, once #pad_shell_operators has
1337
+ # made sure none of them can arrive glued to an adjacent word.
1338
+ SCOPE_BOUNDARY_TOKENS = %w[| || && ; $( ( ) if elif then else fi do done while until case esac].freeze
1339
+
1340
+ # Flags that change what a pattern matches and this check actually replays
1341
+ # verbatim against the real grep binary — mode (E/F/G/P) and w, both
1342
+ # threaded through to #whole_repo_grep_matches below — vs flags that only
1343
+ # change what's printed (irrelevant to a files-with-matches re-run). A flag
1344
+ # belongs here only once something below replays it.
1345
+ SCOPE_MATCH_FLAGS = %w[E F G P w].freeze
1346
+ SCOPE_NOOP_FLAGS = %w[c i n o q r l z H h].freeze
1347
+
1348
+ # Recognized flags git grep has no equivalent for (-w and -x are not
1349
+ # symmetric) — declined as not-analyzable, reason naming the flag, rather
1350
+ # than replayed wrong or silently dropped.
1351
+ SCOPE_DECLINED_FLAGS = { "x" => "-x (whole-line match) has no git-grep equivalent" }.freeze
1352
+
1353
+ # git grep, not a raw recursive grep: it skips .git's own object store (a
1354
+ # raw `grep -r .` would trawl it) and matches "the tree" the way the rest
1355
+ # of this corpus's git-based gates already do (diff-scope's own gates are
1356
+ # git diff, not find/grep). Small fixed budget — runs once per recognized
1357
+ # invocation, not per gate.
1358
+ SCOPE_SEARCH_TIMEOUT = 30
1359
+
1360
+ # I12/AC3: rehearse's scope-asymmetry check. I11 shipped a bug its own new
1361
+ # boundary discipline could not catch: a gate's grep-family search covered
1362
+ # only `lib`, while the identifier it renamed also lived in a `test/` file
1363
+ # no lane declared — three consistent statements, all drawn from the same
1364
+ # too-narrow grep. For each gate whose command is a recognizable
1365
+ # file-scoped grep invocation, re-run its own pattern across the whole repo
1366
+ # and report anything that matches outside the gate's own declared paths,
1367
+ # flagging loudest whatever also lies outside every declared lane's touch
1368
+ # set (I12/AC4) — the file no lane may legally fix. touch_globs is that
1369
+ # union; parse_lanes returns [] with no ```lanes``` block, which still
1370
+ # yields the outside-every-lane half of the report. Reports only: never
1371
+ # touches rehearse's exit code, RED/GREEN/BROKEN, or the rehearsal stamp.
1372
+ #
1373
+ # #90/AC7: takes the already-EXECUTED gate results, not a single base_dir —
1374
+ # each gate's own resolved `dir` (execute_gates already computed it, per gate)
1375
+ # is what the re-run replays against, so a cross-repo iteration is covered
1376
+ # gate-by-gate in its own repo, and each finding names the repo it came from.
1377
+ def scope_asymmetry_report(gate_results, text)
1378
+ touch_globs = parse_lanes(text).flat_map { |l| l["touch"] || [] }
1379
+ findings = []
1380
+ not_analyzable = []
1381
+
1382
+ gate_results.each do |g|
1383
+ grep_invocations(g[:cmd].to_s).each do |inv|
1384
+ if inv[:status] != :recognized
1385
+ not_analyzable << { id: g[:id], reason: inv[:reason] }
1386
+ next
1387
+ end
1388
+
1389
+ finding = scope_finding(inv, g[:dir], touch_globs)
1390
+ if finding
1391
+ findings << finding.merge(id: g[:id], repo: repo_name_for(g[:dir]))
1392
+ else
1393
+ not_analyzable << { id: g[:id], reason: "whole-repo re-run failed" }
1394
+ end
1395
+ end
1396
+ end
1397
+
1398
+ { findings: findings, not_analyzable: not_analyzable }
1399
+ end
1400
+
1401
+ # Best-effort label for a scope-asymmetry finding: the repos/<name> a gate's
1402
+ # resolved dir sits under, or nil for a `cwd` outside repos/ entirely (legal —
1403
+ # #test_run_gates_honors_cwd_outside_repo — just not attributable to one repo).
1404
+ def repo_name_for(dir)
1405
+ repos_root = space.path.join("repos")
1406
+ return nil unless dir.to_s.start_with?("#{repos_root}/")
1407
+ dir.relative_path_from(repos_root).to_s.split("/").first
1408
+ end
1409
+
1410
+ # Re-runs a recognized invocation's own pattern across the whole repo and
1411
+ # splits what it finds into: the gate's own declared scope (expected —
1412
+ # that's what the gate already searches, so it's not reported), elsewhere
1413
+ # but inside some lane's touch set, and elsewhere outside every lane's
1414
+ # touch set. nil signals the re-run itself failed (caller counts it as
1415
+ # not-analyzable rather than reporting a guess).
1416
+ def scope_finding(inv, base_dir, touch_globs)
1417
+ matched = whole_repo_grep_matches(inv, base_dir)
1418
+ return nil unless matched
1419
+
1420
+ elsewhere = matched.reject { |f| within_declared_scope?(f, inv[:paths], base_dir) }
1421
+ within_lanes, outside_lanes = elsewhere.partition { |f| in_touch_set?(f, touch_globs) }
1422
+ { pattern: inv[:pattern], paths: inv[:paths], outside_lanes: outside_lanes.sort, within_lanes: within_lanes.sort }
1423
+ end
1424
+
1425
+ # The files (relative paths) an invocation's own pattern matches anywhere
1426
+ # in the repo, via the real git-grep binary with the same
1427
+ # matching-relevant flags — never a Ruby reimplementation of grep's
1428
+ # pattern semantics. nil on anything other than a clean "matched"/"no
1429
+ # matches" outcome (exit 0/1); [] is a real, examined zero.
1430
+ def whole_repo_grep_matches(inv, base_dir)
1431
+ flags = ["-l", "-I"]
1432
+ flags << "-i" if inv[:case_insensitive]
1433
+ flags << "-w" if inv[:word_boundary]
1434
+ flags << "-#{inv[:mode]}" unless inv[:mode] == "G"
1435
+ cmd = "git grep #{flags.join(' ')} -- #{Shellwords.escape(inv[:pattern])}"
1436
+ captured = capture_with_timeout(cmd, dir: base_dir, timeout: SCOPE_SEARCH_TIMEOUT)
1437
+ return [] if captured[:exit_code] == 1
1438
+ return nil if captured[:timed_out] || captured[:exit_code] != 0
1439
+
1440
+ captured[:stdout].each_line.map(&:chomp).reject(&:empty?)
1441
+ end
1442
+
1443
+ # Is relative_path inside one of an invocation's own declared search
1444
+ # paths? A declared path that's a real directory at rehearsal time covers
1445
+ # anything under it; a file (or a path a lane hasn't written yet, same
1446
+ # BROKEN-adjacent case #classify_rehearsal already names) covers only
1447
+ # itself.
1448
+ def within_declared_scope?(relative_path, declared_paths, base_dir)
1449
+ declared_paths.any? do |p|
1450
+ p = p.sub(%r{/\z}, "")
1451
+ relative_path == p || (base_dir.join(p).directory? && relative_path.start_with?("#{p}/"))
1452
+ end
1453
+ end
1454
+
1455
+ # Every grep-family invocation recognized in one gate's shell command. A
1456
+ # command that never mentions grep/egrep/fgrep isn't this check's concern
1457
+ # at all (silently absent, same as e.g. `bundle exec rake test`) — only a
1458
+ # command that DOES attempt one and can't be cleanly recognized is
1459
+ # reported not-analyzable (I12/AC4's "counted and identifiable").
1460
+ def grep_invocations(cmd)
1461
+ return [] unless cmd.match?(/\b(?:#{SCOPE_GREP_BINARIES.join('|')})\b/)
1462
+
1463
+ segments = shell_segments(cmd)
1464
+ return [{ status: :not_analyzable, reason: "unparseable command" }] unless segments
1465
+
1466
+ found = segments.filter_map { |seg| grep_invocation(seg) }
1467
+ return [{ status: :not_analyzable, reason: "could not locate a recognizable grep invocation" }] if found.empty?
1468
+
1469
+ found
1470
+ end
1471
+
1472
+ # Splits a gate's shell command into simple-command segments at pipes,
1473
+ # `&&`/`||`/`;`, command substitution, and control keywords (see
1474
+ # SCOPE_BOUNDARY_TOKENS). Returns nil on anything Shellwords itself can't
1475
+ # tokenize (an unmatched quote), so the caller counts it instead of
1476
+ # guessing at it.
1477
+ def shell_segments(cmd)
1478
+ tokens = expand_quoted_substitutions(shell_tokenize(cmd))
1479
+ segments = [[]]
1480
+ tokens.each do |tok|
1481
+ if SCOPE_BOUNDARY_TOKENS.include?(tok)
1482
+ segments << []
1483
+ else
1484
+ segments.last << tok
1485
+ end
1486
+ end
1487
+ segments.reject(&:empty?)
1488
+ rescue ArgumentError
1489
+ nil
1490
+ end
1491
+
1492
+ def shell_tokenize(cmd)
1493
+ Shellwords.split(pad_shell_operators(cmd))
1494
+ end
1495
+
1496
+ # Shellwords treats shell operator/substitution punctuation as ordinary
1497
+ # word characters whenever it isn't whitespace-separated (its own docs
1498
+ # warn this isn't a command-line parser) — pads whitespace around
1499
+ # `$(` `&&` `||` `;` `|` `(` `)` and folds a bare newline to `;` (its own
1500
+ # statement terminator, otherwise invisible to Shellwords as anything but
1501
+ # whitespace) so each always surfaces as its own token. Quoted spans and
1502
+ # backslash-escapes are passed through untouched, so a pattern's own `|`
1503
+ # or `$` — inside a quote — is never mistaken for shell syntax.
1504
+ def pad_shell_operators(text)
1505
+ out = String.new
1506
+ text.scan(/'[^']*'|"(?:[^"\\]|\\.)*"|\\.|[^'"\\]+/m) do |chunk|
1507
+ if chunk.start_with?("'", '"', "\\")
1508
+ out << chunk
1509
+ else
1510
+ out << chunk.gsub(/\$\(|&&|\|\||[();|\n]/) { |op| op == "\n" ? " ; " : " #{op} " }
1511
+ end
1512
+ end
1513
+ out
1514
+ end
1515
+
1516
+ # A token that is ITSELF a whole `$(...)` survives #pad_shell_operators
1517
+ # intact only when the substitution sat inside a still-open quote
1518
+ # (protected, so nothing inside it got space-padded) — recurse into its
1519
+ # contents exactly as the top-level command was tokenized.
1520
+ def expand_quoted_substitutions(tokens)
1521
+ tokens.flat_map do |tok|
1522
+ m = tok.match(/\A\$\((.*)\)\z/m)
1523
+ next [tok] unless m
1524
+
1525
+ ["$("] + expand_quoted_substitutions(shell_tokenize(m[1])) + [")"]
1526
+ end
1527
+ end
1528
+
1529
+ # Recognizes one simple-command segment as a file-scoped grep-family
1530
+ # invocation, or declines with a specific reason. Never guesses: `-v`
1531
+ # inverts per-line matching (fine for filtering a computed list, wrong for
1532
+ # "does this pattern occur elsewhere") so it's declined rather than
1533
+ # silently reinterpreted; so is any unsupported flag or a path operand
1534
+ # that still carries an unresolved shell variable.
1535
+ def grep_invocation(segment)
1536
+ i = 0
1537
+ i += 1 while segment[i] == "!"
1538
+ return nil unless segment[i] && SCOPE_GREP_BINARIES.include?(segment[i])
1539
+ i += 1
1540
+
1541
+ flag_chars = []
1542
+ loop do
1543
+ tok = segment[i]
1544
+ break unless tok
1545
+ if tok == "--"
1546
+ i += 1
1547
+ break
1548
+ elsif tok.start_with?("-") && tok != "-"
1549
+ flag_chars.concat(tok.delete_prefix("-").chars)
1550
+ i += 1
1551
+ else
1552
+ break
1553
+ end
1554
+ end
1555
+
1556
+ unsupported = flag_chars - (SCOPE_MATCH_FLAGS + SCOPE_NOOP_FLAGS + SCOPE_DECLINED_FLAGS.keys)
1557
+ return { status: :not_analyzable, reason: "unsupported flag(s): #{unsupported.uniq.join(', ')}" } if unsupported.any?
1558
+ return { status: :not_analyzable, reason: "-v (inverted match) semantics not safely reinterpreted" } if flag_chars.include?("v")
1559
+
1560
+ declined = (flag_chars & SCOPE_DECLINED_FLAGS.keys).uniq
1561
+ return { status: :not_analyzable, reason: declined.map { |f| SCOPE_DECLINED_FLAGS[f] }.join("; ") } if declined.any?
1562
+
1563
+ modes = (flag_chars & %w[E F G P]).uniq
1564
+ return { status: :not_analyzable, reason: "ambiguous pattern flags: #{modes.join(', ')}" } if modes.size > 1
1565
+ return { status: :not_analyzable, reason: "no pattern operand" } unless segment[i]
1566
+
1567
+ pattern = segment[i]
1568
+ paths = segment[(i + 1)..] || []
1569
+ return { status: :not_analyzable, reason: "no path operand (pipeline/stdin search)" } if paths.empty?
1570
+ return { status: :not_analyzable, reason: "path operand contains an unresolved shell variable" } if paths.any? { |p| p.include?("$") }
1571
+
1572
+ { status: :recognized, pattern: pattern, paths: paths, case_insensitive: flag_chars.include?("i"),
1573
+ word_boundary: flag_chars.include?("w"), mode: modes.first || "G" }
1574
+ end
1575
+
1079
1576
  def iteration_id(entry)
1080
1577
  "I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
1081
1578
  end
@@ -1091,6 +1588,10 @@ module Space::Architect
1091
1588
  # identically to `provision` (same base resolution, same worktree_add primitive)
1092
1589
  # so no dispatch/integrate/gate path dead-ends on a missing worktree. Returns the
1093
1590
  # (possibly refreshed) lane entry; a no-op when already materialized or undeclared.
1591
+ # base_explicit: false — this call never had an operator-given --base to honor, so
1592
+ # a surviving lane branch (e.g. dispatched work, worktree since removed) is always
1593
+ # just reattached at its own tip, never refused or re-pointed (#88 is about an
1594
+ # explicit ask being dropped; this path never has one to drop).
1094
1595
  def ensure_lane_materialized(iteration, lane)
1095
1596
  entry = slice_entry(iteration)
1096
1597
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
@@ -1102,7 +1603,7 @@ module Space::Architect
1102
1603
  return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
1103
1604
 
1104
1605
  worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
1105
- **recorded_lane_fields(lane_entry))
1606
+ base_explicit: false, **recorded_lane_fields(lane_entry))
1106
1607
  (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
1107
1608
  end
1108
1609
 
@@ -1168,6 +1669,35 @@ module Space::Architect
1168
1669
  [resolved_harness, resolved_model, resolved_effort]
1169
1670
  end
1170
1671
 
1672
+ # #89/AC1-AC5: resolve the claude-code --allowedTools grant for a dispatch (local or
1673
+ # --as-job), shared so both paths resolve precedence identically. The CLI flag beats
1674
+ # the lane's recorded allowed_tools:/append_allowed_tools: (frozen-declaration or a
1675
+ # prior dispatch's stamp) — resolved independently for replace vs. append, so a flag
1676
+ # on one axis and a lane value on the other both apply (Objective A). Meaningless for
1677
+ # opencode/pi (no equivalent grant mechanism) — still resolved/recorded for visibility,
1678
+ # but Harness.for only wires it into the claude-code branch's argv.
1679
+ def resolve_allowed_tools(lane_entry, allowed_tools:, append_allowed_tools:)
1680
+ replace, replace_from =
1681
+ if allowed_tools
1682
+ [allowed_tools, "--allowed-tools flag"]
1683
+ elsif lane_entry["allowed_tools"]
1684
+ [lane_entry["allowed_tools"], "lane's allowed_tools:"]
1685
+ else
1686
+ [nil, "default"]
1687
+ end
1688
+
1689
+ append, append_from =
1690
+ if append_allowed_tools
1691
+ [append_allowed_tools, "--append-allowed-tools flag"]
1692
+ elsif lane_entry["append_allowed_tools"]
1693
+ [lane_entry["append_allowed_tools"], "lane's append_allowed_tools:"]
1694
+ end
1695
+
1696
+ final = Harness::ClaudeCodeHarness.resolve_tools(replace: replace, append: append)
1697
+ provenance = "allowed-tools: #{replace_from}#{append_from ? " + #{append_from}" : ""} → #{final}"
1698
+ [final, replace, append, provenance]
1699
+ end
1700
+
1171
1701
  # Compose a dispatch --as-job spec per the space-server's job contract: prompt +
1172
1702
  # workspace.dir (the lane worktree) + environment (deps/network/mounts/env or
1173
1703
  # secrets) + harness (claude/backend/args) + provenance. mounts are src:dst with
@@ -1310,13 +1840,30 @@ module Space::Architect
1310
1840
 
1311
1841
  # Is a changed path inside a lane's declared touch set? Single-sourced so the
1312
1842
  # in-bounds check (d) and merge_lane!'s conflict classification can never drift.
1313
- # A trailing `dir/**` is matched twice: bare (PATHNAME stops it at direct
1314
- # children) and as `dir/**/*`, whose whole-component `**/` does cross `/`.
1315
1843
  def in_touch_set?(path, globs)
1316
- globs.any? do |g|
1317
- File.fnmatch(g, path, TOUCH_FNM) ||
1318
- (g.end_with?("/**") && File.fnmatch("#{g}/*", path, TOUCH_FNM))
1319
- end
1844
+ globs.any? { |g| Space::Core::Paths.touch_match?(g, path) }
1845
+ end
1846
+
1847
+ # Merge a lane's verbatim block into the current "## Builder Report" body for
1848
+ # #transcribe_evidence!: replace its own "### <lane>" subsection in place if
1849
+ # present (preserving the order lanes were already transcribed in), else append
1850
+ # a new one after the others. Subsection boundaries are matched against the
1851
+ # iteration's declared lane names (like KNOWN_HEADINGS for top-level sections),
1852
+ # not any "### " line, so a verbatim report containing its own "### " heading
1853
+ # can't fool the parser.
1854
+ def replace_lane_report(body, lane, raw, lane_names)
1855
+ block = "### #{lane}\n\n#{raw}"
1856
+ return block if placeholder_body?(body)
1857
+
1858
+ headings = lane_names.map { |n| "### #{n}" }
1859
+ lines = body.lines
1860
+ start = lines.index { |l| l.chomp == "### #{lane}" }
1861
+ return "#{body.strip}\n\n#{block}" unless start
1862
+
1863
+ finish = ((start + 1)...lines.length).find { |i| headings.include?(lines[i].chomp) } || lines.length
1864
+ before = lines[0...start].join.strip
1865
+ after = lines[finish..].join.strip
1866
+ [before, block, after].reject(&:empty?).join("\n\n")
1320
1867
  end
1321
1868
 
1322
1869
  # Replace (or, with append:, extend) the body of a "## Heading" section, leaving
@@ -1365,18 +1912,58 @@ module Space::Architect
1365
1912
  lines[(start + 1)...finish].join.strip
1366
1913
  end
1367
1914
 
1915
+ # The raw (unparsed) text inside the fenced ```gates block, or nil when the
1916
+ # block itself is absent. Single-sourced so #parse_gates and the rehearsal
1917
+ # stamp's content digest (#gates_digest) can never read two different
1918
+ # slices of the same section.
1919
+ def gates_block_source(text)
1920
+ body = section_body(text, "## Acceptance Criteria")
1921
+ return nil unless body
1922
+ match = body.match(/^```gates\n(.*?)^```/m)
1923
+ match && match[1]
1924
+ end
1925
+
1368
1926
  # Extract and parse the fenced ```gates block from the Acceptance Criteria section.
1369
1927
  # Returns an array of gate hashes (string-keyed). Returns [] when the block is
1370
1928
  # absent, empty, or contains only YAML comments.
1371
1929
  def parse_gates(text)
1372
- body = section_body(text, "## Acceptance Criteria")
1373
- return [] unless body
1374
- match = body.match(/^```gates\n(.*?)^```/m)
1375
- return [] unless match
1376
- parsed = YAML.safe_load(match[1], aliases: false)
1930
+ raw = gates_block_source(text)
1931
+ return [] unless raw
1932
+ parsed = YAML.safe_load(raw, aliases: false)
1377
1933
  parsed.is_a?(Array) ? parsed : []
1378
1934
  end
1379
1935
 
1936
+ # A stable digest of the gates block's raw content — the key the I09/AC7
1937
+ # rehearsal stamp is validated against. Any byte edit to the fenced block
1938
+ # (add, remove, reorder, reword) changes this and invalidates the stamp.
1939
+ def gates_digest(text)
1940
+ Digest::SHA256.hexdigest(gates_block_source(text).to_s)
1941
+ end
1942
+
1943
+ # #73, narrowly (I09/AC8): true only when the AC section still carries the
1944
+ # scaffold's untouched placeholder AND there is no active gate — a
1945
+ # hand-authored prose-only AC (placeholder replaced, still no gates) is not
1946
+ # this. Call only after lint_gates! has validated the block parses cleanly.
1947
+ def untouched_ac_placeholder?(text)
1948
+ body = section_body(text, "## Acceptance Criteria")
1949
+ return false unless body
1950
+ body.include?(AC1_PLACEHOLDER) && parse_gates(text).empty?
1951
+ end
1952
+
1953
+ # I09/AC7: freeze! refuses without a fresh rehearsal stamp — one whose
1954
+ # gates_digest matches the CURRENT (about-to-be-frozen) gates block. Stale
1955
+ # (gates edited since) or absent (never rehearsed) both refuse identically;
1956
+ # the stamp records that the architect looked, never that gates passed.
1957
+ def ensure_rehearsed!(iteration, entry, text)
1958
+ stamp = entry["rehearsal"]
1959
+ return if stamp && stamp["gates_digest"] == gates_digest(text)
1960
+
1961
+ raise Space::Core::Error,
1962
+ "Iteration '#{iteration}' has not been rehearsed against its current gates — " \
1963
+ "run `architect rehearse #{iteration}` first, or `architect freeze #{iteration} " \
1964
+ "--skip-rehearse REASON` to skip deliberately."
1965
+ end
1966
+
1380
1967
  # Extract and parse the fenced ```lanes block from the Specification section.
1381
1968
  # Returns an array of lane declaration hashes (string-keyed). Returns [] when the
1382
1969
  # block is absent, empty, or contains only YAML comments (back-compat).
@@ -1429,8 +2016,26 @@ module Space::Architect
1429
2016
  return
1430
2017
  end
1431
2018
  result = GateLint.call(gates)
1432
- return if result.success?
1433
- raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
2019
+ raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}" unless result.success?
2020
+
2021
+ warn_bare_repo_prefix!(gates, warnings) if warnings
2022
+ end
2023
+
2024
+ # I09/AC9(b), #39: a gate whose `cmd` carries a literal `repos/<name>/` prefix
2025
+ # with no `cwd` set is warned about, not failed — `cmd` already resolves
2026
+ # against the repo tree (`cwd` is what's space-root-relative), so this
2027
+ # pattern is usually a leftover space-root-relative path, but is legal and
2028
+ # occasionally correct. Threaded through the same warnings: channel
2029
+ # lint_gates! already carries, never a second channel.
2030
+ def warn_bare_repo_prefix!(gates, warnings)
2031
+ gates.each do |g|
2032
+ g = g.transform_keys(&:to_s)
2033
+ next if g["cwd"]
2034
+ next unless g["cmd"].to_s.match?(BARE_REPO_PREFIX)
2035
+ warnings << "gate '#{g["id"]}': cmd contains a literal 'repos/<name>/' path with no cwd set — " \
2036
+ "cmd already resolves against the repo tree, so this is likely a leftover space-root-relative " \
2037
+ "path (legal, occasionally correct — verify it)"
2038
+ end
1434
2039
  end
1435
2040
 
1436
2041
  # Raises if any lane in the entry has been dispatched (dispatched_at or integrate_sha set).
@@ -1533,10 +2138,145 @@ module Space::Architect
1533
2138
  st.success?
1534
2139
  end
1535
2140
 
2141
+ # Uncommitted work in a lane worktree — untracked files
2142
+ # included. Hard rule 7 forbids builder commits, so this is the only
2143
+ # signal an in-flight or completed-but-unintegrated lane's output ever
2144
+ # leaves behind; "no commits on the branch" is the EXPECTED state, not
2145
+ # evidence the worktree is empty. nil when the worktree doesn't exist,
2146
+ # isn't a registered git worktree, or is clean; otherwise the number of
2147
+ # changed/untracked entries `git status --porcelain` reports (a count is
2148
+ # enough — callers never dump the whole status).
2149
+ def dirty_file_count(repo_path, wt_path)
2150
+ return nil unless wt_path.exist? && worktree_registered?(repo_path, wt_path)
2151
+ out, _, st = git_capture("-C", wt_path.to_s, "status", "--porcelain")
2152
+ return nil unless st.success?
2153
+ count = out.each_line.count { |l| !l.strip.empty? }
2154
+ count.zero? ? nil : count
2155
+ end
2156
+
2157
+ # Would re-pointing `branch`'s worktree to base_sha actually run `git
2158
+ # reset --hard` over uncommitted work? nil when there's nothing to lose
2159
+ # (branch absent, worktree unregistered, already at base_sha, or clean);
2160
+ # otherwise the #dirty_file_count that would be discarded. Read-only —
2161
+ # used by provision's multi-lane preflight (#refuse_repoint_if_dirty!),
2162
+ # never by the mutating path itself (attach_lane_branch! already has
2163
+ # tip_sha in scope and calls #dirty_file_count directly).
2164
+ def repoint_dirty_count(repo_path, wt_path, branch, base_sha)
2165
+ return nil unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
2166
+ tip_out, _, tip_st = git_capture("-C", repo_path.to_s, "rev-parse", branch)
2167
+ tip_sha = tip_st.success? ? tip_out.strip : nil
2168
+ return nil if tip_sha == base_sha
2169
+ dirty_file_count(repo_path, wt_path)
2170
+ end
2171
+
2172
+ # Multi-lane atomicity for provision's re-point path — every
2173
+ # declared lane is checked for uncommitted work BEFORE any lane's
2174
+ # worktree_add runs, so a dirty lane discovered late in the set can never
2175
+ # leave an earlier lane already re-pointed.
2176
+ def refuse_repoint_if_dirty!(iteration, lanes, base, id)
2177
+ dirty = lanes.filter_map do |l|
2178
+ repo_path = space.path.join("repos", l["repo"])
2179
+ wt_path = space.path.join(l["worktree"] || "build/#{id}-#{l["name"]}/wt")
2180
+ branch = "lane/#{id}-#{l["name"]}"
2181
+ resolved, _, st = git_capture("-C", repo_path.to_s, "rev-parse", resolve_lane_base(l["repo"], base))
2182
+ next nil unless st.success?
2183
+ count = repoint_dirty_count(repo_path, wt_path, branch, resolved.strip)
2184
+ count ? [l["name"], count] : nil
2185
+ end
2186
+ return if dirty.empty?
2187
+
2188
+ names = dirty.map { |n, c| "#{n} (#{c})" }.join(", ")
2189
+ raise Space::Core::Error,
2190
+ "Refusing to provision '#{iteration}' — re-pointing to the requested base would discard uncommitted " \
2191
+ "work (untracked files included) in: #{names}. Dispatch or integrate the lane(s) first, or re-run " \
2192
+ "with --force to discard it and re-point."
2193
+ end
2194
+
1536
2195
  def worktree_registered?(repo_path, wt_path)
1537
2196
  out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
1538
2197
  real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
1539
2198
  out.lines.any? { |l| l.start_with?("worktree ") && l.chomp.delete_prefix("worktree ") == real }
1540
2199
  end
2200
+
2201
+ # The branch checked out at wt_path, per `git worktree list --porcelain`
2202
+ # (read before removal, since the entry disappears once the worktree is
2203
+ # gone) — nil if wt_path isn't a registered worktree of repo_path at all.
2204
+ def worktree_branch(repo_path, wt_path)
2205
+ out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
2206
+ real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
2207
+ entry = out.split("\n\n").find { |e| e.lines.any? { |l| l.chomp == "worktree #{real}" } }
2208
+ entry&.lines&.find { |l| l.start_with?("branch ") }&.chomp&.delete_prefix("branch refs/heads/")
2209
+ end
2210
+
2211
+ # #88: attach `branch` at wt_path, choosing among the three lane-branch states
2212
+ # the issue distinguishes — absent (create), present with no commits beyond the
2213
+ # base it was recorded from (safe to re-point — the fast-follow case), or
2214
+ # present with commits of its own (refuse unless force). Returns [outcome,
2215
+ # base_sha], where base_sha is the commit the worktree actually ends up on —
2216
+ # never the freshly-resolved one when nothing about the tree moved (the
2217
+ # provenance half of the bug).
2218
+ #
2219
+ # base_explicit: false short-circuits to "just attach whatever's there" —
2220
+ # never refuses, never re-points, and preserves the previously recorded
2221
+ # base_sha instead of overwriting it with a base nobody asked for (see
2222
+ # ensure_lane_materialized).
2223
+ def attach_lane_branch!(repo_path, wt_path, branch, base_ref, base_sha, recorded_base, base_explicit:, force:)
2224
+ unless branch_exists?(repo_path, branch)
2225
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
2226
+ return [:created, base_sha, nil]
2227
+ end
2228
+
2229
+ registered = worktree_registered?(repo_path, wt_path)
2230
+ tip_out, _, tip_st = git_capture("-C", repo_path.to_s, "rev-parse", branch)
2231
+ tip_sha = tip_st.success? ? tip_out.strip : nil
2232
+
2233
+ unless base_explicit
2234
+ return [:unchanged, recorded_base || base_sha, nil] if registered
2235
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
2236
+ return [:created, recorded_base || tip_sha, nil]
2237
+ end
2238
+
2239
+ if tip_sha == base_sha
2240
+ return [:unchanged, base_sha, nil] if registered
2241
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, branch)
2242
+ return [:created, base_sha, nil]
2243
+ end
2244
+
2245
+ # Hard rule 7 forbids builder commits, so an in-flight lane's
2246
+ # entire output is uncommitted working-tree state — carries_own_commits?
2247
+ # below can't see it (no commits to count). Checked first: it's the more
2248
+ # common loss (partial — reset --hard spares untracked files) and the one
2249
+ # this iteration exists to close.
2250
+ dirty = dirty_file_count(repo_path, wt_path)
2251
+ if dirty && !force
2252
+ raise Space::Core::Error,
2253
+ "Lane branch '#{branch}' worktree has #{dirty} uncommitted change(s) (untracked files included) — " \
2254
+ "re-pointing to '#{base_ref}' (#{base_sha[0, 8]}) would run `git reset --hard` and discard them. " \
2255
+ "Dispatch or integrate it first, or re-run with --force to discard them and re-point."
2256
+ end
2257
+
2258
+ if !force && carries_own_commits?(repo_path, recorded_base, tip_sha)
2259
+ raise Space::Core::Error,
2260
+ "Lane branch '#{branch}' carries commits of its own — it is at #{tip_sha ? tip_sha[0, 8] : "?"}, but " \
2261
+ "base '#{base_ref}' (#{base_sha[0, 8]}) was requested. Re-pointing would discard its commits — " \
2262
+ "dispatch or integrate it first, or re-run with --force to discard them and re-point onto the new base."
2263
+ end
2264
+
2265
+ if registered
2266
+ git_run("-C", wt_path.to_s, "reset", "--hard", base_sha)
2267
+ else
2268
+ git_run("-C", repo_path.to_s, "worktree", "add", "-B", branch, wt_path.to_s, base_sha)
2269
+ end
2270
+ [:repointed, base_sha, dirty]
2271
+ end
2272
+
2273
+ # #88/AC2-AC3: does the branch carry commits beyond the base it was recorded
2274
+ # from? Unrecorded provenance can't be proven safe, so it counts as "carries
2275
+ # its own" — conservative by design, not merely by omission.
2276
+ def carries_own_commits?(repo_path, recorded_base, tip_sha)
2277
+ return true if recorded_base.nil? || tip_sha.nil?
2278
+ out, _, st = git_capture("-C", repo_path.to_s, "rev-list", "--count", "#{recorded_base}..#{tip_sha}")
2279
+ !(st.success? && out.strip.to_i.zero?)
2280
+ end
1541
2281
  end
1542
2282
  end