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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -0
- data/lib/space_architect/architect_project.rb +843 -103
- data/lib/space_architect/bug_report.rb +18 -7
- data/lib/space_architect/cli/architect.rb +182 -18
- data/lib/space_architect/cli/research.rb +3 -4
- data/lib/space_architect/harness.rb +87 -30
- data/lib/space_architect/session_sync/runner.rb +4 -2
- data/lib/space_architect/skill_installer.rb +3 -3
- data/lib/space_architect/templates/iteration.md.erb +35 -7
- data/lib/space_core/cli/help.rb +2 -0
- data/lib/space_core/commands.rb +32 -1
- data/lib/space_core/paths.rb +35 -0
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/version.rb +1 -1
- data/lib/space_src/cli/sync.rb +23 -10
- data/lib/space_src/cli.rb +1 -0
- data/lib/space_src/cloner.rb +2 -0
- data/lib/space_src/nav.rb +1 -0
- data/lib/space_src/sync/engine.rb +46 -7
- data/lib/space_src/sync/report.rb +18 -0
- data/lib/space_src.rb +1 -0
- data/skill/architect/SKILL.md +111 -20
- data/skill/architect/dispatch.md +111 -14
- metadata +2 -1
|
@@ -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
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
502
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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.
|
|
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 —
|
|
798
|
-
#
|
|
799
|
-
#
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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,
|
|
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,
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
#
|
|
873
|
-
#
|
|
874
|
-
#
|
|
875
|
-
#
|
|
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"]
|
|
882
|
-
l["harness"]
|
|
883
|
-
l["model"]
|
|
884
|
-
l["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,
|
|
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,
|
|
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?
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1373
|
-
return [] unless
|
|
1374
|
-
|
|
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
|
-
|
|
1433
|
-
|
|
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
|