space-architect 9.0.0 → 9.1.1

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: 02caeb5cc690ab3d847e54fed5eb88c1493fa2402ace662ef7f25d2aeb4d055b
4
- data.tar.gz: 5fa1fc73aaf8e912a74b1daf36e392c4d8afdc19cf1e6af22cc94702ef4146fd
3
+ metadata.gz: 154e74a35cc7d14274f2f6fec65f40400727adbf3ce4bcf69000c2f00405a07e
4
+ data.tar.gz: 2d6407b32a61f8c056e866e12dceab65b6a0b35f281ec6b1721c73233517da49
5
5
  SHA512:
6
- metadata.gz: b0f3278ba088effaba68eb1942872bb38dc7b73301bc7f0c5229ab773a69174da531d76009a2725912ab2615c67f1b6321ac3e20648e919adfd471e8a41eb309
7
- data.tar.gz: 550862ccc0030a536896d219c2645c7194ec56357eebe9ae3eb08024811b60aca7fd857fd2e86daa80a406fa54e0050b922f80926ea66a14fcbb6fa71bcfda5b
6
+ metadata.gz: db472c7ffcff0c4228f9c6bda1baad0c00cb0283defa3a686924267d51d252b9f24e3cdf65a8d50076f0354f5af1894ff6ff9799c2b783069a8bf52567206c2b
7
+ data.tar.gz: 444e06a1bbcbefe3f827a065cefba6383d04606215abf0ab016069f7b254a8c61d2343aa91a648a3a2be92ae1a47c97b18dbcdc63bb62a92d23c63b3b8b10e6f
data/README.md CHANGED
@@ -5,288 +5,59 @@
5
5
  > **The Architect Loop — structured judgment-and-build cycles for humans and their agents!** ✨🛰️
6
6
 
7
7
  `space-architect` is the gem behind the **Architect Loop**: a structured
8
- judgment-and-build cycle for you and a fleet of headless AI builders, run
9
- inside task-scoped **spaces** — directories that hold repos, notes, and
10
- artifacts under one obvious filesystem root. 🌌
8
+ judgment-and-build cycle in which a strong reasoning model (the *architect*)
9
+ plans, freezes acceptance criteria, and judges the work of a fleet of headless
10
+ AI *builders* — one per lane, each in its own git worktree. The loop runs inside
11
+ task-scoped **spaces**: directories that hold repos, notes, and artifacts under
12
+ one obvious filesystem root. Nothing lives only in a chat window — every
13
+ decision, spec, and verdict is committed to the space repo.
11
14
 
12
15
  It installs **one executable**, `architect`. The spaces substrate and the
13
- evergreen-checkout engine live in two sibling gems:
16
+ evergreen-checkout engine are two sibling gems:
14
17
 
15
18
  | Gem | Dependency | What it provides |
16
19
  |-----|------------|------------------|
17
- | **space-cadet** | hard | The spaces substrate (`Space::Core`): create, manage & containerize task-scoped workspaces — the `space` binary — plus the copy-on-write evergreen-checkout substrate (`Cloner`, `SCM`) repos provision from |
20
+ | **space-cadet** | hard | The spaces substrate (`Space::Core`): create, manage & containerize task-scoped workspaces — the `space` binary — plus the copy-on-write evergreen-checkout substrate repos provision from |
18
21
  | **repo-tender** | soft | Evergreen clone tending — the `repo-tender` binary (with the deprecated `src` shim) — and the launchd agent the `architect sessions agent` commands drive. Install with `gem install repo-tender` when you want the session-sync rail — everything else works without it |
19
22
 
20
- Until 8.x this gem was the monolith: it shipped `space`, `architect`, and `src`
21
- binaries (and `architect space …` / `architect src …` forwarders) plus the
22
- vendored `Space::Core` / `Space::Src` subtrees. The 9.0.0 split carved the
23
- tools into their own gems; this gem is the loop only. If you're on 8.x, get
24
- `space` from the **space-cadet** gem and `src` from **repo-tender** now — and
25
- `gem uninstall space-architect` first: the 8.x gem's `space`/`src` executables
26
- collide with the ones the new gems ship.
27
-
28
- ## What's a space? 🪐
29
-
30
- A space is just a regular directory with a tiny YAML identity file and room for
31
- everything a task needs:
32
-
33
- ```text
34
- ~/architect/spaces/20260531-name-of-space/
35
- space.yaml # identity: id, title, status, repos, notes, tags
36
- README.md
37
- repos/ # cloned (or copy-on-write'd) repositories
38
- notes/ # scratch, prompts, logs
39
- architecture/ # iteration files: I<NN>-<name>.md + ARCHITECT.md index + BRIEF.md
40
- build/ # lane worktrees + scratch (build/<id>-<lane>/)
41
- tmp/ # workspace-local temp — use this instead of /tmp
42
- ```
43
-
44
- Run an `architect` command from inside a space and it just works — the loop
45
- walks up from `$PWD` until it finds the nearest `space.yaml`. No "current
46
- space" state to get out of sync; where you *are* is the space you mean. 🧭
47
-
48
- Create and manage spaces with the **space-cadet** gem:
49
-
50
- ```sh
51
- space init # create XDG config + state files
52
- space new "Name of Space" -r org/repo -r org/lib # blast off a new space 🚀
53
- space list # see all your spaces
54
- ```
55
-
56
23
  ## Installation 📦
