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 +4 -4
- data/CHANGELOG.md +40 -0
- data/lib/space_architect/agent_installer.rb +18 -4
- data/lib/space_architect/architect_project.rb +306 -9
- data/lib/space_architect/cli/architect.rb +60 -2
- data/lib/space_architect/iteration_emission.rb +325 -0
- data/lib/space_architect/routing.rb +97 -48
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- data/skill/architect/SKILL.md +8 -6
- data/skill/architect-action/SKILL.md +22 -28
- data/skill/architect-discuss/SKILL.md +15 -15
- data/skill/architect-discuss/dispatch.md +10 -18
- 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: 2a29d0c5688ed96e02a49a159385f508a845714e1741577384f6a6369a1fbb7e
|
|
4
|
+
data.tar.gz: 2b46a7ffee6ee544c9f5e30b7926f4ee5d541d81032be53617cec19f5e519bfd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
8
|
-
#
|
|
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[
|
|
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 ["
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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|
|