space-architect 1.3.0 → 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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +267 -158
  4. data/exe/architect +1 -1
  5. data/exe/space +2 -2
  6. data/exe/src +13 -0
  7. data/lib/space_architect/{architect_mission.rb → architect_project.rb} +475 -125
  8. data/lib/space_architect/cli/architect.rb +248 -178
  9. data/lib/space_architect/cli/research.rb +94 -0
  10. data/lib/space_architect/cli/space.rb +25 -31
  11. data/lib/space_architect/cli/src.rb +20 -14
  12. data/lib/space_architect/cli.rb +22 -22
  13. data/lib/space_architect/dispatcher.rb +5 -1
  14. data/lib/space_architect/gate_evaluator.rb +65 -0
  15. data/lib/space_architect/gate_lint.rb +140 -0
  16. data/lib/space_architect/harness.rb +145 -17
  17. data/lib/space_architect/research/mux.rb +127 -0
  18. data/lib/space_architect/research/registry.rb +70 -0
  19. data/lib/space_architect/research/renderer.rb +101 -0
  20. data/lib/space_architect/research/run.rb +7 -0
  21. data/lib/space_architect/research/supervisor.rb +108 -0
  22. data/lib/space_architect/research.rb +13 -0
  23. data/lib/space_architect/run_creator.rb +53 -0
  24. data/lib/space_architect/skill_installer.rb +81 -79
  25. data/lib/space_architect/templates/architect.md.erb +15 -4
  26. data/lib/space_architect/templates/brief.md.erb +5 -5
  27. data/lib/space_architect/templates/iteration.md.erb +17 -6
  28. data/lib/space_architect.rb +8 -21
  29. data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
  30. data/lib/space_core/cli/base_command.rb +19 -0
  31. data/lib/space_core/cli/build.rb +27 -0
  32. data/lib/space_core/cli/config.rb +49 -0
  33. data/lib/space_core/cli/current.rb +16 -0
  34. data/lib/space_core/cli/help.rb +123 -0
  35. data/lib/space_core/cli/helpers.rb +115 -0
  36. data/lib/space_core/cli/init.rb +29 -0
  37. data/lib/space_core/cli/list.rb +24 -0
  38. data/lib/space_core/cli/new.rb +38 -0
  39. data/lib/space_core/cli/pack.rb +29 -0
  40. data/lib/space_core/cli/path.rb +16 -0
  41. data/lib/space_core/cli/repeatable_options.rb +75 -0
  42. data/lib/space_core/cli/repo.rb +76 -0
  43. data/lib/space_core/cli/run.rb +29 -0
  44. data/lib/space_core/cli/shell.rb +125 -0
  45. data/lib/space_core/cli/show.rb +21 -0
  46. data/lib/space_core/cli/status.rb +33 -0
  47. data/lib/space_core/cli/use.rb +17 -0
  48. data/lib/space_core/cli.rb +177 -0
  49. data/lib/{space_architect → space_core}/config.rb +1 -1
  50. data/lib/{space_architect → space_core}/errors.rb +1 -1
  51. data/lib/{space_architect → space_core}/git_client.rb +1 -1
  52. data/lib/{space_architect → space_core}/mise_client.rb +1 -1
  53. data/lib/space_core/oci_builder.rb +56 -0
  54. data/lib/space_core/oci_packer.rb +99 -0
  55. data/lib/space_core/oci_runner.rb +73 -0
  56. data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
  57. data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
  58. data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
  59. data/lib/{space_architect → space_core}/slugger.rb +1 -1
  60. data/lib/{space_architect → space_core}/space.rb +11 -3
  61. data/lib/{space_architect → space_core}/space_store.rb +13 -13
  62. data/lib/{space_architect → space_core}/state.rb +1 -1
  63. data/lib/space_core/templates/oci/dockerfile.erb +63 -0
  64. data/lib/space_core/templates/oci/dockerignore.erb +17 -0
  65. data/lib/space_core/templates/oci/entrypoint.sh.erb +10 -0
  66. data/lib/{space_architect → space_core}/terminal.rb +1 -1
  67. data/lib/space_core/version.rb +7 -0
  68. data/lib/{space_architect → space_core}/warnings.rb +1 -1
  69. data/lib/{space_architect → space_core}/xdg.rb +1 -1
  70. data/lib/space_core.rb +24 -0
  71. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
  72. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
  73. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
  74. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
  75. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
  76. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
  77. data/lib/space_src/cli/shell.rb +122 -0
  78. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
  79. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
  80. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
  81. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
  82. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
  83. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
  84. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
  85. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
  86. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
  87. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
  88. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
  89. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
  90. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
  91. data/lib/space_src/migration.rb +43 -0
  92. data/lib/space_src/nav.rb +98 -0
  93. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
  94. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
  95. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
  96. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
  97. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
  98. data/lib/space_src/shell_integration.rb +321 -0
  99. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
  100. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
  101. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
  102. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
  103. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
  104. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
  105. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
  106. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
  107. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
  108. data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
  109. data/lib/space_src.rb +37 -0
  110. data/skill/architect/SKILL.md +110 -54
  111. data/skill/architect/dispatch.md +147 -39
  112. data/skill/architect/research.md +46 -37
  113. data/skill/architect-research/SKILL.md +2 -2
  114. data/skill/architect-vocabulary/SKILL.md +24 -21
  115. metadata +127 -68
  116. data/lib/space_architect/cli/config.rb +0 -61
  117. data/lib/space_architect/cli/current.rb +0 -22
  118. data/lib/space_architect/cli/helpers.rb +0 -117
  119. data/lib/space_architect/cli/init.rb +0 -35
  120. data/lib/space_architect/cli/list.rb +0 -30
  121. data/lib/space_architect/cli/new.rb +0 -43
  122. data/lib/space_architect/cli/options.rb +0 -12
  123. data/lib/space_architect/cli/path.rb +0 -22
  124. data/lib/space_architect/cli/repo.rb +0 -88
  125. data/lib/space_architect/cli/shell.rb +0 -137
  126. data/lib/space_architect/cli/show.rb +0 -27
  127. data/lib/space_architect/cli/status.rb +0 -39
  128. data/lib/space_architect/cli/use.rb +0 -23
  129. data/lib/space_architect/version.rb +0 -5
  130. data/vendor/repo-tender/lib/space_architect/pristine.rb +0 -44