57
24
 
58
- Add it to your `Gemfile`:
59
-
60
- ```ruby
61
- gem "space-architect"
62
- ```
63
-
64
- ```bash
65
- bundle install
66
- ```
67
-
68
- Or grab it yourself:
69
-
70
25
  ```bash
71
26
  gem install space-architect
72
27
  gem install repo-tender # optional: enables the sessions launchd agent
73
28
  ```
74
29
 
75
- The gem installs one executable: `architect`. 🎀
76
-
77
- ## Quick start 🎀
78
-
79
- ```sh
80
- # Spaces (space-cadet gem)
81
- space init # create XDG config + state files
82
- space new "Name of Space" -r org/repo -r org/lib # blast off a new space 🚀
83
- space use 20260531-name-of-space # cd into a space by id
84
-
85
- # Architect Loop (run from inside a space)
86
- architect install-skills # install agent skills (once per machine)
87
- architect init # scaffold ARCHITECT.md + architecture/
88
- architect new my-feature # scaffold the next iteration file
89
- architect freeze my-feature # lock the Acceptance Criteria ❄️
90
- architect dispatch my-feature lane-a # send a headless builder to work
91
- ```
92
-
93
- ## `architect` — the Architect Loop 🏗️
94
-
95
- The **Architect Loop** is a structured build cycle for you and headless AI
96
- builders. Each loop lives inside a space as a *project*.
97
-
98
- **Roles:**
99
-
100
- - **Architect** — the judgment role: a strong reasoning model (or you), run
101
- interactively. Arbitrates disagreements, writes and freezes iteration files,
102
- calls kill/continue, merges builder output. Never writes implementation code.
103
- - **Builder** — the execution role: a cheaper model run headless via `architect
104
- dispatch`, one per lane in its own git worktree. Reads the iteration's Builder
105
- Prompt, does the work, writes raw evidence to `build/<id>-<lane>/report.md`.
106
- Never grades its own work; never edits `architecture/`.
107
-
108
- The loop is **model-agnostic** — which models fill the two roles is your choice
109
- (e.g. a strong Claude model judging a cheaper one on the same plan, or a
110
- cross-vendor pairing for more independent review). Set it per dispatch with
111
- `architect dispatch --model …`, or run several pairings head-to-head as a
112
- **variant set** (`architect variant add`). See
113
- [docs/DESIGN.md](docs/DESIGN.md) §1–§2 for the reasoning.
114
-
115
- **Filesystem layout:**
116
-
117
- ```text
118
- architecture/
119
- ARCHITECT.md # cross-iteration index; project-wide state
120
- BRIEF.md # durable §-numbered project contract (optional)
121
- I01-<iteration>.md # one self-contained file per iteration
122
- build/
123
- I01-<iteration>-<lane>/ # lane worktree + scratch per dispatch
124
- run.jsonl # streamed builder output
125
- report.md # builder report (transcribed into the iteration file verbatim)
126
- ```
127
-
128
- **Iteration file anatomy** — one file, grown section by section. You author the
129
- *content* in fresh scratch files; the CLI owns the *persistence* (each command
130
- writes the section, commits it, and prints back what changed). Every committing
131
- command takes `-m`/`--message` and `--message-from <file>` — your first line
132
- completes the subject after a short canonical prefix (`I01 spec: <subject>`),
133
- the rest becomes the body. The space's git log is the loop's durable memory:
134
- write detailed messages.
135
-
136
- | Section | Holds | How you persist it |
137
- |---------|-------|--------------------|
138
- | `## Grounds` | why — research / brief distilled (optional) | `architect section <it> grounds --from <f>` |
139
- | `## Specification` | what/how — the full delegation contract | `architect section <it> specification --from <f>` |
140
- | `## Acceptance Criteria` | proof — exact gate commands + thresholds | `architect freeze <it>` ❄️ |
141
- | `## Builder Prompt` | the exact lane-prompt(s) dispatched | `architect section <it> prompt --append --lane <l> --from <f>` |
142
- | `## Builder Report` | raw evidence, transcribed verbatim | `architect evidence <it> --lane <l>` |
143
- | `## Verdict` | rulings + per-AC PASS/FAIL + KILL/CONTINUE | `architect section <it> verdict --from <f>` |
144
-
145
- **The freeze ❄️** — `architect freeze <iteration>` commits the frozen region
146
- (Grounds / Specification / Acceptance Criteria), records the `freeze_sha`, and
147
- prints the frozen Acceptance Criteria back. Any change to those sections
148
- afterward is an automatic iteration FAIL. The builder never edits the iteration
149
- file.
150
-
151
- **Re-grounding 🧭** — `architect init` also scaffolds a `SessionStart` hook that
152
- runs `architect ground` (emitting `ARCHITECT.md`, `BRIEF.md`, and the in-flight
153
- iteration) so every fresh session starts oriented — the loop leans on
154
- fresh-session judgment, and this is what makes picking up cold cheap. Builders
155
- inside a lane worktree are never grounded.
156
-
157
- **Command surface:**
158
-
159
- ```sh
160
- architect init # scaffold ARCHITECT.md + the space.yaml project: block + SessionStart hook
161
- architect brief new --from <f> # write the durable project BRIEF.md (authored in a scratch file)
162
- architect new <iteration> # scaffold architecture/I<NN>-<iteration>.md
163
- architect section <it> <section> --from <f> # write + commit a section (add -m/--message-from for a detailed commit)
164
- architect freeze <iteration> # freeze the Acceptance Criteria ❄️
165
- architect worktree add <repo> <it> <lane> # isolated worktree per lane (2–4 lanes)
166
- architect dispatch <it> <lane> --prompt <f> # copy the lane prompt in + dispatch a builder (--detach to survive long runs)
167
- architect verify <iteration> # post-flight mechanical checks (reports only)
168
- architect evidence <it> --lane <lane> # transcribe the builder's report verbatim
169
- architect gate <iteration> # run the frozen gate commands, stream raw output
170
- architect merge <it> <lane> # integrate ONE judged-passing lane (--no-ff)
171
- architect integrate <it> --lanes a,b # integrate a set of passing lanes, in order
172
- # landing (PR body + push/PR command) is the architect's end-of-project procedure (see skill)
173
- architect status # project state (read-only)
174
- architect variant add|compare|promote … # competing (harness, model) lanes over one frozen spec
175
- architect research dispatch|status|wait … # parallel read-only research lanes (see below)
176
- ```
177
-
178
- `architect help` (or bare `architect`, `architect --help`) lists these grouped by
179
- loop phase — **Spec · Build · Judge · Land · Project · Groups**, in canonical
180
- order — and, inside an architect space, ends with a compact loop-status block
181
- (project status + current iteration) so you can see where you are in the loop. 🧭
182
-
183
- A typical session:
184
-
185
- ```sh
186
- architect init # first time
187
- architect new my-feature # scaffold I01-my-feature.md
188
- architect section my-feature specification --from spec.md -m "pull-based dispatcher seam"
189
- architect freeze my-feature # lock it ❄️
190
- architect dispatch my-feature lane-a --prompt tmp/prompts/I01-lane-a.md --detach # send a builder; poll the report
191
- architect verify my-feature # mechanical post-flight checks
192
- architect evidence my-feature --lane lane-a # transcribe raw evidence
193
- architect gate my-feature # run the frozen gates yourself
194
- # … read the diff against the spec, then write the Verdict …
195
- architect integrate my-feature --lanes lane-a # merge passing lanes → project/<slug>
196
- # landing (PR body + push/PR command) is the architect's end-of-project procedure (see skill)
197
- ```
198
-
199
- ### Streaming builder output 📡
200
-
201
- `architect dispatch` can push the builder's pi JSONL event stream to an ingest
202
- server for
203
- live viewing:
204
-
205
- ```sh
206
- # Push to an already-created run (you supply the full ingest URL):
207
- architect dispatch my-feature lane-a \
208
- --push-url $HOST/runs/<id>/ingest \
209
- --push-token $INGEST_TOKEN
210
-
211
- # Create a run and push in one step (requires --push-token = server's INGEST_TOKEN):
212
- architect dispatch my-feature lane-a \
213
- --push-host $HOST \
214
- --push-token $INGEST_TOKEN
215
- ```
216
-
217
- `--push-host` POSTs to `<HOST>/runs`, parses the new run id from the `201`
218
- response, derives `<HOST>/runs/<id>/ingest`, and streams there; the created run
219
- id and ingest URL are printed after dispatch starts. `--push-url` and
220
- `--push-host` are mutually exclusive, both require `--push-token`, and neither
221
- can be combined with `--detach` (the push tees the live pipe in-process).
30
+ The gem installs one executable: `architect`. 🎀 (Or add
31
+ `gem "space-architect"` to your Gemfile and `bundle install`.)
222
32
 
