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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d95b9e18cb5c8dbaf32c57ad55915e12b387ee5a68eb5cea89e52c0ab3a2542
4
- data.tar.gz: d95ef73a8929c852f58c8712f7dfce3bd14eab18e06c0e518f8bdd6c55625588
3
+ metadata.gz: 2a29d0c5688ed96e02a49a159385f508a845714e1741577384f6a6369a1fbb7e
4
+ data.tar.gz: 2b46a7ffee6ee544c9f5e30b7926f4ee5d541d81032be53617cec19f5e519bfd
5
5
  SHA512:
6
- metadata.gz: 1e36ea6062b43c49d1ebcf69fac6205af9e7f24cb5e51bff1d2ae70db518ef07facafa41d17e8bb7de2736d8748b84ab97d42a7ce70513046b7b089c4ff1d2cc
7
- data.tar.gz: 659816b4ea7286f5a24fb8ff16d0e0eb2783258051ceddeab53f00ba8e2e3ea9adfbcd1150132d751a5fd4f0531070689796a3c01135f2e49c04faa5c7c51224
6
+ metadata.gz: d418323e0ef3c26cfdf2ca7d06aac9fcec9da1d74c8ccbb0a339cc5f0e85eaf9838ab5bf380f7949f28d30a416e7d5b2a0c9043e1fe74d3659ffbfaf5d764641
7
+ data.tar.gz: 6fdbc0e2e89a34ff61f661202fb04e0f452a5f2b096db28b2dfd0a3adf41e6b9194a82b62253e2e1d4e9647886244d0ff0e38339d898726b93d8bcebc207800f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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
+ ## [9.8.0] - 2026-10-30
9
+
10
+ ### Removed
11
+
12
+ - **The coordinator role (I10)** — the architect session itself drives the
13
+ loop in driver mode (the pi extension's `/loop` and agent-side launch
14
+ install a driver playbook instead of spawning a detached coordinator).
15
+ The role leaves the routing known set and the `architect routing init`
16
+ template (byte-exact contract re-frozen through both parsers), and
17
+ install no longer ships its playbook — a stale user-level
18
+ `architect-coordinator.md` is removed on install.
19
+
20
+ ### Changed
21
+
22
+ - **Routing deprecation tolerance** — unknown `architect.yaml` role keys
23
+ (a retired key, or a typo) inform instead of rejecting: every command
24
+ proceeds, with one inform line naming the key and the known-role set.
25
+ Upgrading never breaks on an older config.
26
+ - **Shipped skills rewritten for driver mode** — architect,
27
+ architect-action, and architect-discuss describe the session-driven
28
+ loop: one-hop escalations, state re-derived from disk each wake,
29
+ compaction after each child launch.
30
+
31
+ ## [9.7.0] - 2026-10-30
32
+
33
+ ### Added
34
+
35
+ - **Structured-record emission (I16)** — the loop commands now mirror the
36
+ iteration record to a space-server as structured data: `architect
37
+ freeze`/`verdict`/`integrate` and lane completion POST to the four ingest
38
+ mouths (`/iterations/ingest/{freeze,lane,verdict,integration}`). Payloads
39
+ derive from the canonical sources (the iteration file, space.yaml lane
40
+ records, gate sidecars); the verdict detail rides a new fenced ` ```rulings `
41
+ YAML block (ac_rulings + phase0_resolutions) in the `## Verdict` body.
42
+ Config: `~/.config/space-architect/emission.yaml` (host/token/disabled),
43
+ session-sink semantics — unconfigured is fully inert, ENV overrides per
44
+ field (`SPACE_ARCHITECT_EMISSION_HOST/_TOKEN/_DISABLED`), best-effort
45
+ POSTs that never raise into or block a loop command. `architect emit`
46
+ replays the emission byte-stable.
47
+
8
48
  ## [9.6.0] - 2026-10-17
9
49
 
10
50
  ### Changed
@@ -4,8 +4,10 @@ require "open3"
4
4
 
5
5
  module Space
6
6
  module Architect
7
- # Installs the role playbooks (architect-coordinator/spec/builder/judge/
8
- # narrator) to ~/.pi/agent/agents. The playbook text is rendered by the
7
+ # Installs the role playbooks (architect-spec/builder/judge/narrator) to
8
+ # ~/.pi/agent/agents and removes a stale architect-coordinator.md left by a
9
+ # pre-driver-era install (I10: the coordinator role retired — the architect
10
+ # session itself drives the loop). The playbook text is rendered by the
9
11
  # installed architect-loop extension itself — the extension owns that text