@@ -5,14 +5,15 @@ require "erb"
5
5
  require "open3"
6
6
  require "fileutils"
7
7
  require "pathname"
8
+ require "tempfile"
8
9
 
9
- module SpaceArchitect
10
- # Manages an architect-loop mission inside a space: one self-contained file per
10
+ module Space::Architect
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,42 +38,84 @@ module SpaceArchitect
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 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::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
- raise Error, "iteration '#{name}' already exists in space.yaml"
111
+ raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
69
112
  end
70
113
 
71
114
  ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
72
115
  nn = format("%02d", ordinal)
73
116
  rel = "architecture/I#{nn}-#{name}.md"
74
117
  path = space.path.join(rel)
75
- raise Error, "#{rel} already exists" if path.exist?
118
+ raise Space::Core::Error, "#{rel} already exists" if path.exist?
76
119
 
77
120
  FileUtils.mkdir_p(path.dirname)
78
121
  path.write(render_iteration(nn, name))
@@ -88,14 +131,14 @@ module SpaceArchitect
88
131
  b
89
132
  end
90
133
 
91
- git_run("-C", space.path.to_s, "add", rel, Space::METADATA_FILE)
134
+ git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
92
135
  git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: scaffold #{name}")
93
136
 
94
137
  path
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,19 +153,22 @@ module SpaceArchitect
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
- raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
118
- unless path.read.match?(/^## Acceptance Criteria/)
119
- raise Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing"
160
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
161
+ text = path.read
162
+ unless text.match?(/^## Acceptance Criteria/)
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)
125
- raise Error,
171
+ raise Space::Core::Error,
126
172
  "Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
127
173
  "refusing to re-freeze. Restore them to their frozen state or use a new iteration."
128
174
  end
@@ -153,19 +199,19 @@ module SpaceArchitect
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)
160
206
  brief_path = space.path.join("architecture", "BRIEF.md")