223
- ### Research lanes 🔭
33
+ Upgrading from the 8.x monolith? Uninstall `space-architect` 8.x **first** —
34
+ its `space`/`src` executables collide with the ones the new gems ship — see
35
+ [the upgrade guide](docs/how-to/upgrade-from-the-8x-monolith.md).
224
36
 
225
- When an iteration needs facts the repo doesn't already have, fan out parallel
226
- **read-only** research lanes — detached `pi -p --mode json` researchers
227
- (read-only by prompt contract; the supervisor-injected guard denies git writes)
228
- that you supervise:
37
+ ## 60-second start 🎀
229
38
 
230
- ```sh
231
- architect research dispatch 01-official-api.prompt.md 02-changelog.prompt.md
232
- architect research wait # tails each lane's run.jsonl; --level 1-4, --quiet, --thinking
233
- architect research status # status of dispatched runs
234
- ```
235
-
236
- Researchers gather; the architect verifies the load-bearing claims against
237
- sources and writes the iteration's **Grounds** section.
238
-
239
- ### Skills 🧠
240
-
241
- `architect install-skills` installs the bundled `architect`,
242
- `architect-research`, and `architect-vocabulary` skills for your harness:
39
+ Run one full loop iteration in a scratch space — the
40
+ [tutorial](docs/tutorials/one-loop-iteration.md) walks it end to end:
243
41
 
