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 +4 -4
- data/CHANGELOG.md +17 -0
- data/lib/space_architect/architect_project.rb +297 -2
- data/lib/space_architect/cli/architect.rb +55 -0
- data/lib/space_architect/iteration_emission.rb +325 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f12c744b4e442b1ba96e09e2ede78bf7f048932d61bd891c667b65f62c78674a
|
|
4
|
+
data.tar.gz: 87e62449c0d0539fdf688fc7faa43909cbc0dc034c5a0ee477b645f3ceac7a9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/space_architect.rb
CHANGED
|
@@ -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.
|
|
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
|