161
207
  if brief_path.exist? && !force
162
- raise Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite"
208
+ raise Space::Core::Error, "architecture/BRIEF.md already exists — edit it directly (idempotent guard), or pass --force to overwrite"
163
209
  end
164
210
 
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
 
@@ -176,7 +222,7 @@ module SpaceArchitect
176
222
  def write_section!(iteration, section, body:, append: false, lane: nil)
177
223
  spec = SECTIONS[section]
178
224
  unless spec
179
- raise Error,
225
+ raise Space::Core::Error,
180
226
  "Unknown section '#{section}' — one of: #{SECTIONS.keys.join(', ')}. " \
181
227
  "(Acceptance Criteria is set by `architect freeze`; Builder Report by `architect evidence`.)"
182
228
  end
@@ -184,10 +230,10 @@ module SpaceArchitect
184
230
  entry = slice_entry(iteration)
185
231
  rel = entry["file"]
186
232
  path = space.path.join(rel)
187
- raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
233
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
188
234
 
189
235
  if spec[:frozen] && entry["freeze_sha"]
190
- raise Error,
236
+ raise Space::Core::Error,
191
237
  "#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
192
238
  "frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
193
239
  end
@@ -205,19 +251,47 @@ module SpaceArchitect
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)
211
285
  entry = slice_entry(iteration)
212
286
  rel = entry["file"]
213
287
  path = space.path.join(rel)
214
- raise Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
288
+ raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
215
289
 
216
290
  id = iteration_id(entry)
217
291
  report = space.path.join("build", lane ? "#{id}-#{lane}" : id, "report.md")
218
- raise Error, "builder report not found: #{report}" unless report.exist?
292
+ raise Space::Core::Error, "builder report not found: #{report}" unless report.exist?
219
293
  raw = report.read
220
- raise Error, "builder report is empty: #{report}" if raw.strip.empty?
294
+ raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty?
221
295
 
222
296
  block = lane ? "### #{lane}\n\n#{raw.rstrip}" : raw.rstrip
223
297
  path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
@@ -240,7 +314,7 @@ module SpaceArchitect
240
314
  text =
241
315
  if ref
242
316
  out, _, st = git_capture("-C", space.path.to_s, "show", "#{ref}:#{rel}")
243
- raise Error, "could not read #{rel} at #{ref}" unless st.success?
317
+ raise Space::Core::Error, "could not read #{rel} at #{ref}" unless st.success?
244
318
  out
245
319
  else
246
320
  space.path.join(rel).read
@@ -255,30 +329,32 @@ module SpaceArchitect
255
329
  def merge_lane!(iteration, lane, message: nil)
256
330
  entry = slice_entry(iteration)
257
331
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
258
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
332
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
259
333
 
260
334
  checks = lane_mechanical_checks(entry, lane_entry)
261
335
  if checks[:no_builder_commits] == false
262
- raise Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
336
+ raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
263
337
  end
264
338
  if checks[:in_bounds] == false
265
- raise Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
339
+ raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset and re-dispatch."
266
340
  end
267
341
 
268
342
  repo = lane_entry["repo"]
269
343
  repo_path = space.path.join("repos", repo)
270
344
  id = iteration_id(entry)
271
345
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
272
- raise Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
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
- raise Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
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?
@@ -291,7 +367,7 @@ module SpaceArchitect
291
367
  unless mst.success?
292
368
  conflicts, = git_capture("-C", repo_path.to_s, "diff", "--name-only", "--diff-filter=U")
293
369
  git_capture("-C", repo_path.to_s, "merge", "--abort")
294
- raise Error,
370
+ raise Space::Core::Error,
295
371
  "Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) — the lane plan was " \
