space-architect 2.0.0.rc1 → 2.0.0.rc2

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.
@@ -5,14 +5,15 @@ require "erb"
5
5
  require "open3"
6
6
  require "fileutils"
7
7
  require "pathname"
8
+ require "tempfile"
8
9
 
9
10
  module Space::Architect
10
- # Manages an architect-loop mission inside a space: one self-contained file per
11
+ # Manages an architect-loop project inside a space: one self-contained file per
11
12
  # iteration at architecture/I<NN>-<iteration>.md (Grounds / Specification / Acceptance Criteria / Builder
12
13
  # Prompt / Builder Report / Verdict), grown one commit per section. The freeze
13
14
  # is the commit that establishes the Acceptance Criteria; the frozen region (everything
14
15
  # above "## Builder Prompt") is read-only afterward.
15
- class ArchitectMission
16
+ class ArchitectProject
16
17
  # The heading that separates the frozen sections (Grounds/Specification/Acceptance Criteria)
17
18
  # from the appended-after-freeze sections (Builder Prompt/Report/Verdict).
18
19
  FROZEN_BOUNDARY = /^## Builder Prompt/
@@ -37,32 +38,74 @@ module Space::Architect
37
38
  "## Builder Prompt", "## Builder Report", "## Verdict"
38
39
  ].freeze
39
40
 