10
12
  # (it renders the per-space .pi/agents files from the same source), so this
11
13
  # installer carries no second copy to drift. Routing pins come from the
@@ -14,7 +16,7 @@ module Space
14
16
  # machine-wide fallback for sessions that cannot see a space's .pi dir;
15
17
  # a space's own .pi/agents files always win inside that space.
16
18
  module AgentInstaller
17
- ROLES = %w[coordinator spec builder judge narrator].freeze
19
+ ROLES = %w[spec builder judge narrator].freeze
18
20
 
19
21
  # Renders each role's agent file and prints it between ===FILE===<role>
20
22
  # markers. Dynamic import (node -e runs CJS; import() is legal there) so
@@ -29,7 +31,7 @@ module Space
29
31
  console.error("no architect.yaml found — run `architect routing init --global` to scaffold the machine-wide config first");
30
32
  process.exit(1);
31
33
  }
32
- for (const role of ["coordinator", "spec", "builder", "judge", "narrator"]) {
34
+ for (const role of ["spec", "builder", "judge", "narrator"]) {
33
35
  console.log("===FILE===" + role);
34
36
  console.log(renderAgentFile(role, config));
35
37
  }
@@ -53,11 +55,23 @@ module Space
53
55
  results = ROLES.map do |role|
54
56
  write_agent(dest.join("architect-#{role}.md"), rendered.fetch(role), force: force, dry_run: dry_run)
55
57
  end
58
+ results.concat(remove_stale_coordinator(dest, dry_run: dry_run))
56
59
  { dest_root: dest, agents: results, dry_run: dry_run }
57
60
  end
58
61
 
59
62
  private
60
63
 
64
+ # The retired coordinator playbook: remove, never update — the loop is
65
+ # driven by the architect session itself, so a leftover fifth playbook
66
+ # can only mislead. A dry run reports the would-be removal untouched.
67
+ def remove_stale_coordinator(dest, dry_run:)
68
+ stale = dest.join("architect-coordinator.md")
69
+ return [] unless stale.exist?
70
+
71
+ FileUtils.rm(stale) unless dry_run
72
+ [{ name: stale.basename.to_s, action: dry_run ? :would_remove : :removed, path: stale }]
73
+ end
74
+
61
75
  def default_dest(env)
62
76
  Pathname.new(env.fetch("PI_AGENTS_DIR", File.join(pi_agent_dir(env), "agents")))
63
77
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "yaml"
4
4
  require "erb"
5
+ require "json"
5
6
  require "open3"
6
7
  require "fileutils"
7
8
  require "pathname"
@@ -92,8 +93,23 @@ module Space::Architect
92
93
  }
93
94
  JSON
94
95
 
95
- def initialize(space:)
96
+ def initialize(space:, emitter: nil)
96
97
  @space = space
98
+ # I16: the emission sink — an injected emitter (tests) always wins;
99
+ # the default resolves the emission config lazily, once per instance,
100
+ # and memoizes. nil resolution = unconfigured = every call site no-ops.
101
+ @injected_emitter = emitter unless emitter.nil?
102
+ end
103
+
104
+ # The resolved emitter for this space, or nil when unconfigured (no file,
105
+ # missing host/token, or disabled — Space::Architect::IterationEmission).
106
+ # Memoized on the first call-site resolution so machine state cannot
107
+ # differ between the five hooks of one command invocation.
108
+ def emitter
109
+ return @injected_emitter if instance_variable_defined?(:@injected_emitter)
110
+ return @resolved_emitter if instance_variable_defined?(:@resolved_emitter)
111
+
112
+ @resolved_emitter = IterationEmission.emitter_for(space)
97
113
  end
98
114
 
99
115
  def init!(message: nil)
@@ -261,6 +277,17 @@ module Space::Architect
261
277
  git_run("-C", space.path.to_s, "commit", "-m",
262
278
  compose_message("I#{nn} freeze:", "I#{nn}: record freeze sha", message), "--", Space::Core::Space::METADATA_FILE)
263
279
 