296
372
  "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
297
373
  end
@@ -300,9 +376,14 @@ module SpaceArchitect
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
@@ -314,14 +395,14 @@ module SpaceArchitect
314
395
  # Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
315
396
  # first conflict (a disjointness defect). Never decides which lanes pass.
316
397
  def integrate!(iteration, lanes:, teardown: false)
317
- raise Error, "No lanes given to integrate" if lanes.nil? || lanes.empty?
398
+ raise Space::Core::Error, "No lanes given to integrate" if lanes.nil? || lanes.empty?
318
399
 
319
400
  merged = []
320
401
  lanes.each do |lane|
321
402
  merged << merge_lane!(iteration, lane)
322
- rescue Error => e
403
+ rescue Space::Core::Error => e
323
404
  done = merged.map { |m| m[:lane] }.join(", ")
324
- raise Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
405
+ raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
325
406
  end
326
407
 
327
408
  if teardown
@@ -334,86 +415,218 @@ module SpaceArchitect
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"]
343
- raise Error, "Iteration '#{iteration}' is not frozen — freeze before running gates." unless freeze_sha
464
+ raise Space::Core::Error, "Iteration '#{iteration}' is not frozen — freeze before running gates." unless freeze_sha
344
465
  rel = entry["file"]
345
466
 
346
467
  text, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}")
347
- raise Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
348
- commands = acceptance_criteria_commands(text)
349
- raise Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if commands.empty?
468
+ raise Space::Core::Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
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
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
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
- raise Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
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 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)
504
+
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
363
519
 
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 }
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 ""
367
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}"
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)
371
561
  if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
372
- raise Error,
562
+ raise Space::Core::Error,
373
563
  "Pass --model when using --harness opencode " \
374
564
  "(#{Harness::CLAUDE_DEFAULT_MODEL} is a Claude model ID, not valid for opencode — " \
375
565
  "try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
376
566
  end
377
567
  if effort && harness.to_s != "opencode"
378
- raise Error,
568
+ raise Space::Core::Error,
379
569
  "effort is opencode-only (sets opencode reasoningEffort) — " \
380
570
  "set effort only on opencode lanes (harness: opencode)"
381
571
  end
382
572
 
383
573
  entry = slice_entry(iteration)
384
574
  repo_path = space.path.join("repos", repo)
385
- raise Error, "repos/#{repo} does not exist" unless repo_path.exist?
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"
392
583
  base_sha, _, wt_status = git_capture("-C", repo_path.to_s, "rev-parse", base_ref)
393
- raise Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success?
584
+ raise Space::Core::Error, "Could not resolve base ref '#{base_ref}' in #{repo}" unless wt_status.success?
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
@@ -451,10 +664,10 @@ module SpaceArchitect
451
664
  def variant_promote(iteration, winner)
452
665
  entry = slice_entry(iteration)
453
666
  variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
454
- raise Error, "Iteration '#{iteration}' has no variant set — nothing to promote" if variant_lanes.empty?
667
+ raise Space::Core::Error, "Iteration '#{iteration}' has no variant set — nothing to promote" if variant_lanes.empty?
455
668
 
456
669
  names = variant_lanes.map { |l| l["name"] }
457
- raise Error, "Cannot promote '#{winner}' — not a variant lane of iteration '#{iteration}'" unless names.include?(winner)
670
+ raise Space::Core::Error, "Cannot promote '#{winner}' — not a variant lane of iteration '#{iteration}'" unless names.include?(winner)
458
671
  discarded_names = names - [winner]
459
672
 
460
673
  update_architect_block do |b|
@@ -477,7 +690,7 @@ module SpaceArchitect
477
690
  def variant_compare(iteration)
478
691
  entry = slice_entry(iteration)
479
692
  variant_lanes = (entry["lanes"] || []).select { |l| l["variant"] }
480
- raise Error, "Iteration '#{iteration}' has no variant set — nothing to compare" if variant_lanes.empty?
693
+ raise Space::Core::Error, "Iteration '#{iteration}' has no variant set — nothing to compare" if variant_lanes.empty?
481
694
 