244
42
  ```sh
245
- architect install-skills # default: claude (~/.claude/skills/)
246
- architect install-skills --provider opencode # or codex | pi
247
- architect install-skills --project # into ./… instead of globally
248
- architect install-skills --dry-run # show what would change
43
+ space init # create the XDG config + state files (space-cadet gem)
44
+ space new "Docsmith Tutorial" # blast off a new space 🚀
45
+ cd ~/architect/spaces/<id>-docsmith-tutorial
46
+ architect init # scaffold ARCHITECT.md + the space.yaml project: block
249
47
  ```
250
48
 
251
- `architect-vocabulary` loads the system's terms and a short orientation when
252
- you're in a space but don't want to run the loop.
253
-
254
- ### Sessions: the launchd rail 🛰️ (requires repo-tender)
255
-
256
- `architect sessions sync` uploads new conversation transcripts (`~/.pi/agent/sessions`
257
- and `~/.claude/projects`) to a space-server; `architect sessions agent
258
- install|uninstall|status` manages the per-user launchd agent that runs the sync
259
- on an interval. These commands drive **repo-tender**'s launchd agent — a *soft*
260
- dependency, deliberately absent from the gemspec: without the gem, the sessions
261
- agent commands print a short error naming the fix (`gem install repo-tender`)
262
- and the rest of `architect` is unaffected.
263
-
264
- ## Configuration ⚙️
265
-
266
- The spaces config the loop reads lives at `~/.config/space-cadet/config.yml`
267
- (XDG-aware, owned by the space-cadet gem) — see `space config show`. The loop
268
- itself has no config of its own: a space's `space.yaml` `project:` block *is*
269
- the loop's state.
49
+ From there the loop is: `architect new` → `architect section …` → `architect
50
+ freeze` → `architect dispatch` → `architect gate` → `architect integrate`. The
51
+ tutorial runs every step on a scratch repo.
270
52
 
271
- ## Embedding 📚
53
+ ## The four quadrants 🧭
272
54
 