280
+ # I16 emission — mouth 1 (freeze): identity + the frozen gates, derived
281
+ # from the just-committed text. The already-frozen early return above
282
+ # emits nothing. Best-effort: unconfigured no-ops, failures never block.
283
+ safe_emit("freeze #{iteration}") do
284
+ em = emitter
285
+ next unless em
286
+ em.post("freeze", IterationEmission.build_freeze(
287
+ ordinal: entry["ordinal"], name: entry["name"], freeze_sha: sha,
288
+ branch: head_branch, gates: parse_gates(text)))
289
+ end
290
+
264
291
  sha
265
292
  end
266
293
 
@@ -352,6 +379,18 @@ module Space::Architect
352
379
  compose_message("I#{nn} verdict:", "I#{nn}: verdict", message), "--", rel, Space::Core::Space::METADATA_FILE)
353
380
 
354
381
  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
382
+
383
+ # I16 emission — mouth 3 (verdict): the decision plus ac_rulings /
384
+ # phase0_resolutions parsed from the body's ```rulings block when one is
385
+ # present. The body lands in the markdown verbatim either way.
386
+ safe_emit("verdict #{iteration}") do
387
+ em = emitter
388
+ next unless em
389
+ em.post("verdict", IterationEmission.build_verdict(
390
+ ordinal: entry["ordinal"], name: entry["name"], decision: decision,
391
+ rulings: IterationEmission.parse_rulings(body)))
392
+ end
393
+
355
394
  { decision: decision, sha: head.strip }
356
395
  end
357
396
 
@@ -387,6 +426,12 @@ module Space::Architect
387
426
  compose_message("I#{nn} evidence:", "I#{nn}: evidence", message), "--", rel)
388
427
  head, = git_capture("-C", space.path.to_s, "rev-parse", "HEAD")
389
428
 
429
+ # I16 emission — mouth 2 (lane): the lane's disposition scalars from the
430
+ # space.yaml record, status "complete". With lane nil, only when the
431
+ # entry records exactly ONE lane (the single-lane iteration shorthand);
432
+ # a multi-lane unlabeled run has no single owner — skip.
433
+ emit_lane_complete!(iteration, entry, lane)
434
+
390
435
  status_line = raw.lines.reverse_each.find { |l| l.strip.start_with?("STATUS:") }&.strip
391
436
  { sha: head.strip, lines: raw.lines.count, status_line: status_line, lane: lane }
392
437
  end
@@ -408,6 +453,149 @@ module Space::Architect
408
453
  section_body(text, "## Acceptance Criteria")
409
454
  end
410
455
 
