space-architect 9.1.0 → 9.2.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/lib/space_architect/architect_project.rb +141 -24
- data/lib/space_architect/cli/architect.rb +200 -51
- data/lib/space_architect/extension_installer.rb +44 -0
- data/lib/space_architect/looplog.rb +425 -0
- data/lib/space_architect/routing.rb +4 -4
- data/lib/space_architect/runlog.rb +228 -0
- data/lib/space_architect/space_push.rb +274 -0
- data/lib/space_architect/version.rb +1 -1
- data/lib/space_architect.rb +1 -0
- data/skill/architect/SKILL.md +319 -521
- data/skill/architect/dispatch.md +76 -5
- data/skill/architect-plan/SKILL.md +114 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec005bf012468954875cbbd74a2c3d3a4d2fbc2468523f42ec425777a6e9344a
|
|
4
|
+
data.tar.gz: c93720a06c24e62ed03352c243d78a9895c1d98311547b9860e0ad549eea382f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 416269e2e0d4a3fab56cfd8a3cf5f8afdc63db75ad0b49982e98724fb0eaca66f85970f9c86f07901a543eb198405840c460464e5cb1491fd9d1eaafb9b3624b
|
|
7
|
+
data.tar.gz: 1ba4e232aeb7bdea2670fce2864bf4db7484cc60940ec4f242d3744f393f1082592adab9ac422ef39d6f870bcedd7c6cc89c78770d6409ebf9676f1ea01fa120
|
|
@@ -560,25 +560,24 @@ module Space::Architect
|
|
|
560
560
|
gates = parse_gates(text)
|
|
561
561
|
raise Space::Core::Error, "no gate commands found in the frozen Acceptance Criteria of #{rel}" if gates.empty?
|
|
562
562
|
|
|
563
|
-
|
|
564
|
-
repo_root = nil
|
|
565
|
-
base_dir =
|
|
566
|
-
if lane
|
|
567
|
-
le = lanes.find { |l| l["name"] == lane }
|
|
568
|
-
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
|
|
569
|
-
le = ensure_lane_materialized(iteration, lane)
|
|
570
|
-
repo_root = le["repo"] ? space.path.join("repos", le["repo"]) : nil
|
|
571
|
-
space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
|
|
572
|
-
else
|
|
573
|
-
repo = lanes.first&.dig("repo")
|
|
574
|
-
raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
|
|
575
|
-
space.path.join("repos", repo)
|
|
576
|
-
end
|
|
563
|
+
entry, base_dir, repo = resolve_gate_run(iteration, lane)
|
|
564
|
+
repo_root = lane && repo ? space.path.join("repos", repo) : nil
|
|
577
565
|
raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
|
|
578
566
|
|
|
579
567
|
execute_gates(gates, base_dir: base_dir, lane: lane, repo_root: repo_root)
|
|
580
568
|
end
|
|
581
569
|
|
|
570
|
+
# I07: the gate-side transcript — one durable file under build/gate/ per
|
|
571
|
+
# gate run, so a judge reading whole gates never depends on a terminal tail
|
|
572
|
+
# (agent runners read the last lines; the file is the record read whole).
|
|
573
|
+
# Re-slices the entry itself and re-resolves the run directory exactly as
|
|
574
|
+
# #run_gates does — a no-op when the Gate CLI composes it right after
|
|
575
|
+
# #run_gates, the only intended caller shape. Returns the path.
|
|
576
|
+
def gate_transcript(iteration, gate_results, lane: nil, now: Time.now)
|
|
577
|
+
entry, base_dir, repo = resolve_gate_run(iteration, lane)
|
|
578
|
+
write_transcript(:gate, entry, repo, base_dir, lane, gate_results, nil, now)
|
|
579
|
+
end
|
|
580
|
+
|
|
582
581
|
# Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the
|
|
583
582
|
# freeze, while they can still be fixed — through the identical execution
|
|
584
583
|
# path #run_gates uses at judge time (#execute_gates). space.yaml records no
|
|
@@ -929,12 +928,21 @@ module Space::Architect
|
|
|
929
928
|
base_explicit = !base.nil?
|
|
930
929
|
refuse_repoint_if_dirty!(iteration, lanes, base, iteration_id(entry)) if base_explicit && !force
|
|
931
930
|
|
|
931
|
+
# Sync-or-refuse ONCE per distinct repo of the selected set (lane order,
|
|
932
|
+
# first occurrence) BEFORE any worktree is cut — a divergence aborts the
|
|
933
|
+
# entire provision. An explicit --base is the deliberate case (#88): the
|
|
934
|
+
# pin is cut exactly as given, no sync, no divergence refusal.
|
|
935
|
+
synced = {}
|
|
936
|
+
unless base_explicit
|
|
937
|
+
lanes.map { |l| l["repo"] }.uniq.each { |repo| synced[repo] = sync_integration_branch!(repo, iteration) }
|
|
938
|
+
end
|
|
939
|
+
|
|
932
940
|
lanes.map do |l|
|
|
933
941
|
name = l["name"]
|
|
934
942
|
result = worktree_add(l["repo"], iteration, name, base: resolve_lane_base(l["repo"], base),
|
|
935
943
|
base_explicit: base_explicit, force: force, **recorded_lane_fields(l))
|
|
936
944
|
{ lane: name, worktree: result[:worktree], base_sha: result[:base_sha], outcome: result[:outcome],
|
|
937
|
-
discarded: result[:discarded] }
|
|
945
|
+
discarded: result[:discarded], synced: synced[l["repo"]] }
|
|
938
946
|
end
|
|
939
947
|
end
|
|
940
948
|
|
|
@@ -1261,22 +1269,40 @@ module Space::Architect
|
|
|
1261
1269
|
end
|
|
1262
1270
|
end
|
|
1263
1271
|
|
|
1264
|
-
# I01: the durable, unbounded counterpart to the CLI's bounded terminal
|
|
1265
|
-
# report — one file per
|
|
1266
|
-
# every gate's full command, dir, exit code, verdict, reason,
|
|
1267
|
-
# stdout+stderr
|
|
1268
|
-
#
|
|
1272
|
+
# I01 + I07: the durable, unbounded counterpart to the CLI's bounded terminal
|
|
1273
|
+
# report — one file per run under build/rehearse/ (rehearse) or build/gate/
|
|
1274
|
+
# (gate), holding every gate's full command, dir, exit code, verdict, reason,
|
|
1275
|
+
# and complete stdout+stderr. ONE writer, parameterized by kind: rehearse
|
|
1276
|
+
# additionally carries the scope-asymmetry findings and its RED/GREEN/BROKEN
|
|
1277
|
+
# verdict; gate carries the mechanical PASS/FAIL and no scope section — but
|
|
1278
|
+
# the per-gate emission is shared code, so the two transcripts can never drift.
|
|
1279
|
+
TRANSCRIPT_KINDS = {
|
|
1280
|
+
rehearse: { subdir: "rehearse", header: "Rehearsal transcript" },
|
|
1281
|
+
gate: { subdir: "gate", header: "Gate transcript" }
|
|
1282
|
+
}.freeze
|
|
1283
|
+
|
|
1269
1284
|
def write_rehearsal_transcript(entry, repo, base_dir, gate_results, scope_report, now)
|
|
1270
|
-
|
|
1285
|
+
write_transcript(:rehearse, entry, repo, base_dir, nil, gate_results, scope_report, now)
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
def write_transcript(kind, entry, repo, base_dir, lane, gate_results, scope_report, now)
|
|
1289
|
+
spec = TRANSCRIPT_KINDS.fetch(kind)
|
|
1290
|
+
dir = space.path.join("build", spec[:subdir])
|
|
1271
1291
|
FileUtils.mkdir_p(dir)
|
|
1272
1292
|
path = dir.join("#{iteration_id(entry)}-#{now.strftime('%Y%m%dT%H%M%S.%6N')}.md")
|
|
1273
1293
|
|
|
1274
|
-
lines = ["#
|
|
1275
|
-
lines <<
|
|
1294
|
+
lines = ["# #{spec[:header]} — #{entry['name']} — #{now.iso8601}"]
|
|
1295
|
+
lines << if lane
|
|
1296
|
+
"Lane: #{lane} (#{base_dir})"
|
|
1297
|
+
elsif repo
|
|
1298
|
+
"Repo: #{repo} (#{base_dir})"
|
|
1299
|
+
else
|
|
1300
|
+
"Each gate ran in its own declared cwd"
|
|
1301
|
+
end
|
|
1276
1302
|
|
|
1277
1303
|
gate_results.each do |g|
|
|
1278
1304
|
lines << ""
|
|
1279
|
-
lines << "## #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} — #{g
|
|
1305
|
+
lines << "## #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} — #{transcript_verdict(kind, g)} (exit #{g[:exit_code].inspect})"
|
|
1280
1306
|
lines << "cmd: #{g[:cmd]}"
|
|
1281
1307
|
lines << "dir: #{g[:dir]}"
|
|
1282
1308
|
lines << "reason: #{g[:reason]}" unless g[:reason].to_s.empty?
|
|
@@ -1306,6 +1332,37 @@ module Space::Architect
|
|
|
1306
1332
|
path
|
|
1307
1333
|
end
|
|
1308
1334
|
|
|
1335
|
+
# rehearse reports the RED/GREEN/BROKEN discrimination verdict per gate;
|
|
1336
|
+
# gate reports the mechanical PASS/FAIL.
|
|
1337
|
+
def transcript_verdict(kind, g)
|
|
1338
|
+
if kind == :rehearse
|
|
1339
|
+
g[:rehearsal].to_s.upcase
|
|
1340
|
+
else
|
|
1341
|
+
g[:status] == :pass ? "PASS" : "FAIL"
|
|
1342
|
+
end
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
# WHERE gates run for an iteration — shared by #run_gates and
|
|
1346
|
+
# #gate_transcript so the two can never disagree about it: the lane worktree
|
|
1347
|
+
# when a lane is given (materialized on demand), else the integration repo
|
|
1348
|
+
# checkout. Returns [entry, base_dir, repo_name] — repo_name is the run's
|
|
1349
|
+
# repo (the lane's declared repo, else the first recorded lane's), nil only
|
|
1350
|
+
# for a lane run in a repo-less lane.
|
|
1351
|
+
def resolve_gate_run(iteration, lane)
|
|
1352
|
+
entry = slice_entry(iteration)
|
|
1353
|
+
lanes = entry["lanes"] || []
|
|
1354
|
+
if lane
|
|
1355
|
+
le = lanes.find { |l| l["name"] == lane }
|
|
1356
|
+
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless le
|
|
1357
|
+
le = ensure_lane_materialized(iteration, lane)
|
|
1358
|
+
[entry, space.path.join(le["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt"), le["repo"]]
|
|
1359
|
+
else
|
|
1360
|
+
repo = lanes.first&.dig("repo")
|
|
1361
|
+
raise Space::Core::Error, "No lane/repo recorded for '#{iteration}' — cannot resolve a directory to run gates in" unless repo
|
|
1362
|
+
[entry, space.path.join("repos", repo), repo]
|
|
1363
|
+
end
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1309
1366
|
# I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
|
|
1310
1367
|
# `cwd` of their own — only those gates need one, and only they are ambiguous
|
|
1311
1368
|
# when the drafted ```lanes``` block names more than one repo. A gate with its
|
|
@@ -1622,6 +1679,15 @@ module Space::Architect
|
|
|
1622
1679
|
wt_path = space.path.join(lane_entry["worktree"] || "build/#{iteration_id(entry)}-#{lane}/wt")
|
|
1623
1680
|
return lane_entry if wt_path.exist? && worktree_registered?(repo_path, wt_path)
|
|
1624
1681
|
|
|
1682
|
+
# Same sync-or-refuse as provision, immediately before the cut — a
|
|
1683
|
+
# dispatch/gate/integrate/verify that materializes a missing worktree must
|
|
1684
|
+
# never cut it from a stale integration tip. Already-materialized lanes
|
|
1685
|
+
# returned above and stay a true no-op. The inform goes on $stderr, like
|
|
1686
|
+
# worktree_add's own informs on this path; no-op syncs print nothing.
|
|
1687
|
+
report = sync_integration_branch!(lane_entry["repo"], iteration)
|
|
1688
|
+
$stderr.puts "#{report[:repo]}: fast-forwarded #{report[:branch]} to #{report[:sha][0, 8]} — " \
|
|
1689
|
+
"synced before materializing #{iteration}/#{lane}" if report
|
|
1690
|
+
|
|
1625
1691
|
worktree_add(lane_entry["repo"], iteration, lane, base: resolve_lane_base(lane_entry["repo"], nil),
|
|
1626
1692
|
base_explicit: false, **recorded_lane_fields(lane_entry))
|
|
1627
1693
|
(slice_entry(iteration)["lanes"] || []).find { |l| l["name"] == lane }
|
|
@@ -1735,6 +1801,57 @@ module Space::Architect
|
|
|
1735
1801
|
st.success? && !out.strip.empty? ? out.strip : "HEAD"
|
|
1736
1802
|
end
|
|
1737
1803
|
|
|
1804
|
+
# Fast-forward the repo's project integration branch from its default branch
|
|
1805
|
+
# BEFORE any lane worktree is cut from it (I06 provision-sync — the I05 burn:
|
|
1806
|
+
# a worktree cut from a stale project/<slug> tip). Local-to-local only — no
|
|
1807
|
+
# fetch/pull, no remote-tracking ref is read or written. Compare before moving:
|
|
1808
|
+
# default not ahead → no-op (nil, callers print nothing); default ahead and
|
|
1809
|
+
# integration not ahead → fast-forward; both ahead → refuse (a human merges
|
|
1810
|
+
# deliberately, or pins --base) — --force has no bypass. Bang: it raises.
|
|
1811
|
+
# Returns nil for a no-op, or { repo:, branch:, sha: } for the moved ref so
|
|
1812
|
+
# callers can report the movement (never silently move a branch).
|
|
1813
|
+
def sync_integration_branch!(repo, iteration)
|
|
1814
|
+
repo_path = space.path.join("repos", repo)
|
|
1815
|
+
integration = project_integration_branch
|
|
1816
|
+
return nil unless branch_exists?(repo_path, integration)
|
|
1817
|
+
|
|
1818
|
+
default = repo_default_branch(repo_path)
|
|
1819
|
+
counts_out, counts_err, counts_st = git_capture("-C", repo_path.to_s, "rev-list", "--left-right", "--count",
|
|
1820
|
+
"#{default}...#{integration}")
|
|
1821
|
+
unless counts_st.success?
|
|
1822
|
+
raise Space::Core::Error, "Cannot compare #{default}...#{integration} in repos/#{repo} — #{counts_err.strip}"
|
|
1823
|
+
end
|
|
1824
|
+
|
|
1825
|
+
default_ahead, integration_ahead = counts_out.strip.split.map(&:to_i)
|
|
1826
|
+
return nil if default_ahead.zero?
|
|
1827
|
+
|
|
1828
|
+
default_tip, = git_capture("-C", repo_path.to_s, "rev-parse", default)
|
|
1829
|
+
integration_tip, = git_capture("-C", repo_path.to_s, "rev-parse", integration)
|
|
1830
|
+
if integration_ahead.positive?
|
|
1831
|
+
raise Space::Core::Error,
|
|
1832
|
+
"Refusing to provision '#{iteration}' — repos/#{repo} has diverged: #{default} (#{default_tip[0, 8]}) and " \
|
|
1833
|
+
"#{integration} (#{integration_tip[0, 8]}) each hold commits the other lacks " \
|
|
1834
|
+
"(#{default} ahead by #{default_ahead}, #{integration} ahead by #{integration_ahead}) — not fast-forwardable. " \
|
|
1835
|
+
"A human must merge #{default} into #{integration} deliberately, or pin the base explicitly with " \
|
|
1836
|
+
"`architect provision #{iteration} --base <sha>`. --force does not bypass this refusal."
|
|
1837
|
+
end
|
|
1838
|
+
|
|
1839
|
+
head_out, _, head_st = git_capture("-C", repo_path.to_s, "symbolic-ref", "--short", "HEAD")
|
|
1840
|
+
if head_st.success? && head_out.strip == integration
|
|
1841
|
+
# The integration branch is the clone's checked-out branch (the real
|
|
1842
|
+
# post-integrate state — merge_lane! checks it out) — fetch refuses a
|
|
1843
|
+
# checked-out dst, so fast-forward via merge instead.
|
|
1844
|
+
git_run("-C", repo_path.to_s, "merge", "--ff-only", default)
|
|
1845
|
+
else
|
|
1846
|
+
# Unprefixed refspecs are ff-only by construction — git rejects a non-ff
|
|
1847
|
+
# update here, so the branch can only ever move forward.
|
|
1848
|
+
git_run("-C", repo_path.to_s, "fetch", ".", "#{default}:#{integration}")
|
|
1849
|
+
end
|
|
1850
|
+
|
|
1851
|
+
new_tip, = git_capture("-C", repo_path.to_s, "rev-parse", integration)
|
|
1852
|
+
{ repo: repo, branch: integration, sha: new_tip.strip }
|
|
1853
|
+
end
|
|
1854
|
+
|
|
1738
1855
|
def slice_entry(iteration)
|
|
1739
1856
|
block = space.data["project"] || {}
|
|
1740
1857
|
entry = (block["iterations"] || []).find { |s| s["name"] == iteration }
|
|
@@ -6,6 +6,10 @@ require_relative "../jobs_client"
|
|
|
6
6
|
require_relative "../conversations_client"
|
|
7
7
|
require_relative "../loop_cost"
|
|
8
8
|
require_relative "../session_sync"
|
|
9
|
+
require_relative "../runlog"
|
|
10
|
+
require_relative "../looplog"
|
|
11
|
+
require_relative "../extension_installer"
|
|
12
|
+
require_relative "../space_push"
|
|
9
13
|
|
|
10
14
|
module Space::Architect
|
|
11
15
|
module CLI
|
|
@@ -211,6 +215,41 @@ module Space::Architect
|
|
|
211
215
|
end
|
|
212
216
|
end
|
|
213
217
|
|
|
218
|
+
class Push < BaseCommand
|
|
219
|
+
desc "Pack the current space and push it to a space-server for import"
|
|
220
|
+
phase 53, "Project"
|
|
221
|
+
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
222
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
223
|
+
option :token, required: true, desc: "Bearer token for authorization"
|
|
224
|
+
|
|
225
|
+
def call(space: nil, host: nil, token: nil, **opts)
|
|
226
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
227
|
+
handle_errors do
|
|
228
|
+
raise Space::Core::Error, "--host is required" unless host
|
|
229
|
+
raise Space::Core::Error, "--token is required" unless token
|
|
230
|
+
|
|
231
|
+
render(store.find(space)) do |sp|
|
|
232
|
+
bundle = SpacePush::Packer.new(sp.path).pack
|
|
233
|
+
begin
|
|
234
|
+
terminal.say "Packed #{sp.id}: #{format_bytes(File.size(bundle))}"
|
|
235
|
+
summary = SpacePush.new(host, token).push(bundle)
|
|
236
|
+
terminal.say "Imported: #{summary['slug']} (id #{summary['id']}) — " \
|
|
237
|
+
"#{summary['iterations']} iterations, #{summary['artifacts']} artifacts, #{summary['runs']} runs"
|
|
238
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
239
|
+
ensure
|
|
240
|
+
File.delete(bundle)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
private
|
|
247
|
+
|
|
248
|
+
def format_bytes(size)
|
|
249
|
+
format("%.1f MiB", size.to_f / (1024 * 1024))
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
214
253
|
class Sync < BaseCommand
|
|
215
254
|
desc "Sync tracked repo clones with their remotes (fast-forward only, no rebase/reset)"
|
|
216
255
|
phase 53, "Project"
|
|
@@ -230,7 +269,57 @@ module Space::Architect
|
|
|
230
269
|
end
|
|
231
270
|
end
|
|
232
271
|
|
|
272
|
+
# I07: the bounded per-gate terminal report, shared byte-for-byte by
|
|
273
|
+
# rehearse and gate — one identity line per gate, at most
|
|
274
|
+
# OUTPUT_LINE_CAP lines of tail output, and a pointer at the transcript
|
|
275
|
+
# that holds the rest (the same one-instrument discipline as
|
|
276
|
+
# #execute_gates below the CLI). The verdict label, the reason
|
|
277
|
+
# visibility, and any verdict-specific advisory are hooks; everything
|
|
278
|
+
# else — identity line shape, dir line, output elision — is shared so the
|
|
279
|
+
# two commands can never drift.
|
|
280
|
+
module GateRendering
|
|
281
|
+
# Identity (AC label, gate id, verdict, exit code) always lands on one
|
|
282
|
+
# stdout line, however many lines `cmd` spans — the command itself is
|
|
283
|
+
# shown as at most its first line, truncated; the full command lives
|
|
284
|
+
# in the transcript.
|
|
285
|
+
CMD_LINE_MAX = 100
|
|
286
|
+
OUTPUT_LINE_CAP = 20
|
|
287
|
+
|
|
288
|
+
def render_gate(g, transcript_path)
|
|
289
|
+
terminal.say ""
|
|
290
|
+
terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]}: #{truncated_cmd(g[:cmd])} (exit #{g[:exit_code].inspect}) [#{gate_verdict(g)}]"
|
|
291
|
+
terminal.say " dir: #{terminal.path(g[:dir])}"
|
|
292
|
+
terminal.say " reason: #{g[:reason]}" if gate_reason_visible?(g) && !g[:reason].to_s.empty?
|
|
293
|
+
if g[:shared_with]&.any?
|
|
294
|
+
terminal.say " shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)"
|
|
295
|
+
end
|
|
296
|
+
render_gate_extras(g)
|
|
297
|
+
render_gate_output(g, transcript_path)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def truncated_cmd(cmd)
|
|
301
|
+
first, *rest = cmd.to_s.each_line.map(&:chomp)
|
|
302
|
+
clipped = first.length > CMD_LINE_MAX ? "#{first[0, CMD_LINE_MAX]}…" : first
|
|
303
|
+
rest.any? || clipped != first ? "#{clipped} …" : clipped
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def render_gate_output(g, transcript_path)
|
|
307
|
+
combined = [g[:stdout], g[:stderr]].map { |s| s.to_s.rstrip }.reject(&:empty?).join("\n")
|
|
308
|
+
return if combined.empty?
|
|
309
|
+
|
|
310
|
+
lines = combined.lines(chomp: true)
|
|
311
|
+
if lines.size > OUTPUT_LINE_CAP
|
|
312
|
+
terminal.say lines.last(OUTPUT_LINE_CAP).join("\n")
|
|
313
|
+
terminal.say " … #{lines.size - OUTPUT_LINE_CAP} more line(s) elided — full output in #{terminal.path(transcript_path)}"
|
|
314
|
+
else
|
|
315
|
+
terminal.say lines.join("\n")
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
233
320
|
class Rehearse < BaseCommand
|
|
321
|
+
include GateRendering
|
|
322
|
+
|
|
234
323
|
desc "Rehearse the DRAFTED (unfrozen, working-tree) gates against the repo checkout and report RED/GREEN/BROKEN/EMPTY — runs and reports, never judges"
|
|
235
324
|
phase 12, "Spec"
|
|
236
325
|
argument :iteration, required: true, desc: "Iteration name"
|
|
@@ -283,6 +372,20 @@ module Space::Architect
|
|
|
283
372
|
"Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
|
|
284
373
|
end
|
|
285
374
|
|
|
375
|
+
# GateRendering hooks: rehearse renders the RED/GREEN/BROKEN verdict,
|
|
376
|
+
# shows a gate's reason whenever one exists, and adds the BROKEN
|
|
377
|
+
# advisory — everything else comes from the shared concern.
|
|
378
|
+
def gate_verdict(g) = g[:rehearsal].to_s.upcase
|
|
379
|
+
|
|
380
|
+
def gate_reason_visible?(g) = true
|
|
381
|
+
|
|
382
|
+
def render_gate_extras(g)
|
|
383
|
+
return unless g[:rehearsal] == :broken
|
|
384
|
+
|
|
385
|
+
terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
|
|
386
|
+
"hasn't written yet). Confirm before treating it as a defect."
|
|
387
|
+
end
|
|
388
|
+
|
|
286
389
|
# #90/AC7: with a single defaultable repo, name it as before; a cross-repo
|
|
287
390
|
# run (or one where every gate declares its own `cwd`) has no one repo to
|
|
288
391
|
# name — each gate's own "dir:" line (below) already says where it ran.
|
|
@@ -294,47 +397,6 @@ module Space::Architect
|
|
|
294
397
|
end
|
|
295
398
|
end
|
|
296
399
|
|
|
297
|
-
# I01/AC "bounded terminal report": identity (AC label, gate id, verdict,
|
|
298
|
-
# exit code) always lands on one stdout line, however many lines `cmd`
|
|
299
|
-
# spans — the command itself is shown as at most its first line,
|
|
300
|
-
# truncated; the full command lives in the transcript.
|
|
301
|
-
CMD_LINE_MAX = 100
|
|
302
|
-
OUTPUT_LINE_CAP = 20
|
|
303
|
-
|
|
304
|
-
def render_gate(g, transcript_path)
|
|
305
|
-
terminal.say ""
|
|
306
|
-
terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]}: #{truncated_cmd(g[:cmd])} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
|
|
307
|
-
terminal.say " dir: #{terminal.path(g[:dir])}"
|
|
308
|
-
terminal.say " reason: #{g[:reason]}" unless g[:reason].to_s.empty?
|
|
309
|
-
if g[:shared_with]&.any?
|
|
310
|
-
terminal.say " shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)"
|
|
311
|
-
end
|
|
312
|
-
if g[:rehearsal] == :broken
|
|
313
|
-
terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
|
|
314
|
-
"hasn't written yet). Confirm before treating it as a defect."
|
|
315
|
-
end
|
|
316
|
-
render_gate_output(g, transcript_path)
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def truncated_cmd(cmd)
|
|
320
|
-
first, *rest = cmd.to_s.each_line.map(&:chomp)
|
|
321
|
-
clipped = first.length > CMD_LINE_MAX ? "#{first[0, CMD_LINE_MAX]}…" : first
|
|
322
|
-
rest.any? || clipped != first ? "#{clipped} …" : clipped
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
def render_gate_output(g, transcript_path)
|
|
326
|
-
combined = [g[:stdout], g[:stderr]].map { |s| s.to_s.rstrip }.reject(&:empty?).join("\n")
|
|
327
|
-
return if combined.empty?
|
|
328
|
-
|
|
329
|
-
lines = combined.lines(chomp: true)
|
|
330
|
-
if lines.size > OUTPUT_LINE_CAP
|
|
331
|
-
terminal.say lines.last(OUTPUT_LINE_CAP).join("\n")
|
|
332
|
-
terminal.say " … #{lines.size - OUTPUT_LINE_CAP} more line(s) elided — full output in #{terminal.path(transcript_path)}"
|
|
333
|
-
else
|
|
334
|
-
terminal.say lines.join("\n")
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
|
|
338
400
|
# I12/AC3-AC4: reports scope asymmetry only — never affects RED/GREEN/
|
|
339
401
|
# BROKEN, rehearse's exit code, or the stamp. A file outside every
|
|
340
402
|
# declared lane's touch set is flagged loudest: it's the one no lane
|
|
@@ -562,7 +624,7 @@ module Space::Architect
|
|
|
562
624
|
phase 20, "Build"
|
|
563
625
|
argument :iteration, required: true, desc: "Iteration name"
|
|
564
626
|
argument :space, required: false, desc: "Space identifier (default: $PWD)"
|
|
565
|
-
option :base, default: nil, desc: "Base ref override (default: project/<slug>
|
|
627
|
+
option :base, default: nil, desc: "Base ref override (default: project/<slug> fast-forward-synced from the repo's default branch first — diverged → refuse; --base skips the sync and cuts exactly the given ref)"
|
|
566
628
|
option :lane, default: nil, desc: "Provision only this lane (default: all declared lanes)"
|
|
567
629
|
option :force, type: :boolean, default: false, desc: "Clear and re-create a stale (unregistered) worktree directory"
|
|
568
630
|
|
|
@@ -575,6 +637,12 @@ module Space::Architect
|
|
|
575
637
|
if results.empty?
|
|
576
638
|
terminal.say "No declared lanes to provision for '#{iteration}'"
|
|
577
639
|
else
|
|
640
|
+
# Sync report first (the sync ran before any cut): one line per
|
|
641
|
+
# moved branch — repo, branch, new short tip. No-op syncs are
|
|
642
|
+
# nil here and print nothing.
|
|
643
|
+
results.filter_map { |r| r[:synced] }.uniq.each do |s|
|
|
644
|
+
terminal.say "#{s[:repo]}: fast-forwarded #{s[:branch]} to #{s[:sha][0, 8]}"
|
|
645
|
+
end
|
|
578
646
|
results.each do |r|
|
|
579
647
|
state = case r[:outcome]
|
|
580
648
|
when :created then "created"
|
|
@@ -753,6 +821,8 @@ module Space::Architect
|
|
|
753
821
|
end
|
|
754
822
|
|
|
755
823
|
class Gate < BaseCommand
|
|
824
|
+
include GateRendering
|
|
825
|
+
|
|
756
826
|
desc "Run the frozen Acceptance Criteria gate commands and report PASS/FAIL"
|
|
757
827
|
phase 32, "Judge"
|
|
758
828
|
argument :iteration, required: true, desc: "Iteration name"
|
|
@@ -765,22 +835,29 @@ module Space::Architect
|
|
|
765
835
|
render(store.find(space)) do |sp|
|
|
766
836
|
project = ArchitectProject.new(space: sp)
|
|
767
837
|
results = project.run_gates(iteration, lane: lane)
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
terminal.say " reason: #{r[:reason]}" if r[:status] == :fail && !r[:reason].to_s.empty?
|
|
774
|
-
terminal.say r[:stdout].rstrip unless r[:stdout].strip.empty?
|
|
775
|
-
terminal.say r[:stderr].rstrip unless r[:stderr].strip.empty?
|
|
776
|
-
end
|
|
838
|
+
# I07: the Gate CLI composes run_gates + gate_transcript — the
|
|
839
|
+
# transcript is the durable record a judge reads whole, so the
|
|
840
|
+
# bounded terminal output can always point at it.
|
|
841
|
+
transcript = project.gate_transcript(iteration, results, lane: lane)
|
|
842
|
+
results.each { |r| render_gate(r, transcript) }
|
|
777
843
|
terminal.say ""
|
|
778
844
|
terminal.say "Mechanical gate results above; the Acceptance-Criteria verdict — necessary, not sufficient — remains the architect's."
|
|
845
|
+
terminal.say "transcript: #{terminal.path(transcript)}"
|
|
779
846
|
any_fail = results.any? { |r| r[:status] == :fail }
|
|
780
847
|
CLI.record_outcome(Outcome.new(exit_code: any_fail ? 1 : 0))
|
|
781
848
|
end
|
|
782
849
|
end
|
|
783
850
|
end
|
|
851
|
+
|
|
852
|
+
private
|
|
853
|
+
|
|
854
|
+
# GateRendering hooks: gate renders the mechanical verdict, shows the
|
|
855
|
+
# reason only on a fail, and adds no advisory of its own.
|
|
856
|
+
def gate_verdict(g) = g[:status] == :pass ? "PASS" : "FAIL"
|
|
857
|
+
|
|
858
|
+
def gate_reason_visible?(g) = g[:status] == :fail
|
|
859
|
+
|
|
860
|
+
def render_gate_extras(g) = nil
|
|
784
861
|
end
|
|
785
862
|
|
|
786
863
|
class BugReport < BaseCommand
|
|
@@ -813,6 +890,56 @@ module Space::Architect
|
|
|
813
890
|
end
|
|
814
891
|
end
|
|
815
892
|
|
|
893
|
+
# Pretty-print a pi agent run.jsonl as it streams — the ported runlog
|
|
894
|
+
# printer. No space resolution: the stream is the whole input.
|
|
895
|
+
class Runlog < BaseCommand
|
|
896
|
+
desc "Pretty-print a pi agent run.jsonl as it streams (thinking dim, tool calls one-lined)"
|
|
897
|
+
phase 54, "Project"
|
|
898
|
+
argument :file, required: false, desc: "run.jsonl to read (default: stdin)"
|
|
899
|
+
|
|
900
|
+
def call(file: nil, **opts)
|
|
901
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
902
|
+
handle_errors do
|
|
903
|
+
if file
|
|
904
|
+
begin
|
|
905
|
+
File.open(file) { |f| Space::Architect::Runlog.stream(input: f, io: out, env: project_config.env) }
|
|
906
|
+
rescue Errno::ENOENT
|
|
907
|
+
raise Space::Core::Error, "file not found: #{file}"
|
|
908
|
+
end
|
|
909
|
+
else
|
|
910
|
+
Space::Architect::Runlog.stream(input: $stdin, io: out, env: project_config.env)
|
|
911
|
+
end
|
|
912
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
# Follow every active pi-subagents run transcript in a space, live — the
|
|
918
|
+
# ported looplog follower. BLOCKS until ctrl+c (quiet exit).
|
|
919
|
+
class Looplog < BaseCommand
|
|
920
|
+
desc "Follow every active pi-subagents run in this space, live (--list inventories only)"
|
|
921
|
+
phase 54, "Project"
|
|
922
|
+
argument :space, required: false, desc: "Space identifier (default: nearest ancestor with space.yaml)"
|
|
923
|
+
option :list, type: :flag, default: false, desc: "Inventory only — one line per run (active/age/size/agent/task), no follow"
|
|
924
|
+
option :replay, type: :flag, default: false, desc: "Print existing history on adoption, then follow"
|
|
925
|
+
option :all, type: :flag, default: false, desc: "Adopt every transcript, however stale (default: only within LOOPLOG_WINDOW seconds)"
|
|
926
|
+
|
|
927
|
+
def call(space: nil, list: false, replay: false, all: false, **opts)
|
|
928
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
929
|
+
handle_errors do
|
|
930
|
+
render(store.find(space)) do |sp|
|
|
931
|
+
if list
|
|
932
|
+
Space::Architect::Looplog.list(space: sp.path.to_s, io: out, env: project_config.env)
|
|
933
|
+
else
|
|
934
|
+
Space::Architect::Looplog.follow(space: sp.path.to_s, io: out, env: project_config.env,
|
|
935
|
+
replay: replay, all: all)
|
|
936
|
+
end
|
|
937
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
|
|
816
943
|
# Read-only loop accounting: spend and speed from the pi session
|
|
817
944
|
# artifacts already on disk (run metas + interactive/runner session
|
|
818
945
|
# files), stdout only. All view math lives in LoopCost; this class only
|
|
@@ -995,6 +1122,24 @@ module Space::Architect
|
|
|
995
1122
|
end
|
|
996
1123
|
end
|
|
997
1124
|
|
|
1125
|
+
# I07: install the architect-loop pi extension package via pi's own
|
|
1126
|
+
# mechanics — the gem owns the standing declaration (PACKAGE_SOURCE), pi
|
|
1127
|
+
# owns settings.json. No space resolution: a machine-level operation.
|
|
1128
|
+
class InstallExtensions < BaseCommand
|
|
1129
|
+
desc "Install the architect-loop pi extension package via pi (idempotent by pi's semantics)"
|
|
1130
|
+
phase 53, "Project"
|
|
1131
|
+
|
|
1132
|
+
def call(**opts)
|
|
1133
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
1134
|
+
handle_errors do
|
|
1135
|
+
result = ExtensionInstaller.install(env: project_config.env)
|
|
1136
|
+
result[:steps].each { |s| terminal.say " #{s[:cmd]} (exit #{s[:exit_code]})" }
|
|
1137
|
+
terminal.say "Extensions source: #{result[:source]}"
|
|
1138
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
1139
|
+
end
|
|
1140
|
+
end
|
|
1141
|
+
end
|
|
1142
|
+
|
|
998
1143
|
module Worktree
|
|
999
1144
|
class Add < BaseCommand
|
|
1000
1145
|
desc "Create a worktree for a lane"
|
|
@@ -1438,6 +1583,7 @@ Space::Architect::CLI::Registry.register "init", Space::Architect::CLI::Archit
|
|
|
1438
1583
|
Space::Architect::CLI::Registry.register "ground", Space::Architect::CLI::Architect::Ground
|
|
1439
1584
|
Space::Architect::CLI::Registry.register "new", Space::Architect::CLI::Architect::New
|
|
1440
1585
|
Space::Architect::CLI::Registry.register "status", Space::Architect::CLI::Architect::Status
|
|
1586
|
+
Space::Architect::CLI::Registry.register "push", Space::Architect::CLI::Architect::Push
|
|
1441
1587
|
Space::Architect::CLI::Registry.register "sync", Space::Architect::CLI::Architect::Sync
|
|
1442
1588
|
Space::Architect::CLI::Registry.register "rehearse", Space::Architect::CLI::Architect::Rehearse
|
|
1443
1589
|
Space::Architect::CLI::Registry.register "freeze", Space::Architect::CLI::Architect::Freeze
|
|
@@ -1451,6 +1597,9 @@ Space::Architect::CLI::Registry.register "merge", Space::Architect::CLI::Arc
|
|
|
1451
1597
|
Space::Architect::CLI::Registry.register "integrate", Space::Architect::CLI::Architect::Integrate
|
|
1452
1598
|
Space::Architect::CLI::Registry.register "gate", Space::Architect::CLI::Architect::Gate
|
|
1453
1599
|
Space::Architect::CLI::Registry.register "install-skills", Space::Architect::CLI::Architect::InstallSkills
|
|
1600
|
+
Space::Architect::CLI::Registry.register "install-extensions", Space::Architect::CLI::Architect::InstallExtensions
|
|
1601
|
+
Space::Architect::CLI::Registry.register "runlog", Space::Architect::CLI::Architect::Runlog
|
|
1602
|
+
Space::Architect::CLI::Registry.register "looplog", Space::Architect::CLI::Architect::Looplog
|
|
1454
1603
|
Space::Architect::CLI::Registry.register "bug-report", Space::Architect::CLI::Architect::BugReport
|
|
1455
1604
|
Space::Architect::CLI::Registry.register "cost", Space::Architect::CLI::Architect::Cost
|
|
1456
1605
|
Space::Architect::CLI::Registry.register "brief" do |b|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Space
|
|
6
|
+
module Architect
|
|
7
|
+
# Installs the architect-loop pi extension package through pi's own
|
|
8
|
+
# mechanics. The gem owns the standing declaration (PACKAGE_SOURCE) and the
|
|
9
|
+
# two-step sequence; pi owns settings.json and the rest — idempotent by
|
|
10
|
+
# pi's semantics, safe to re-run.
|
|
11
|
+
module ExtensionInstaller
|
|
12
|
+
PACKAGE_SOURCE = "git:git@github.com:jetpks/pi-extensions.git"
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Runs `<pi_bin> install PACKAGE_SOURCE` then
|
|
16
|
+
# `<pi_bin> update --extension PACKAGE_SOURCE`, in order, aborting on
|
|
17
|
+
# the first failure. --extension (singular) on purpose: the gem
|
|
18
|
+
# reconciles exactly the package it declares — not the operator's other
|
|
19
|
+
# packages. Returns { source:, steps: [{cmd:, exit_code:, output:}] }.
|
|
20
|
+
def install(pi_bin: "pi", env: ENV)
|
|
21
|
+
steps = [
|
|
22
|
+
run_step(pi_bin, ["install", PACKAGE_SOURCE], env),
|
|
23
|
+
run_step(pi_bin, ["update", "--extension", PACKAGE_SOURCE], env)
|
|
24
|
+
]
|
|
25
|
+
{ source: PACKAGE_SOURCE, steps: steps }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def run_step(pi_bin, args, env)
|
|
31
|
+
cmd = [pi_bin, *args].join(" ")
|
|
32
|
+
out, err, status = Open3.capture3(env, pi_bin, *args)
|
|
33
|
+
output = out + err
|
|
34
|
+
raise Space::Core::Error, "#{cmd} failed (exit #{status.exitstatus}):\n#{output}" unless status.success?
|
|
35
|
+
|
|
36
|
+
{ cmd: cmd, exit_code: status.exitstatus, output: output }
|
|
37
|
+
rescue Errno::ENOENT
|
|
38
|
+
raise Space::Core::Error,
|
|
39
|
+
"#{pi_bin}: command not found — '#{cmd}' could not run. Install pi or check your PATH."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|