273
- Two namespaces, published as two gems:
274
-
275
- - **`Space::Core`** (the space-cadet gem) — the substrate: config, state, XDG,
276
- terminal, git/mise clients, the space store. The `space` CLI runs on this.
277
- - **`Space::Architect`** (this gem) — project state, the builder harness,
278
- dispatch, and the research supervisor, over the `Space::Core` substrate.
279
-
280
- ```ruby
281
- require "space_core" # just spaces (space-cadet gem)
282
- require "space_architect" # the loop (pulls in the space-cadet gem)
283
- ```
284
-
285
- ## Documentation 📖
286
-
287
- - **[Command Reference](docs/reference.md)** — every command, flag, and behavior
288
- - **[Design](docs/DESIGN.md)** — the source-backed rationale: the twelve invariant rules (R1–R12), the failure-mode → mitigation table, and why the loop is shaped this way
289
- - **[Changelog](CHANGELOG.md)** — release history
55
+ | Quadrant | Read it when you… | Start here |
56
+ |----------|-------------------|------------|
57
+ | **Tutorials** — learning-oriented | are new and want a guided journey | [docs/tutorials](docs/tutorials/) |
58
+ | **How-to guides** — goal-oriented | have a specific task to do | [docs/how-to](docs/how-to/) |
59
+ | **Reference** — information-oriented | need a fact about a command, flag, or file | [docs/reference](docs/reference/) |
60
+ | **Explanation** — understanding-oriented | want the why behind the design | [docs/explanation](docs/explanation/) |
290
61
 
291
62
  ## Development 🛠️
292
63
 
@@ -297,15 +68,9 @@ bundle exec rake build # build the gem into pkg/
297
68
  bundle exec rake install # build + install into your user gem home
