space-architect 9.6.0 → 9.7.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: f12c744b4e442b1ba96e09e2ede78bf7f048932d61bd891c667b65f62c78674a
4
+ data.tar.gz: 87e62449c0d0539fdf688fc7faa43909cbc0dc034c5a0ee477b645f3ceac7a9e
5
5
  SHA512:
6
- metadata.gz: 1e36ea6062b43c49d1ebcf69fac6205af9e7f24cb5e51bff1d2ae70db518ef07facafa41d17e8bb7de2736d8748b84ab97d42a7ce70513046b7b089c4ff1d2cc
7
- data.tar.gz: 659816b4ea7286f5a24fb8ff16d0e0eb2783258051ceddeab53f00ba8e2e3ea9adfbcd1150132d751a5fd4f0531070689796a3c01135f2e49c04faa5c7c51224
6
+ metadata.gz: c84129b96fa520324e1c15bf00f8f3de595bc642248ce3e72c9af0283b9802f63d2d3e7a15f1c450321bdf6c9840513ecce5cac477adf33cd0a8528bd63d585f
7
+ data.tar.gz: f1fff1968103fd8108619aefbcfd69531c6f2c916c42c4748ea75247ef8bf208d850a3c6bc79b208f12eb1c35b0cb9fc906a10455a357e13cd8ef1c326dec656
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ 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.7.0] - 2026-10-30
9
+
10
+ ### Added
11
+
12
+ - **Structured-record emission (I16)** — the loop commands now mirror the
13
+ iteration record to a space-server as structured data: `architect
14
+ freeze`/`verdict`/`integrate` and lane completion POST to the four ingest
15
+ mouths (`/iterations/ingest/{freeze,lane,verdict,integration}`). Payloads
16
+ derive from the canonical sources (the iteration file, space.yaml lane
17
+ records, gate sidecars); the verdict detail rides a new fenced ` ```rulings `
18
+ YAML block (ac_rulings + phase0_resolutions) in the `## Verdict` body.
19
+ Config: `~/.config/space-architect/emission.yaml` (host/token/disabled),
20
+ session-sink semantics — unconfigured is fully inert, ENV overrides per
21
+ field (`SPACE_ARCHITECT_EMISSION_HOST/_TOKEN/_DISABLED`), best-effort
22
+ POSTs that never raise into or block a loop command. `architect emit`
23
+ replays the emission byte-stable.
24
+
8
25
  ## [9.6.0] - 2026-10-17
9
26
 
10
27
  ### Changed
@@ -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
@@ -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
@@ -847,6 +847,60 @@ module Space::Architect
847
847
  end
848
848
  end
849
849
 
850
+ # I16: re-send the structured iteration emissions for ONE iteration —
851
+ # payloads re-derived from the canonical sources through the exact
852
+ # builders the live hook call sites use, so a replay is byte-identical
853
+ # to what the loop sent. Deliberately single-iteration: no bulk
854
+ # backfill surface, no enumeration of historical I*.md files.
855
+ class Emit < BaseCommand
856
+ desc "Re-send the structured emissions for one iteration (freeze/lane/verdict/integration; idempotent replay)"
857
+ phase 53, "Project"
858
+ argument :iteration, required: true, desc: "Iteration name"
859
+ argument :space, required: false, desc: "Space identifier (default: $PWD)"
860
+ option :facet, default: "all", desc: "Which facet to emit: freeze, lane, verdict, integration, or all (default)"
861
+ option :lane, default: nil, desc: "Lane name filter (lane facet only; default: all recorded lanes)"
862
+
863
+ FACETS = %w[freeze lane verdict integration].freeze
864
+
865
+ def call(iteration:, space: nil, facet: "all", lane: nil, **opts)
866
+ setup_terminal(**opts.slice(:color, :colors))
867
+ handle_errors do
868
+ requested = facet == "all" ? FACETS : FACETS & [facet]
869
+ if requested.empty?
870
+ raise Space::Core::Error,
871
+ "unknown facet '#{facet}' — one of: #{(FACETS + ["all"]).join(', ')}"
872
+ end
873
+
874
+ render(store.find(space)) do |sp|
875
+ project = ArchitectProject.new(space: sp)
876
+ emitter = IterationEmission.emitter_for(sp)
877
+ failed = false
878
+ requested.each do |f|
879
+ payloads = project.emission_replays(iteration, facet: f, lane: lane)
880
+ if payloads.empty?
881
+ terminal.say "nothing to emit (#{f})"
882
+ next
883
+ end
884
+ payloads.each do |payload|
885
+ unless emitter
886
+ terminal.say "emission off (unconfigured)"
887
+ next
888
+ end
889
+ res = emitter.post(f, payload)
890
+ if res
891
+ terminal.say "emitted #{f} → #{res[:status]}"
892
+ else
893
+ failed = true
894
+ terminal.say "emission failed (#{f}) — see stderr"
895
+ end
896
+ end
897
+ end
898
+ CLI.record_outcome(Outcome.new(exit_code: failed ? 1 : 0))
899
+ end
900
+ end
901
+ end
902
+ end
903
+
850
904
  # Pretty-print a pi agent run.jsonl as it streams — the ported runlog
851
905
  # printer. No space resolution: the stream is the whole input.
852
906
  class Runlog < BaseCommand
@@ -1440,6 +1494,7 @@ Space::Architect::CLI::Registry.register "install-extensions", Space::Architect:
1440
1494
  Space::Architect::CLI::Registry.register "install-agents", Space::Architect::CLI::Architect::InstallAgents
1441
1495
  Space::Architect::CLI::Registry.register "runlog", Space::Architect::CLI::Architect::Runlog
1442
1496
  Space::Architect::CLI::Registry.register "looplog", Space::Architect::CLI::Architect::Looplog
1497
+ Space::Architect::CLI::Registry.register "emit", Space::Architect::CLI::Architect::Emit
1443
1498
  Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
1444
1499
  Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
1445
1500
  Space::Architect::CLI::Registry.register "brief" do |b|
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Architect
5
- VERSION = "9.6.0"
5
+ VERSION = "9.7.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"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: space-architect
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.6.0
4
+ version: 9.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -161,6 +161,7 @@ files:
161
161
  - lib/space_architect/gate_evaluator.rb
162
162
  - lib/space_architect/gate_lint.rb
163
163
  - lib/space_architect/harness.rb
164
+ - lib/space_architect/iteration_emission.rb
164
165
  - lib/space_architect/loop_cost.rb
165
166
  - lib/space_architect/looplog.rb
166
167
  - lib/space_architect/pi/builder-guard.ts