space-architect 2.0.0.rc1 → 2.0.0.rc2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +34 -18
- data/lib/space_architect/{architect_mission.rb → architect_project.rb} +396 -77
- data/lib/space_architect/cli/architect.rb +170 -60
- data/lib/space_architect/cli/research.rb +1 -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 +24 -3
- 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 +3 -1
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/help.rb +15 -2
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/repo.rb +1 -1
- data/lib/space_core/cli/run.rb +29 -0
- data/lib/space_core/cli.rb +6 -0
- 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_core/space.rb +10 -2
- data/lib/space_core/space_store.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_core/version.rb +1 -1
- data/skill/architect/SKILL.md +109 -53
- data/skill/architect/dispatch.md +147 -39
- data/skill/architect/research.md +1 -1
- data/skill/architect-research/SKILL.md +2 -2
- data/skill/architect-vocabulary/SKILL.md +24 -21
- metadata +13 -2
|
@@ -5,14 +5,15 @@ require "erb"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "pathname"
|
|
8
|
+
require "tempfile"
|
|
8
9
|
|
|
9
10
|
module Space::Architect
|
|
10
|
-
# Manages an architect-loop
|
|
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,32 +38,74 @@ module Space::Architect
|
|
|
37
38
|
"## Builder Prompt", "## Builder Report", "## Verdict"
|
|
38
39
|
].freeze
|
|
39
40
|
|
|
41
|
+
# Hard per-gate timeout. Generous relative to the full suite (~55s).
|
|
42
|
+
DEFAULT_GATE_TIMEOUT = 900
|
|
43
|
+
|
|
44
|
+
# Sentinel written to prompt.md by worktree_add. dispatch refuses to launch on this content.
|
|
45
|
+
PROMPT_STUB = "<!-- ARCHITECT: write this lane's builder prompt here, then dispatch. -->"
|
|
46
|
+
|
|
47
|
+
# Inlined settings.json template for `architect init`. Registers a SessionStart
|
|
48
|
+
# hook on the three explicit session-start events (startup/clear/resume) so every
|
|
49
|
+
# space gets auto-regrounding. compact is intentionally omitted — reground on
|
|
50
|
+
# explicit session events, not every compaction cycle.
|
|
51
|
+
SETTINGS_JSON_TEMPLATE = <<~JSON
|
|
52
|
+
{
|
|
53
|
+
"hooks": {
|
|
54
|
+
"SessionStart": [
|
|
55
|
+
{
|
|
56
|
+
"matcher": "startup",
|
|
57
|
+
"hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"matcher": "clear",
|
|
61
|
+
"hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"matcher": "resume",
|
|
65
|
+
"hooks": [{"type": "command", "command": "architect", "args": ["ground"]}]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
JSON
|
|
71
|
+
|
|
40
72
|
def initialize(space:)
|
|
41
73
|
@space = space
|
|
42
74
|
end
|
|
43
75
|
|
|
44
76
|
def init!
|
|
45
77
|
handoff_path = space.path.join("architecture", "ARCHITECT.md")
|
|
46
|
-
|
|
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
111
|
raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
|
|
@@ -95,7 +138,7 @@ module Space::Architect
|
|
|
95
138
|
end
|
|
96
139
|
|
|
97
140
|
def status
|
|
98
|
-
block = space.data["
|
|
141
|
+
block = space.data["project"] || {}
|
|
99
142
|
architecture_dir = space.path.join("architecture")
|
|
100
143
|
iteration_files = if architecture_dir.exist?
|
|
101
144
|
architecture_dir.children
|
|
@@ -110,15 +153,18 @@ module Space::Architect
|
|
|
110
153
|
# Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits
|
|
111
154
|
# any pending changes to the iteration file and records HEAD as freeze_sha. If
|
|
112
155
|
# already frozen, refuses when the frozen region has changed since.
|
|
113
|
-
def freeze!(iteration)
|
|
156
|
+
def freeze!(iteration, warnings: nil)
|
|
114
157
|
entry = slice_entry(iteration)
|
|
115
158
|
rel = entry["file"]
|
|
116
159
|
path = space.path.join(rel)
|
|
117
160
|
raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
|
|
118
|
-
|
|
161
|
+
text = path.read
|
|
162
|
+
unless text.match?(/^## Acceptance Criteria/)
|
|
119
163
|
raise Space::Core::Error, "#{rel} has no '## Acceptance Criteria' section — write the Acceptance Criteria before freezing"
|
|
120
164
|
end
|
|
121
165
|
|
|
166
|
+
lint_gates!(text, warnings: warnings)
|
|
167
|
+
|
|
122
168
|
if entry["freeze_sha"]
|
|
123
169
|
sha = entry["freeze_sha"]
|
|
124
170
|
if frozen_region_changed?(sha, rel)
|
|
@@ -153,7 +199,7 @@ module Space::Architect
|
|
|
153
199
|
sha
|
|
154
200
|
end
|
|
155
201
|
|
|
156
|
-
# Scaffold the durable, section-numbered
|
|
202
|
+
# Scaffold the durable, section-numbered project brief at architecture/BRIEF.md
|
|
157
203
|
# and commit it. The brief is the stable cross-iteration address space iterations
|
|
158
204
|
# cite as "BRIEF §N"; it lives outside the per-iteration freeze region.
|
|
159
205
|
def brief_new!(force: false)
|
|
@@ -165,7 +211,7 @@ module Space::Architect
|
|
|
165
211
|
FileUtils.mkdir_p(brief_path.dirname)
|
|
166
212
|
brief_path.write(render_brief)
|
|
167
213
|
git_run("-C", space.path.to_s, "add", "architecture/BRIEF.md")
|
|
168
|
-
git_run("-C", space.path.to_s, "commit", "-m", "Add
|
|
214
|
+
git_run("-C", space.path.to_s, "commit", "-m", "Add project brief") if staged_changes?
|
|
169
215
|
brief_path
|
|
170
216
|
end
|
|
171
217
|
|
|
@@ -205,6 +251,34 @@ module Space::Architect
|
|
|
205
251
|
{ section: section, heading: spec[:heading], sha: head.strip, committed: committed, diffstat: diffstat.strip }
|
|
206
252
|
end
|
|
207
253
|
|
|
254
|
+
# Write the ## Verdict prose AND record the decision to space.yaml in one commit.
|
|
255
|
+
# decision must be "continue" or "kill".
|
|
256
|
+
def record_verdict!(iteration, decision:, body:)
|
|
257
|
+
unless %w[continue kill].include?(decision)
|
|
258
|
+
raise Space::Core::Error,
|
|
259
|
+
"Invalid verdict decision '#{decision}' — must be one of: continue, kill"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
entry = slice_entry(iteration)
|
|
263
|
+
rel = entry["file"]
|
|
264
|
+
path = space.path.join(rel)
|
|
265
|
+
raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
|
|
266
|
+
|
|
267
|
+
path.write(replace_section_body(path.read, SECTIONS["verdict"][:heading], body.strip, append: false))
|
|
268
|
+
|
|
269
|
+
update_architect_block do |b|
|
|
270
|
+
(b["iterations"] || []).each { |s| s["verdict"] = decision if s["name"] == iteration }
|
|
271
|
+
b
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
nn = format("%02d", entry["ordinal"] || 0)
|
|
275
|
+
git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
|
|
276
|
+
git_run("-C", space.path.to_s, "commit", "-m", "I#{nn}: verdict")
|
|
277
|
+
|
|
278
|
+
head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
|
|
279
|
+
{ decision: decision, sha: head.strip }
|
|
280
|
+
end
|
|
281
|
+
|
|
208
282
|
# Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
|
|
209
283
|
# the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
|
|
210
284
|
def transcribe_evidence!(iteration, lane: nil)
|
|
@@ -272,13 +346,15 @@ module Space::Architect
|
|
|
272
346
|
raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
|
|
273
347
|
base_sha = lane_entry["base_sha"]
|
|
274
348
|
lane_branch = "lane/#{id}-#{lane}"
|
|
275
|
-
integration_branch =
|
|
349
|
+
integration_branch = project_integration_branch
|
|
276
350
|
|
|
277
351
|
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
|
|
278
352
|
raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
|
|
279
353
|
|
|
280
354
|
git_run("-C", wt_path.to_s, "add", "-A")
|
|
281
355
|
git_run("-C", wt_path.to_s, "commit", "-m", message || "lane #{lane}: integrate")
|
|
356
|
+
integrate_sha_raw, = git_capture("-C", wt_path.to_s, "rev-parse", "HEAD")
|
|
357
|
+
integrate_sha = integrate_sha_raw.strip
|
|
282
358
|
|
|
283
359
|
_o, _e, exists = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", "--quiet", integration_branch)
|
|
284
360
|
if exists.success?
|
|
@@ -300,9 +376,14 @@ module Space::Architect
|
|
|
300
376
|
diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")
|
|
301
377
|
|
|
302
378
|
update_architect_block do |b|
|
|
379
|
+
b["integration_branch"] = integration_branch
|
|
303
380
|
(b["iterations"] || []).each do |s|
|
|
304
381
|
next unless s["name"] == iteration
|
|
305
|
-
(s["lanes"] || []).each
|
|
382
|
+
(s["lanes"] || []).each do |l|
|
|
383
|
+
next unless l["name"] == lane
|
|
384
|
+
l["integration_branch"] = integration_branch
|
|
385
|
+
l["integrate_sha"] = integrate_sha
|
|
386
|
+
end
|
|
306
387
|
end
|
|
307
388
|
b
|
|
308
389
|
end
|
|
@@ -334,9 +415,49 @@ module Space::Architect
|
|
|
334
415
|
merged
|
|
335
416
|
end
|
|
336
417
|
|
|
337
|
-
#
|
|
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"]
|
|
@@ -345,26 +466,95 @@ module Space::Architect
|
|
|
345
466
|
|
|
346
467
|
text, _, st = git_capture("-C", space.path.to_s, "show", "#{freeze_sha}:#{rel}")
|
|
347
468
|
raise Space::Core::Error, "could not read frozen #{rel} at #{freeze_sha[0, 8]}" unless st.success?
|
|
348
|
-
|
|
349
|
-
raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if
|
|
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
477
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
|
|
478
|
+
repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil
|
|
356
479
|
space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
|
|
357
480
|
else
|
|
358
481
|
repo = lanes.first&.dig("repo")
|
|
359
482
|
raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
|
|
360
483
|
space.path.join("repos", repo)
|
|
361
484
|
end
|
|
362
|
-
raise Space::Core::Error, "directory does not exist: #{
|
|
485
|
+
raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
|
|
486
|
+
|
|
487
|
+
gates.map do |gate|
|
|
488
|
+
g = gate.transform_keys(&:to_s)
|
|
489
|
+
dir =
|
|
490
|
+
if (cwd = g["cwd"])
|
|
491
|
+
gate_cwd = space.path.join(cwd)
|
|
492
|
+
if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
|
|
493
|
+
base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
|
|
494
|
+
else
|
|
495
|
+
gate_cwd
|
|
496
|
+
end
|
|
497
|
+
else
|
|
498
|
+
base_dir
|
|
499
|
+
end
|
|
500
|
+
raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
|
|
501
|
+
|
|
502
|
+
effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
|
|
503
|
+
captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
|
|
363
504
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
505
|
+
if captured[:timed_out]
|
|
506
|
+
status = :fail
|
|
507
|
+
reason = "timed out after #{effective}s"
|
|
508
|
+
else
|
|
509
|
+
ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
|
|
510
|
+
status = ev.pass? ? :pass : :fail
|
|
511
|
+
reason = ev.reason
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
{ id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
|
|
515
|
+
stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
|
|
516
|
+
dir: dir, status: status, reason: reason }
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Emit grounding reads for the architect's SessionStart hook.
|
|
521
|
+
#
|
|
522
|
+
# Prints to stdout (via the caller), in order:
|
|
523
|
+
# 1. architecture/ARCHITECT.md — always, if present
|
|
524
|
+
# 2. architecture/BRIEF.md — if present
|
|
525
|
+
# 3. In-flight iteration file — resolved as:
|
|
526
|
+
# a) space.data["project"]["current_iteration"] entry's file, if it exists on disk
|
|
527
|
+
# b) highest-ordinal architecture/I<NN>-*.md otherwise
|
|
528
|
+
# c) nothing if neither
|
|
529
|
+
#
|
|
530
|
+
# WORKTREE GUARD (load-bearing, §1): when session_cwd is inside a builder
|
|
531
|
+
# worktree (<space>/build/<id>/wt/**), returns "" and the caller emits nothing.
|
|
532
|
+
# Builders never receive architect grounding.
|
|
533
|
+
#
|
|
534
|
+
# session_cwd defaults to Dir.pwd; callers may inject a path for testing or
|
|
535
|
+
# to pass the value received from the hook's stdin JSON {"cwd": "..."}.
|
|
536
|
+
def ground(session_cwd: nil)
|
|
537
|
+
cwd = File.expand_path(session_cwd || Dir.pwd)
|
|
538
|
+
build_root = space.path.join("build").to_s
|
|
539
|
+
if cwd.start_with?("#{build_root}/") && cwd.match?(%r{/build/[^/]+/wt(/|\z)})
|
|
540
|
+
return ""
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
parts = []
|
|
544
|
+
|
|
545
|
+
architect_path = space.path.join("architecture", "ARCHITECT.md")
|
|
546
|
+
parts << "=== architecture/ARCHITECT.md ===\n\n#{architect_path.read}" if architect_path.exist?
|
|
547
|
+
|
|
548
|
+
brief_path = space.path.join("architecture", "BRIEF.md")
|
|
549
|
+
parts << "=== architecture/BRIEF.md ===\n\n#{brief_path.read}" if brief_path.exist?
|
|
550
|
+
|
|
551
|
+
iter_path = resolve_inflight_iteration
|
|
552
|
+
if iter_path
|
|
553
|
+
rel = iter_path.relative_path_from(space.path).to_s
|
|
554
|
+
parts << "=== #{rel} ===\n\n#{iter_path.read}"
|
|
367
555
|
end
|
|
556
|
+
|
|
557
|
+
parts.join("\n")
|
|
368
558
|
end
|
|
369
559
|
|
|
370
560
|
def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil)
|
|
@@ -385,7 +575,8 @@ module Space::Architect
|
|
|
385
575
|
raise Space::Core::Error, "repos/#{repo} does not exist" unless repo_path.exist?
|
|
386
576
|
|
|
387
577
|
id = iteration_id(entry)
|
|
388
|
-
wt_path
|
|
578
|
+
wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
|
|
579
|
+
build_dir = space.path.join("build", "#{id}-#{lane}")
|
|
389
580
|
FileUtils.mkdir_p(wt_path.dirname)
|
|
390
581
|
|
|
391
582
|
base_ref = base || "HEAD"
|
|
@@ -394,26 +585,48 @@ module Space::Architect
|
|
|
394
585
|
base_sha = base_sha.strip
|
|
395
586
|
|
|
396
587
|
branch = "lane/#{id}-#{lane}"
|
|
397
|
-
|
|
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
|
|
@@ -538,7 +751,7 @@ module Space::Architect
|
|
|
538
751
|
def dispatch(iteration, lane, model: nil, max_turns: 200,
|
|
539
752
|
claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, detach: false,
|
|
540
753
|
push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
|
|
541
|
-
push_client: nil)
|
|
754
|
+
push_client: nil, timeout: nil)
|
|
542
755
|
raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
|
|
543
756
|
raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
|
|
544
757
|
raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
|
|
@@ -565,6 +778,10 @@ module Space::Architect
|
|
|
565
778
|
report_path = build_dir.join("report.md")
|
|
566
779
|
raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
|
|
567
780
|
|
|
781
|
+
prompt_content = prompt_path.read.strip
|
|
782
|
+
raise Space::Core::Error, "Write this lane's prompt to #{prompt_path} before dispatching." \
|
|
783
|
+
if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
|
|
784
|
+
|
|
568
785
|
bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
|
|
569
786
|
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
|
|
570
787
|
bin: bin, config_dir: build_dir, effort: resolved_effort)
|
|
@@ -585,6 +802,7 @@ module Space::Architect
|
|
|
585
802
|
end
|
|
586
803
|
|
|
587
804
|
run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path }
|
|
805
|
+
run_kwargs[:timeout] = timeout if timeout
|
|
588
806
|
if resolved_harness == "claude-code"
|
|
589
807
|
run_kwargs[:push_url] = push_url if push_url
|
|
590
808
|
run_kwargs[:push_token] = push_token if push_token
|
|
@@ -593,6 +811,7 @@ module Space::Architect
|
|
|
593
811
|
exit_code = harness_obj.run(**run_kwargs)
|
|
594
812
|
|
|
595
813
|
result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
|
|
814
|
+
result[:timed_out] = true if exit_code == Harness::ClaudeCodeHarness::TIMEOUT_EXIT_CODE
|
|
596
815
|
result[:created_run_id] = created_run_id if created_run_id
|
|
597
816
|
result[:push_url] = push_url if push_url
|
|
598
817
|
result
|
|
@@ -603,12 +822,78 @@ module Space::Architect
|
|
|
603
822
|
|
|
604
823
|
attr_reader :space
|
|
605
824
|
|
|
825
|
+
# Resolve the in-flight iteration file for ground output.
|
|
826
|
+
# Rule: (a) current_iteration from project block → entry's file if it exists on disk,
|
|
827
|
+
# (b) else highest-ordinal architecture/I<NN>-*.md,
|
|
828
|
+
# (c) else nil.
|
|
829
|
+
def resolve_inflight_iteration
|
|
830
|
+
block = space.data["project"] || {}
|
|
831
|
+
arch_dir = space.path.join("architecture")
|
|
832
|
+
return nil unless arch_dir.exist?
|
|
833
|
+
|
|
834
|
+
current = block["current_iteration"]
|
|
835
|
+
if current
|
|
836
|
+
entry = (block["iterations"] || []).find { |s| s["name"] == current }
|
|
837
|
+
if entry && entry["file"]
|
|
838
|
+
path = space.path.join(entry["file"])
|
|
839
|
+
return path if path.exist?
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
candidates = arch_dir.children.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
|
|
844
|
+
return nil if candidates.empty?
|
|
845
|
+
candidates.max_by { |f| f.basename.to_s[/\AI(\d+)/, 1].to_i }
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# Spawn cmd in dir with pgroup: true, writing stdout/stderr to temp files so
|
|
849
|
+
# pipe buffers can never block. Polls with WNOHANG; kills the process group on
|
|
850
|
+
# timeout. Returns { stdout:, stderr:, exit_code:, timed_out: }.
|
|
851
|
+
def capture_with_timeout(cmd, dir:, timeout:)
|
|
852
|
+
out_f = Tempfile.new(["gate-stdout", ".log"])
|
|
853
|
+
err_f = Tempfile.new(["gate-stderr", ".log"])
|
|
854
|
+
pid = Process.spawn(cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
|
|
855
|
+
|
|
856
|
+
deadline = Time.now + timeout
|
|
857
|
+
status = nil
|
|
858
|
+
timed_out = false
|
|
859
|
+
|
|
860
|
+
until status
|
|
861
|
+
if Time.now > deadline
|
|
862
|
+
timed_out = true
|
|
863
|
+
Process.kill("TERM", -pid) rescue nil
|
|
864
|
+
sleep 0.5
|
|
865
|
+
Process.kill("KILL", -pid) rescue nil
|
|
866
|
+
Process.wait(pid) rescue nil
|
|
867
|
+
break
|
|
868
|
+
end
|
|
869
|
+
_, st = Process.waitpid2(pid, Process::WNOHANG)
|
|
870
|
+
if st
|
|
871
|
+
status = st
|
|
872
|
+
else
|
|
873
|
+
sleep 0.05
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
out_f.rewind; err_f.rewind
|
|
878
|
+
{ stdout: out_f.read, stderr: err_f.read, exit_code: status&.exitstatus, timed_out: timed_out }
|
|
879
|
+
ensure
|
|
880
|
+
out_f&.close!
|
|
881
|
+
err_f&.close!
|
|
882
|
+
end
|
|
883
|
+
|
|
606
884
|
def iteration_id(entry)
|
|
607
885
|
"I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
|
|
608
886
|
end
|
|
609
887
|
|
|
888
|
+
def project_integration_branch
|
|
889
|
+
b = space.data["project"] || {}
|
|
890
|
+
return b["integration_branch"] if b["integration_branch"]
|
|
891
|
+
slug = space.title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
|
892
|
+
"project/#{slug}"
|
|
893
|
+
end
|
|
894
|
+
|
|
610
895
|
def slice_entry(iteration)
|
|
611
|
-
block = space.data["
|
|
896
|
+
block = space.data["project"] || {}
|
|
612
897
|
entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
|
|
613
898
|
raise Space::Core::Error, "Iteration '#{iteration}' not recorded in space.yaml — run `architect new #{iteration}` first" unless entry
|
|
614
899
|
entry
|
|
@@ -642,21 +927,40 @@ module Space::Architect
|
|
|
642
927
|
# (a) frozen sections of the iteration file untouched since freeze
|
|
643
928
|
checks[:frozen_untouched] = (!frozen_region_changed?(freeze_sha, rel) if freeze_sha && rel)
|
|
644
929
|
|
|
645
|
-
# (b) no builder commits in the worktree
|
|
646
|
-
log_out, = git_capture("-C", wt_path.to_s, "log", "#{base_sha}..")
|
|
647
|
-
|
|
930
|
+
# (b) no builder commits in the worktree (the architect's integrate commit is excluded)
|
|
931
|
+
log_out, = git_capture("-C", wt_path.to_s, "log", "--format=%H", "#{base_sha}..")
|
|
932
|
+
commit_shas = log_out.strip.split("\n").map(&:strip).reject(&:empty?)
|
|
933
|
+
recorded_integrate = lane["integrate_sha"]&.strip
|
|
934
|
+
builder_shas = recorded_integrate ? commit_shas.reject { |s| s == recorded_integrate } : commit_shas
|
|
935
|
+
checks[:no_builder_commits] = builder_shas.empty?
|
|
648
936
|
|
|
649
937
|
# (c) builder's scratch report exists and is non-empty
|
|
650
938
|
report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
|
|
651
939
|
checks[:report_exists] = report.exist? && !report.read.strip.empty?
|
|
652
940
|
|
|
653
|
-
# (d) in-bounds: changed paths ⊆ touch_set (
|
|
941
|
+
# (d) in-bounds: changed paths ⊆ touch_set (:no_touch_set if none recorded)
|
|
654
942
|
checks[:in_bounds] = if touch_set.empty?
|
|
655
|
-
|
|
943
|
+
:no_touch_set
|
|
656
944
|
else
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
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) } }
|
|
660
964
|
end
|
|
661
965
|
|
|
662
966
|
checks
|
|
@@ -702,30 +1006,34 @@ module Space::Architect
|
|
|
702
1006
|
lines[(start + 1)...finish].join.strip
|
|
703
1007
|
end
|
|
704
1008
|
|
|
705
|
-
#
|
|
706
|
-
#
|
|
707
|
-
#
|
|
708
|
-
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)
|
|
709
1013
|
body = section_body(text, "## Acceptance Criteria")
|
|
710
1014
|
return [] unless body
|
|
711
|
-
|
|
712
|
-
return []
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
cmd_idx = header.index { |c| c.downcase == "command" } || 1
|
|
716
|
-
ac_idx = header.index { |c| c.downcase.start_with?("ac") } || 0
|
|
717
|
-
|
|
718
|
-
rows[2..].to_a.filter_map do |line|
|
|
719
|
-
cells = split_md_row(line)
|
|
720
|
-
command = cells[cmd_idx].to_s.gsub(/\A`+|`+\z/, "").strip
|
|
721
|
-
next if command.empty?
|
|
722
|
-
{ ac: cells[ac_idx].to_s.strip, command: command }
|
|
723
|
-
end
|
|
1015
|
+
match = body.match(/^```gates\n(.*?)^```/m)
|
|
1016
|
+
return [] unless match
|
|
1017
|
+
parsed = YAML.safe_load(match[1], aliases: false)
|
|
1018
|
+
parsed.is_a?(Array) ? parsed : []
|
|
724
1019
|
end
|
|
725
1020
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1021
|
+
# Lint the gates block in the given iteration file text. Raises Space::Core::Error
|
|
1022
|
+
# with aggregated messages on failure. Absent/empty gates appends a warning to
|
|
1023
|
+
# the optional warnings array but does not fail.
|
|
1024
|
+
def lint_gates!(text, warnings: nil)
|
|
1025
|
+
gates = begin
|
|
1026
|
+
parse_gates(text)
|
|
1027
|
+
rescue Psych::SyntaxError => e
|
|
1028
|
+
raise Space::Core::Error, "ill-formed gates block: #{e.message}"
|
|
1029
|
+
end
|
|
1030
|
+
if gates.empty?
|
|
1031
|
+
warnings << "no gates — this iteration is prose-judged only" if warnings
|
|
1032
|
+
return
|
|
1033
|
+
end
|
|
1034
|
+
result = GateLint.call(gates)
|
|
1035
|
+
return if result.success?
|
|
1036
|
+
raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
|
|
729
1037
|
end
|
|
730
1038
|
|
|
731
1039
|
def staged_changes?
|
|
@@ -757,8 +1065,8 @@ module Space::Architect
|
|
|
757
1065
|
end
|
|
758
1066
|
|
|
759
1067
|
def update_architect_block
|
|
760
|
-
block = space.data["
|
|
761
|
-
space.data["
|
|
1068
|
+
block = space.data["project"] || { "status" => "active", "current_iteration" => nil, "iterations" => [] }
|
|
1069
|
+
space.data["project"] = yield(block)
|
|
762
1070
|
space.save
|
|
763
1071
|
end
|
|
764
1072
|
|
|
@@ -772,5 +1080,16 @@ module Space::Architect
|
|
|
772
1080
|
def git_capture(*args)
|
|
773
1081
|
Open3.capture3("git", *args)
|
|
774
1082
|
end
|
|
1083
|
+
|
|
1084
|
+
def branch_exists?(repo_path, branch)
|
|
1085
|
+
_, _, st = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", branch)
|
|
1086
|
+
st.success?
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
def worktree_registered?(repo_path, wt_path)
|
|
1090
|
+
out, _, _ = git_capture("-C", repo_path.to_s, "worktree", "list", "--porcelain")
|
|
1091
|
+
real = File.exist?(wt_path.to_s) ? File.realpath(wt_path.to_s) : wt_path.to_s
|
|
1092
|
+
out.lines.any? { |l| l.start_with?("worktree ") && l.chomp.delete_prefix("worktree ") == real }
|
|
1093
|
+
end
|
|
775
1094
|
end
|
|
776
1095
|
end
|