298
69
  ```
299
70
 
300
- ## Contributing 💝
301
-
302
71
  Bug reports and pull requests are welcome on GitHub at
303
- [https://github.com/jetpks/space-architect](https://github.com/jetpks/space-architect)!
304
-
305
- ## License 📄
306
-
307
- Available as open source under the terms of the
308
- [MIT License](https://opensource.org/licenses/MIT).
72
+ [https://github.com/jetpks/space-architect](https://github.com/jetpks/space-architect).
73
+ Available as open source under the [MIT License](LICENSE.txt).
309
74
 
310
75
  ---
311
76
 
@@ -127,38 +127,47 @@ module Space::Architect
127
127
  end
128
128
 
129
129
  # Allocate the next ordinal and scaffold architecture/I<NN>-<iteration>.md.
130
+ # Serialized across processes: ordinal allocation is a read-modify-write of
131
+ # space.yaml's project block, and two concurrent `architect new` processes
132
+ # would duplicate the ordinal and silently drop the loser's iterations
133
+ # entry (last writer wins). The metadata is saved by atomic replace, so the
134
+ # lock is a sibling file under tmp/ (gitignored, space-standard) — never
135
+ # space.yaml itself — and the metadata is re-loaded inside the lock because
136
+ # every process booted with its own pre-scaffold snapshot.
130
137
  def new_iteration!(name, message: nil)
131
- block = space.data["project"] || {}
132
- iterations = block["iterations"] || []
133
- if iterations.any? { |s| s["name"] == name }
134
- raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
135
- end
138
+ with_scaffold_lock do
139
+ block = space.data["project"] || {}
140
+ iterations = block["iterations"] || []
141
+ if iterations.any? { |s| s["name"] == name }
142
+ raise Space::Core::Error, "iteration '#{name}' already exists in space.yaml"
143
+ end
136
144
 
137
- ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
138
- nn = format("%02d", ordinal)
139
- rel = "architecture/I#{nn}-#{name}.md"
140
- path = space.path.join(rel)
141
- raise Space::Core::Error, "#{rel} already exists" if path.exist?
145
+ ordinal = (iterations.map { |s| s["ordinal"] || 0 }.max || 0) + 1
146
+ nn = format("%02d", ordinal)
147
+ rel = "architecture/I#{nn}-#{name}.md"
148
+ path = space.path.join(rel)
149
+ raise Space::Core::Error, "#{rel} already exists" if path.exist?
142
150
 
143
- FileUtils.mkdir_p(path.dirname)
144
- path.write(render_iteration(nn, name))
151
+ FileUtils.mkdir_p(path.dirname)
152
+ path.write(render_iteration(nn, name))
145
153
 
146
- update_architect_block do |b|
147
- b["current_iteration"] = name
148
- list = b["iterations"] || []
149
- list << {
150
- "name" => name, "ordinal" => ordinal, "file" => rel,
151
- "freeze_sha" => nil, "verdict" => "pending", "lanes" => []
152
- }
153
- b["iterations"] = list
154
- b
155
- end
154
+ update_architect_block do |b|
155
+ b["current_iteration"] = name
156
+ list = b["iterations"] || []
157
+ list << {
158
+ "name" => name, "ordinal" => ordinal, "file" => rel,
159
+ "freeze_sha" => nil, "verdict" => "pending", "lanes" => []
160
+ }
161
+ b["iterations"] = list
162
+ b
163
+ end
156
164
 
157
- git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
158
- git_run("-C", space.path.to_s, "commit", "-m",
159
- compose_message("I#{nn} scaffold:", "I#{nn}: scaffold #{name}", message))
165
+ git_run("-C", space.path.to_s, "add", rel, Space::Core::Space::METADATA_FILE)
166
+ git_run("-C", space.path.to_s, "commit", "-m",
167
+ compose_message("I#{nn} scaffold:", "I#{nn}: scaffold #{name}", message))
160
168
 
161
- path
169
+ path
170
+ end
162
171
  end
163
172
 
164
173
  def status
@@ -701,8 +710,12 @@ module Space::Architect
701
710
  def worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?, harness: nil, model: nil,
702
711
  variant: false, effort: nil, touch: nil, force: false, err: $stderr)
703
712
  model, suffix_level = Harness.parse_model_suffix(model)
704
- harness, model = resolve_harness_model(harness, model)
705
- resolved_level = effort || suffix_level || project_defaults["effort"]
713
+ harness, model, inform = resolve_harness_model(harness, model)
714
+ err.puts inform if inform
715
+ # The config layer sits above project.effort — architect.yaml is the
716
+ # dedicated routing layer; unconfigured spaces fall through unchanged.
717
+ resolved_level = effort || suffix_level ||
718
+ Routing.for_space(space.path)&.dig("builder", "thinking") || project_defaults["effort"]
706
719
  Harness.validate_thinking_level!(resolved_level)
707
720
  if effort.nil? && suffix_level
708
721
  err.puts "thinking: model suffix ':#{suffix_level}' parsed from lane model → level=#{suffix_level} (model stripped)"
@@ -916,12 +929,21 @@ module Space::Architect
916
929
  base_explicit = !base.nil?
917
930
  refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force
918
931
 
932
+ # Sync-or-refuse ONCE per distinct repo of the selected set (lane order,
933
+ # first occurrence) BEFORE any worktree is cut — a divergence aborts the
934
+ # entire provision. An explicit --base is the deliberate case (#88): the
935
+ # pin is cut exactly as given, no sync, no divergence refusal.
936
+ synced = {}
937
+ unless base_explicit
938
+ lanes.map { |l| l["repo"] }.uniq.each { |repo| synced[repo] = sync_integration_branch!(repo, iteration) }
939
+ end
940
+
919
941
  lanes.map do |l|
920
942
  name = l["name"]
921
943
  result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
922
944
  base_explicit: base_explicit, force: force, **recorded_lane_fields(l))
923
945
  { lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome],
924
- discarded: result[:discarded] }
946
+ discarded: result[:discarded], synced: synced[l["repo"]] }
925
947
  end
926
948
  end
927
949
 
@@ -1033,6 +1055,25 @@ module Space::Architect
1033
1055
 
1034
1056
  attr_reader :space
1035
1057
 
1058
+ # Exclusive cross-process lock around one whole critical section (used by
1059
+ # new_iteration!): flock on .git/architect.lock — the metadata file itself
1060
+ # cannot be the lock because save() replaces it by atomic rename (a new
1061
+ # inode would not contend), and .git is the one directory every scaffoldable
1062
+ # space guarantees (new_iteration! commits) that git never reports as
1063
+ # untracked noise (git's own index.lock lives by the same convention).
1064
+ # Inside the lock the metadata is adopted in place (data.replace, keeping
1065
+ # object identity for every holder of this space) — each process booted
1066
+ # with its own pre-scaffold snapshot, so the ordinal must be read from
1067
+ # disk truth.
1068
+ def with_scaffold_lock
1069
+ lock_path = space.path.join(".git", "architect.lock")
1070
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
1071
+ lock.flock(File::LOCK_EX)
1072
+ space.data.replace(Space::Core::Space.load(space.path).data)
1073
+ yield
1074
+ end
1075
+ end
1076
+
1036
1077
  # The vendored builder-guard extension shipped in the gem (lib/space_architect/pi/).
1037
1078
  def vendored_guard_path
1038
1079
  Pathname.new(__dir__).join("pi/builder-guard.ts")
@@ -1590,6 +1631,15 @@ module Space::Architect
1590
1631
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
1591
1632
  return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
1592
1633
 
1634
+ # Same sync-or-refuse as provision, immediately before the cut — a
1635
+ # dispatch/gate/integrate/verify that materializes a missing worktree must
1636
+ # never cut it from a stale integration tip. Already-materialized lanes
1637
+ # returned above and stay a true no-op. The inform goes on $stderr, like
1638
+ # worktree_add's own informs on this path; no-op syncs print nothing.
1639
+ report = sync_integration_branch!(lane_entry["repo"], iteration)
1640
+ $stderr.puts "#{report[:repo]}: fast-forwarded #{report[:branch]} to #{report[:sha][0, 8]} — " \
1641
+ "synced before materializing #{iteration}/#{lane}" if report
1642
+
1593
1643
  worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
1594
1644
  base_explicit: false, **recorded_lane_fields(lane_entry))
1595
1645
  (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
@@ -1605,19 +1655,35 @@ module Space::Architect
1605
1655
  end
1606
1656
 
1607
1657
  # Resolve harness + model by precedence: explicit value > (dispatch only) the
1608
- # lane's stored value > space.yaml project.harness/project.model > the pi
1609
- # default model. Shared by worktree_add and dispatch so both read the same
1610
- # defaults. A stored model is only honored when its stored harness still matches
1611
- # the resolved harness — a dispatch-time harness override drops the old harness's
1612
- # stored model instead of leaking it into the new harness's run. A stored
1613
- # legacy harness name survives resolution (so a stale space.yaml
1614
- # surfaces as Harness.for's actionable pi-only error, not a silent default).
1658
+ # lane's stored value > architect.yaml builder-role model > space.yaml
1659
+ # project.harness/project.model > the pi default model. Shared by worktree_add
1660
+ # and dispatch so both read the same defaults. A stored model is only honored
1661
+ # when its stored harness still matches the resolved harness — a dispatch-time
1662
+ # harness override drops the old harness's stored model instead of leaking it
1663
+ # into the new harness's run. A stored legacy harness name survives resolution
1664
+ # (so a stale space.yaml surfaces as Harness.for's actionable pi-only error,
1665
+ # not a silent default).
1666
+ #
1667
+ # Returns [harness, model, inform] — inform is the loud last-resort line (nil
1668
+ # otherwise) for the caller to print once to its err stream when the model
1669
+ # fell all the way through to Harness::DEFAULT_MODEL (no flag, no stored
1670
+ # value, no architect.yaml builder role, no project.model).
1615
1671
  def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
1616
1672
  defaults = project_defaults
1617
1673
  resolved_harness = (harness || stored_harness || defaults["harness"] || "pi").to_s
1618
1674
  stored_model = nil unless stored_harness.nil? || stored_harness.to_s == resolved_harness
1619
- resolved_model = model || stored_model || defaults["model"] || Harness::DEFAULT_MODEL
1620
- [resolved_harness, resolved_model]
1675
+ config_model = Routing.for_space(space.path)&.dig("builder", "model")
1676
+ resolved_model = model || stored_model || config_model || defaults["model"] || Harness::DEFAULT_MODEL
1677
+ inform = default_model_inform if model.nil? && stored_model.nil? && config_model.nil? && defaults["model"].nil?
1678
+ [resolved_harness, resolved_model, inform]
1679
+ end
1680
+
1681
+ # The loud last-resort inform — the bare DEFAULT_MODEL constant is a last
1682
+ # resort, not a silent fallback: name that no architect.yaml was found, the
1683
+ # two paths looked at, and the default in use.
1684
+ def default_model_inform
1685
+ "model: no architect.yaml found (looked at #{Routing.space_path(space.path)} + #{Routing.global_path}) — " \
1686
+ "using default '#{Harness::DEFAULT_MODEL}'"
1621
1687
  end
1622
1688
 
1623
1689
  # --prompt: the caller authors the lane prompt anywhere (a fresh scratch file) and
@@ -1640,8 +1706,9 @@ module Space::Architect
1640
1706
  # precedence and thinking-level translation from one place.
1641
1707
  def resolve_dispatch_harness(lane_entry, model:, harness:, effort:, force:, err:)
1642
1708
  model, suffix_level = Harness.parse_model_suffix(model)
1643
- resolved_harness, resolved_model = resolve_harness_model(harness, model,
1709
+ resolved_harness, resolved_model, inform = resolve_harness_model(harness, model,
1644
1710
  stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
1711
+ err.puts inform if inform
1645
1712
 
1646
1713
  resolved_effort =
1647
1714
  if effort
@@ -1651,7 +1718,9 @@ module Space::Architect
1651
1718
  err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
1652
1719
  suffix_level
1653
1720
  else
1654
- lane_entry["effort"]
1721
+ # Dispatch's tail is the lane's stored effort (project.effort was never
1722
+ # in the dispatch chain); architect.yaml builder thinking slots in above it.
1723
+ lane_entry["effort"] || Routing.for_space(space.path)&.dig("builder", "thinking")
1655
1724
  end
1656
1725
 
1657
1726
  [resolved_harness, resolved_model, resolved_effort]
@@ -1684,6 +1753,57 @@ module Space::Architect
1684
1753
  st.success? && !out.strip.empty? ? out.strip : "HEAD"
1685
1754
  end
1686
1755
 
1756
+ # Fast-forward the repo's project integration branch from its default branch
1757
+ # BEFORE any lane worktree is cut from it (I06 provision-sync — the I05 burn:
1758
+ # a worktree cut from a stale project/<slug> tip). Local-to-local only — no
1759
+ # fetch/pull, no remote-tracking ref is read or written. Compare before moving:
1760
+ # default not ahead → no-op (nil, callers print nothing); default ahead and
1761
+ # integration not ahead → fast-forward; both ahead → refuse (a human merges
1762
+ # deliberately, or pins --base) — --force has no bypass. Bang: it raises.
1763
+ # Returns nil for a no-op, or { repo:, branch:, sha: } for the moved ref so
1764
+ # callers can report the movement (never silently move a branch).
1765
+ def sync_integration_branch!(repo, iteration)
1766
+ repo_path = space.path.join("repos", repo)
1767
+ integration = project_integration_branch
1768
+ return nil unless branch_exists?(repo_path, integration)
1769
+
1770
+ default = repo_default_branch(repo_path)
1771
+ counts_out, counts_err, counts_st = git_capture("-C", repo_path.to_s, "rev-list", "--left-right", "--count",
1772
+ "#{default}...#{integration}")
1773
+ unless counts_st.success?
1774
+ raise Space::Core::Error, "Cannot compare #{default}...#{integration} in repos/#{repo} — #{counts_err.strip}"
1775
+ end
1776
+
1777
+ default_ahead, integration_ahead = counts_out.strip.split.map(&:to_i)
1778
+ return nil if default_ahead.zero?
1779
+
1780
+ default_tip, = git_capture("-C", repo_path.to_s, "rev-parse", default)
1781
+ integration_tip, = git_capture("-C", repo_path.to_s, "rev-parse", integration)
1782
+ if integration_ahead.positive?
1783
+ raise Space::Core::Error,
1784
+ "Refusing to provision '#{iteration}' — repos/#{repo} has diverged: #{default} (#{default_tip[0, 8]}) and " \
1785
+ "#{integration} (#{integration_tip[0, 8]}) each hold commits the other lacks " \
1786
+ "(#{default} ahead by #{default_ahead}, #{integration} ahead by #{integration_ahead}) — not fast-forwardable. " \
1787
+ "A human must merge #{default} into #{integration} deliberately, or pin the base explicitly with " \
1788
+ "`architect provision #{iteration} --base <sha>`. --force does not bypass this refusal."
1789
+ end
1790
+
1791
+ head_out, _, head_st = git_capture("-C", repo_path.to_s, "symbolic-ref", "--short", "HEAD")
1792
+ if head_st.success? && head_out.strip == integration
1793
+ # The integration branch is the clone's checked-out branch (the real
1794
+ # post-integrate state — merge_lane! checks it out) — fetch refuses a
1795
+ # checked-out dst, so fast-forward via merge instead.
1796
+ git_run("-C", repo_path.to_s, "merge", "--ff-only", default)
1797
+ else
1798
+ # Unprefixed refspecs are ff-only by construction — git rejects a non-ff
1799
+ # update here, so the branch can only ever move forward.
1800
+ git_run("-C", repo_path.to_s, "fetch", ".", "#{default}:#{integration}")
1801
+ end
1802
+
1803
+ new_tip, = git_capture("-C", repo_path.to_s, "rev-parse", integration)
1804
+ { repo: repo, branch: integration, sha: new_tip.strip }
1805
+ end
1806
+
1687
1807
  def slice_entry(iteration)
1688
1808
  block = space.data["project"] || {}
1689
1809
  entry = (block["iterations"] || []).find { |s| s["name"] == iteration }