482
695
  winner = entry["winner"]
483
696
  {
@@ -500,7 +713,7 @@ module SpaceArchitect
500
713
  def worktree_remove(iteration, lane)
501
714
  entry = slice_entry(iteration)
502
715
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
503
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
716
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
504
717
 
505
718
  repo = lane_entry["repo"]
506
719
  repo_path = space.path.join("repos", repo)
@@ -536,50 +749,153 @@ module SpaceArchitect
536
749
  end
537
750
 
538
751
  def dispatch(iteration, lane, model: nil, max_turns: 200,
539
- claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil)
752
+ claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
753
+ push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
754
+ push_client: nil, timeout: nil)
755
+ raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
756
+ raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
757
+ raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
758
+ if detach && (push_url || push_host)
759
+
540
760
  entry = slice_entry(iteration)
541
761
  lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
542
- raise Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
762
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
543
763
 
544
764
  resolved_harness = harness || lane_entry["harness"] || "claude-code"
545
765
  resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
546
766
  resolved_effort = effort || lane_entry["effort"]
547
767
 
768
+ raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
769
+ if push_host && resolved_harness != "claude-code"
770
+
548
771
  id = iteration_id(entry)
549
772
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
550
- raise Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
773
+ raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
551
774
 
552
775
  build_dir = space.path.join("build", "#{id}-#{lane}")
553
776
  prompt_path = build_dir.join("prompt.md")
554
777
  run_log_path = build_dir.join("run.jsonl")
555
778
  report_path = build_dir.join("report.md")
556
- raise Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
779
+ raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
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
557
784
 
558
785
  bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
559
786
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
560
787
  bin: bin, config_dir: build_dir, effort: resolved_effort)
561
788
 
562
- exit_code = harness_obj.run(
563
- prompt_path: prompt_path,
564
- run_log_path: run_log_path,
565
- chdir: wt_path
566
- )
789
+ if detach
790
+ pid = harness_obj.run_detached(
791
+ prompt_path: prompt_path,
792
+ run_log_path: run_log_path,
793
+ chdir: wt_path
794
+ )
795
+ { pid: pid, run_log: run_log_path, report: report_path, worktree: wt_path }
796
+ else
797
+ created_run_id = nil
798
+ if push_host
799
+ creator = run_creator || RunCreator.new(push_host, push_token)
800
+ created_run_id = creator.create
801
+ push_url = "#{push_host.chomp('/')}/runs/#{created_run_id}/ingest"
802
+ end
803
+
804
+ run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path }
805
+ run_kwargs[:timeout] = timeout if timeout
806
+ if resolved_harness == "claude-code"
807
+ run_kwargs[:push_url] = push_url if push_url
808
+ run_kwargs[:push_token] = push_token if push_token
809
+ run_kwargs[:push_client] = push_client if push_client
810
+ end
811
+ exit_code = harness_obj.run(**run_kwargs)
567
812
 
568
- { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
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
815
+ result[:created_run_id] = created_run_id if created_run_id
816
+ result[:push_url] = push_url if push_url
817
+ result
818
+ end
569
819
  end
570
820
 
571
821
  private
572
822
 
573
823
  attr_reader :space
574
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
+
575
884
  def iteration_id(entry)
576
885
  "I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
577
886
  end
578
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
+
579
895
  def slice_entry(iteration)
580
- block = space.data["architect"] || {}
896
+ block = space.data["project"] || {}
581
897
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
582
- raise Error, "Iteration '#{iteration}' not recorded in space.yaml — run `architect new #{iteration}` first" unless entry
898
+ raise Space::Core::Error, "Iteration '#{iteration}' not recorded in space.yaml — run `architect new #{iteration}` first" unless entry
583
899
  entry
584
900
  end
585
901
 
@@ -611,21 +927,40 @@ module SpaceArchitect
611
927
  # (a) frozen sections of the iteration file untouched since freeze
612
928
  checks[:frozen_untouched] = (!frozen_region_changed?(freeze_sha, rel) if freeze_sha && rel)
613
929
 
614
- # (b) no builder commits in the worktree
615
- log_out, = git_capture("-C", wt_path.to_s, "log", "#{base_sha}..")
616
- 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?
617
936
 
618
937
  # (c) builder's scratch report exists and is non-empty
619
938
  report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
620
939
  checks[:report_exists] = report.exist? && !report.read.strip.empty?
621
940
 
622
- # (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)
623
942
  checks[:in_bounds] = if touch_set.empty?
624
- nil
943
+ :no_touch_set
625
944
  else
626
- status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
627
- changed = status_out.lines.map { |l| l[3..].to_s.strip }
628
- 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) } }
629
964
  end