456
+ # I16 S4 — `architect emit`: re-derive the emission payloads for one facet
457
+ # from the canonical sources (space.yaml, the frozen iteration file via
458
+ # `git show`, the gate sidecars) through the EXACT builders the live hook
459
+ # call sites use, so a re-send replays the same bytes. Returns the array
460
+ # of payloads for the facet — empty for "nothing to emit". Deliberately
461
+ # single-iteration: no bulk backfill surface, no enumeration of
462
+ # historical I*.md files.
463
+ def emission_replays(iteration, facet:, lane: nil)
464
+ entry = slice_entry(iteration)
465
+ case facet
466
+ when "freeze" then freeze_replay(entry)
467
+ when "lane" then lane_replays(entry, lane)
468
+ when "verdict" then verdict_replay(entry)
469
+ when "integration" then integration_replay(entry)
470
+ else raise Space::Core::Error, "unknown facet '#{facet}' — one of: freeze, lane, verdict, integration"
471
+ end
472
+ end
473
+
474
+ # freeze facet: gates re-parsed from the frozen file's byte state (same
475
+ # parse_gates path as the live freeze), branch re-read live — identical
476
+ # whenever the space repo's HEAD branch hasn't changed since the freeze.
477
+ def freeze_replay(entry)
478
+ sha = entry["freeze_sha"]
479
+ return [] unless sha
480
+
481
+ text, _, st = git_capture("-C", space.path.to_s, "show", "#{sha}:#{entry['file']}")
482
+ raise Space::Core::Error, "could not read #{entry['file']} at freeze #{sha[0, 8]}" unless st.success?
483
+
484
+ [IterationEmission.build_freeze(
485
+ ordinal: entry["ordinal"], name: entry["name"], freeze_sha: sha,
486
+ branch: head_branch, gates: parse_gates(text))]
487
+ end
488
+
489
+ # lane facet: the space.yaml lane records (or --lane NAME). When the lane
490
+ # has a gate sidecar, replay the gate-results shape (scalars minus status
491
+ # + gate_results re-attached from the sidecar, transcript from the sibling
492
+ # .md) — the live gate-time payload; otherwise the completion shape
493
+ # (status "complete") — the live evidence-time payload.
494
+ def lane_replays(entry, lane)
495
+ lanes = entry["lanes"] || []
496
+ if lane
497
+ lanes = lanes.select { |l| l["name"] == lane }
498
+ raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{entry['name']}'" if lanes.empty?
499
+ end
500
+
501
+ lanes.map do |record|
502
+ sidecar = newest_lane_sidecar(record["name"])
503
+ if sidecar
504
+ IterationEmission.build_lane(ordinal: entry["ordinal"], name: entry["name"],
505
+ lane_record: record, gate_results: lane_sidecar_results(sidecar))
506
+ else
507
+ IterationEmission.build_lane(ordinal: entry["ordinal"], name: entry["name"],
508
+ lane_record: record, status: "complete")
509
+ end
510
+ end
511
+ end
512
+
513
+ # verdict facet: the entry's recorded decision + the current iteration
514
+ # file's Verdict section ```rulings block (the section the live verdict
515
+ # command wrote — post-freeze, so it is read from the working tree).
516
+ def verdict_replay(entry)
517
+ decision = entry["verdict"]
518
+ return [] if decision.nil? || decision == "pending"
519
+
520
+ path = space.path.join(entry["file"])
521
+ raise Space::Core::Error, "#{entry['file']} does not exist" unless path.exist?
522
+
523
+ [IterationEmission.build_verdict(ordinal: entry["ordinal"], name: entry["name"],
524
+ decision: decision,
525
+ rulings: IterationEmission.parse_rulings(section_body(path.read, "## Verdict").to_s))]
526
+ end
527
+
528
+ # integration facet: the lanes' recorded integrate_sha/integration_branch
529
+ # plus the project integration branch. "nothing to emit" ([]) when no lane
530
+ # has merge data.
531
+ def integration_replay(entry)
532
+ lanes = (entry["lanes"] || []).select { |l| l["integrate_sha"] }
533
+ return [] if lanes.empty?
534
+
535
+ branch = lanes.filter_map { |l| l["integration_branch"] }.first || project_integration_branch
536
+ merge_commits = lane_merge_commits(lanes, branch)
537
+ [IterationEmission.build_integration(
538
+ ordinal: entry["ordinal"], name: entry["name"], branch: branch,
539
+ lanes: lanes.map { |l|
540
+ { name: l["name"], merge_sha: merge_commits[l["name"]] || l["integrate_sha"],
541
+ integration_branch: l["integration_branch"] }
542
+ })]
543
+ end
544
+
545
+ # The live integration emission carries each lane's repo MERGE commit (the
546
+ # tip after its --no-ff merge); space.yaml records the lane WORKTREE commit
547
+ # (integrate_sha). The merge commit is derivable from the canonical repo
548
+ # history — each merge's second parent is exactly the lane branch tip — so
549
+ # the re-send replays the same merge_sha the live path posted. When the
550
+ # branch history no longer holds the merge (rewritten/deleted), the
551
+ # recorded integrate_sha is the only remaining truth and is used as-is.
552
+ def lane_merge_commits(lanes, branch)
553
+ lanes.group_by { |l| l["repo"] }.flat_map do |repo, group|
554
+ next [] unless repo
555
+ out, _, st = git_capture("-C", space.path.join("repos", repo).to_s,
556
+ "rev-list", "--merges", "--parents", branch)
557
+ next [] unless st.success?
558
+ group.filter_map do |l|
559
+ next unless l["integrate_sha"]
560
+ commit = out.strip.split("\n")
561
+ .find { |line| line.split.drop(1).include?(l["integrate_sha"]) }
562
+ &.split
563
+ &.first
564
+ commit ? [l["name"], commit] : nil
565
+ end
566
+ end.to_h
567
+ end
568
+
569
+ # The newest lane gate sidecar in build/gate/ whose sibling .md transcript
570
+ # names the lane: sidecar files are keyed to their .md by basename, and
571
+ # the .md's "Lane: <name> (…}" header line is the only recorded lane
572
+ # association. Filenames embed a monotonic timestamp — reverse sort is
573
+ # newest-first.
574
+ def newest_lane_sidecar(lane_name)
575
+ dir = space.path.join("build", "gate")
576
+ return nil unless dir.exist?
577
+
578
+ # paths:exempt - the .extname == ".json" filter structurally cannot match a dotfile-prefixed name (".json" has no extname), so raw enumeration of the build/gate scratch dir is already dotfile-safe here
579
+ candidates = dir.children.select { |f| f.extname == ".json" }.sort_by(&:to_s).reverse
580
+ candidates.find do |sidecar|
581
+ md = sidecar.sub_ext(".md")
582
+ md.exist? && File.foreach(md).any? { |line| line.start_with?("Lane: #{lane_name} (") }
583
+ end
584
+ end
585
+
586
+ # Re-attach gate_results from a sidecar: same entry shape the live
587
+ # gate-time lane emission built (reason omitted when empty — the server
588
+ # stores NULL for ""), transcript from the sibling .md.
589
+ def lane_sidecar_results(sidecar)
590
+ transcript = sidecar.sub_ext(".md").read
591
+ JSON.parse(sidecar.read).map do |r|
592
+ IterationEmission.map_gate_result(
593
+ { gate_id: r["gate_id"], status: r["status"], reason: r["reason"] },
594
+ transcript)
595
+ end
596
+ end
597
+
598
+
411
599
  # Integrate ONE architect-judged-passing lane: commit the builder's working tree on
