space-architect 5.0.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -0
- data/lib/space_architect/architect_project.rb +242 -66
- data/lib/space_architect/cli/architect.rb +114 -36
- data/lib/space_architect/harness.rb +176 -18
- data/lib/space_core/cli/run.rb +3 -2
- data/lib/space_core/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e8120606794bd415a99f1fffa4f850a141aeb0674a94b809311c19513e2bef3
|
|
4
|
+
data.tar.gz: 45796109310f890c1e20c13cd01c42bc09d88dbd3cb0dd8fb8b3dbb73cad8c0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cd1e6c226adfce0082aeee3aea0b9f7e24dbcbd583ab8ce715c79ed2e9352baf4274922c241f7f25599f42bcee044ba878aa622555e4c813928bbf26038985b
|
|
7
|
+
data.tar.gz: 3ea9beaa3bce9a280893b5d2d1021dfe050285e6584d9ff42d010548eeb955ddede01f286251ddec3a966b679dab650be129a4cbac36a666cdbe3d8966a3ac7b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,120 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.2.0] - 2026-07-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`pi` harness (`PiHarness`)** — `pi -p --mode json --model <m> --session-dir
|
|
13
|
+
<build_dir> --no-approve` as a first-class dispatch harness alongside
|
|
14
|
+
claude-code and opencode. The session dir is redirected into the lane's
|
|
15
|
+
`build/` dir via `--session-dir` so nothing leaks to
|
|
16
|
+
`~/.pi/agent/sessions/`. `when "pi"` factory branch; `dispatch`/
|
|
17
|
+
`worktree_add` recognize `pi`; CLI descs updated to include `pi`. opencode
|
|
18
|
+
and claude-code paths byte-for-byte unchanged (I09).
|
|
19
|
+
- **Unified `--effort` / `--thinking` / `--reasoning` knob** — three aliases
|
|
20
|
+
for one thinking-level setting, normalized to
|
|
21
|
+
`off, minimal, low, medium, high, xhigh, max`. Per-harness
|
|
22
|
+
`translate_thinking`: claude `--effort` (low/medium/high/xhigh/max,
|
|
23
|
+
off→omit, minimal→low); opencode `reasoningEffort` (low/medium/high,
|
|
24
|
+
xhigh/max→high, off/minimal→omit); pi `--thinking` passthrough (pi's
|
|
25
|
+
`thinkingLevelMap` clamps, architect doesn't). `--force-{effort,thinking,reasoning}`
|
|
26
|
+
(dispatch-only) skips the clamp and passes the literal — binary rejection is
|
|
27
|
+
final. `--quiet` threads a null `err:` sink. A trailing `:level` model-suffix
|
|
28
|
+
(e.g. `foo:high`) is parsed and translated across all harnesses
|
|
29
|
+
(explicit > suffix > stored) (I10).
|
|
30
|
+
- **Per-harness sensible model defaults** via `Harness.default_model_for`:
|
|
31
|
+
`claude-code → claude-sonnet-5`, `pi → qwen3-27b-optiq`,
|
|
32
|
+
`opencode → fireworks-ai/accounts/fireworks/models/glm-5p2`. The constant
|
|
33
|
+
`CLAUDE_DEFAULT_MODEL` keeps its name (to avoid a `research/supervisor.rb`
|
|
34
|
+
ripple) but its value is now `claude-sonnet-5` (I12).
|
|
35
|
+
- **`space.yaml` project defaults** — `project.harness` / `project.model` /
|
|
36
|
+
`project.effort` (all optional) may be declared in `space.yaml` and sit
|
|
37
|
+
between the CLI flag and the per-harness default in the resolution chain.
|
|
38
|
+
Pin a project's harness/model/effort once instead of per command; the user
|
|
39
|
+
authors them in `space.yaml` directly (no new CLI setter) (I12).
|
|
40
|
+
- **Dispatch stamp** (fixes the `status`-wrong-harness-model bug): `dispatch`
|
|
41
|
+
now stamps the resolved `harness`/`model`/`effort` onto the lane entry
|
|
42
|
+
alongside `dispatched_at`, so `architect status` reads what actually ran on
|
|
43
|
+
the last dispatch — not the `worktree_add`-time values or the global default
|
|
44
|
+
(I12).
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **Shared `resolve_harness_model` helper** DRY across `worktree_add` and
|
|
49
|
+
`dispatch`. Precedence: explicit CLI flag > lane entry's stored value >
|
|
50
|
+
`space.yaml` project default > per-harness sensible default. A stored model
|
|
51
|
+
is only honored when the stored harness still matches the resolved harness,
|
|
52
|
+
so a `--harness` override drops the old harness's model instead of leaking it
|
|
53
|
+
(I12).
|
|
54
|
+
- **Section-write idempotence** — `replace_section_body` now strips a leading
|
|
55
|
+
line equal to the target `## <Heading>` from the supplied body before
|
|
56
|
+
writing. Makes `architect section <it> <sec>` idempotent: `--from
|
|
57
|
+
<file-with-heading>` and `--from <body-only>` produce byte-identical output;
|
|
58
|
+
running it twice never accumulates a duplicate heading. Narrow — only the
|
|
59
|
+
target heading, so `### fix` sub-headings and wrong-section headings survive
|
|
60
|
+
(I11).
|
|
61
|
+
|
|
62
|
+
### Removed
|
|
63
|
+
|
|
64
|
+
- The `worktree_add` opencode/pi nil-model guards and the factory opencode/pi
|
|
65
|
+
`model == CLAUDE_DEFAULT_MODEL` guards — per-harness defaults cover nil, and
|
|
66
|
+
an explicit `--model claude-sonnet-5 --harness pi` is a valid deliberate
|
|
67
|
+
choice (via openrouter/anthropic routing); the binary rejects an invalid
|
|
68
|
+
model. Stale "Pass --model" tests rewritten to assert default-resolution
|
|
69
|
+
(I12).
|
|
70
|
+
|
|
71
|
+
## [5.1.0] - 2026-07-19
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- **`architect integrate --into <branch>`** — merge a lane into a named branch
|
|
76
|
+
instead of the slug-derived `project/<slug>` default (#47). Outside-touch-set
|
|
77
|
+
conflict message gains an `--into` hint; inside-touch conflict keeps "spec
|
|
78
|
+
defect".
|
|
79
|
+
- **Conductor commit-mode** — `commit_mode: conductor` in `space.yaml` plus a
|
|
80
|
+
`--commit-mode` CLI flag on `verify`/`integrate`: canonical conductor commits
|
|
81
|
+
are classified as non-builder in the lane mechanical check, with canonical
|
|
82
|
+
message-shape matching (#55).
|
|
83
|
+
- **`architect sync` subcommand** (`--ff-only`, per-repo status) plus a
|
|
84
|
+
`ground` stale-repo WARNING with behind count and `--into` hint. **No
|
|
85
|
+
auto-sync** — the operator runs `sync` (#49).
|
|
86
|
+
- **`architect freeze --force` / `section --force`** — re-freeze an iteration
|
|
87
|
+
whose frozen region changed since the last freeze, or write a frozen section
|
|
88
|
+
after the freeze (pre-dispatch amend paths). Both refuse if any lane has
|
|
89
|
+
`dispatched_at` OR `integrate_sha` — moving `freeze_sha` or rewriting a frozen
|
|
90
|
+
section after a builder has run against the AC breaks the cardinal invariant
|
|
91
|
+
(AC freeze before results exist; judging quotes the freeze commit). The guard
|
|
92
|
+
names the offending lane and the reason.
|
|
93
|
+
- **`architect merge --into <branch>` / `--commit-mode <mode>`** — wires the
|
|
94
|
+
existing `merge_lane!` `into:`/`commit_mode:` kwargs through the `Merge` CLI,
|
|
95
|
+
matching `Integrate`'s surface.
|
|
96
|
+
- **`architect provision --force` / `worktree add --force`** — `worktree_add`
|
|
97
|
+
gains a `force: false` kwarg. When a worktree dir exists but is unknown to git
|
|
98
|
+
(stale, e.g. left by an aborted provision), `force: true` clears it via
|
|
99
|
+
`FileUtils.rm_rf` and re-creates; without `force` it still raises with a
|
|
100
|
+
`--force` hint so a genuine git-tracked dir is never silently destroyed.
|
|
101
|
+
`ensure_lane_materialized` (the auto-recovery path) stays non-force —
|
|
102
|
+
auto-recovery never silently `rm -rf`s; only an explicit operator `--force`.
|
|
103
|
+
|
|
104
|
+
### Changed
|
|
105
|
+
|
|
106
|
+
- **`dir/**` in-bounds touch glob is now recursive** — matches
|
|
107
|
+
`Dir.glob('**/**')` semantics; single-star `*` stays non-recursive.
|
|
108
|
+
Deep-globbed files inside a lane's declared touch set are in-bounds; outside
|
|
109
|
+
is still a spec defect (#52, #54).
|
|
110
|
+
- **`dispatched_at` recorded in `space.yaml` at dispatch time** (#18).
|
|
111
|
+
|
|
112
|
+
### Fixed
|
|
113
|
+
|
|
114
|
+
- **Canonical `section`/`freeze` commit message shape** — per-section commits
|
|
115
|
+
and the freeze commit carry the correct canonical prefix and body.
|
|
116
|
+
- **`space run --help` steers to the `--` separator** — a quoted multi-word
|
|
117
|
+
command otherwise arrives as one argv token → opaque in-guest failure; the
|
|
118
|
+
desc/argument/example now show the `--` form, with a subprocess test (#28).
|
|
119
|
+
- **Dropped a load-time "assigned but unused variable" warning** — `build_dir`
|
|
120
|
+
in `worktree_add` was computed but never read.
|
|
121
|
+
|
|
8
122
|
## [5.0.0] - 2026-07-06
|
|
9
123
|
|
|
10
124
|
### Added
|
|
@@ -20,13 +20,13 @@ module Space::Architect
|
|
|
20
20
|
FROZEN_BOUNDARY = /^## Builder Prompt/
|
|
21
21
|
|
|
22
22
|
# Sections the architect writes (and the CLI commits) via `architect section`.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# command (`architect evidence`) because it is transcribed verbatim from scratch.
|
|
23
|
+
# Builder Report has its own command (`architect evidence`) because it is
|
|
24
|
+
# transcribed verbatim from scratch.
|
|
26
25
|
# `frozen: true` sections live above the freeze boundary and are refused once frozen.
|
|
27
26
|
SECTIONS = {
|
|
28
27
|
"grounds" => { heading: "## Grounds", message: "grounds", prefix: "grounds", frozen: true },
|
|
29
28
|
"specification" => { heading: "## Specification", message: "specification", prefix: "spec", frozen: true },
|
|
29
|
+
"acceptance-criteria" => { heading: "## Acceptance Criteria", message: "acceptance criteria", prefix: "ac", frozen: true },
|
|
30
30
|
"prompt" => { heading: "## Builder Prompt", message: "dispatched", prefix: "prompt", frozen: false },
|
|
31
31
|
"verdict" => { heading: "## Verdict", message: "verdict", prefix: "verdict", frozen: false }
|
|
32
32
|
}.freeze
|
|
@@ -158,7 +158,8 @@ module Space::Architect
|
|
|
158
158
|
# Freeze the iteration: the iteration file must carry a "## Acceptance Criteria" section. Commits
|
|
159
159
|
# any pending changes to the iteration file and records HEAD as freeze_sha. If
|
|
160
160
|
# already frozen, refuses when the frozen region has changed since.
|
|
161
|
-
|
|
161
|
+
# With force: true, re-freezes a changed frozen region if no lane is dispatched yet.
|
|
162
|
+
def freeze!(iteration, warnings: nil, message: nil, force: false)
|
|
162
163
|
entry = slice_entry(iteration)
|
|
163
164
|
rel = entry["file"]
|
|
164
165
|
path = space.path.join(rel)
|
|
@@ -174,11 +175,17 @@ module Space::Architect
|
|
|
174
175
|
if entry["freeze_sha"]
|
|
175
176
|
sha = entry["freeze_sha"]
|
|
176
177
|
if frozen_region_changed?(sha, rel)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
if force
|
|
179
|
+
dispatched_guard!(entry)
|
|
180
|
+
# fall through to commit path to re-freeze with new sha
|
|
181
|
+
else
|
|
182
|
+
raise Space::Core::Error,
|
|
183
|
+
"Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
|
|
184
|
+
"refusing to re-freeze. Restore them to their frozen state or use a new iteration."
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
return sha
|
|
180
188
|
end
|
|
181
|
-
return sha
|
|
182
189
|
end
|
|
183
190
|
|
|
184
191
|
files = [rel]
|
|
@@ -208,6 +215,9 @@ module Space::Architect
|
|
|
208
215
|
b
|
|
209
216
|
end
|
|
210
217
|
|
|
218
|
+
git_run("-C", space.path.to_s, "commit", "-m",
|
|
219
|
+
compose_message("I#{nn} freeze:", "I#{nn}: record freeze sha", message), "--", Space::Core::Space::METADATA_FILE)
|
|
220
|
+
|
|
211
221
|
sha
|
|
212
222
|
end
|
|
213
223
|
|
|
@@ -232,14 +242,15 @@ module Space::Architect
|
|
|
232
242
|
|
|
233
243
|
# Write one section of the iteration file and commit it with the canonical
|
|
234
244
|
# per-section message, in one call. Refuses to write a frozen section
|
|
235
|
-
# (Grounds/Specification) once the iteration is frozen.
|
|
236
|
-
#
|
|
237
|
-
|
|
245
|
+
# (Grounds/Specification/Acceptance Criteria) once the iteration is frozen.
|
|
246
|
+
# With force: true, writes a frozen section if no lane is dispatched yet.
|
|
247
|
+
# Builder Report is not here (use evidence).
|
|
248
|
+
def write_section!(iteration, section, body:, append: false, lane: nil, message: nil, force: false)
|
|
238
249
|
spec = SECTIONS[section]
|
|
239
250
|
unless spec
|
|
240
251
|
raise Space::Core::Error,
|
|
241
252
|
"Unknown section '#{section}' — one of: #{SECTIONS.keys.join(', ')}. " \
|
|
242
|
-
"(
|
|
253
|
+
"(Builder Report is written by `architect evidence`.)"
|
|
243
254
|
end
|
|
244
255
|
|
|
245
256
|
entry = slice_entry(iteration)
|
|
@@ -248,13 +259,19 @@ module Space::Architect
|
|
|
248
259
|
raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
|
|
249
260
|
|
|
250
261
|
if spec[:frozen] && entry["freeze_sha"]
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
262
|
+
if force
|
|
263
|
+
dispatched_guard!(entry)
|
|
264
|
+
else
|
|
265
|
+
raise Space::Core::Error,
|
|
266
|
+
"#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
|
|
267
|
+
"frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
|
|
268
|
+
end
|
|
254
269
|
end
|
|
255
270
|
|
|
256
271
|
block = lane ? "### #{lane}\n\n#{body.strip}" : body.strip
|
|
257
|
-
|
|
272
|
+
new_text = replace_section_body(path.read, spec[:heading], block, append: append)
|
|
273
|
+
lint_gates!(new_text) if section == "acceptance-criteria"
|
|
274
|
+
path.write(new_text)
|
|
258
275
|
|
|
259
276
|
nn = format("%02d", entry["ordinal"] || 0)
|
|
260
277
|
_o, _e, cst = git_capture("-C", space.path.to_s, "commit", "-m",
|
|
@@ -342,13 +359,13 @@ module Space::Architect
|
|
|
342
359
|
# the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
|
|
343
360
|
# Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
|
|
344
361
|
# (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
|
|
345
|
-
def merge_lane!(iteration, lane, message: nil)
|
|
362
|
+
def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil)
|
|
346
363
|
entry = slice_entry(iteration)
|
|
347
364
|
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
348
365
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
349
366
|
lane_entry = ensure_lane_materialized(iteration, lane)
|
|
350
367
|
|
|
351
|
-
checks = lane_mechanical_checks(entry, lane_entry)
|
|
368
|
+
checks = lane_mechanical_checks(entry, lane_entry, commit_mode: commit_mode)
|
|
352
369
|
if checks[:no_builder_commits] == false
|
|
353
370
|
raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
|
|
354
371
|
end
|
|
@@ -363,7 +380,7 @@ module Space::Architect
|
|
|
363
380
|
raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
|
|
364
381
|
base_sha = lane_entry["base_sha"]
|
|
365
382
|
lane_branch = "lane/#{id}-#{lane}"
|
|
366
|
-
integration_branch = project_integration_branch
|
|
383
|
+
integration_branch = into || project_integration_branch
|
|
367
384
|
|
|
368
385
|
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain")
|
|
369
386
|
raise Space::Core::Error, "Lane '#{lane}' worktree has no changes to integrate." if status_out.strip.empty?
|
|
@@ -385,9 +402,22 @@ module Space::Architect
|
|
|
385
402
|
unless mst.success?
|
|
386
403
|
conflicts, = git_capture("-C", repo_path.to_s, "diff", "--name-only", "--diff-filter=U")
|
|
387
404
|
git_capture("-C", repo_path.to_s, "merge", "--abort")
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
405
|
+
conflict_files = conflicts.split
|
|
406
|
+
lane_touch_set = lane_entry["touch_set"] || []
|
|
407
|
+
fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
408
|
+
outside = conflict_files.reject do |f|
|
|
409
|
+
lane_touch_set.any? { |g| File.fnmatch(g, f, fnm) || (g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm)) }
|
|
410
|
+
end
|
|
411
|
+
if !lane_touch_set.empty? && outside.empty?
|
|
412
|
+
raise Space::Core::Error,
|
|
413
|
+
"Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — the lane plan was " \
|
|
414
|
+
"not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
|
|
415
|
+
else
|
|
416
|
+
raise Space::Core::Error,
|
|
417
|
+
"Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — conflicting files " \
|
|
418
|
+
"are outside the lane's touch set; this looks like a branch mismatch: the lane is being merged " \
|
|
419
|
+
"into '#{integration_branch}'. Use --into <branch> to target the correct branch. #{merr.strip}"
|
|
420
|
+
end
|
|
391
421
|
end
|
|
392
422
|
|
|
393
423
|
merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
|
|
@@ -414,14 +444,14 @@ module Space::Architect
|
|
|
414
444
|
# first conflict (a disjointness defect). Never decides which lanes pass. With no
|
|
415
445
|
# lanes and teardown: true, tears down every lane recorded for the iteration instead
|
|
416
446
|
# (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
|
|
417
|
-
def integrate!(iteration, lanes: nil, teardown: false, message: nil)
|
|
447
|
+
def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil)
|
|
418
448
|
lanes = Array(lanes)
|
|
419
449
|
return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
|
|
420
450
|
raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
|
|
421
451
|
|
|
422
452
|
merged = []
|
|
423
453
|
lanes.each do |lane|
|
|
424
|
-
merged << merge_lane!(iteration, lane, message: message)
|
|
454
|
+
merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into)
|
|
425
455
|
rescue Space::Core::Error => e
|
|
426
456
|
done = merged.map { |m| m[:lane] }.join(", ")
|
|
427
457
|
raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
|
|
@@ -534,20 +564,53 @@ module Space::Architect
|
|
|
534
564
|
parts << "=== #{rel} ===\n\n#{iter_path.read}"
|
|
535
565
|
end
|
|
536
566
|
|
|
567
|
+
space.repos.each do |repo|
|
|
568
|
+
name = repo["name"]
|
|
569
|
+
repo_path = space.path.join("repos", name).to_s
|
|
570
|
+
next unless Dir.exist?(repo_path)
|
|
571
|
+
|
|
572
|
+
branch_out, _, branch_st = git_capture("-C", repo_path, "symbolic-ref", "--short", "HEAD")
|
|
573
|
+
next unless branch_st.success?
|
|
574
|
+
branch = branch_out.strip
|
|
575
|
+
|
|
576
|
+
git_capture("-C", repo_path, "fetch", "origin")
|
|
577
|
+
|
|
578
|
+
count_out, _, count_st = git_capture("-C", repo_path, "rev-list", "--left-right", "--count",
|
|
579
|
+
"#{branch}...origin/#{branch}")
|
|
580
|
+
next unless count_st.success?
|
|
581
|
+
|
|
582
|
+
behind = count_out.strip.split[1].to_i
|
|
583
|
+
if behind > 0
|
|
584
|
+
parts << "WARNING: repos/#{name} local #{branch} is #{behind} commits behind " \
|
|
585
|
+
"origin/#{branch} — run `architect sync #{name}`"
|
|
586
|
+
end
|
|
587
|
+
rescue
|
|
588
|
+
# tolerate fetch or comparison failures silently
|
|
589
|
+
end
|
|
590
|
+
|
|
537
591
|
parts.join("\n")
|
|
538
592
|
end
|
|
539
593
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
594
|
+
# Sync tracked repo clones with their remotes (fast-forward only).
|
|
595
|
+
# Returns an array of result hashes: { repo:, status:, message: }.
|
|
596
|
+
# With no repo_name, syncs every tracked repo; with a name, syncs only that one.
|
|
597
|
+
def sync_repos(repo_name: nil)
|
|
598
|
+
repos = space.repos
|
|
599
|
+
if repo_name
|
|
600
|
+
repos = repos.select { |r| r["name"] == repo_name }
|
|
601
|
+
raise Space::Core::Error, "repo '#{repo_name}' not tracked in this space" if repos.empty?
|
|
546
602
|
end
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
603
|
+
repos.map { |r| sync_one_repo(r["name"]) }
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def worktree_add(repo, iteration, lane, base: nil, harness: nil, model: nil, variant: false,
|
|
607
|
+
effort: nil, touch: nil, force: false, err: $stderr)
|
|
608
|
+
model, suffix_level = Harness.parse_model_suffix(model)
|
|
609
|
+
harness, model = resolve_harness_model(harness, model)
|
|
610
|
+
resolved_level = effort || suffix_level || project_defaults["effort"]
|
|
611
|
+
Harness.validate_thinking_level!(resolved_level)
|
|
612
|
+
if effort.nil? && suffix_level
|
|
613
|
+
err.puts "thinking: model suffix ':#{suffix_level}' parsed from lane model → level=#{suffix_level} (model stripped)"
|
|
551
614
|
end
|
|
552
615
|
|
|
553
616
|
entry = slice_entry(iteration)
|
|
@@ -556,7 +619,6 @@ module Space::Architect
|
|
|
556
619
|
|
|
557
620
|
id = iteration_id(entry)
|
|
558
621
|
wt_path = space.path.join("build", "#{id}-#{lane}", "wt")
|
|
559
|
-
build_dir = space.path.join("build", "#{id}-#{lane}")
|
|
560
622
|
FileUtils.mkdir_p(wt_path.dirname)
|
|
561
623
|
|
|
562
624
|
base_ref = base || "HEAD"
|
|
@@ -566,11 +628,15 @@ module Space::Architect
|
|
|
566
628
|
|
|
567
629
|
branch = "lane/#{id}-#{lane}"
|
|
568
630
|
|
|
569
|
-
# Guard: an existing directory that is not a registered worktree is ambiguous
|
|
631
|
+
# Guard: an existing directory that is not a registered worktree is ambiguous.
|
|
570
632
|
if wt_path.exist? && !worktree_registered?(repo_path, wt_path)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
633
|
+
if force
|
|
634
|
+
FileUtils.rm_rf(wt_path)
|
|
635
|
+
else
|
|
636
|
+
raise Space::Core::Error,
|
|
637
|
+
"#{wt_path} exists but is not a registered git worktree of #{repo} — " \
|
|
638
|
+
"resolve manually before re-running worktree_add, or re-run with --force to clear and re-create it"
|
|
639
|
+
end
|
|
574
640
|
end
|
|
575
641
|
|
|
576
642
|
# Skip git worktree add when the branch and worktree already exist (idempotent re-run).
|
|
@@ -594,7 +660,7 @@ module Space::Architect
|
|
|
594
660
|
"model" => model,
|
|
595
661
|
"variant" => variant
|
|
596
662
|
}
|
|
597
|
-
new_fields["effort"] =
|
|
663
|
+
new_fields["effort"] = resolved_level if resolved_level
|
|
598
664
|
new_fields["touch_set"] = Array(touch) if touch && !Array(touch).empty?
|
|
599
665
|
|
|
600
666
|
update_architect_block do |b|
|
|
@@ -727,7 +793,7 @@ module Space::Architect
|
|
|
727
793
|
# record worktree/base_sha/integration_branch. Idempotent — an already-materialized
|
|
728
794
|
# lane is skipped, not re-created. Refuses until the iteration is frozen, because
|
|
729
795
|
# declarations are not authoritative until then.
|
|
730
|
-
def provision(iteration, base: nil, lane: nil)
|
|
796
|
+
def provision(iteration, base: nil, lane: nil, force: false)
|
|
731
797
|
entry = slice_entry(iteration)
|
|
732
798
|
raise Space::Core::Error,
|
|
733
799
|
"Iteration '#{iteration}' is not frozen — freeze before provisioning (declarations are not authoritative until frozen)." \
|
|
@@ -745,37 +811,50 @@ module Space::Architect
|
|
|
745
811
|
{ lane: name, worktree: wt_path, base_sha: l["base_sha"], created: false }
|
|
746
812
|
else
|
|
747
813
|
result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
|
|
748
|
-
**recorded_lane_fields(l))
|
|
814
|
+
force: force, **recorded_lane_fields(l))
|
|
749
815
|
{ lane: name, worktree: result[:worktree], base_sha: result[:base_sha], created: true }
|
|
750
816
|
end
|
|
751
817
|
end
|
|
752
818
|
end
|
|
753
819
|
|
|
754
|
-
def verify(iteration)
|
|
820
|
+
def verify(iteration, commit_mode: nil)
|
|
755
821
|
entry = slice_entry(iteration)
|
|
756
822
|
(entry["lanes"] || []).map do |lane|
|
|
757
823
|
ensure_lane_materialized(iteration, lane["name"])
|
|
758
|
-
{ lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane) }
|
|
824
|
+
{ lane: lane["name"], repo: lane["repo"], checks: lane_mechanical_checks(entry, lane, commit_mode: commit_mode) }
|
|
759
825
|
end
|
|
760
826
|
end
|
|
761
827
|
|
|
762
828
|
def dispatch(iteration, lane, model: nil, max_turns: 200,
|
|
763
|
-
claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
|
|
764
|
-
push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
|
|
829
|
+
claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil, force: false, quiet: false,
|
|
830
|
+
detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
|
|
765
831
|
push_client: nil, timeout: nil, prompt: nil, now: Time.now)
|
|
766
832
|
raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
|
|
767
833
|
raise Space::Core::Error, "--push-host requires --push-token" if push_host && !push_token
|
|
768
834
|
raise Space::Core::Error, "--detach cannot be combined with --push-url or --push-host" \
|
|
769
835
|
if detach && (push_url || push_host)
|
|
770
836
|
|
|
837
|
+
err = quiet ? File.open(File::NULL, "w") : $stderr
|
|
838
|
+
|
|
771
839
|
entry = slice_entry(iteration)
|
|
772
840
|
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
773
841
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
774
842
|
lane_entry = ensure_lane_materialized(iteration, lane)
|
|
775
843
|
|
|
776
|
-
|
|
777
|
-
resolved_model
|
|
778
|
-
|
|
844
|
+
model, suffix_level = Harness.parse_model_suffix(model)
|
|
845
|
+
resolved_harness, resolved_model = resolve_harness_model(harness, model,
|
|
846
|
+
stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
|
|
847
|
+
|
|
848
|
+
resolved_effort =
|
|
849
|
+
if effort
|
|
850
|
+
Harness.validate_thinking_level!(effort) unless force
|
|
851
|
+
effort
|
|
852
|
+
elsif suffix_level
|
|
853
|
+
err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
|
|
854
|
+
suffix_level
|
|
855
|
+
else
|
|
856
|
+
lane_entry["effort"]
|
|
857
|
+
end
|
|
779
858
|
|
|
780
859
|
raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
|
|
781
860
|
if push_host && resolved_harness != "claude-code"
|
|
@@ -804,18 +883,23 @@ module Space::Architect
|
|
|
804
883
|
if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
|
|
805
884
|
|
|
806
885
|
bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
|
|
807
|
-
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
# Stamp launch time onto the lane entry:
|
|
811
|
-
# (a dispatch that raises above
|
|
812
|
-
#
|
|
886
|
+
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin,
|
|
887
|
+
config_dir: build_dir, effort: resolved_effort, force: force, err: err)
|
|
888
|
+
|
|
889
|
+
# Stamp launch time and the resolved harness/model/effort onto the lane entry:
|
|
890
|
+
# after every preflight validation has passed (a dispatch that raises above
|
|
891
|
+
# records nothing) and before the blocking run or a detached dispatch returns.
|
|
892
|
+
# A re-dispatch overwrites the prior values, so `architect status` always reads
|
|
893
|
+
# what actually ran on the last dispatch.
|
|
813
894
|
update_architect_block do |b|
|
|
814
895
|
(b["iterations"] || []).each do |s|
|
|
815
896
|
next unless s["name"] == iteration
|
|
816
897
|
(s["lanes"] || []).each do |l|
|
|
817
898
|
next unless l["name"] == lane
|
|
818
899
|
l["dispatched_at"] = now.iso8601
|
|
900
|
+
l["harness"] = resolved_harness
|
|
901
|
+
l["model"] = resolved_model
|
|
902
|
+
l["effort"] = resolved_effort if resolved_effort
|
|
819
903
|
end
|
|
820
904
|
end
|
|
821
905
|
b
|
|
@@ -844,6 +928,7 @@ module Space::Architect
|
|
|
844
928
|
run_kwargs[:push_url] = push_url if push_url
|
|
845
929
|
run_kwargs[:push_token] = push_token if push_token
|
|
846
930
|
run_kwargs[:push_client] = push_client if push_client
|
|
931
|
+
run_kwargs[:err] = err if quiet
|
|
847
932
|
end
|
|
848
933
|
exit_code = harness_obj.run(**run_kwargs)
|
|
849
934
|
|
|
@@ -981,6 +1066,27 @@ module Space::Architect
|
|
|
981
1066
|
# The lane's declared harness/model/variant/effort, as worktree_add kwargs — so a
|
|
982
1067
|
# re-materialize preserves them instead of merging back to defaults. touch_set is
|
|
983
1068
|
# deliberately omitted: worktree_add leaves it untouched, so it already survives.
|
|
1069
|
+
# The space.yaml project block's optional harness/model/effort defaults (empty
|
|
1070
|
+
# hash when the block or the keys are absent).
|
|
1071
|
+
def project_defaults
|
|
1072
|
+
space.data["project"] || {}
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# Resolve harness + model by precedence: explicit value > (dispatch only) the
|
|
1076
|
+
# lane's stored value > space.yaml project.harness/project.model > the
|
|
1077
|
+
# per-harness sensible default (model only, keyed on the resolved harness).
|
|
1078
|
+
# Shared by worktree_add and dispatch so both read the same defaults. A stored
|
|
1079
|
+
# model is only honored when its stored harness still matches the resolved
|
|
1080
|
+
# harness — a dispatch-time harness override drops the old harness's stored
|
|
1081
|
+
# model instead of leaking it into the new harness's run.
|
|
1082
|
+
def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
|
|
1083
|
+
defaults = project_defaults
|
|
1084
|
+
resolved_harness = (harness || stored_harness || defaults["harness"] || "claude-code").to_s
|
|
1085
|
+
stored_model = nil unless stored_harness.nil? || stored_harness.to_s == resolved_harness
|
|
1086
|
+
resolved_model = model || stored_model || defaults["model"] || Harness.default_model_for(resolved_harness)
|
|
1087
|
+
[resolved_harness, resolved_model]
|
|
1088
|
+
end
|
|
1089
|
+
|
|
984
1090
|
def recorded_lane_fields(lane_entry)
|
|
985
1091
|
{
|
|
986
1092
|
harness: lane_entry["harness"] || "claude-code",
|
|
@@ -1030,7 +1136,7 @@ module Space::Architect
|
|
|
1030
1136
|
|
|
1031
1137
|
# The four per-lane post-flight checks, shared by `verify` (reports) and
|
|
1032
1138
|
# `merge_lane!` (refuses on failure) so the two can never drift.
|
|
1033
|
-
def lane_mechanical_checks(entry, lane)
|
|
1139
|
+
def lane_mechanical_checks(entry, lane, commit_mode: nil)
|
|
1034
1140
|
freeze_sha = entry["freeze_sha"]
|
|
1035
1141
|
rel = entry["file"]
|
|
1036
1142
|
lane_name = lane["name"]
|
|
@@ -1043,12 +1149,21 @@ module Space::Architect
|
|
|
1043
1149
|
# (a) frozen sections of the iteration file untouched since freeze
|
|
1044
1150
|
checks[:frozen_untouched] = (!frozen_region_changed?(freeze_sha, rel) if freeze_sha && rel)
|
|
1045
1151
|
|
|
1046
|
-
# (b) no builder commits in the worktree (the architect's integrate commit is excluded
|
|
1047
|
-
|
|
1048
|
-
|
|
1152
|
+
# (b) no builder commits in the worktree (the architect's integrate commit is excluded;
|
|
1153
|
+
# in conductor mode, canonical conductor commits are also excluded)
|
|
1154
|
+
effective_commit_mode = commit_mode || space.data.dig("project", "commit_mode") || "strict"
|
|
1155
|
+
log_out, = git_capture("-C", wt_path.to_s, "log", "--format=%H%x09%s", "#{base_sha}..")
|
|
1156
|
+
commit_entries = log_out.strip.split("\n").filter_map do |line|
|
|
1157
|
+
sha, subject = line.strip.split("\t", 2)
|
|
1158
|
+
{ sha: sha, subject: subject.to_s } unless sha.nil? || sha.empty?
|
|
1159
|
+
end
|
|
1049
1160
|
recorded_integrate = lane["integrate_sha"]&.strip
|
|
1050
|
-
|
|
1051
|
-
|
|
1161
|
+
canonical_conductor = "#{iteration_id(entry)}-#{lane_name}: builder output"
|
|
1162
|
+
builder_commits = commit_entries.reject do |c|
|
|
1163
|
+
c[:sha] == recorded_integrate ||
|
|
1164
|
+
(effective_commit_mode == "conductor" && c[:subject] == canonical_conductor)
|
|
1165
|
+
end
|
|
1166
|
+
checks[:no_builder_commits] = builder_commits.empty?
|
|
1052
1167
|
|
|
1053
1168
|
# (c) builder's scratch report exists and is non-empty
|
|
1054
1169
|
report = space.path.join("build", "#{iteration_id(entry)}-#{lane_name}", "report.md")
|
|
@@ -1059,7 +1174,7 @@ module Space::Architect
|
|
|
1059
1174
|
:no_touch_set
|
|
1060
1175
|
else
|
|
1061
1176
|
# -z: NUL-delimited; renames emit new_path NUL old_path — include both
|
|
1062
|
-
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain", "-z")
|
|
1177
|
+
status_out, = git_capture("-C", wt_path.to_s, "status", "--porcelain", "-z", "-uall")
|
|
1063
1178
|
changed = []
|
|
1064
1179
|
entries = status_out.split("\0")
|
|
1065
1180
|
i = 0
|
|
@@ -1076,7 +1191,12 @@ module Space::Architect
|
|
|
1076
1191
|
changed << orig if orig && !orig.empty?
|
|
1077
1192
|
end
|
|
1078
1193
|
fnm = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
1079
|
-
changed.all?
|
|
1194
|
+
changed.all? do |f|
|
|
1195
|
+
touch_set.any? do |g|
|
|
1196
|
+
File.fnmatch(g, f, fnm) ||
|
|
1197
|
+
(g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm))
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1080
1200
|
end
|
|
1081
1201
|
|
|
1082
1202
|
checks
|
|
@@ -1084,7 +1204,10 @@ module Space::Architect
|
|
|
1084
1204
|
|
|
1085
1205
|
# Replace (or, with append:, extend) the body of a "## Heading" section, leaving
|
|
1086
1206
|
# every other section byte-untouched. Append replaces a placeholder body (only a
|
|
1087
|
-
# template comment) the first time, then stacks subsections after it.
|
|
1207
|
+
# template comment) the first time, then stacks subsections after it. A supplied
|
|
1208
|
+
# block that redundantly repeats the section's own heading as its first line (the
|
|
1209
|
+
# natural way to author a complete section) has that leading line stripped first,
|
|
1210
|
+
# so writing the same section twice never accumulates a duplicate heading.
|
|
1088
1211
|
def replace_section_body(text, heading, new_block, append:)
|
|
1089
1212
|
lines = text.lines
|
|
1090
1213
|
start = lines.index { |l| l.chomp == heading }
|
|
@@ -1093,11 +1216,14 @@ module Space::Architect
|
|
|
1093
1216
|
finish = ((start + 1)...lines.length).find { |i| KNOWN_HEADINGS.include?(lines[i].chomp) } || lines.length
|
|
1094
1217
|
body = lines[(start + 1)...finish].join
|
|
1095
1218
|
|
|
1219
|
+
first, rest = new_block.strip.split("\n", 2)
|
|
1220
|
+
block = first == heading ? rest.to_s.strip : new_block.strip
|
|
1221
|
+
|
|
1096
1222
|
new_body =
|
|
1097
1223
|
if append && !placeholder_body?(body)
|
|
1098
|
-
"#{body.strip}\n\n#{
|
|
1224
|
+
"#{body.strip}\n\n#{block}"
|
|
1099
1225
|
else
|
|
1100
|
-
|
|
1226
|
+
block
|
|
1101
1227
|
end
|
|
1102
1228
|
|
|
1103
1229
|
prefix = lines[0..start].join.rstrip
|
|
@@ -1190,6 +1316,17 @@ module Space::Architect
|
|
|
1190
1316
|
raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
|
|
1191
1317
|
end
|
|
1192
1318
|
|
|
1319
|
+
# Raises if any lane in the entry has been dispatched (dispatched_at or integrate_sha set).
|
|
1320
|
+
# Used by freeze! and write_section! --force to prevent rewriting frozen content after a
|
|
1321
|
+
# builder has run (moving freeze_sha post-dispatch breaks the AC cardinal invariant).
|
|
1322
|
+
def dispatched_guard!(entry)
|
|
1323
|
+
lane = (entry["lanes"] || []).find { |l| l["dispatched_at"] || l["integrate_sha"] }
|
|
1324
|
+
return unless lane
|
|
1325
|
+
raise Space::Core::Error,
|
|
1326
|
+
"Lane '#{lane["name"]}' is already dispatched — cannot re-freeze or write frozen sections " \
|
|
1327
|
+
"after dispatch (a builder has run against the frozen AC; rewriting it breaks the cardinal invariant)."
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1193
1330
|
def staged_changes?
|
|
1194
1331
|
_o, _e, st = git_capture("-C", space.path.to_s, "diff", "--cached", "--quiet")
|
|
1195
1332
|
!st.success? # --quiet exits non-zero when there are staged differences
|
|
@@ -1235,6 +1372,45 @@ module Space::Architect
|
|
|
1235
1372
|
Open3.capture3("git", *args)
|
|
1236
1373
|
end
|
|
1237
1374
|
|
|
1375
|
+
def sync_one_repo(name)
|
|
1376
|
+
repo_path = space.path.join("repos", name).to_s
|
|
1377
|
+
|
|
1378
|
+
dirty_out, _, _ = git_capture("-C", repo_path, "status", "--porcelain")
|
|
1379
|
+
return { repo: name, status: :dirty, message: "#{name}: dirty working tree — skipping" } if dirty_out.strip.length > 0
|
|
1380
|
+
|
|
1381
|
+
branch_out, _, branch_st = git_capture("-C", repo_path, "symbolic-ref", "--short", "HEAD")
|
|
1382
|
+
unless branch_st.success?
|
|
1383
|
+
return { repo: name, status: :error, message: "#{name}: detached HEAD — skipping" }
|
|
1384
|
+
end
|
|
1385
|
+
branch = branch_out.strip
|
|
1386
|
+
|
|
1387
|
+
_, fetch_err, fetch_st = git_capture("-C", repo_path, "fetch", "origin")
|
|
1388
|
+
unless fetch_st.success?
|
|
1389
|
+
return { repo: name, status: :error, message: "#{name}: fetch failed — #{fetch_err.strip}" }
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
count_out, _, count_st = git_capture("-C", repo_path, "rev-list", "--left-right", "--count",
|
|
1393
|
+
"#{branch}...origin/#{branch}")
|
|
1394
|
+
unless count_st.success?
|
|
1395
|
+
return { repo: name, status: :error, message: "#{name}: could not compare with origin/#{branch}" }
|
|
1396
|
+
end
|
|
1397
|
+
|
|
1398
|
+
ahead, behind = count_out.strip.split.map(&:to_i)
|
|
1399
|
+
return { repo: name, status: :up_to_date, message: "#{name}: up to date" } if behind == 0
|
|
1400
|
+
|
|
1401
|
+
if ahead > 0
|
|
1402
|
+
return { repo: name, status: :diverged,
|
|
1403
|
+
message: "#{name}: behind #{behind}, diverged #{ahead} — not fast-forwardable, resolve manually" }
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
_, ff_err, ff_st = git_capture("-C", repo_path, "merge", "--ff-only", "origin/#{branch}")
|
|
1407
|
+
if ff_st.success?
|
|
1408
|
+
{ repo: name, status: :fast_forwarded, message: "#{name}: fast-forwarded #{behind} commits" }
|
|
1409
|
+
else
|
|
1410
|
+
{ repo: name, status: :ff_failed, message: "#{name}: merge --ff-only failed — #{ff_err.strip}" }
|
|
1411
|
+
end
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1238
1414
|
def branch_exists?(repo_path, branch)
|
|
1239
1415
|
_, _, st = git_capture("-C", repo_path.to_s, "rev-parse", "--verify", branch)
|
|
1240
1416
|
st.success?
|
|
@@ -31,7 +31,13 @@ module Space::Architect
|
|
|
31
31
|
# Authored-content intake shared by section/verdict/brief: a file, an
|
|
32
32
|
# inline flag, or stdin — canonical files are only ever written by the CLI.
|
|
33
33
|
def read_body(from: nil, body: nil, stdin: false, what: "section body")
|
|
34
|
-
|
|
34
|
+
if from
|
|
35
|
+
begin
|
|
36
|
+
return File.read(from)
|
|
37
|
+
rescue Errno::ENOENT
|
|
38
|
+
raise Space::Core::Error, "file for --from not found: #{from}; provide the #{what} via --from <file>, --body <text>, or --stdin"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
35
41
|
return body if body
|
|
36
42
|
return $stdin.read if stdin
|
|
37
43
|
|
|
@@ -41,10 +47,32 @@ module Space::Architect
|
|
|
41
47
|
# Commit-message intake for commit_message_options: --message-from wins
|
|
42
48
|
# over -m/--message; nil means the command's canonical default message.
|
|
43
49
|
def read_commit_message(message: nil, message_from: nil)
|
|
44
|
-
|
|
50
|
+
if message_from
|
|
51
|
+
begin
|
|
52
|
+
return File.read(message_from)
|
|
53
|
+
rescue Errno::ENOENT
|
|
54
|
+
raise Space::Core::Error, "file for --message-from not found: #{message_from}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
45
57
|
|
|
46
58
|
message
|
|
47
59
|
end
|
|
60
|
+
|
|
61
|
+
# --effort/--thinking/--reasoning are three aliases for one thinking-level knob.
|
|
62
|
+
# At most one may be passed per invocation.
|
|
63
|
+
def resolve_thinking_alias(effort:, thinking:, reasoning:)
|
|
64
|
+
given = { effort: effort, thinking: thinking, reasoning: reasoning }.compact
|
|
65
|
+
raise Space::Core::Error, "pass only one of --effort/--thinking/--reasoning" if given.size > 1
|
|
66
|
+
given.values.first
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --force-effort/--force-thinking/--force-reasoning are three aliases for the
|
|
70
|
+
# force escape hatch. At most one may be passed per invocation.
|
|
71
|
+
def resolve_force_thinking_alias(force_effort:, force_thinking:, force_reasoning:)
|
|
72
|
+
given = { force_effort: force_effort, force_thinking: force_thinking, force_reasoning: force_reasoning }.compact
|
|
73
|
+
raise Space::Core::Error, "pass only one of --force-effort/--force-thinking/--force-reasoning" if given.size > 1
|
|
74
|
+
given.values.first
|
|
75
|
+
end
|
|
48
76
|
end
|
|
49
77
|
|
|
50
78
|
module Architect
|
|
@@ -146,7 +174,7 @@ module Space::Architect
|
|
|
146
174
|
lane_list = s["lanes"] || []
|
|
147
175
|
lanes_str = lane_list.map do |l|
|
|
148
176
|
h = l["harness"] || "claude-code"
|
|
149
|
-
m = l["model"] || Harness
|
|
177
|
+
m = l["model"] || Harness.default_model_for(h)
|
|
150
178
|
eff = l["effort"] ? "·#{l['effort']}" : ""
|
|
151
179
|
"#{l['name']}(#{l['repo']}·#{h}·#{m}#{eff})"
|
|
152
180
|
end.join(", ")
|
|
@@ -174,20 +202,40 @@ module Space::Architect
|
|
|
174
202
|
end
|
|
175
203
|
end
|
|
176
204
|
|
|
205
|
+
class Sync < BaseCommand
|
|
206
|
+
desc "Sync tracked repo clones with their remotes (fast-forward only, no rebase/reset)"
|
|
207
|
+
phase 53, "Project"
|
|
208
|
+
argument :repo, required: false, desc: "Repo name to sync (default: all tracked repos)"
|
|
209
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
210
|
+
|
|
211
|
+
def call(repo: nil, space: nil, **opts)
|
|
212
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
213
|
+
handle_errors do
|
|
214
|
+
render(store.find(space)) do |sp|
|
|
215
|
+
project = ArchitectProject.new(space: sp)
|
|
216
|
+
results = project.sync_repos(repo_name: repo)
|
|
217
|
+
results.each { |r| terminal.say r[:message] }
|
|
218
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
177
224
|
class Freeze < BaseCommand
|
|
178
225
|
desc "Freeze the iteration's frozen region (Grounds/Specification/Acceptance Criteria) and record the freeze SHA"
|
|
179
226
|
phase 12, "Spec"
|
|
180
227
|
argument :iteration, required: true, desc: "Iteration name"
|
|
181
228
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
229
|
+
option :force, type: :boolean, default: false, desc: "Re-freeze even if the frozen region changed (pre-dispatch only)"
|
|
182
230
|
commit_message_options
|
|
183
231
|
|
|
184
|
-
def call(iteration:, space: nil, message: nil, message_from: nil, **opts)
|
|
232
|
+
def call(iteration:, space: nil, message: nil, message_from: nil, force: false, **opts)
|
|
185
233
|
setup_terminal(**opts.slice(:color, :colors))
|
|
186
234
|
handle_errors do
|
|
187
235
|
render(store.find(space)) do |sp|
|
|
188
236
|
project = ArchitectProject.new(space: sp)
|
|
189
237
|
warnings = []
|
|
190
|
-
sha = project.freeze!(iteration, warnings: warnings,
|
|
238
|
+
sha = project.freeze!(iteration, warnings: warnings, force: force,
|
|
191
239
|
message: read_commit_message(message: message, message_from: message_from))
|
|
192
240
|
terminal.say "Frozen #{iteration} at #{sha}"
|
|
193
241
|
warnings.each { |w| terminal.say "Warning: #{w}" }
|
|
@@ -206,15 +254,17 @@ module Space::Architect
|
|
|
206
254
|
class Verify < BaseCommand
|
|
207
255
|
desc "Post-flight mechanical lane checks — frozen-untouched, no builder commits, report exists, in-bounds (reports only, no judgment)"
|
|
208
256
|
phase 30, "Judge"
|
|
209
|
-
argument :iteration,
|
|
210
|
-
argument :space,
|
|
257
|
+
argument :iteration, required: true, desc: "Iteration name"
|
|
258
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
259
|
+
option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
|
|
211
260
|
|
|
212
|
-
def call(iteration:, space: nil, **opts)
|
|
261
|
+
def call(iteration:, space: nil, commit_mode: nil, **opts)
|
|
213
262
|
setup_terminal(**opts.slice(:color, :colors))
|
|
214
263
|
handle_errors do
|
|
215
264
|
render(store.find(space)) do |sp|
|
|
216
265
|
project = ArchitectProject.new(space: sp)
|
|
217
|
-
|
|
266
|
+
terminal.say "Effective commit_mode: #{commit_mode}" if commit_mode
|
|
267
|
+
results = project.verify(iteration, commit_mode: commit_mode)
|
|
218
268
|
|
|
219
269
|
if results.empty?
|
|
220
270
|
terminal.say "No lanes recorded for iteration '#{iteration}'"
|
|
@@ -258,10 +308,16 @@ module Space::Architect
|
|
|
258
308
|
argument :lane, required: true, desc: "Lane name"
|
|
259
309
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
260
310
|
option :prompt, default: nil, desc: "Read the lane prompt from this file (copied byte-for-byte to build/<id>-<lane>/prompt.md)"
|
|
261
|
-
option :model, default: nil, desc: "Builder model to pin (default: the lane's model, else the
|
|
311
|
+
option :model, default: nil, desc: "Builder model to pin (default: the lane's stored model, else space.yaml project.model, else the per-harness sensible default). Any provider/tier; pin a full id, not a floating alias"
|
|
262
312
|
option :max_turns, default: "200", desc: "Max turns for the builder"
|
|
263
|
-
option :harness, default: nil, desc: "Harness override (claude-code, opencode)"
|
|
264
|
-
option :effort, default: nil, desc: "
|
|
313
|
+
option :harness, default: nil, desc: "Harness override (claude-code, opencode, pi)"
|
|
314
|
+
option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
|
|
315
|
+
option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
|
|
316
|
+
option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness"
|
|
317
|
+
option :force_effort, default: nil, desc: "Force the literal level onto the harness flag, skipping architect's clamp — alias for --force-thinking/--force-reasoning (dispatch only; the binary's rejection is final)"
|
|
318
|
+
option :force_thinking, default: nil, desc: "Force the literal level onto the harness flag, skipping architect's clamp — alias for --force-effort/--force-reasoning (dispatch only; the binary's rejection is final)"
|
|
319
|
+
option :force_reasoning, default: nil, desc: "Force the literal level onto the harness flag, skipping architect's clamp — alias for --force-effort/--force-thinking (dispatch only; the binary's rejection is final)"
|
|
320
|
+
option :quiet, type: :boolean, default: false, desc: "Suppress thinking-translation and harness run-time warn lines (liveness/push) on $stderr for this dispatch"
|
|
265
321
|
option :detach, type: :boolean, default: false, desc: "Detach the builder process (returns immediately with PID; poll report for completion)"
|
|
266
322
|
option :timeout, default: "14400", desc: "Wall-clock timeout in seconds (0 disables; default 4h); foreground only"
|
|
267
323
|
option :push_url, default: nil, desc: "HTTP endpoint for streaming push (POST body to this URL)"
|
|
@@ -269,17 +325,24 @@ module Space::Architect
|
|
|
269
325
|
option :push_host, default: nil, desc: "Base URL of the ingest server; the CLI creates a run via POST <host>/runs and streams to /runs/<id>/ingest (requires --push-token)"
|
|
270
326
|
|
|
271
327
|
def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
|
|
272
|
-
max_turns: "200", harness: nil, effort: nil,
|
|
328
|
+
max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
|
|
329
|
+
force_effort: nil, force_thinking: nil, force_reasoning: nil, quiet: false, detach: false,
|
|
273
330
|
timeout: "14400", push_url: nil, push_token: nil, push_host: nil, **opts)
|
|
274
331
|
setup_terminal(**opts.slice(:color, :colors))
|
|
275
332
|
handle_errors do
|
|
333
|
+
level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
|
|
334
|
+
forced_level = resolve_force_thinking_alias(force_effort: force_effort,
|
|
335
|
+
force_thinking: force_thinking, force_reasoning: force_reasoning)
|
|
336
|
+
|
|
276
337
|
render(store.find(space)) do |sp|
|
|
277
338
|
project = ArchitectProject.new(space: sp)
|
|
278
339
|
kwargs = { max_turns: max_turns.to_i, detach: detach }
|
|
279
340
|
kwargs[:prompt] = prompt if prompt
|
|
280
341
|
kwargs[:model] = model if model
|
|
281
342
|
kwargs[:harness] = harness if harness
|
|
282
|
-
kwargs[:effort] =
|
|
343
|
+
kwargs[:effort] = forced_level || level if forced_level || level
|
|
344
|
+
kwargs[:force] = true if forced_level
|
|
345
|
+
kwargs[:quiet] = true if quiet
|
|
283
346
|
kwargs[:timeout] = timeout.to_i unless detach
|
|
284
347
|
kwargs[:push_url] = push_url if push_url
|
|
285
348
|
kwargs[:push_token] = push_token if push_token
|
|
@@ -316,13 +379,14 @@ module Space::Architect
|
|
|
316
379
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
317
380
|
option :base, default: nil, desc: "Base ref override (default: project/<slug> HEAD if it exists, else the repo's default branch)"
|
|
318
381
|
option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
|
|
382
|
+
option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
|
|
319
383
|
|
|
320
|
-
def call(iteration:, space: nil, base: nil, lane: nil, **opts)
|
|
384
|
+
def call(iteration:, space: nil, base: nil, lane: nil, force: false, **opts)
|
|
321
385
|
setup_terminal(**opts.slice(:color, :colors))
|
|
322
386
|
handle_errors do
|
|
323
387
|
render(store.find(space)) do |sp|
|
|
324
388
|
project = ArchitectProject.new(space: sp)
|
|
325
|
-
results = project.provision(iteration, base: base, lane: lane)
|
|
389
|
+
results = project.provision(iteration, base: base, lane: lane, force: force)
|
|
326
390
|
if results.empty?
|
|
327
391
|
terminal.say "No declared lanes to provision for '#{iteration}'"
|
|
328
392
|
else
|
|
@@ -341,23 +405,24 @@ module Space::Architect
|
|
|
341
405
|
desc "Write a section of the iteration file and commit it (one call)"
|
|
342
406
|
phase 11, "Spec"
|
|
343
407
|
argument :iteration, required: true, desc: "Iteration name"
|
|
344
|
-
argument :section, required: true, desc: "Section: grounds, specification, prompt, verdict"
|
|
408
|
+
argument :section, required: true, desc: "Section: grounds, specification, acceptance-criteria, prompt, verdict"
|
|
345
409
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
346
410
|
option :from, default: nil, desc: "Read the section body from this file"
|
|
347
411
|
option :body, default: nil, desc: "Inline section body (one-liners)"
|
|
348
412
|
option :stdin, type: :boolean, default: false, desc: "Read the section body from stdin"
|
|
349
413
|
option :append, type: :boolean, default: false, desc: "Append a ### <lane> subsection instead of replacing"
|
|
350
414
|
option :lane, default: nil, desc: "Lane name for an appended ### subsection"
|
|
415
|
+
option :force, type: :boolean, default: false, desc: "Write a frozen section (pre-dispatch only)"
|
|
351
416
|
commit_message_options
|
|
352
417
|
|
|
353
418
|
def call(iteration:, section:, space: nil, from: nil, body: nil, stdin: false, append: false, lane: nil,
|
|
354
|
-
message: nil, message_from: nil, **opts)
|
|
419
|
+
message: nil, message_from: nil, force: false, **opts)
|
|
355
420
|
setup_terminal(**opts.slice(:color, :colors))
|
|
356
421
|
handle_errors do
|
|
357
422
|
content = read_body(from: from, body: body, stdin: stdin, what: "section body")
|
|
358
423
|
render(store.find(space)) do |sp|
|
|
359
424
|
project = ArchitectProject.new(space: sp)
|
|
360
|
-
res = project.write_section!(iteration, section, body: content, append: append, lane: lane,
|
|
425
|
+
res = project.write_section!(iteration, section, body: content, append: append, lane: lane, force: force,
|
|
361
426
|
message: read_commit_message(message: message, message_from: message_from))
|
|
362
427
|
if res[:committed]
|
|
363
428
|
terminal.say "Committed #{res[:heading]} → #{res[:sha][0, 8]}"
|
|
@@ -425,17 +490,19 @@ module Space::Architect
|
|
|
425
490
|
class Merge < BaseCommand
|
|
426
491
|
desc "Integrate ONE judged-passing lane (merges --no-ff; runs no gates, makes no verdict)"
|
|
427
492
|
phase 41, "Land"
|
|
428
|
-
argument :iteration,
|
|
429
|
-
argument :lane,
|
|
430
|
-
argument :space,
|
|
493
|
+
argument :iteration, required: true, desc: "Iteration name"
|
|
494
|
+
argument :lane, required: true, desc: "Lane name (architect-judged passing)"
|
|
495
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
496
|
+
option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
|
|
497
|
+
option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
|
|
431
498
|
commit_message_options
|
|
432
499
|
|
|
433
|
-
def call(iteration:, lane:, space: nil, message: nil, message_from: nil, **opts)
|
|
500
|
+
def call(iteration:, lane:, space: nil, message: nil, message_from: nil, into: nil, commit_mode: nil, **opts)
|
|
434
501
|
setup_terminal(**opts.slice(:color, :colors))
|
|
435
502
|
handle_errors do
|
|
436
503
|
render(store.find(space)) do |sp|
|
|
437
504
|
project = ArchitectProject.new(space: sp)
|
|
438
|
-
r = project.merge_lane!(iteration, lane,
|
|
505
|
+
r = project.merge_lane!(iteration, lane, into: into, commit_mode: commit_mode,
|
|
439
506
|
message: read_commit_message(message: message, message_from: message_from))
|
|
440
507
|
terminal.say "Merged #{lane} → #{r[:integration_branch]} (#{r[:merge_sha][0, 8]})"
|
|
441
508
|
terminal.say r[:diffstat] unless r[:diffstat].empty?
|
|
@@ -449,13 +516,15 @@ module Space::Architect
|
|
|
449
516
|
class Integrate < BaseCommand
|
|
450
517
|
desc "Integrate the architect-supplied set of passing lanes, in order (stops on conflict)"
|
|
451
518
|
phase 40, "Land"
|
|
452
|
-
argument :iteration,
|
|
453
|
-
argument :space,
|
|
454
|
-
option :lanes,
|
|
455
|
-
option :teardown,
|
|
519
|
+
argument :iteration, required: true, desc: "Iteration name"
|
|
520
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
521
|
+
option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
|
|
522
|
+
option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
|
|
523
|
+
option :commit_mode, default: nil, desc: "Commit mode override (strict|conductor); overrides space.yaml commit_mode for this run"
|
|
524
|
+
option :into, required: false, desc: "Merge into this branch instead of the slug-derived project/<slug> default"
|
|
456
525
|
commit_message_options
|
|
457
526
|
|
|
458
|
-
def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, **opts)
|
|
527
|
+
def call(iteration:, space: nil, lanes: nil, teardown: false, message: nil, message_from: nil, commit_mode: nil, into: nil, **opts)
|
|
459
528
|
setup_terminal(**opts.slice(:color, :colors))
|
|
460
529
|
handle_errors do
|
|
461
530
|
lane_names = lanes.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
@@ -465,7 +534,8 @@ module Space::Architect
|
|
|
465
534
|
render(store.find(space)) do |sp|
|
|
466
535
|
project = ArchitectProject.new(space: sp)
|
|
467
536
|
results = project.integrate!(iteration, lanes: lane_names, teardown: teardown,
|
|
468
|
-
message: read_commit_message(message: message, message_from: message_from)
|
|
537
|
+
message: read_commit_message(message: message, message_from: message_from),
|
|
538
|
+
commit_mode: commit_mode, into: into)
|
|
469
539
|
if lane_names.empty?
|
|
470
540
|
if results.empty?
|
|
471
541
|
terminal.say "Nothing to tear down for #{iteration}"
|
|
@@ -570,19 +640,26 @@ module Space::Architect
|
|
|
570
640
|
argument :iteration, required: true, desc: "Iteration name"
|
|
571
641
|
argument :lane, required: true, desc: "Lane name"
|
|
572
642
|
option :base, default: nil, desc: "Base ref (default: HEAD of repo)"
|
|
573
|
-
option :harness, default:
|
|
574
|
-
option :model, default: nil, desc: "Model (
|
|
575
|
-
option :effort, default: nil, desc: "
|
|
643
|
+
option :harness, default: nil, desc: "Harness (claude-code, opencode, pi; default: space.yaml project.harness, else claude-code)"
|
|
644
|
+
option :model, default: nil, desc: "Model; a trailing :<level> suffix (e.g. foo:high) is parsed into --effort (default: space.yaml project.model, else the per-harness sensible default)"
|
|
645
|
+
option :effort, default: nil, desc: "Thinking/reasoning effort level — alias for --thinking/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness at dispatch time"
|
|
646
|
+
option :thinking, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--reasoning (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness at dispatch time"
|
|
647
|
+
option :reasoning, default: nil, desc: "Thinking/reasoning effort level — alias for --effort/--thinking (off, minimal, low, medium, high, xhigh, max); translated + clamped to the lane's harness at dispatch time"
|
|
648
|
+
option :quiet, type: :boolean, default: false, desc: "Suppress the thinking-translation inform line on $stderr"
|
|
576
649
|
option :touch, default: nil, desc: "Comma-separated file globs the lane may touch (records its touch_set for in-bounds + merge checks)"
|
|
650
|
+
option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
|
|
577
651
|
|
|
578
|
-
def call(repo:, iteration:, lane:, base: nil, harness:
|
|
652
|
+
def call(repo:, iteration:, lane:, base: nil, harness: nil, model: nil,
|
|
653
|
+
effort: nil, thinking: nil, reasoning: nil, quiet: false, touch: nil, force: false, **opts)
|
|
579
654
|
setup_terminal(**opts.slice(:color, :colors))
|
|
580
655
|
handle_errors do
|
|
656
|
+
level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
|
|
581
657
|
render(store.find) do |sp|
|
|
582
658
|
project = ArchitectProject.new(space: sp)
|
|
583
659
|
touch_set = touch ? touch.split(",").map(&:strip).reject(&:empty?) : nil
|
|
584
|
-
|
|
585
|
-
|
|
660
|
+
err = quiet ? File.open(File::NULL, "w") : $stderr
|
|
661
|
+
result = project.worktree_add(repo, iteration, lane, base: base, harness: harness, model: model,
|
|
662
|
+
effort: level, touch: touch_set, force: force, err: err)
|
|
586
663
|
terminal.say "Worktree: #{terminal.path(result[:worktree])}"
|
|
587
664
|
terminal.say "Base SHA: #{result[:base_sha]}"
|
|
588
665
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
@@ -756,6 +833,7 @@ Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Archit
|
|
|
756
833
|
Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
|
|
757
834
|
Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
|
|
758
835
|
Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
|
|
836
|
+
Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
|
|
759
837
|
Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
|
|
760
838
|
Space::Architect::CLI::Registry.register "verify", Space::Architect::CLI::Architect::Verify
|
|
761
839
|
Space::Architect::CLI::Registry.register "provision", Space::Architect::CLI::Architect::Provision
|
|
@@ -10,30 +10,73 @@ require "uri"
|
|
|
10
10
|
|
|
11
11
|
module Space::Architect
|
|
12
12
|
module Harness
|
|
13
|
-
CLAUDE_DEFAULT_MODEL = "claude-sonnet-
|
|
13
|
+
CLAUDE_DEFAULT_MODEL = "claude-sonnet-5"
|
|
14
|
+
|
|
15
|
+
# Per-harness sensible default model, used when neither an explicit --model
|
|
16
|
+
# nor a space.yaml project.model is given.
|
|
17
|
+
DEFAULT_MODELS = {
|
|
18
|
+
"claude-code" => CLAUDE_DEFAULT_MODEL,
|
|
19
|
+
"pi" => "qwen3-27b-optiq",
|
|
20
|
+
"opencode" => "fireworks-ai/accounts/fireworks/models/glm-5p2"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def self.default_model_for(harness_name)
|
|
24
|
+
DEFAULT_MODELS[harness_name.to_s]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The normalized internal thinking vocabulary — pi's fullest set. Every alias
|
|
28
|
+
# (--effort/--thinking/--reasoning) is normalized to a member of this array;
|
|
29
|
+
# each harness then translates + clamps down to its own flag's vocabulary.
|
|
30
|
+
THINKING_LEVELS = %w[off minimal low medium high xhigh max].freeze
|
|
31
|
+
|
|
32
|
+
def self.validate_thinking_level!(level)
|
|
33
|
+
return if level.nil? || THINKING_LEVELS.include?(level)
|
|
34
|
+
raise Space::Core::Error,
|
|
35
|
+
"unknown thinking level '#{level}' — valid: #{THINKING_LEVELS.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Split a "model:level" id into [model, level] (level nil, model unchanged if no
|
|
39
|
+
# suffix). Only a trailing segment that is a member of THINKING_LEVELS is treated
|
|
40
|
+
# as a suffix — a model id containing a literal colon for another reason is left alone.
|
|
41
|
+
def self.parse_model_suffix(model)
|
|
42
|
+
return [model, nil] unless model
|
|
43
|
+
base, _, suffix = model.rpartition(":")
|
|
44
|
+
return [model, nil] if base.empty? || !THINKING_LEVELS.include?(suffix)
|
|
45
|
+
[base, suffix]
|
|
46
|
+
end
|
|
14
47
|
|
|
15
|
-
#
|
|
48
|
+
# Per-harness translate + clamp: input a normalized level (or nil) and whether the
|
|
49
|
+
# caller forced it (skips clamping — the literal is passed through unmodified).
|
|
50
|
+
# Returns [translated_level_or_nil, inform_line_or_nil].
|
|
51
|
+
def self.translate_thinking(harness, level, force: false)
|
|
52
|
+
case harness.to_s
|
|
53
|
+
when "claude-code" then ClaudeCodeHarness.translate_thinking(level, force: force)
|
|
54
|
+
when "opencode" then OpenCodeHarness.translate_thinking(level, force: force)
|
|
55
|
+
when "pi" then PiHarness.translate_thinking(level, force: force)
|
|
56
|
+
else nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Factory keyed by harness name. Translates + clamps the normalized `effort`
|
|
61
|
+
# level to the harness's own vocabulary, printing one inform line to `err`
|
|
62
|
+
# (default $stderr; thread a null writer to suppress) when it clamps/strips.
|
|
63
|
+
# With force: true, the literal `effort` value is passed through unmodified.
|
|
16
64
|
# For opencode: config_dir is required (build/<id>-<lane> dir outside the worktree).
|
|
17
|
-
def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil)
|
|
65
|
+
def self.for(name, model:, max_turns:, bin: nil, config_dir: nil, effort: nil, force: false, err: $stderr)
|
|
66
|
+
translated, inform = translate_thinking(name, effort, force: force)
|
|
67
|
+
err.puts(inform) if inform
|
|
68
|
+
|
|
18
69
|
case name.to_s
|
|
19
70
|
when "claude-code"
|
|
20
|
-
|
|
21
|
-
raise Space::Core::Error,
|
|
22
|
-
"effort is opencode-only (sets opencode reasoningEffort) — " \
|
|
23
|
-
"claude-code effort is set via the prompt"
|
|
24
|
-
end
|
|
25
|
-
ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin)
|
|
71
|
+
ClaudeCodeHarness.new(model: model, max_turns: max_turns, bin: bin, effort: translated)
|
|
26
72
|
when "opencode"
|
|
27
|
-
if model == CLAUDE_DEFAULT_MODEL
|
|
28
|
-
raise Space::Core::Error,
|
|
29
|
-
"Pass --model when using --harness opencode (the claude-sonnet-4-6 default " \
|
|
30
|
-
"is a Claude model ID and will not work with opencode — " \
|
|
31
|
-
"try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
|
|
32
|
-
end
|
|
33
73
|
raise Space::Core::Error, "config_dir is required for opencode harness" unless config_dir
|
|
34
|
-
OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort:
|
|
74
|
+
OpenCodeHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
|
|
75
|
+
when "pi"
|
|
76
|
+
raise Space::Core::Error, "config_dir is required for pi harness" unless config_dir
|
|
77
|
+
PiHarness.new(model: model, max_turns: max_turns, bin: bin, config_dir: config_dir, effort: translated)
|
|
35
78
|
else
|
|
36
|
-
raise Space::Core::Error, "Unknown harness '#{name}' — valid values: claude-code, opencode"
|
|
79
|
+
raise Space::Core::Error, "Unknown harness '#{name}' — valid values: claude-code, opencode, pi"
|
|
37
80
|
end
|
|
38
81
|
end
|
|
39
82
|
|
|
@@ -45,11 +88,27 @@ module Space::Architect
|
|
|
45
88
|
"Bash(git branch:*)"
|
|
46
89
|
].join(",")
|
|
47
90
|
|
|
48
|
-
|
|
91
|
+
# claude-code's --effort accepts low/medium/high/xhigh/max; it has no off level
|
|
92
|
+
# (stripped — omit --effort) and no minimal level (clamped to low).
|
|
93
|
+
ACCEPTED_LEVELS = %w[low medium high xhigh max].freeze
|
|
94
|
+
CLAMP_MAP = { "minimal" => "low" }.freeze
|
|
95
|
+
|
|
96
|
+
def self.translate_thinking(level, force: false)
|
|
97
|
+
return [nil, nil] if level.nil?
|
|
98
|
+
return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
|
|
99
|
+
return [level, nil] if ACCEPTED_LEVELS.include?(level)
|
|
100
|
+
return [nil, "thinking: off → claude-code (no --effort; claude-code has no off level)"] if level == "off"
|
|
101
|
+
|
|
102
|
+
clamped = CLAMP_MAP.fetch(level)
|
|
103
|
+
[clamped, "thinking: #{level} → claude-code #{clamped} (clamped; claude-code has no #{level} level)"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def initialize(model:, max_turns:, bin: nil, effort: nil,
|
|
49
107
|
allowed_tools: ALLOWED_TOOLS, disallowed_tools: DISALLOWED_TOOLS)
|
|
50
108
|
@model = model
|
|
51
109
|
@max_turns = max_turns
|
|
52
110
|
@bin = bin || ENV.fetch("ARCHITECT_CLAUDE_BIN", "claude")
|
|
111
|
+
@effort = effort
|
|
53
112
|
@allowed_tools = allowed_tools
|
|
54
113
|
@disallowed_tools = disallowed_tools
|
|
55
114
|
end
|
|
@@ -179,6 +238,7 @@ module Space::Architect
|
|
|
179
238
|
"--max-turns", @max_turns.to_s
|
|
180
239
|
]
|
|
181
240
|
args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
|
|
241
|
+
args += ["--effort", @effort] if @effort
|
|
182
242
|
args
|
|
183
243
|
end
|
|
184
244
|
|
|
@@ -237,6 +297,23 @@ module Space::Architect
|
|
|
237
297
|
end
|
|
238
298
|
|
|
239
299
|
class OpenCodeHarness
|
|
300
|
+
# opencode's reasoningEffort accepts only low/medium/high; off/minimal are
|
|
301
|
+
# stripped (omit reasoningEffort) and xhigh/max are clamped down to high.
|
|
302
|
+
ACCEPTED_LEVELS = %w[low medium high].freeze
|
|
303
|
+
CLAMP_MAP = { "xhigh" => "high", "max" => "high" }.freeze
|
|
304
|
+
|
|
305
|
+
def self.translate_thinking(level, force: false)
|
|
306
|
+
return [nil, nil] if level.nil?
|
|
307
|
+
return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
|
|
308
|
+
return [level, nil] if ACCEPTED_LEVELS.include?(level)
|
|
309
|
+
|
|
310
|
+
if (clamped = CLAMP_MAP[level])
|
|
311
|
+
[clamped, "thinking: #{level} → opencode #{clamped} (clamped; opencode accepts low/medium/high)"]
|
|
312
|
+
else
|
|
313
|
+
[nil, "thinking: #{level} → opencode (omitting reasoningEffort; opencode has no #{level} level)"]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
240
317
|
def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil)
|
|
241
318
|
@model = model
|
|
242
319
|
@max_turns = max_turns
|
|
@@ -346,5 +423,86 @@ module Space::Architect
|
|
|
346
423
|
args
|
|
347
424
|
end
|
|
348
425
|
end
|
|
426
|
+
|
|
427
|
+
# `pi -p --mode json`, session redirected into the lane's build dir via --session-dir
|
|
428
|
+
# (pi otherwise auto-saves under ~/.pi/agent/sessions/, organized by mangled CWD).
|
|
429
|
+
class PiHarness
|
|
430
|
+
TIMEOUT_EXIT_CODE = 124
|
|
431
|
+
|
|
432
|
+
# pi's --thinking accepts the full normalized vocabulary unchanged — pi's own
|
|
433
|
+
# per-model thinkingLevelMap clamps further; architect does not clamp for pi.
|
|
434
|
+
def self.translate_thinking(level, force: false)
|
|
435
|
+
return [nil, nil] if level.nil?
|
|
436
|
+
return [level, "thinking: force --effort=#{level} (unmodified, may be rejected)"] if force
|
|
437
|
+
[level, nil]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# max_turns is accepted for interface parity with the other harnesses but is
|
|
441
|
+
# intentionally absent from argv — pi has no turn cap; the wall-clock timeout
|
|
442
|
+
# (below) is the only bound on a run.
|
|
443
|
+
def initialize(model:, max_turns:, bin: nil, config_dir:, effort: nil)
|
|
444
|
+
@model = model
|
|
445
|
+
@bin = bin || ENV.fetch("ARCHITECT_PI_BIN", "pi")
|
|
446
|
+
@config_dir = Pathname.new(config_dir)
|
|
447
|
+
@effort = effort
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def run(prompt_path:, run_log_path:, chdir:, timeout: nil, **)
|
|
451
|
+
prompt_path = Pathname.new(prompt_path)
|
|
452
|
+
run_log_path = Pathname.new(run_log_path)
|
|
453
|
+
|
|
454
|
+
File.open(prompt_path, "r") do |prompt_io|
|
|
455
|
+
File.open(run_log_path, "w") do |log|
|
|
456
|
+
Sync do
|
|
457
|
+
child = Async::Process::Child.new(*argv, chdir: chdir.to_s, in: prompt_io, out: log, err: log)
|
|
458
|
+
timed_out = false
|
|
459
|
+
timeout_task = nil
|
|
460
|
+
|
|
461
|
+
# Same TERM→grace→KILL escalation as ClaudeCodeHarness — with: Async::Process::Child's
|
|
462
|
+
# #wait_thread ensure goes straight to KILL, so a concurrent fiber does the escalation.
|
|
463
|
+
if timeout && timeout > 0
|
|
464
|
+
timeout_task = Async(transient: true) do
|
|
465
|
+
sleep timeout
|
|
466
|
+
timed_out = true
|
|
467
|
+
Process.kill("TERM", -child.pid) rescue nil
|
|
468
|
+
sleep 0.5
|
|
469
|
+
Process.kill("KILL", -child.pid) rescue nil
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
status = child.wait
|
|
474
|
+
timeout_task&.stop
|
|
475
|
+
|
|
476
|
+
timed_out ? TIMEOUT_EXIT_CODE : status.exitstatus
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def run_detached(prompt_path:, run_log_path:, chdir:)
|
|
483
|
+
prompt_path = Pathname.new(prompt_path)
|
|
484
|
+
run_log_path = Pathname.new(run_log_path)
|
|
485
|
+
|
|
486
|
+
prompt_io = File.open(prompt_path, "r")
|
|
487
|
+
log = File.open(run_log_path, "w")
|
|
488
|
+
begin
|
|
489
|
+
pid = Process.spawn(*argv, chdir: chdir.to_s, pgroup: true,
|
|
490
|
+
in: prompt_io, out: log, err: log)
|
|
491
|
+
Process.detach(pid)
|
|
492
|
+
ensure
|
|
493
|
+
prompt_io.close
|
|
494
|
+
log.close
|
|
495
|
+
end
|
|
496
|
+
pid
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
private
|
|
500
|
+
|
|
501
|
+
def argv
|
|
502
|
+
args = [@bin, "-p", "--mode", "json", "--model", @model, "--session-dir", @config_dir.to_s, "--no-approve"]
|
|
503
|
+
args += ["--thinking", @effort] if @effort
|
|
504
|
+
args
|
|
505
|
+
end
|
|
506
|
+
end
|
|
349
507
|
end
|
|
350
508
|
end
|
data/lib/space_core/cli/run.rb
CHANGED
|
@@ -5,11 +5,12 @@ require_relative "../oci_runner"
|
|
|
5
5
|
|
|
6
6
|
module Space::Core::CLI
|
|
7
7
|
class Run < BaseCommand
|
|
8
|
-
desc "Run the packed OCI image for the current space (auth injected at runtime)"
|
|
8
|
+
desc "Run the packed OCI image for the current space (auth injected at runtime). Pass a command and its arguments after `--` so they forward as separate argv tokens"
|
|
9
9
|
|
|
10
|
-
argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell)"
|
|
10
|
+
argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell). Use `--` to pass a command with arguments, e.g. `space run -- hermes -z \"hello\"`"
|
|
11
11
|
option :tty, type: :boolean, default: nil, desc: "Force interactive TTY (default: auto-detect)"
|
|
12
12
|
option :env, type: :array, desc: "Host env var to forward into the container (repeatable; adds to run.env)"
|
|
13
|
+
example "-- hermes -z \"What is 17 plus 4?\" # `--` forwards the command and its args as separate tokens (a quoted multi-word command arrives as one token and fails in-guest)"
|
|
13
14
|
|
|
14
15
|
def call(command: [], tty: nil, env: [], **opts)
|
|
15
16
|
setup_terminal(**opts.slice(:color, :colors))
|
data/lib/space_core/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: space-architect
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Jacobs
|
|
@@ -205,6 +205,20 @@ dependencies:
|
|
|
205
205
|
- - "~>"
|
|
206
206
|
- !ruby/object:Gem::Version
|
|
207
207
|
version: '6.0'
|
|
208
|
+
- !ruby/object:Gem::Dependency
|
|
209
|
+
name: mutant-minitest
|
|
210
|
+
requirement: !ruby/object:Gem::Requirement
|
|
211
|
+
requirements:
|
|
212
|
+
- - "~>"
|
|
213
|
+
- !ruby/object:Gem::Version
|
|
214
|
+
version: '0.16'
|
|
215
|
+
type: :development
|
|
216
|
+
prerelease: false
|
|
217
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
218
|
+
requirements:
|
|
219
|
+
- - "~>"
|
|
220
|
+
- !ruby/object:Gem::Version
|
|
221
|
+
version: '0.16'
|
|
208
222
|
- !ruby/object:Gem::Dependency
|
|
209
223
|
name: rake
|
|
210
224
|
requirement: !ruby/object:Gem::Requirement
|