space-architect 9.6.0 → 9.8.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.
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "json"
6
+ require "yaml"
7
+ require "pathname"
8
+
9
+ module Space::Architect
10
+ # I16 — the structured-record emission layer: config resolution (session-sink
11
+ # mirror: unconfigured = fully inert, configured = capture on, mute knob
12
+ # available), the best-effort Net::HTTP client, the payload builders that
13
+ # derive from the canonical sources the loop commands already hold, and the
14
+ # ```rulings block parser. ArchitectProject's five hook call sites guard
15
+ # through `emitter&.post` inside a rescue-all belt; nothing here may ever
16
+ # raise into a loop command or change its exit code, stdout, or commits.
17
+ module IterationEmission
18
+ # Frozen tuning (mirrors the spec; session-sink pins its own constants the
19
+ # same way): per attempt 2s open / 5s read, at most 2 attempts, retry on
20
+ # network errors or 5xx ONLY — 4xx is never retried.
21
+ OPEN_TIMEOUT = 2
22
+ READ_TIMEOUT = 5
23
+ MAX_ATTEMPTS = 2
24
+
25
+ # The four server mouths (I15, frozen): POST <host>/iterations/ingest/<mouth>.
26
+ MOUTHS = %w[freeze lane verdict integration].freeze
27
+
28
+ # Iteration/lane status vocabularies (frozen): nothing else is emitted.
29
+ ITERATION_STATUSES = %w[frozen integrated].freeze
30
+ LANE_STATUSES = %w[complete integrated].freeze
31
+
32
+ # The space-slug rule (architect_project.rb :1642), with the emission-layer
33
+ # fallback: space.data["id"] when the title is absent or slugifies empty.
34
+ def self.slug_for(space)
35
+ slug = space.title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
36
+ slug.empty? ? space.data["id"].to_s : slug
37
+ rescue StandardError
38
+ space.data["id"].to_s
39
+ end
40
+
41
+ # ---- config resolution (the Routing.global_path pattern) ---------------
42
+
43
+ # Global file ~/.config/space-architect/emission.yaml, path overridable via
44
+ # SPACE_ARCHITECT_EMISSION_GLOBAL_CONFIG — exactly Routing.global_path's
45
+ # shape (env pin first, else the ~/.config path; parity outranks XDG).
46
+ def self.global_path(env: ENV)
47
+ override = env["SPACE_ARCHITECT_EMISSION_GLOBAL_CONFIG"]
48
+ return Pathname.new(override) unless override.nil?
49
+
50
+ Pathname.new("~/.config/space-architect/emission.yaml").expand_path
51
+ end
52
+
53
+ # Resolve the configured Emitter for a space, or nil when unconfigured:
54
+ # no file, missing host/token, or disabled. ENV beats the file per field,
55
+ # both ways: SPACE_ARCHITECT_EMISSION_HOST / _TOKEN override their file
56
+ # values; _DISABLED ("1"/"true" mutes; any other present value unmutes)
57
+ # overrides the file's disabled knob in both directions. A malformed or
58
+ # unreadable file is INERT PLUS one stderr line naming the path and the
59
+ # problem; an absent file is inert and silent (a no-config machine is a
60
+ # normal state — zero output, zero network).
61
+ def self.emitter_for(space, env: ENV, err: $stderr)
62
+ config = read_config(global_path(env: env), err: err)
63
+ disabled_env = env["SPACE_ARCHITECT_EMISSION_DISABLED"]
64
+ host = env["SPACE_ARCHITECT_EMISSION_HOST"] || config&.[](:host)
65
+ token = env["SPACE_ARCHITECT_EMISSION_TOKEN"] || config&.[](:token)
66
+ disabled =
67
+ if disabled_env.nil?
68
+ config&.[](:disabled) == true
69
+ else
70
+ %w[1 true].include?(disabled_env.strip.downcase)
71
+ end
72
+
73
+ return nil if disabled || host.to_s.strip.empty? || token.to_s.strip.empty?
74
+
75
+ Emitter.new(host: host.to_s.strip, token: token.to_s, slug: slug_for(space))
76
+ end
77
+
78
+ def self.read_config(path, err:)
79
+ return nil unless File.file?(path.to_s)
80
+
81
+ doc = YAML.safe_load(File.read(path.to_s), aliases: true)
82
+ unless doc.is_a?(Hash)
83
+ err.puts "emission config (#{path}): expected a YAML mapping, got #{doc.inspect} — file config ignored"
84
+ return nil
85
+ end
86
+ {
87
+ host: doc["host"], token: doc["token"], disabled: doc["disabled"] == true
88
+ }
89
+ rescue Psych::Exception => e
90
+ err.puts "emission config (#{path}): malformed YAML — #{e.message} — file config ignored"
91
+ nil
92
+ rescue StandardError => e
93
+ err.puts "emission config (#{path}): unreadable — #{e.class}: #{e.message} — file config ignored"
94
+ nil
95
+ end
96
+
97
+ # ---- the client ---------------------------------------------------------
98
+
99
+ # One best-effort POST per call to <host>/iterations/ingest/<mouth>.
100
+ # NEVER raises into the caller: at most MAX_ATTEMPTS attempts (retry on
101
+ # network errors or 5xx only), then exactly one stderr line — the mouth
102
+ # path plus the failure class/status with a bounded response excerpt.
103
+ # Never the token, never the payload. On 2xx returns
104
+ # {status:, body:} (body JSON-parsed when possible).
105
+ class Emitter
106
+ attr_reader :host, :token, :slug
107
+
108
+ def initialize(host:, token:, slug:)
109
+ @host = host.sub(%r{/+\z}, "") # trailing slashes stripped once
110
+ @token = token
111
+ @slug = slug
112
+ end
113
+
114
+ def post(mouth, payload)
115
+ path = "/iterations/ingest/#{mouth}"
116
+ body = JSON.generate(payload)
117
+ response = nil
118
+ last_error = nil
119
+ MAX_ATTEMPTS.times do
120
+ begin
121
+ response, last_error = post_once(path, body)
122
+ rescue *NETWORK_ERRORS => e
123
+ last_error = e # network failure: fall through to the retry
124
+ end
125
+ break if response && response.code.to_i.between?(200, 499)
126
+ # 5xx and network failures fall through to the retry; 4xx breaks —
127
+ # never retried.
128
+ end
129
+
130
+ return result_for(response) if response&.code.to_i.between?(200, 299)
131
+
132
+ report_failure(path, response, last_error)
133
+ nil
134
+ end
135
+
136
+ private
137
+
138
+ def post_once(path, body)
139
+ uri = URI.parse("#{@host}#{path}")
140
+ uri.scheme ||= "http"
141
+ http = Net::HTTP.new(uri.host, uri.port)
142
+ http.use_ssl = uri.scheme == "https"
143
+ http.open_timeout = OPEN_TIMEOUT
144
+ http.read_timeout = READ_TIMEOUT
145
+ request = Net::HTTP::Post.new(uri.request_uri)
146
+ request["Content-Type"] = "application/json"
147
+ request["Authorization"] = "Bearer #{@token}"
148
+ request["X-Space-Slug"] = @slug
149
+ request.body = body
150
+ response = http.request(request)
151
+ [response, nil]
152
+ end
153
+
154
+ NETWORK_ERRORS = [
155
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
156
+ Errno::EPIPE, Errno::EACCES, Errno::EADDRNOTAVAIL, Errno::ETIMEDOUT,
157
+ SocketError, IOError, EOFError, SystemCallError,
158
+ Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse,
159
+ OpenSSL::SSL::SSLError, Timeout::Error
160
+ ].freeze
161
+
162
+ def result_for(response)
163
+ { status: response.code.to_i, body: safe_parse(response.body) }
164
+ end
165
+
166
+ def safe_parse(raw)
167
+ JSON.parse(raw.to_s)
168
+ rescue JSON::ParserError, ArgumentError
169
+ raw.to_s
170
+ end
171
+
172
+ # One stderr line: mouth path + failure class/status + bounded response
173
+ # excerpt when one arrived. The token lives only in the Authorization
174
+ # header; the payload is never echoed; a response body is server-written
175
+ # text and never echoes either.
176
+ def report_failure(path, response, last_error)
177
+ detail =
178
+ if response
179
+ "status #{response.code} — #{response.body.to_s[0, 200]}"
180
+ else
181
+ "#{last_error&.class || 'network failure'}"
182
+ end
183
+ $stderr.puts "#{path}: emission failed after #{MAX_ATTEMPTS} attempt(s): #{detail}"
184
+ end
185
+ end
186
+
187
+ # ---- payload builders (contract keys only; nil/empty scalars omitted) --
188
+ #
189
+ # The server's frozen contracts (repos/all-fax-no-printer
190
+ # app/contracts/ingest_iteration_*.rb) 422 unknown keys and empty `expect`
191
+ # maps — builders emit ONLY contract keys and omit `expect` ENTIRELY when
192
+ # the gate declares none (never {}).
193
+
194
+ def self.compact_scalars(hash)
195
+ hash.reject { |_k, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
196
+ end
197
+
198
+ # One gate as the freeze mouth stores it: id/ac/cmd/cwd/timeout/expect,
199
+ # nil fields omitted, expect dropped entirely when absent or empty.
200
+ def self.map_gate(g)
201
+ g = g.transform_keys(&:to_s)
202
+ compact_scalars(
203
+ "id" => g["id"], "ac" => g["ac"], "cmd" => g["cmd"], "cwd" => g["cwd"],
204
+ "timeout" => g["timeout"], "expect" => (g["expect"].is_a?(Hash) && !g["expect"].empty? ? g["expect"] : nil)
205
+ )
206
+ end
207
+
208
+ def self.build_freeze(ordinal:, name:, freeze_sha:, branch:, gates:)
209
+ compact_scalars(
210
+ "iteration" => compact_scalars(
211
+ "ordinal" => ordinal, "name" => name, "freeze_sha" => freeze_sha,
212
+ "branch" => branch, "status" => "frozen"
213
+ ),
214
+ "gates" => Array(gates).map { |g| map_gate(g) }
215
+ )
216
+ end
217
+
218
+ # Lane-mouth scalars as stored in the space.yaml lane record, re-sliced
219
+ # from it — never a hand-maintained copy. status/gate_results ride when
220
+ # given (nil/empty omitted; an absent key leaves stored rows untouched).
221
+ def self.build_lane(ordinal:, name:, lane_record:, status: nil, gate_results: nil)
222
+ r = lane_record.transform_keys(&:to_s)
223
+ lane = compact_scalars(
224
+ "name" => r["name"], "repo" => r["repo"], "base_sha" => r["base_sha"],
225
+ "model" => r["model"], "harness" => r["harness"], "effort" => r["effort"],
226
+ "touch_set" => (Array(r["touch_set"]) unless Array(r["touch_set"]).empty?),
227
+ "bounds_override_reason" => r["bounds_override_reason"],
228
+ "status" => status,
229
+ "gate_results" => gate_results
230
+ )
231
+ { "iteration" => compact_scalars("ordinal" => ordinal, "name" => name), "lane" => lane }
232
+ end
233
+
234
+ def self.map_gate_result(g, transcript)
235
+ result = compact_scalars(
236
+ "gate_id" => g[:gate_id], "status" => g[:status].to_s,
237
+ "reason" => g[:reason].to_s, "transcript" => transcript
238
+ )
239
+ result.delete("reason") if g[:reason].to_s.empty?
240
+ result
241
+ end
242
+
243
+ # A gate transcript sidecar entry: the structured mirror of one executed
244
+ # gate (all keys always present — it is the faithful machine mirror).
245
+ def self.map_sidecar_gate(g)
246
+ {
247
+ "gate_id" => g[:id].to_s, "status" => g[:status].to_s, "reason" => g[:reason].to_s,
248
+ "stdout" => g[:stdout].to_s, "stderr" => g[:stderr].to_s,
249
+ "exit_code" => g[:exit_code], "cmd" => g[:cmd].to_s, "dir" => g[:dir].to_s
250
+ }
251
+ end
252
+
253
+ def self.build_verdict(ordinal:, name:, decision:, rulings:)
254
+ verdict = compact_scalars(
255
+ "decision" => decision,
256
+ "ac_rulings" => (rulings[:ac_rulings] if rulings[:ac_rulings]&.any?),
257
+ "phase0_resolutions" => (rulings[:phase0_resolutions] if rulings[:phase0_resolutions]&.any?)
258
+ )
259
+ { "iteration" => compact_scalars("ordinal" => ordinal, "name" => name), "verdict" => verdict }
260
+ end
261
+
262
+ def self.build_integration(ordinal:, name:, branch:, lanes:)
263
+ {
264
+ "iteration" => compact_scalars("ordinal" => ordinal, "name" => name, "status" => "integrated"),
265
+ "integration" => {
266
+ "branch" => branch,
267
+ "lanes" => lanes.map { |l| compact_scalars("name" => l[:name], "merge_sha" => l[:merge_sha], "integration_branch" => l[:integration_branch]) }
268
+ }
269
+ }
270
+ end
271
+
272
+ # ---- the ```rulings block (S3) ------------------------------------------
273
+ #
274
+ # A fenced YAML block inside the ## Verdict body — the ```gates/```lanes
275
+ # discipline applied to verdict detail. Valid entries ride the emission
276
+ # verbatim; ANY invalid entry is dropped with one loud stderr line naming
277
+ # it; a malformed block (bad YAML, wrong shape) is dropped with one line;
278
+ # no block at all is silent decision-only. Extraction never raises.
279
+
280
+ def self.parse_rulings(body, err: $stderr)
281
+ match = body.to_s.match(/^```rulings\n(.*?)^```/m)
282
+ return {} unless match
283
+
284
+ doc = YAML.safe_load(match[1], aliases: true)
285
+ unless doc.is_a?(Hash)
286
+ err.puts "rulings block: expected a YAML mapping, got #{doc.inspect} — dropped (decision-only emission)"
287
+ return {}
288
+ end
289
+
290
+ {
291
+ ac_rulings: valid_rulings(Array(doc["ac_rulings"]), err: err),
292
+ phase0_resolutions: valid_resolutions(Array(doc["phase0_resolutions"]), err: err)
293
+ }
294
+ rescue Psych::Exception, StandardError => e
295
+ err.puts "rulings block: malformed YAML — #{e.message} — dropped (decision-only emission)"
296
+ {}
297
+ end
298
+
299
+ # ruling is FREE text (the PASS/FAIL/MODIFY/NOT-APPLICABLE convention is
300
+ # documented, not enforced); only shape is validated here.
301
+ def self.valid_rulings(entries, err:)
302
+ entries.filter_map do |entry|
303
+ unless entry.is_a?(Hash) && entry["ac"].is_a?(String) && !entry["ac"].strip.empty? &&
304
+ entry["ruling"].is_a?(String) && !entry["ruling"].strip.empty? &&
305
+ (entry.key?("note") ? entry["note"].is_a?(String) || entry["note"].nil? : true)
306
+ err.puts "rulings block: dropped invalid ac_rulings entry #{entry.inspect[0, 120]}"
307
+ next
308
+ end
309
+ compact_scalars("ac" => entry["ac"], "ruling" => entry["ruling"], "note" => entry["note"])
310
+ end
311
+ end
312
+
313
+ def self.valid_resolutions(entries, err:)
314
+ entries.filter_map do |entry|
315
+ unless entry.is_a?(Hash) && entry["topic"].is_a?(String) && !entry["topic"].strip.empty? &&
316
+ (entry.key?("lane") ? entry["lane"].is_a?(String) || entry["lane"].nil? : true) &&
317
+ (entry.key?("resolution") ? entry["resolution"].is_a?(String) || entry["resolution"].nil? : true)
318
+ err.puts "rulings block: dropped invalid phase0_resolutions entry #{entry.inspect[0, 120]}"
319
+ next
320
+ end
321
+ compact_scalars("topic" => entry["topic"], "lane" => entry["lane"], "resolution" => entry["resolution"])
322
+ end
323
+ end
324
+ end
325
+ end
@@ -17,48 +17,47 @@ module Space::Architect
17
17
  # XDG_CONFIG_HOME); space-local is architect.yaml beside the loaded
18
18
  # space.yaml (the CLI already resolved the space — no walk-up).
19
19
  module Routing
20
- ROLES = %w[architect spec builder judge coordinator narrator].freeze
21
- # The five agent roles that must be routed after the merge; `architect` is optional.
22
- REQUIRED_ROLES = %w[spec builder judge coordinator narrator].freeze
20
+ ROLES = %w[architect spec builder judge narrator].freeze
21
+ # The four agent roles that must be routed after the merge; `architect` is optional.
22
+ REQUIRED_ROLES = %w[spec builder judge narrator].freeze
23
23
  ENTRY_KEYS = %w[model thinking].freeze
24
24
 
25
25
  # I09: the `architect routing init` scaffold — the exact bytes the command
26
26
  # writes, living beside the parsers that must accept it. Example models are
27
27
  # loud REPLACE_MEs; the header comment carries both locations and the merge
28
- # order so the file is self-documenting. Byte-exact contract: the extension
29
- # lane's tests carry an inline copy, and the command round-trips the write
30
- # through parse below — a template the machinery rejects is a bug.
28
+ # order so the file is self-documenting. Byte-exact contract (1021 B): the
29
+ # extension lane's tests carry an inline copy, and the command round-trips
30
+ # the write through parse below — a template the machinery rejects is a bug.
31
+ # I10: the detached driver role retired (the architect session drives the
32
+ # loop itself); the scaffold routes the four required roles plus optional architect.
31
33
  TEMPLATE = <<~'YAML'.freeze
32
- # architect.yaml — space-architect loop role routing, scaffolded by `architect routing init`.
33
- # Locations (merged in this order; the space-local entry wins per role):
34
- # global: ~/.config/space-architect/architect.yaml (machine-wide)
35
- # local: architect.yaml beside this space's space.yaml (this file)
36
- # A local entry overrides the global entry for THAT ROLE only; after the
37
- # merge every agent role must be routed: spec, builder, judge, coordinator,
38
- # narrator (architect is optional).
39
- # Replace every example/REPLACE_ME below with a real provider/model-id
40
- # (e.g. zai/glm-5.3). thinking is optional: off|minimal|low|medium|high|xhigh|max.
41
- # Once saved, the next /loop or architect command picks the file up.
42
- version: 1
43
- roles:
44
- architect:
45
- model: example/REPLACE_ME
46
- thinking: high
47
- spec:
48
- model: example/REPLACE_ME
49
- thinking: high
50
- builder:
51
- model: example/REPLACE_ME
52
- thinking: high
53
- judge:
54
- model: example/REPLACE_ME
55
- thinking: high
56
- coordinator:
57
- model: example/REPLACE_ME
58
- thinking: high
59
- narrator:
60
- model: example/REPLACE_ME
61
- thinking: low
34
+ # architect.yaml — space-architect loop role routing, scaffolded by `architect routing init`.
35
+ # Locations (merged in this order; the space-local entry wins per role):
36
+ # global: ~/.config/space-architect/architect.yaml (machine-wide)
37
+ # local: architect.yaml beside this space's space.yaml (this file)
38
+ # A local entry overrides the global entry for THAT ROLE only; after the
39
+ # merge every agent role must be routed: spec, builder, judge, narrator
40
+ # (architect is optional).
41
+ # Replace every example/REPLACE_ME below with a real provider/model-id
42
+ # (e.g. zai/glm-5.3). thinking is optional: off|minimal|low|medium|high|xhigh|max.
43
+ # Once saved, the next /loop or architect command picks the file up.
44
+ version: 1
45
+ roles:
46
+ architect:
47
+ model: example/REPLACE_ME
48
+ thinking: high
49
+ spec:
50
+ model: example/REPLACE_ME
51
+ thinking: high
52
+ builder:
53
+ model: example/REPLACE_ME
54
+ thinking: high
55
+ judge:
56
+ model: example/REPLACE_ME
57
+ thinking: high
58
+ narrator:
59
+ model: example/REPLACE_ME
60
+ thinking: low
62
61
  YAML
63
62
 
64
63
  # The one canonical cure line appended to default_model_inform's last-resort
@@ -75,14 +74,34 @@ module Space::Architect
75
74
  Pathname.new(space_dir).join("architect.yaml")
76
75
  end
77
76
 
78
- # Parse one file's text into { role => {"model" => id, "thinking" => level|nil} }.
79
- # +path+ is used only in error messages. Frozen v1 rules: integer version 1,
80
- # the six known roles, entries ⊆ {model, thinking} with a non-empty model,
81
- # duplicate mapping keys rejected (psych's plain load silently keeps the
82
- # last — the npm yaml the extension parses with throws), aliases accepted,
83
- # a trailing :level suffix on model parsed via Harness.parse_model_suffix
84
- # and beating the thinking key.
77
+ # Parse one file's text into { role => {"model" => id, "thinking" => level|nil} }
78
+ # for the KNOWN roles. +path+ is used only in error messages. Frozen v1 rules:
79
+ # integer version 1, the five known roles, entries ⊆ {model, thinking} with a
80
+ # non-empty model, duplicate mapping keys rejected (psych's plain load silently
81
+ # keeps the last — the npm yaml the extension parses with throws), aliases
82
+ # accepted, a trailing :level suffix on model parsed via Harness.parse_model_suffix
83
+ # and beating the thinking key. Unknown role keys (I10 deprecation tolerance: a
84
+ # config still carrying a retired role key, or a typo) are skipped for
85
+ # routing and collected — see split_known_and_unknown.
85
86
  def self.parse(text, path)
87
+ split_known_and_unknown(text, path).first
88
+ end
89
+
90
+ # The pure deprecation seam (I10): the unknown role keys under `roles:` in one
91
+ # file's text, in document order. Same parse rules as
92
+ # parse (known-role validation included); only unknown entries are skipped
93
+ # unvalidated, so the inform never fires for a config the parser rejects.
94
+ def self.unknown_role_keys(text, path)
95
+ split_known_and_unknown(text, path).last
96
+ end
97
+
98
+ # One parse, two results: [known-role hash, unknown role keys]. An unknown
99
+ # STRING key is skipped whole for routing — its entry content is never
100
+ # validated (an ignored key is ignored whole; garbage under
101
+ # roles.<retired-role>.thinking must not raise) — and collected for the one
102
+ # deprecation inform. A non-string key is corruption, not a retired role
103
+ # name, and still fails loud under the known-role error.
104
+ def self.split_known_and_unknown(text, path)
86
105
  assert_unique_keys!(text, path)
87
106
  doc = YAML.safe_load(text, aliases: true)
88
107
  unless doc.is_a?(Hash)
@@ -98,8 +117,14 @@ module Space::Architect
98
117
  end
99
118
 
100
119
  parsed = {}
120
+ unknown = []
101
121
  roles.each do |role, entry|
102
- unless ROLES.include?(role)
122
+ if role.is_a?(String)
123
+ unless ROLES.include?(role)
124
+ unknown << role # an ignored key is ignored whole: skip for routing, no entry validation
125
+ next
126
+ end
127
+ else
103
128
  raise Space::Core::Error,
104
129
  "architect.yaml (#{path}): roles.#{role} — expected one of #{ROLES.join('|')}, got #{role.inspect}"
105
130
  end
@@ -136,24 +161,48 @@ module Space::Architect
136
161
  end
137
162
  parsed[role] = { "model" => model, "thinking" => suffix_level || thinking }
138
163
  end
139
- parsed
164
+ [parsed, unknown]
140
165
  rescue Psych::Exception => e
141
166
  raise Space::Core::Error, "architect.yaml (#{path}): malformed YAML — #{e.message}"
142
167
  end
143
168
 
169
+ # The one deprecation inform for unknown role keys (I10): names every
170
+ # roles.<key> found, the known-role set, and that the entries are ignored.
171
+ # Fired at most once per PROCESS via the class-level flag — worktree_add and
172
+ # dispatch each resolve routing twice per invocation, so per-call emission
173
+ # would double-print. A config with no unknown key never composes the
174
+ # message, so the memo never fires. Tests reset it with
175
+ # remove_instance_variable(:@deprecation_informed).
176
+ def self.inform_deprecated_roles!(err, keys, found)
177
+ return if keys.empty? || @deprecation_informed
178
+
179
+ @deprecation_informed = true
180
+ err.puts "architect.yaml (#{found.join(' + ')}): ignoring unknown role key(s) " \
181
+ "#{keys.map { |k| "roles.#{k}" }.join(', ')} — known roles: #{ROLES.join(', ')} (each entry is ignored)"
182
+ end
183
+
144
184
  # The merged routing for a space: [global, space-local] merged per role key
145
185
  # (a space file may override only builder and inherit the rest). nil when
146
186
  # NEITHER file exists — no config is a normal state: no error, no side
147
187
  # effects. Any found file that is malformed/invalid fails loud at parse;
148
- # after the merge all four agent roles must be routed.
149
- def self.for_space(space_dir)
188
+ # after the merge all four agent roles must be routed. +err+ is additive
189
+ # (I10): when given, a merged config carrying unknown role keys prints the
190
+ # one per-process deprecation inform on it; existing callers (no err)
191
+ # resolve silently as before.
192
+ def self.for_space(space_dir, err: nil)
150
193
  global = global_path
151
194
  local = space_path(space_dir)
152
195
  found = [global, local].select { |p| File.file?(p.to_s) }
153
196
  return nil if found.empty?
154
197
 
155
198
  merged = {}
156
- found.each { |p| merged.merge!(parse(File.read(p.to_s), p)) }
199
+ unknown = []
200
+ found.each do |p|
201
+ known, keys = split_known_and_unknown(File.read(p.to_s), p)
202
+ merged.merge!(known)
203
+ unknown |= keys
204
+ end
205
+ inform_deprecated_roles!(err, unknown, found) if err
157
206
  missing = REQUIRED_ROLES - merged.keys
158
207
  if missing.any?
159
208
  raise Space::Core::Error,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Architect
5
- VERSION = "9.6.0"
5
+ VERSION = "9.8.0"
6
6
  end
7
7
  end
@@ -7,6 +7,7 @@ require_relative "space_architect/harness"
7
7
  require_relative "space_architect/routing"
8
8
  require_relative "space_architect/gate_lint"
9
9
  require_relative "space_architect/gate_evaluator"
10
+ require_relative "space_architect/iteration_emission"
10
11
  require_relative "space_architect/architect_project"
11
12
  require_relative "space_architect/skill_installer"
12
13
  require_relative "space_architect/agent_installer"
@@ -72,13 +72,14 @@ the space durable across sessions:
72
72
  - **architect** — the judgment seat (this session in either mode: inline
73
73
  here, judgment in `/architect-discuss`): plans, arbitrates, rules on
74
74
  escalations, judges, lands. In inline mode it also does the work directly.
75
- - **coordinator** — a detached, cheap-model session that drives one plan:
76
- spawns and tends spec → builders → judge per iteration; relays escalations
77
- verbatim; never does lane work.
75
+ - **driver** — the architect session itself, in driver mode (the /loop
76
+ tool installs the driver playbook as operating instructions): spawns and
77
+ tends spec → builders → judge per iteration; rules escalations in-seat;
78
+ never does lane work.
78
79
  - **spec** — writes one iteration's Grounds/Specification/Acceptance
79
80
  Criteria; rehearses; freezes; writes the `notes/spec-digest-*` handoff.
80
81
  - **builder** — implementation in one lane's worktree; reports raw evidence
81
- only; never commits. Spawned by the coordinator as a subagent, or run via
82
+ only; never commits. Spawned by the driver as a subagent, or run via
82
83
  the `architect dispatch` CLI (which streams to
83
84
  `build/<id>-<lane>/run.jsonl`).
84
85
  - **judge** — a fresh session that runs the cold gates and rules per-AC
@@ -146,13 +147,14 @@ Read-only orientation commands: `architect status`, `architect looplog
146
147
 
147
148
  - **`/architect-discuss`** — engineering exploration and arbitration,
148
149
  judging a finished run, landing PRs, iteration surgery.
149
- - **`/architect-action`** — write the plan doc and launch the coordinator.
150
+ - **`/architect-action`** — write the plan doc and drive the loop yourself
151
+ as the driver.
150
152
  - **`/architect-status`** — "what are we working on in here? is it done?"
151
153
  - **`/architect-research`** — discovery-scale research.
152
154
 
153
155
  ## Maintenance
154
156
 
155
157
  This skill carries the shared vocabulary inline. When loop mechanics change
156
- (the coordinator flow, dispatch, routing), re-read this file against the
158
+ (the driver flow, dispatch, routing), re-read this file against the
157
159
  `architect-discuss` skill and the gem README and update the glossary —
158
160
  drift between the two is silent confusion for every future session.