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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +103 -0
- data/README.md +267 -158
- data/exe/architect +1 -1
- data/exe/space +2 -2
- data/exe/src +13 -0
- data/lib/space_architect/{architect_mission.rb → architect_project.rb} +475 -125
- data/lib/space_architect/cli/architect.rb +248 -178
- data/lib/space_architect/cli/research.rb +94 -0
- data/lib/space_architect/cli/space.rb +25 -31
- data/lib/space_architect/cli/src.rb +20 -14
- data/lib/space_architect/cli.rb +22 -22
- data/lib/space_architect/dispatcher.rb +5 -1
- data/lib/space_architect/gate_evaluator.rb +65 -0
- data/lib/space_architect/gate_lint.rb +140 -0
- data/lib/space_architect/harness.rb +145 -17
- data/lib/space_architect/research/mux.rb +127 -0
- data/lib/space_architect/research/registry.rb +70 -0
- data/lib/space_architect/research/renderer.rb +101 -0
- data/lib/space_architect/research/run.rb +7 -0
- data/lib/space_architect/research/supervisor.rb +108 -0
- data/lib/space_architect/research.rb +13 -0
- data/lib/space_architect/run_creator.rb +53 -0
- data/lib/space_architect/skill_installer.rb +81 -79
- data/lib/space_architect/templates/architect.md.erb +15 -4
- data/lib/space_architect/templates/brief.md.erb +5 -5
- data/lib/space_architect/templates/iteration.md.erb +17 -6
- data/lib/space_architect.rb +8 -21
- data/lib/{space_architect → space_core}/atomic_write.rb +1 -1
- data/lib/space_core/cli/base_command.rb +19 -0
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/config.rb +49 -0
- data/lib/space_core/cli/current.rb +16 -0
- data/lib/space_core/cli/help.rb +123 -0
- data/lib/space_core/cli/helpers.rb +115 -0
- data/lib/space_core/cli/init.rb +29 -0
- data/lib/space_core/cli/list.rb +24 -0
- data/lib/space_core/cli/new.rb +38 -0
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/path.rb +16 -0
- data/lib/space_core/cli/repeatable_options.rb +75 -0
- data/lib/space_core/cli/repo.rb +76 -0
- data/lib/space_core/cli/run.rb +29 -0
- data/lib/space_core/cli/shell.rb +125 -0
- data/lib/space_core/cli/show.rb +21 -0
- data/lib/space_core/cli/status.rb +33 -0
- data/lib/space_core/cli/use.rb +17 -0
- data/lib/space_core/cli.rb +177 -0
- data/lib/{space_architect → space_core}/config.rb +1 -1
- data/lib/{space_architect → space_core}/errors.rb +1 -1
- data/lib/{space_architect → space_core}/git_client.rb +1 -1
- data/lib/{space_architect → space_core}/mise_client.rb +1 -1
- data/lib/space_core/oci_builder.rb +56 -0
- data/lib/space_core/oci_packer.rb +99 -0
- data/lib/space_core/oci_runner.rb +73 -0
- data/lib/{space_architect → space_core}/repo_reference.rb +1 -1
- data/lib/{space_architect → space_core}/repo_resolver.rb +1 -1
- data/lib/{space_architect → space_core}/shell_integration.rb +1 -1
- data/lib/{space_architect → space_core}/slugger.rb +1 -1
- data/lib/{space_architect → space_core}/space.rb +11 -3
- data/lib/{space_architect → space_core}/space_store.rb +13 -13
- data/lib/{space_architect → space_core}/state.rb +1 -1
- data/lib/space_core/templates/oci/dockerfile.erb +63 -0
- data/lib/space_core/templates/oci/dockerignore.erb +17 -0
- data/lib/space_core/templates/oci/entrypoint.sh.erb +10 -0
- data/lib/{space_architect → space_core}/terminal.rb +1 -1
- data/lib/space_core/version.rb +7 -0
- data/lib/{space_architect → space_core}/warnings.rb +1 -1
- data/lib/{space_architect → space_core}/xdg.rb +1 -1
- data/lib/space_core.rb +24 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/clone.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/config.rb +7 -7
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/daemon.rb +46 -30
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/options.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/org.rb +9 -9
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/repo.rb +9 -9
- data/lib/space_src/cli/shell.rb +122 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/status.rb +7 -7
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli/sync.rb +17 -17
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cli.rb +42 -11
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/cloner.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/contract.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/duration.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/model.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/config/store.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/client.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/forge/github.rb +4 -4
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/agent.rb +5 -5
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/launchd/plist.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/log_rotator.rb +1 -1
- data/lib/space_src/migration.rb +43 -0
- data/lib/space_src/nav.rb +98 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/paths.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/client.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/git.rb +4 -4
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/scm/status.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/shell.rb +1 -1
- data/lib/space_src/shell_integration.rb +321 -0
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/lock.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/state/store.rb +2 -2
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/engine.rb +12 -12
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/sync/repo_plan.rb +3 -3
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/interactive_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/json_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/mode.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/plain_reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/ui/reporter.rb +1 -1
- data/{vendor/repo-tender/lib/space_architect/pristine → lib/space_src}/version.rb +2 -2
- data/lib/space_src.rb +37 -0
- data/skill/architect/SKILL.md +110 -54
- data/skill/architect/dispatch.md +147 -39
- data/skill/architect/research.md +46 -37
- data/skill/architect-research/SKILL.md +2 -2
- data/skill/architect-vocabulary/SKILL.md +24 -21
- metadata +127 -68
- data/lib/space_architect/cli/config.rb +0 -61
- data/lib/space_architect/cli/current.rb +0 -22
- data/lib/space_architect/cli/helpers.rb +0 -117
- data/lib/space_architect/cli/init.rb +0 -35
- data/lib/space_architect/cli/list.rb +0 -30
- data/lib/space_architect/cli/new.rb +0 -43
- data/lib/space_architect/cli/options.rb +0 -12
- data/lib/space_architect/cli/path.rb +0 -22
- data/lib/space_architect/cli/repo.rb +0 -88
- data/lib/space_architect/cli/shell.rb +0 -137
- data/lib/space_architect/cli/show.rb +0 -27
- data/lib/space_architect/cli/status.rb +0 -39
- data/lib/space_architect/cli/use.rb +0 -23
- data/lib/space_architect/version.rb +0 -5
- 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
|
|
10
|
-
# Manages an architect-loop
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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["
|
|
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["
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
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
|
-
|
|
349
|
-
raise Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if
|
|
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
|
-
|
|
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: #{
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
"
|
|
409
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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["
|
|
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
|
-
|
|
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 (
|
|
941
|
+
# (d) in-bounds: changed paths ⊆ touch_set (:no_touch_set if none recorded)
|
|
623
942
|
checks[:in_bounds] = if touch_set.empty?
|
|
624
|
-
|
|
943
|
+
:no_touch_set
|
|
625
944
|
else
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
changed
|
|
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
|
-
#
|
|
675
|
-
#
|
|
676
|
-
#
|
|
677
|
-
def
|
|
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
|
-
|
|
681
|
-
return []
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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["
|
|
730
|
-
space.data["
|
|
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
|