412
600
  # the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
413
601
  # Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
@@ -534,6 +722,14 @@ module Space::Architect
534
722
  raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
535
723
  end
536
724
 
725
+ # I16 emission — mouth 4 (integration): one integration POST, then a
726
+ # per-merged-lane lane-mouth scalar re-emission re-sliced from space.yaml
727
+ # AFTER the merge mutations (a bounds override accepted at integrate
728
+ # time must land in the record). Teardown-only calls never reach this —
729
+ # they early-return above. Emission happens before teardown so a
730
+ # teardown failure can never cost an already-merged lane its record.
731
+ emit_integration!(iteration, merged)
732
+
537
733
  teardown_lanes!(iteration, merged, force: force) if teardown
538
734
  merged
539
735
  ensure
@@ -575,7 +771,9 @@ module Space::Architect
575
771
  # #run_gates, the only intended caller shape. Returns the path.
576
772
  def gate_transcript(iteration, gate_results, lane: nil, now: Time.now)
577
773
  entry, base_dir, repo = resolve_gate_run(iteration, lane)
578
- write_transcript(:gate, entry, repo, base_dir, lane, gate_results, nil, now)
774
+ path = write_transcript(:gate, entry, repo, base_dir, lane, gate_results, nil, now)
775
+ emit_lane_gate_results!(entry, lane, path, gate_results) if lane
776
+ path
579
777
  end
580
778
 
581
779
  # Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the
@@ -709,12 +907,12 @@ module Space::Architect
709
907
  def worktree_add(repo, iteration, lane, base: nil, base_explicit: !base.nil?, harness: nil, model: nil,
710
908
  variant: false, effort: nil, touch: nil, force: false, err: $stderr)
711
909
  model, suffix_level = Harness.parse_model_suffix(model)
712
- harness, model, inform = resolve_harness_model(harness, model)
910
+ harness, model, inform = resolve_harness_model(harness, model, err: err)
713
911
  err.puts inform if inform
714
912
  # The config layer sits above project.effort — architect.yaml is the
715
913
  # dedicated routing layer; unconfigured spaces fall through unchanged.
716
914
  resolved_level = effort || suffix_level ||
717
- Routing.for_space(space.path)&.dig("builder", "thinking") || project_defaults["effort"]
915
+ Routing.for_space(space.path, err: err)&.dig("builder", "thinking") || project_defaults["effort"]
718
916
  Harness.validate_thinking_level!(resolved_level)
719
917
  if effort.nil? && suffix_level
720
918
  err.puts "thinking: model suffix ':#{suffix_level}' parsed from lane model → level=#{suffix_level} (model stripped)"
@@ -1074,6 +1272,103 @@ module Space::Architect
1074
1272
  body.empty? ? composed : "#{composed}\n\n#{body}"
1075
1273
  end
1076
1274
 