630
965
 
631
966
  checks
@@ -637,7 +972,7 @@ module SpaceArchitect
637
972
  def replace_section_body(text, heading, new_block, append:)
638
973
  lines = text.lines
639
974
  start = lines.index { |l| l.chomp == heading }
640
- raise Error, "section heading '#{heading}' not found in iteration file" unless start
975
+ raise Space::Core::Error, "section heading '#{heading}' not found in iteration file" unless start
641
976
 
642
977
  finish = ((start + 1)...lines.length).find { |i| KNOWN_HEADINGS.include?(lines[i].chomp) } || lines.length
643
978
  body = lines[(start + 1)...finish].join
@@ -671,30 +1006,34 @@ module SpaceArchitect
671
1006
  lines[(start + 1)...finish].join.strip
672
1007
  end
673
1008
 
674
- # Parse the Acceptance Criteria markdown table into [{ac:, command:}]. Reads the
675
- # Command column by header name (so an added "Brief §" column doesn't shift it);
676
- # strips surrounding backticks and unescapes \| inside a cell.
677
- 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)
678
1013
  body = section_body(text, "## Acceptance Criteria")
679
1014
  return [] unless body
680
- rows = body.lines.map(&:strip).select { |l| l.start_with?("|") }
681
- return [] if rows.length < 2
682
-
683
- header = split_md_row(rows[0])
684
- cmd_idx = header.index { |c| c.downcase == "command" } || 1
685
- ac_idx = header.index { |c| c.downcase.start_with?("ac") } || 0
686
-
687
- rows[2..].to_a.filter_map do |line|
688
- cells = split_md_row(line)
689
- command = cells[cmd_idx].to_s.gsub(/\A`+|`+\z/, "").strip
690
- next if command.empty?
691
- { ac: cells[ac_idx].to_s.strip, command: command }
692
- 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 : []
693
1019
  end
694
1020
 
695
- def split_md_row(line)
696
- inner = line.strip.sub(/\A\|/, "").sub(/\|\z/, "")
697
- 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")}"
698
1037
  end
699
1038
 
700
1039
  def staged_changes?
@@ -726,8 +1065,8 @@ module SpaceArchitect
726
1065
  end
727
1066
 
728
1067
  def update_architect_block
729
- block = space.data["architect"] || { "status" => "active", "current_iteration" => nil, "iterations" => [] }
730
- space.data["architect"] = yield(block)
1068
+ block = space.data["project"] || { "status" => "active", "current_iteration" => nil, "iterations" => [] }
1069
+ space.data["project"] = yield(block)
731
1070
  space.save
732
1071
  end
733
1072
 
@@ -735,11 +1074,22 @@ module SpaceArchitect
735
1074
  out, err, status = Open3.capture3("git", *args)
736
1075
  return if status.success?
737
1076
  output = [out, err].map(&:strip).reject(&:empty?).join(" ")
738
- raise Error, "git #{args.join(' ')} failed: #{output}"
1077
+ raise Space::Core::Error, "git #{args.join(' ')} failed: #{output}"
739
1078
  end
740
1079
 
741
1080
  def git_capture(*args)
742
1081
  Open3.capture3("git", *args)
743
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
744
1094
  end
745
1095
  end