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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6846eab011f92f0b54a7bb886d7b97cf8c727beb3fc6d279a2db603062645520
4
- data.tar.gz: 0bc8f0b22f785fd2d3ed77419a1ad3b6058e4b76a6aaee41b7a66793b865702a
3
+ metadata.gz: 3e8120606794bd415a99f1fffa4f850a141aeb0674a94b809311c19513e2bef3
4
+ data.tar.gz: 45796109310f890c1e20c13cd01c42bc09d88dbd3cb0dd8fb8b3dbb73cad8c0b
5
5
  SHA512:
6
- metadata.gz: 77216fd23779d4ee5ae16f3c141fc9f7670b3efc3c8b7fe31292c31284be0903616505c4199fbe4e5d7b12988d0638e7a31cba600a4625caad2b939255fc9370
7
- data.tar.gz: ca1fb9e9bd1c20c6ef201d60f763cdcb87b18b9c289169ff601be6c858b0558f283986b2b24bb82cf73b4b1001c3ce0a9955bd02675557548fcc2e930aea27d2
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
- # Acceptance Criteria is intentionally absent it is set by `architect freeze`,
24
- # the one code path that creates the freeze commit. Builder Report has its own
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
- def freeze!(iteration, warnings: nil, message: nil)
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
- raise Space::Core::Error,
178
- "Frozen sections of #{rel} changed since freeze #{sha[0, 8]} — " \
179
- "refusing to re-freeze. Restore them to their frozen state or use a new iteration."
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. Acceptance Criteria is
236
- # NOT writable here (use freeze); Builder Report is not here (use evidence).
237
- def write_section!(iteration, section, body:, append: false, lane: nil, message: nil)
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
- "(Acceptance Criteria is set by `architect freeze`; Builder Report by `architect evidence`.)"
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
- raise Space::Core::Error,
252
- "#{spec[:heading]} is frozen for #{iteration} (freeze #{entry["freeze_sha"][0, 8]}) — " \
253
- "frozen sections are read-only after the freeze commit. Open a new iteration to change the contract."
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
- path.write(replace_section_body(path.read, spec[:heading], block, append: append))
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
- raise Space::Core::Error,
389
- "Merge conflict integrating lane '#{lane}' (#{conflicts.split.join(", ")}) the lane plan was " \
390
- "not disjoint = a spec defect. Kill the conflicting lane and re-spec; do not hand-resolve. #{merr.strip}"
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
- def worktree_add(repo, iteration, lane, base: nil, harness: "claude-code", model: nil, variant: false, effort: nil, touch: nil)
541
- if harness.to_s == "opencode" && (model.nil? || model == Harness::CLAUDE_DEFAULT_MODEL)
542
- raise Space::Core::Error,
543
- "Pass --model when using --harness opencode " \
544
- "(#{Harness::CLAUDE_DEFAULT_MODEL} is a Claude model ID, not valid for opencode — " \
545
- "try e.g. fireworks-ai/accounts/fireworks/models/glm-5p2)"
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
- if effort && harness.to_s != "opencode"
548
- raise Space::Core::Error,
549
- "effort is opencode-only (sets opencode reasoningEffort) — " \
550
- "set effort only on opencode lanes (harness: opencode)"
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 — refuse.
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
- raise Space::Core::Error,
572
- "#{wt_path} exists but is not a registered git worktree of #{repo} — " \
573
- "resolve manually before re-running worktree_add"
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"] = effort if 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, detach: false,
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
- resolved_harness = harness || lane_entry["harness"] || "claude-code"
777
- resolved_model = model || lane_entry["model"] || Harness::CLAUDE_DEFAULT_MODEL
778
- resolved_effort = effort || lane_entry["effort"]
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
- bin: bin, config_dir: build_dir, effort: resolved_effort)
809
-
810
- # Stamp launch time onto the lane entry: after every preflight validation has passed
811
- # (a dispatch that raises above records nothing) and before the blocking run or a
812
- # detached dispatch returns. A re-dispatch overwrites the prior value.
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
- log_out, = git_capture("-C", wt_path.to_s, "log", "--format=%H", "#{base_sha}..")
1048
- commit_shas = log_out.strip.split("\n").map(&:strip).reject(&:empty?)
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
- builder_shas = recorded_integrate ? commit_shas.reject { |s| s == recorded_integrate } : commit_shas
1051
- checks[:no_builder_commits] = builder_shas.empty?
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? { |f| touch_set.any? { |g| File.fnmatch(g, f, fnm) } }
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#{new_block.strip}"
1224
+ "#{body.strip}\n\n#{block}"
1099
1225
  else
1100
- new_block.strip
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
- return File.read(from) if from
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
- return File.read(message_from) if message_from
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::CLAUDE_DEFAULT_MODEL
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, required: true, desc: "Iteration name"
210
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
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
- results = project.verify(iteration)
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 reference default claude-sonnet-4-6). Any provider/tier; pin a full id, not a floating alias"
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: "Reasoning effort override (opencode only; sets reasoningEffort in the model config)"
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, detach: false,
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] = effort if 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, required: true, desc: "Iteration name"
429
- argument :lane, required: true, desc: "Lane name (architect-judged passing)"
430
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
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, required: true, desc: "Iteration name"
453
- argument :space, required: false, desc: "Space identifier (default: $PWD)"
454
- option :lanes, required: false, desc: "Comma-separated passing lane names (you decide the set)"
455
- option :teardown, type: :boolean, default: false, desc: "Remove worktrees + delete lane branches after merge"
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: "claude-code", desc: "Harness (claude-code, opencode)"
574
- option :model, default: nil, desc: "Model (required for opencode)"
575
- option :effort, default: nil, desc: "Reasoning effort (opencode only; sets reasoningEffort in the model config)"
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: "claude-code", model: nil, effort: nil, touch: nil, **opts)
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
- result = project.worktree_add(repo, iteration, lane, base: base,
585
- harness: harness, model: model, effort: effort, touch: touch_set)
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-4-6"
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
- # Factory keyed by harness name.
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
- if effort
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: 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
- def initialize(model:, max_turns:, bin: nil,
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
@@ -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))
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "5.0.0"
5
+ VERSION = "5.2.0"
6
6
  end
7
7
  end
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.0.0
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