1275
+ # The emission belt: a payload-build or call-site bug must never change a
1276
+ # loop command's exit code, stdout, or commit behavior — exactly one
1277
+ # stderr line, then the command carries on. (Emitter#post itself never
1278
+ # raises; this belt guards the payload construction around it.)
1279
+ def safe_emit(context)
1280
+ yield
1281
+ rescue Exception => e # belt, by contract — even a NoMemoryError must not kill the loop
1282
+ $stderr.puts "emission skipped (#{context}): #{e.class}: #{e.message.to_s[0, 200]}"
1283
+ end
1284
+
1285
+ # I16 emission — mouth 2 (lane): the lane's disposition scalars from the
1286
+ # space.yaml record, status "complete". With lane nil, only when the
1287
+ # entry records exactly ONE lane (the single-lane iteration shorthand);
1288
+ # a multi-lane unlabeled run has no single owner — skip.
1289
+ def emit_lane_complete!(iteration, entry, lane)
1290
+ lane_name = lane
1291
+ unless lane_name
1292
+ lanes = entry["lanes"] || []
1293
+ lane_name = lanes.first["name"] if lanes.one?
1294
+ end
1295
+ return unless lane_name
1296
+
1297
+ safe_emit("lane complete #{lane_name}") do
1298
+ em = emitter
1299
+ next unless em
1300
+ record = (slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane_name }
1301
+ next unless record
1302
+ em.post("lane", IterationEmission.build_lane(
1303
+ ordinal: entry["ordinal"], name: entry["name"], lane_record: record, status: "complete"))
1304
+ end
1305
+ end
1306
+
1307
+ # I16: the structured sidecar — a JSON mirror of the executed gates
1308
+ # (gate_id/status/reason/stdout/stderr/exit_code/cmd/dir) written beside
1309
+ # the transcript .md (same basename; additive — the .md stays
1310
+ # byte-unchanged). Lane runs only: unlabeled gate runs emit nothing, and
1311
+ # `architect emit` re-attaches gate_results from the newest sidecar whose
1312
+ # sibling .md names the lane.
1313
+ def write_gate_sidecar(transcript_path, gate_results)
1314
+ safe_emit("gate sidecar #{transcript_path.basename}") do
1315
+ sidecar = transcript_path.sub_ext(".json")
1316
+ sidecar.write(JSON.generate(gate_results.map { |g| IterationEmission.map_sidecar_gate(g) }))
1317
+ end
1318
+ end
1319
+
1320
+ # I16 emission — mouth 2 (lane), gate-results shape: the same scalars as
1321
+ # the completion shape MINUS status, plus one gate_results entry per gate
1322
+ # ({gate_id, status exactly "pass"/"fail", reason omitted when empty,
1323
+ # transcript = the .md content just written}). Only called for lane runs.
1324
+ def emit_lane_gate_results!(entry, lane, transcript_path, gate_results)
1325
+ write_gate_sidecar(transcript_path, gate_results)
1326
+ safe_emit("lane gate results #{lane}") do
1327
+ em = emitter
1328
+ next unless em
1329
+ record = (entry["lanes"] || []).find { |l| l["name"] == lane }
1330
+ next unless record
1331
+ transcript = transcript_path.read
1332
+ em.post("lane", IterationEmission.build_lane(
1333
+ ordinal: entry["ordinal"], name: entry["name"], lane_record: record,
1334
+ gate_results: gate_results.map { |g|
1335
+ IterationEmission.map_gate_result({ gate_id: g[:id], status: g[:status], reason: g[:reason] }, transcript)
1336
+ }))
1337
+ end
1338
+ end
1339
+
1340
+ # I16 emission — mouth 4 (integration): one integration POST, then a
1341
+ # per-merged-lane lane-mouth scalar re-emission re-sliced from space.yaml
1342
+ # AFTER the merge mutations (a bounds override accepted at integrate time
1343
+ # must land in the record). Emission happens before teardown so a teardown
1344
+ # failure can never cost an already-merged lane its record.
1345
+ def emit_integration!(iteration, merged)
1346
+ safe_emit("integration #{iteration}") do
1347
+ em = emitter
1348
+ next unless em
1349
+ fresh = slice_entry(iteration)
1350
+ em.post("integration", IterationEmission.build_integration(
1351
+ ordinal: fresh["ordinal"], name: fresh["name"],
1352
+ branch: merged.first[:integration_branch],
1353
+ lanes: merged.map { |m|
1354
+ { name: m[:lane], merge_sha: m[:merge_sha], integration_branch: m[:integration_branch] }
1355
+ }))
1356
+ (fresh["lanes"] || []).each do |l|
1357
+ next unless merged.any? { |m| m[:lane] == l["name"] }
1358
+ em.post("lane", IterationEmission.build_lane(
1359
+ ordinal: fresh["ordinal"], name: fresh["name"], lane_record: l, status: "integrated"))
1360
+ end
1361
+ end
1362
+ end
1363
+
1364
+ # The space repo's HEAD branch, nil when detached (git abbrev-ref prints
1365
+ # "HEAD" there; a real branch of that name is not a thing git creates).
1366
+ def head_branch
1367
+ out, = git_capture("-C", space.path.to_s, "rev-parse", "--abbrev-ref", "HEAD")
1368
+ branch = out.strip
1369
+ branch unless branch == "HEAD"
1370
+ end
1371
+
1077
1372
  # integrate!'s own space.yaml commit, by pathspec — like record_verdict!'s sweep, so