41
+ # Hard per-gate timeout. Generous relative to the full suite (~55s).
42
+ DEFAULT_GATE_TIMEOUT = 900
43
+
44
+ # Sentinel written to prompt.md by worktree_add. dispatch refuses to launch on this content.
45
+ PROMPT_STUB = "<!-- ARCHITECT: write this lane's builder prompt here, then dispatch. -->"
46
+
47
+ # Inlined settings.json template for `architect init`. Registers a SessionStart
48
+ # hook on the three explicit session-start events (startup/clear/resume) so every
49
+ # space gets auto-regrounding. compact is intentionally omitted — reground on
50
+ # explicit session events, not every compaction cycle.
51
+ SETTINGS_JSON_TEMPLATE = <<~JSON
52
+ {
53
+ "hooks": {
54
+ "SessionStart": [
55
+ {
56
+ "matcher": "startup",
57
+ "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
58
+ },
59
+ {
60
+ "matcher": "clear",
61
+ "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
62
+ },
63
+ {
64
+ "matcher": "resume",
65
+ "hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ JSON
71
+
40
72
  def initialize(space:)
41
73
  @space = space
42
74
  end
43
75
 
44
76
  def init!
45
77
  handoff_path = space.path.join("architecture", "ARCHITECT.md")
46
- if handoff_path.exist?
47
- raise Space::Core::Error, "architecture/ARCHITECT.md already exists — remove it first or edit it directly (idempotent guard)"
78
+ settings_path = space.path.join(".claude", "settings.json")
79
+ to_add = []
80
+
81
+ unless handoff_path.exist?
82
+ FileUtils.mkdir_p(handoff_path.dirname)
83
+ handoff_path.write(render_handoff)
84
+ update_architect_block do |b|
85
+ b.merge("status" => "active", "current_iteration" => nil, "iterations" => [])
86
+ end
87
+ to_add << "architecture/ARCHITECT.md"
88
+ to_add << Space::Core::Space::METADATA_FILE
48
89
  end
49
90
 
50
- FileUtils.mkdir_p(handoff_path.dirname)
51
- handoff_path.write(render_handoff)
52
-
53
- update_architect_block do |b|
54
- b.merge("status" => "active", "current_iteration" => nil, "iterations" => [])
91
+ unless settings_path.exist?
92
+ FileUtils.mkdir_p(settings_path.dirname)
93
+ settings_path.write(SETTINGS_JSON_TEMPLATE)
94
+ to_add << ".claude/settings.json"
55
95
  end
56
96
 
57
- git_run("-C", space.path.to_s, "add", "architecture/ARCHITECT.md", Space::Core::Space::METADATA_FILE)
58
- git_run("-C", space.path.to_s, "commit", "-m", "Initialize architect mission")
97
+ if to_add.any?
98
+ git_run("-C", space.path.to_s, "add", *to_add)
99
+ msg = to_add.include?("architecture/ARCHITECT.md") ? "Initialize architect project" : "Add architect settings"
100
+ git_run("-C", space.path.to_s, "commit", "-m", msg)
101
+ end
59
102
 
60
103
  handoff_path
61
104
  end
62
105
 
63
106
  # Allocate the next ordinal and scaffold architecture/I<NN>-<iteration>.md.
64
107
  def new_iteration!(name)
65
- block = space.data["architect"] || {}
108
+ block = space.data["project"] || {}
66
109
  iterations = block["iterations"] || []
67
110
  if iterations.any? { |s| s["name"] == name }
68
111
  raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
@@ -95,7 +138,7 @@ module Space::Architect
95
138
  end
96
139
 
97
140
  def status
98
- block = space.data["architect"] || {}
141
+ block = space.data["project"] || {}
99
142
  architecture_dir = space.path.join("architecture")
100
143
  iteration_files = if architecture_dir.exist?
101
144
  architecture_dir.children
@@ -110,15 +153,18 @@ module Space::Architect
110
153
  # Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits
111
154
  # any pending changes to the iteration file and records HEAD as freeze_sha. If
112
155
  # already frozen, refuses when the frozen region has changed since.
113
- def freeze!(iteration)
156
+ def freeze!(iteration, warnings: nil)
114
157
  entry = slice_entry(iteration)
115
158
  rel = entry["file"]
116
159
  path = space.path.join(rel)
117
160
  raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
118
- unless path.read.match?(/^## Acceptance Criteria/)
161
+ text = path.read
162
+ unless text.match?(/^## Acceptance Criteria/)
119
163
  raise Space::Core::Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing"
120
164
  end
121
165
 
166
+ lint_gates!(text, warnings: warnings)
167
+
122
168
  if entry["freeze_sha"]
123
169
  sha = entry["freeze_sha"]
124
170
  if frozen_region_changed?(sha, rel)
@@ -153,7 +199,7 @@ module Space::Architect
153
199
  sha
154
200
  end
155
201
 
156
- # Scaffold the durable, section-numbered mission brief at architecture/BRIEF.md
202
+ # Scaffold the durable, section-numbered project brief at architecture/BRIEF.md
157
203
  # and commit it. The brief is the stable cross-iteration address space iterations
158
204
  # cite as "BRIEF §N"; it lives outside the per-iteration freeze region.
159
205
  def brief_new!(force: false)
@@ -165,7 +211,7 @@ module Space::Architect
165
211
  FileUtils.mkdir_p(brief_path.dirname)
166
212
  brief_path.write(render_brief)
167
213
  git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md")
168
- git_run("-C", space.path.to_s, "commit", "-m", "Add mission brief") if staged_changes?
214
+ git_run("-C", space.path.to_s, "commit", "-m", "Add project brief") if staged_changes?
169
215
  brief_path
170
216
  end
171
217
 
@@ -205,6 +251,34 @@ module Space::Architect
205
251
  { section: section, heading: spec[:heading], sha: head.strip, committed: committed, diffstat: diffstat.strip }
206
252
  end
207
253
 
254
+ # Write the ## Verdict prose AND record the decision to space.yaml in one commit.
255
+ # decision must be "continue" or "kill".
256
+ def record_verdict!(iteration, decision:, body:)
257
+ unless %w[continue kill].include?(decision)
258
+ raise Space::Core::Error,
259
+ "Invalid verdict decision '#{decision}' — must be one of: continue, kill"
260
+ end
261
+
262
+ entry = slice_entry(iteration)
263
+ rel = entry["file"]
264
+ path = space.path.join(rel)
265
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
266
+
267
+ path.write(replace_section_body(path.read, SECTIONS["verdict"][:heading], body.strip, append: false))
268
+
269
+ update_architect_block do |b|
270
+ (b["iterations"] || []).each { |s| s["verdict"] = decision if s["name"] == iteration }
271
+ b
272
+ end
273
+
274
+ nn = format("%02d", entry["ordinal"] || 0)
275
+ git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
276
+ git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: verdict")
277
+
278
+ head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
279
+ { decision: decision, sha: head.strip }
280
+ end
281
+
208
282
  # Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
209
283
  # the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
210
284
  def transcribe_evidence!(iteration, lane: nil)
@@ -272,13 +346,15 @@ module Space::Architect
272
346
  raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
273
347
  base_sha = lane_entry["base_sha"]
274
348
  lane_branch = "lane/#{id}-#{lane}"
275
- integration_branch = "lane/#{id}"
349
+ integration_branch = project_integration_branch
276
350
 
277
351
  status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
278
352
  raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
279
353
 
280
354
  git_run("-C", wt_path.to_s, "add", "-A")
281
355
  git_run("-C", wt_path.to_s, "commit", "-m", message || "lane #{lane}: integrate")
356
+ integrate_sha_raw, = git_capture("-C", wt_path.to_s, "rev-parse", "HEAD")
357
+ integrate_sha = integrate_sha_raw.strip
282
358
 
283
359
  _o, _e, exists = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", "--quiet", integration_branch)
284
360
  if exists.success?
@@ -300,9 +376,14 @@ module Space::Architect
300
376
  diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")
301
377
 
302
378
  update_architect_block do |b|
379
+ b["integration_branch"] = integration_branch
303
380
  (b["iterations"] || []).each do |s|
304
381
  next unless s["name"] == iteration
305
- (s["lanes"] || []).each { |l| l["integration_branch"] = integration_branch if l["name"] == lane }
382
+ (s["lanes"] || []).each do |l|
383
+ next unless l["name"] == lane
384
+ l["integration_branch"] = integration_branch
385
+ l["integrate_sha"] = integrate_sha
386
+ end
306
387
  end
307
388
  b
308
389
  end
@@ -334,9 +415,49 @@ module Space::Architect
334
415
  merged
335
416
  end
336
417
 
337
- # Run the iteration's frozen Acceptance Criteria gate commands and stream raw
338
- # stdout/stderr + exit codes. A path-resolving RUNNER ONLY no threshold
339
- # comparison, no PASS/FAIL. The verdict is the architect reading this output.
418
+ # Generate the end-of-project PR command(s) for each integrated repo.
419
+ # Writes a PR body to build/land/<repo>-pr-body.md and returns, per repo:
420
+ # { repo:, integration_branch:, body_file:, command:, context: }.
421
+ # Raises Space::Core::Error if nothing has been integrated yet.
422
+ # Side-effect-free: no git write, no push, no gh.
423
+ def land
424
+ b = space.data["project"] || {}
425
+ integration_branch = project_integration_branch
426
+
427
+ integrated_lanes = (b["iterations"] || []).flat_map do |s|
428
+ (s["lanes"] || []).filter_map { |l| { iteration: s, lane: l } if l["integration_branch"] }
429
+ end
430
+
431
+ raise Space::Core::Error, "nothing integrated yet — integrate a lane before landing" if integrated_lanes.empty?
432
+
433
+ repos = integrated_lanes.map { |e| e[:lane]["repo"] }.uniq
434
+
435
+ repos.map do |repo|
436
+ body_dir = space.path.join("build", "land")
437
+ FileUtils.mkdir_p(body_dir)
438
+ body_path = body_dir.join("#{repo}-pr-body.md")
439
+
440
+ iterations = b["iterations"] || []
441
+ body = +"# #{space.title}\n\nMerges `#{integration_branch}` → `main`.\n\n## Iterations\n\n"
442
+ iterations.each do |s|
443
+ nn = format("%02d", s["ordinal"])
444
+ verdict = s["verdict"] || "—"
445
+ body << "- I#{nn} #{s["name"]} — #{verdict}\n"
446
+ end
447
+ body_path.write(body)
448
+
449
+ cmd = %(gh pr create --base main --head #{integration_branch} --title "#{space.title}" --body-file #{body_path})
450
+ context = "# Run from repos/#{repo} on branch #{integration_branch} (gh pushes it)"
451
+ { repo: repo, integration_branch: integration_branch, body_file: body_path.to_s, command: cmd, context: context }
452
+ end
453
+ end
454
+
455
+ # Run the iteration's frozen Acceptance Criteria gate commands. Each gate is
456
+ # executed in the resolved cwd (per-gate `cwd` overrides the base dir), under
457
+ # a hard timeout, and evaluated against its `expect` block. Returns an array
458
+ # of result hashes with :status (:pass/:fail) and :reason in addition to the
459
+ # raw :stdout/:stderr/:exit_code. The mechanical verdict belongs here; the AC
460
+ # verdict remains the architect's.
340
461
  def run_gates(iteration, lane: nil)
341
462
  entry = slice_entry(iteration)
342
463
  freeze_sha = entry["freeze_sha"]
@@ -345,26 +466,95 @@ module Space::Architect
345
466
 
346
467
  text, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}")
347
468
  raise Space::Core::Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
348
- commands = acceptance_criteria_commands(text)
349
- raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if commands.empty?
469
+ gates = parse_gates(text)
470
+ raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if gates.empty?
350
471
 
351
472
  lanes = entry["lanes"] || []
352
- dir =
473
+ repo_root = nil
474
+ base_dir =
353
475
  if lane
354
476
  le = lanes.find { |l| l["name"] == lane }
355
477
  raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
478
+ repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil
356
479
  space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
357
480
  else
358
481
  repo = lanes.first&.dig("repo")
359
482
  raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
360
483
  space.path.join("repos", repo)
361
484
  end
362
- raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
485
+ raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
486
+
487
+ gates.map do |gate|
488
+ g = gate.transform_keys(&:to_s)
489
+ dir =
490
+ if (cwd = g["cwd"])
491
+ gate_cwd = space.path.join(cwd)
492
+ if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
493
+ base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
494
+ else
495
+ gate_cwd
496
+ end
497
+ else
498
+ base_dir
499
+ end
500
+ raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
501
+
502
+ effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
503
+ captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
363
504
 
364
- commands.map do |row|
365
- out, err, status = Open3.capture3(row[:command], chdir: dir.to_s)
366
- { ac: row[:ac], command: row[:command], stdout: out, stderr: err, exit_code: status.exitstatus, dir: dir }
505
+ if captured[:timed_out]
506
+ status = :fail
507
+ reason = "timed out after #{effective}s"
508
+ else
509
+ ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
510
+ status = ev.pass? ? :pass : :fail
511
+ reason = ev.reason
512
+ end
513
+
514
+ { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
515
+ stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
516
+ dir: dir, status: status, reason: reason }
517
+ end
518
+ end
519
+
520
+ # Emit grounding reads for the architect's SessionStart hook.
521
+ #
522
+ # Prints to stdout (via the caller), in order:
523
+ # 1. architecture/ARCHITECT.md — always, if present
524
+ # 2. architecture/BRIEF.md — if present
525
+ # 3. In-flight iteration file — resolved as:
526
+ # a) space.data["project"]["current_iteration"] entry's file, if it exists on disk
527
+ # b) highest-ordinal architecture/I<NN>-*.md otherwise
528
+ # c) nothing if neither
529
+ #
530
+ # WORKTREE GUARD (load-bearing, §1): when session_cwd is inside a builder
531
+ # worktree (<space>/build/<id>/wt/**), returns "" and the caller emits nothing.
532
+ # Builders never receive architect grounding.
533
+ #
534
+ # session_cwd defaults to Dir.pwd; callers may inject a path for testing or
535
+ # to pass the value received from the hook's stdin JSON {"cwd": "..."}.
536
+ def ground(session_cwd: nil)
537
+ cwd = File.expand_path(session_cwd || Dir.pwd)
538
+ build_root = space.path.join("build").to_s
539
+ if cwd.start_with?("#{build_root}/") && cwd.match?(%r{/build/[^/]+/wt(/|\z)})
540
+ return ""
541
+ end
542
+
543
+ parts = []
544
+
545
+ architect_path = space.path.join("architecture", "ARCHITECT.md")
546
+ parts << "=== architecture/ARCHITECT.md ===\n\n#{architect_path.read}" if architect_path.exist?
547
+
548
+ brief_path = space.path.join("architecture", "BRIEF.md")
549
+ parts << "=== architecture/BRIEF.md ===\n\n#{brief_path.read}" if brief_path.exist?
550
+
551
+ iter_path = resolve_inflight_iteration
552
+ if iter_path
553
+ rel = iter_path.relative_path_from(space.path).to_s
554
+ parts << "=== #{rel} ===\n\n#{iter_path.read}"
367
555
  end
556
+
557
+ parts.join("\n")
368
558
  end
369
559
 
370
560
  def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil)
@@ -385,7 +575,8 @@ module Space::Architect
385
575
  raise Space::Core::Error, "repos/#{repo} does not exist" unless repo_path.exist?
386
576
 
387
577
  id = iteration_id(entry)
388
- wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
578
+ wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
579
+ build_dir = space.path.join("build", "#{id}-#{lane}")
389
580
  FileUtils.mkdir_p(wt_path.dirname)
390
581
 
391
582
  base_ref = base || "HEAD"
@@ -394,26 +585,48 @@ module Space::Architect
394
585
  base_sha = base_sha.strip
395
586
 
396
587
  branch = "lane/#{id}-#{lane}"
397
- git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
588
+
589
+ # Guard: an existing directory that is not a registered worktree is ambiguous — refuse.
590
+ if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
591
+ raise Space::Core::Error,
592
+ "#{wt_path} exists but is not a registered git worktree of #{repo} — " \
593
+ "resolve manually before re-running worktree_add"
594
+ end
595
+
596
+ # Skip git worktree add when the branch and worktree already exist (idempotent re-run).
597
+ unless branch_exists?(repo_path, branch) && worktree_registered?(repo_path, wt_path)
598
+ git_run("-C", repo_path.to_s, "worktree", "add", wt_path.to_s, "-b", branch, base_sha)
599
+ end
600
+
601
+ # Seed prompt.md with a placeholder stub so the architect has a place to write the prompt.
602
+ # Never overwrite an existing file (real prompt or stub from a prior run).
603
+ prompt_path = build_dir.join("prompt.md")
604
+ prompt_path.write("#{PROMPT_STUB}\n") unless prompt_path.exist?
605
+
606
+ new_fields = {
607
+ "name" => lane,
608
+ "repo" => repo,
609
+ "base_sha" => base_sha,
610
+ "worktree" => "build/#{id}-#{lane}/wt",
611
+ "integration_branch" => nil,
612
+ "harness" => harness.to_s,
613
+ "model" => model,
614
+ "variant" => variant
615
+ }
616
+ new_fields["effort"] = effort if effort
617
+ new_fields["touch_set"] = Array(touch) if touch && !Array(touch).empty?
398
618
 
399
619
  update_architect_block do |b|
400
620
  (b["iterations"] || []).each do |s|
401
621
  next unless s["name"] == iteration
402
622
  lanes = s["lanes"] || []
403
- lane_entry = {
404
- "name" => lane,
405
- "repo" => repo,
406
- "base_sha" => base_sha,
407
- "worktree" => "build/#{id}-#{lane}/wt",
408
- "integration_branch" => nil,
409
- "harness" => harness.to_s,
410
- "model" => model,
411
- "variant" => variant
412
- }
413
- lane_entry["effort"] = effort if effort
414
- lane_entry["touch_set"] = Array(touch) if touch && !Array(touch).empty?
415
- lanes << lane_entry
416
- s["lanes"] = lanes
623
+ existing = lanes.find { |l| l["name"] == lane }
624
+ if existing
625
+ existing.merge!(new_fields)
626
+ else
627
+ lanes << new_fields
628
+ s["lanes"] = lanes
629
+ end
417
630
  end
418
631
  b
419
632
  end
@@ -538,7 +751,7 @@ module Space::Architect
538
751
  def dispatch(iteration, lane, model: nil, max_turns: 200,
539
752
  claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
540
753
  push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
541
- push_client: nil)
754
+ push_client: nil, timeout: nil)
542
755
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
543
756
  raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
544
757
  raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
@@ -565,6 +778,10 @@ module Space::Architect
565
778
  report_path = build_dir.join("report.md")
566
779
  raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
567
780
 
781
+ prompt_content = prompt_path.read.strip
782
+ raise Space::Core::Error, "Write this lane's prompt to #{prompt_path} before dispatching." \
783
+ if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
784
+
568
785
  bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
569
786
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
570
787
  bin: bin, config_dir: build_dir, effort: resolved_effort)
@@ -585,6 +802,7 @@ module Space::Architect
585
802
  end
586
803
 
587
804
  run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path }
805
+ run_kwargs[:timeout] = timeout if timeout
588
806
  if resolved_harness == "claude-code"
589
807
  run_kwargs[:push_url] = push_url if push_url
590
808
  run_kwargs[:push_token] = push_token if push_token
@@ -593,6 +811,7 @@ module Space::Architect
593
811
  exit_code = harness_obj.run(**run_kwargs)
594
812
 
595
813
  result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
814
+ result[:timed_out] = true if exit_code == Harness::ClaudeCodeHarness::TIMEOUT_EXIT_CODE
596
815
  result[:created_run_id] = created_run_id if created_run_id
597
816
  result[:push_url] = push_url if push_url
598
817
  result
@@ -603,12 +822,78 @@ module Space::Architect
603
822
 
604
823
  attr_reader :space
605
824
 
825
+ # Resolve the in-flight iteration file for ground output.
826
+ # Rule: (a) current_iteration from project block → entry's file if it exists on disk,
827
+ # (b) else highest-ordinal architecture/I<NN>-*.md,
828
+ # (c) else nil.
829
+ def resolve_inflight_iteration
830
+ block = space.data["project"] || {}
831
+ arch_dir = space.path.join("architecture")
832
+ return nil unless arch_dir.exist?
833
+
834
+ current = block["current_iteration"]
835
+ if current
836
+ entry = (block["iterations"] || []).find { |s| s["name"] == current }
837
+ if entry && entry["file"]
838
+ path = space.path.join(entry["file"])
839
+ return path if path.exist?
840
+ end
841
+ end
842
+
843
+ candidates = arch_dir.children.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
844
+ return nil if candidates.empty?
845
+ candidates.max_by { |f| f.basename.to_s[/\AI(\d+)/, 1].to_i }
846
+ end
847
+
848
+ # Spawn cmd in dir with pgroup: true, writing stdout/stderr to temp files so
849
+ # pipe buffers can never block. Polls with WNOHANG; kills the process group on
850
+ # timeout. Returns { stdout:, stderr:, exit_code:, timed_out: }.
851
+ def capture_with_timeout(cmd, dir:, timeout:)
852
+ out_f = Tempfile.new(["gate-stdout", ".log"])
853
+ err_f = Tempfile.new(["gate-stderr", ".log"])
854
+ pid = Process.spawn(cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
855
+
856
+ deadline = Time.now + timeout
857
+ status = nil
858
+ timed_out = false
859
+
860
+ until status
861
+ if Time.now > deadline
862
+ timed_out = true
863
+ Process.kill("TERM", -pid) rescue nil
864
+ sleep 0.5
865
+ Process.kill("KILL", -pid) rescue nil
866
+ Process.wait(pid) rescue nil
867
+ break
868
+ end
869
+ _, st = Process.waitpid2(pid, Process::WNOHANG)
870
+ if st
871
+ status = st
872
+ else
873
+ sleep 0.05
874
+ end
875
+ end
876
+
877
+ out_f.rewind; err_f.rewind
878
+ { stdout: out_f.read, stderr: err_f.read, exit_code: status&.exitstatus, timed_out: timed_out }
879
+ ensure
880
+ out_f&.close!
881
+ err_f&.close!
882
+ end
883
+
606
884
  def iteration_id(entry)
607
885
  "I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
608
886
  end
609
887
 
888
+ def project_integration_branch
889
+ b = space.data["project"] || {}
890
+ return b["integration_branch"] if b["integration_branch"]
891
+ slug = space.title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
892
+ "project/#{slug}"
893
+ end
894
+
610
895
  def slice_entry(iteration)
611
- block = space.data["architect"] || {}
896
+ block = space.data["project"] || {}
612
897
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
613
898
  raise Space::Core::Error, "Iteration '#{iteration}' not recorded in space.yaml — run `architect new #{iteration}` first" unless entry
614
899
  entry
@@ -642,21 +927,40 @@ module Space::Architect
642
927
  # (a) frozen sections of the iteration file untouched since freeze
643
928
  checks[:frozen_untouched] = (!frozen_region_changed?(freeze_sha, rel) if freeze_sha && rel)
644
929
 
645
- # (b) no builder commits in the worktree
646
- log_out, = git_capture("-C", wt_path.to_s, "log", "#{base_sha}..")
647
- checks[:no_builder_commits] = log_out.strip.empty?
930
+ # (b) no builder commits in the worktree (the architect's integrate commit is excluded)
931
+ log_out, = git_capture("-C", wt_path.to_s, "log", "--format=%H", "#{base_sha}..")
932
+ commit_shas = log_out.strip.split("\n").map(&:strip).reject(&:empty?)
933
+ recorded_integrate = lane["integrate_sha"]&.strip
934
+ builder_shas = recorded_integrate ? commit_shas.reject { |s| s == recorded_integrate } : commit_shas
935
+ checks[:no_builder_commits] = builder_shas.empty?
648
936
 
649
937
  # (c) builder's scratch report exists and is non-empty
650
938
  report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
651
939
  checks[:report_exists] = report.exist? && !report.read.strip.empty?
652
940
 
653
- # (d) in-bounds: changed paths ⊆ touch_set (nil if no touch_set recorded)
941
+ # (d) in-bounds: changed paths ⊆ touch_set (:no_touch_set if none recorded)
654
942
  checks[:in_bounds] = if touch_set.empty?
655
- nil
943
+ :no_touch_set
656
944
  else
657
- status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
658
- changed = status_out.lines.map { |l| l[3..].to_s.strip }
659
- changed.all? { |f| touch_set.any? { |g| File.fnmatch(g, f) } }
945
+ # -z: NUL-delimited; renames emit new_path NUL old_path — include both
946
+ status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain", "-z")
947
+ changed = []
948
+ entries = status_out.split("\0")
949
+ i = 0
950
+ while i < entries.length
951
+ entry = entries[i]
952
+ i += 1
953
+ next if entry.empty? || entry.length < 3
954
+ code = entry[0, 2]
955
+ path = entry[3..]
956
+ changed << path if path && !path.empty?
957
+ next unless code[0] == "R" || code[0] == "C"
958
+ orig = entries[i]
959
+ i += 1
960
+ changed << orig if orig && !orig.empty?
961
+ end
962
+ fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
963
+ changed.all? { |f| touch_set.any? { |g| File.fnmatch(g, f, fnm) } }
660
964
  end
661
965
 
662
966
  checks
@@ -702,30 +1006,34 @@ module Space::Architect
702
1006
  lines[(start + 1)...finish].join.strip
703
1007
  end
704
1008
 
705
- # Parse the Acceptance Criteria markdown table into [{ac:, command:}]. Reads the
706
- # Command column by header name (so an added "Brief §" column doesn't shift it);
707
- # strips surrounding backticks and unescapes \| inside a cell.
708
- def acceptance_criteria_commands(text)
1009
+ # Extract and parse the fenced ```gates block from the Acceptance Criteria section.
1010
+ # Returns an array of gate hashes (string-keyed). Returns [] when the block is
1011
+ # absent, empty, or contains only YAML comments.
1012
+ def parse_gates(text)
709
1013
  body = section_body(text, "## Acceptance Criteria")
710
1014
  return [] unless body
711
- rows = body.lines.map(&:strip).select { |l| l.start_with?("|") }
712
- return [] if rows.length < 2
713
-
714
- header = split_md_row(rows[0])
715
- cmd_idx = header.index { |c| c.downcase == "command" } || 1
716
- ac_idx = header.index { |c| c.downcase.start_with?("ac") } || 0
717
-
718
- rows[2..].to_a.filter_map do |line|
719
- cells = split_md_row(line)
720
- command = cells[cmd_idx].to_s.gsub(/\A`+|`+\z/, "").strip
721
- next if command.empty?
722
- { ac: cells[ac_idx].to_s.strip, command: command }
723
- end
1015
+ match = body.match(/^```gates\n(.*?)^```/m)
1016
+ return [] unless match
1017
+ parsed = YAML.safe_load(match[1], aliases: false)
1018
+ parsed.is_a?(Array) ? parsed : []
724
1019
  end
725
1020
 
726
- def split_md_row(line)
727
- inner = line.strip.sub(/\A\|/, "").sub(/\|\z/, "")
728
- inner.split(/(?<!\\)\|/).map { |c| c.strip.gsub('\\|', "|") }
1021
+ # Lint the gates block in the given iteration file text. Raises Space::Core::Error
1022
+ # with aggregated messages on failure. Absent/empty gates appends a warning to
1023
+ # the optional warnings array but does not fail.
1024
+ def lint_gates!(text, warnings: nil)
1025
+ gates = begin
1026
+ parse_gates(text)
1027
+ rescue Psych::SyntaxError => e
1028
+ raise Space::Core::Error, "ill-formed gates block: #{e.message}"
1029
+ end
1030
+ if gates.empty?
1031
+ warnings << "no gates — this iteration is prose-judged only" if warnings
1032
+ return
1033
+ end
1034
+ result = GateLint.call(gates)
1035
+ return if result.success?
1036
+ raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
729
1037
  end
730
1038
 
731
1039
  def staged_changes?
@@ -757,8 +1065,8 @@ module Space::Architect
757
1065
  end
758
1066
 
759
1067
  def update_architect_block
760
- block = space.data["architect"] || { "status" => "active", "current_iteration" => nil, "iterations" => [] }
761
- space.data["architect"] = yield(block)
1068
+ block = space.data["project"] || { "status" => "active", "current_iteration" => nil, "iterations" => [] }
1069
+ space.data["project"] = yield(block)
762
1070
  space.save
763
1071
  end
764
1072
 
@@ -772,5 +1080,16 @@ module Space::Architect
772
1080
  def git_capture(*args)
773
1081
  Open3.capture3("git", *args)
774
1082
  end
1083
+
1084
+ def branch_exists?(repo_path, branch)
1085
+ _, _, st = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", branch)
1086
+ st.success?
1087
+ end
1088
+
1089
+ def worktree_registered?(repo_path, wt_path)
1090
+ out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
1091
+ real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
1092
+ out.lines.any? { |l| l.start_with?("worktree ") && l.chomp.delete_prefix("worktree ") == real }
1093
+ end
775
1094
  end
776
1095
  end