space-architect 9.0.0 → 9.1.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: 02caeb5cc690ab3d847e54fed5eb88c1493fa2402ace662ef7f25d2aeb4d055b
4
- data.tar.gz: 5fa1fc73aaf8e912a74b1daf36e392c4d8afdc19cf1e6af22cc94702ef4146fd
3
+ metadata.gz: c2dfa94f98278929ea382e4411856749de3af270511f60760ee4e3bed83daf53
4
+ data.tar.gz: 33c9bf994eafc772c67399876826b77f685a7ac9e3417f38b37054d853b5479b
5
5
  SHA512:
6
- metadata.gz: b0f3278ba088effaba68eb1942872bb38dc7b73301bc7f0c5229ab773a69174da531d76009a2725912ab2615c67f1b6321ac3e20648e919adfd471e8a41eb309
7
- data.tar.gz: 550862ccc0030a536896d219c2645c7194ec56357eebe9ae3eb08024811b60aca7fd857fd2e86daa80a406fa54e0050b922f80926ea66a14fcbb6fa71bcfda5b
6
+ metadata.gz: 2b7c099ff9ffc9b1cf6e37d48a687b2c08c4f2c0463ea5f1e77d156f214bdf9e4f5d0059e1be9fa1247de440c50ea091ffb933db5f02c3bb8e2d8d75ed0f3996
7
+ data.tar.gz: 5ce698b9ed448dbab61db46302c5f51f4e0bd850c8748c052131ffea4ef718326a3a4de47562092ba782dcfcfa042c6dedfe1c56675119b9ba2d98e29caf5f49
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)"
@@ -1033,6 +1046,25 @@ module Space::Architect
1033
1046
 
1034
1047
  attr_reader :space
1035
1048
 
1049
+ # Exclusive cross-process lock around one whole critical section (used by
1050
+ # new_iteration!): flock on .git/architect.lock — the metadata file itself
1051
+ # cannot be the lock because save() replaces it by atomic rename (a new
1052
+ # inode would not contend), and .git is the one directory every scaffoldable
1053
+ # space guarantees (new_iteration! commits) that git never reports as
1054
+ # untracked noise (git's own index.lock lives by the same convention).
1055
+ # Inside the lock the metadata is adopted in place (data.replace, keeping
1056
+ # object identity for every holder of this space) — each process booted
1057
+ # with its own pre-scaffold snapshot, so the ordinal must be read from
1058
+ # disk truth.
1059
+ def with_scaffold_lock
1060
+ lock_path = space.path.join(".git", "architect.lock")
1061
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
1062
+ lock.flock(File::LOCK_EX)
1063
+ space.data.replace(Space::Core::Space.load(space.path).data)
1064
+ yield
1065
+ end
1066
+ end
1067
+
1036
1068
  # The vendored builder-guard extension shipped in the gem (lib/space_architect/pi/).
1037
1069
  def vendored_guard_path
1038
1070
  Pathname.new(__dir__).join("pi/builder-guard.ts")
@@ -1605,19 +1637,35 @@ module Space::Architect
1605
1637
  end
1606
1638
 
1607
1639
  # 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).
1640
+ # lane's stored value > architect.yaml builder-role model > space.yaml
1641
+ # project.harness/project.model > the pi default model. Shared by worktree_add
1642
+ # and dispatch so both read the same defaults. A stored model is only honored
1643
+ # when its stored harness still matches the resolved harness — a dispatch-time
1644
+ # harness override drops the old harness's stored model instead of leaking it
1645
+ # into the new harness's run. A stored legacy harness name survives resolution
1646
+ # (so a stale space.yaml surfaces as Harness.for's actionable pi-only error,
1647
+ # not a silent default).
1648
+ #
1649
+ # Returns [harness, model, inform] — inform is the loud last-resort line (nil
1650
+ # otherwise) for the caller to print once to its err stream when the model
1651
+ # fell all the way through to Harness::DEFAULT_MODEL (no flag, no stored
1652
+ # value, no architect.yaml builder role, no project.model).
1615
1653
  def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
1616
1654
  defaults = project_defaults
1617
1655
  resolved_harness = (harness || stored_harness || defaults["harness"] || "pi").to_s
1618
1656
  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]
1657
+ config_model = Routing.for_space(space.path)&.dig("builder", "model")
1658
+ resolved_model = model || stored_model || config_model || defaults["model"] || Harness::DEFAULT_MODEL
1659
+ inform = default_model_inform if model.nil? && stored_model.nil? && config_model.nil? && defaults["model"].nil?
1660
+ [resolved_harness, resolved_model, inform]
1661
+ end
1662
+
1663
+ # The loud last-resort inform — the bare DEFAULT_MODEL constant is a last
1664
+ # resort, not a silent fallback: name that no architect.yaml was found, the
1665
+ # two paths looked at, and the default in use.
1666
+ def default_model_inform
1667
+ "model: no architect.yaml found (looked at #{Routing.space_path(space.path)} + #{Routing.global_path}) — " \
1668
+ "using default '#{Harness::DEFAULT_MODEL}'"
1621
1669
  end
1622
1670
 
1623
1671
  # --prompt: the caller authors the lane prompt anywhere (a fresh scratch file) and
@@ -1640,8 +1688,9 @@ module Space::Architect
1640
1688
  # precedence and thinking-level translation from one place.
1641
1689
  def resolve_dispatch_harness(lane_entry, model:, harness:, effort:, force:, err:)
1642
1690
  model, suffix_level = Harness.parse_model_suffix(model)
1643
- resolved_harness, resolved_model = resolve_harness_model(harness, model,
1691
+ resolved_harness, resolved_model, inform = resolve_harness_model(harness, model,
1644
1692
  stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
1693
+ err.puts inform if inform
1645
1694
 
1646
1695
  resolved_effort =
1647
1696
  if effort
@@ -1651,7 +1700,9 @@ module Space::Architect
1651
1700
  err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
1652
1701
  suffix_level
1653
1702
  else
1654
- lane_entry["effort"]
1703
+ # Dispatch's tail is the lane's stored effort (project.effort was never
1704
+ # in the dispatch chain); architect.yaml builder thinking slots in above it.
1705
+ lane_entry["effort"] || Routing.for_space(space.path)&.dig("builder", "thinking")
1655
1706
  end
1656
1707
 
1657
1708
  [resolved_harness, resolved_model, resolved_effort]