1078
1373
  # other uncommitted work in the space isn't pulled in. A call that mutated nothing
1079
1374
  # (teardown-only over lanes with no worktree, or a call that raised before touching
@@ -1697,12 +1992,14 @@ module Space::Architect
1697
1992
  # Returns [harness, model, inform] — inform is the loud last-resort line (nil
1698
1993
  # otherwise) for the caller to print once to its err stream when the model
1699
1994
  # fell all the way through to Harness::DEFAULT_MODEL (no flag, no stored
1700
- # value, no architect.yaml builder role, no project.model).
1701
- def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
1995
+ # value, no architect.yaml builder role, no project.model). +err+ is additive
1996
+ # (I10): given a stream, a config carrying an unknown role key prints the one
1997
+ # per-process deprecation inform on it; nil (the default) resolves silently.
1998
+ def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil, err: nil)
1702
1999
  defaults = project_defaults
1703
2000
  resolved_harness = (harness || stored_harness || defaults["harness"] || "pi").to_s
1704
2001
  stored_model = nil unless stored_harness.nil? || stored_harness.to_s == resolved_harness
1705
- config_model = Routing.for_space(space.path)&.dig("builder", "model")
2002
+ config_model = Routing.for_space(space.path, err: err)&.dig("builder", "model")
1706
2003
  resolved_model = model || stored_model || config_model || defaults["model"] || Harness::DEFAULT_MODEL
1707
2004
  inform = default_model_inform if model.nil? && stored_model.nil? && config_model.nil? && defaults["model"].nil?
1708
2005
  [resolved_harness, resolved_model, inform]
@@ -1742,7 +2039,7 @@ module Space::Architect
1742
2039
  def resolve_dispatch_harness(lane_entry, model:, harness:, effort:, force:, err:)
1743
2040
  model, suffix_level = Harness.parse_model_suffix(model)
1744
2041
  resolved_harness, resolved_model, inform = resolve_harness_model(harness, model,
1745
- stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
2042
+ stored_harness: lane_entry["harness"], stored_model: lane_entry["model"], err: err)
1746
2043
  err.puts inform if inform
1747
2044
 
1748
2045
  resolved_effort =
@@ -1755,7 +2052,7 @@ module Space::Architect
1755
2052
  else
1756
2053
  # Dispatch's tail is the lane's stored effort (project.effort was never
1757
2054
  # in the dispatch chain); architect.yaml builder thinking slots in above it.
1758
- lane_entry["effort"] || Routing.for_space(space.path)&.dig("builder", "thinking")
2055
+ lane_entry["effort"] || Routing.for_space(space.path, err: err)&.dig("builder", "thinking")
1759
2056
  end
1760
2057
 
1761
2058
  [resolved_harness, resolved_model, resolved_effort]
@@ -172,7 +172,10 @@ module Space::Architect
172
172
  # constant as the last resort it is, not ordinary behavior.
173
173
  # Fully qualified: the Architect::Routing subcommand namespace
174
174
  # (I09) lexically shadows Space::Architect::Routing inside this module.
175
- config_model = Space::Architect::Routing.for_space(sp.path)&.dig("builder", "model")
175
+ # err: $stderr makes status one of the three deprecation-inform surfaces
176
+ # (I10) — a config still carrying an unknown role key informs once per
177
+ # process, here or on whichever surface resolves routing first.
178
+ config_model = Space::Architect::Routing.for_space(sp.path, err: $stderr)&.dig("builder", "model")
176
179
 
177
180
  terminal.say "Project status: #{block['status'] || '(none)'}"
178
181
  terminal.say "Current iteration: #{block['current_iteration'] || '(none)'}"
@@ -847,6 +850,60 @@ module Space::Architect
847
850
  end
848
851
  end
849
852
 
853
+ # I16: re-send the structured iteration emissions for ONE iteration —
854
+ # payloads re-derived from the canonical sources through the exact
855
+ # builders the live hook call sites use, so a replay is byte-identical
856
+ # to what the loop sent. Deliberately single-iteration: no bulk
857
+ # backfill surface, no enumeration of historical I*.md files.
858
+ class Emit < BaseCommand
859
+ desc "Re-send the structured emissions for one iteration (freeze/lane/verdict/integration; idempotent replay)"
860
+ phase 53, "Project"
861
+ argument :iteration, required: true, desc: "Iteration name"
862
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
863
+ option :facet, default: "all", desc: "Which facet to emit: freeze, lane, verdict, integration, or all (default)"
864
+ option :lane, default: nil, desc: "Lane name filter (lane facet only; default: all recorded lanes)"
865
+
866
+ FACETS = %w[freeze lane verdict integration].freeze
867
+
868
+ def call(iteration:, space: nil, facet: "all", lane: nil, **opts)
869
+ setup_terminal(**opts.slice(:color, :colors))
870
+ handle_errors do
871
+ requested = facet == "all" ? FACETS : FACETS & [facet]
872
+ if requested.empty?
873
+ raise Space::Core::Error,
874
+ "unknown facet '#{facet}' — one of: #{(FACETS + ["all"]).join(', ')}"
875
+ end
876
+
877
+ render(store.find(space)) do |sp|
878
+ project = ArchitectProject.new(space: sp)
879
+ emitter = IterationEmission.emitter_for(sp)
880
+ failed = false
881
+ requested.each do |f|
882
+ payloads = project.emission_replays(iteration, facet: f, lane: lane)
883
+ if payloads.empty?
884
+ terminal.say "nothing to emit (#{f})"
885
+ next
886
+ end
887
+ payloads.each do |payload|
888
+ unless emitter
889
+ terminal.say "emission off (unconfigured)"
890
+ next
891
+ end
892
+ res = emitter.post(f, payload)
893
+ if res
894
+ terminal.say "emitted #{f} → #{res[:status]}"
895
+ else
896
+ failed = true
897
+ terminal.say "emission failed (#{f}) — see stderr"
898
+ end
899
+ end
900
+ end
901
+ CLI.record_outcome(Outcome.new(exit_code: failed ? 1 : 0))
902
+ end
903
+ end
904
+ end
905
+ end
906
+
850
907
  # Pretty-print a pi agent run.jsonl as it streams — the ported runlog
851
908
  # printer. No space resolution: the stream is the whole input.
852
909
  class Runlog < BaseCommand
@@ -1108,7 +1165,7 @@ module Space::Architect
1108
1165
  # global-routing pins — the machine-wide fallback for sessions that
1109
1166
  # cannot see a space's .pi dir. Needs install-extensions to have run.
1110
1167
  class InstallAgents < BaseCommand
1111
- desc "Install the role playbooks (architect-coordinator/spec/builder/judge/narrator) to ~/.pi/agent/agents, rendered from the installed architect-loop extension"
1168
+ desc "Install the role playbooks (architect-spec/builder/judge/narrator) to ~/.pi/agent/agents, rendered from the installed architect-loop extension, and remove a stale architect-coordinator.md"
1112
1169
  phase 53, "Project"
1113
1170
  option :force, type: :boolean, default: false, desc: "Overwrite existing playbooks that differ"
1114
1171
  option :dry_run, type: :boolean, default: false, desc: "Print what would happen without writing files"
@@ -1440,6 +1497,7 @@ Space::Architect::CLI::Registry.register "install-extensions", Space::Architect:
1440
1497
  Space::Architect::CLI::Registry.register "install-agents", Space::Architect::CLI::Architect::InstallAgents
1441
1498
  Space::Architect::CLI::Registry.register "runlog", Space::Architect::CLI::Architect::Runlog
1442
1499
  Space::Architect::CLI::Registry.register "looplog", Space::Architect::CLI::Architect::Looplog
1500
+ Space::Architect::CLI::Registry.register "emit", Space::Architect::CLI::Architect::Emit
1443
1501
  Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
1444
1502
  Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
1445
1503
  Space::Architect::CLI::Registry.register "brief" do |b|