space-architect 7.0.0 → 7.1.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 +34 -0
- data/lib/space_architect/architect_project.rb +88 -20
- data/lib/space_architect/cli/architect.rb +65 -9
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +7 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 17559f544d11b36996a3e8f927a8e4c9d2db3f178687c726fee14793c7ec6de4
|
|
4
|
+
data.tar.gz: 5b7c00fc0748f05e5c557339d011f3583816399a2261c79ae45e93e3f9e78ae5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db1f7cf0ed0ce5401483b7f3e2e429e5caf2200d1baeb450940f6afddd017744bd449a5b05dc2cfbbfc70787967ec01b6542adbd301c69e470917f6b8887f41c
|
|
7
|
+
data.tar.gz: b1c4fb81c6eeb1c9158e77a4e75aa21e01d4d77ac203e448c8ba564c76c2977bff9959fb481f480858a647f52262f3bcc516743220f6113d8a17c3f614f0f5fc
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ 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
|
+
## [7.1.0] - 2026-08-16
|
|
9
|
+
|
|
10
|
+
One iteration of the Architect Loop (I01, space
|
|
11
|
+
20260816-architect-rehearse-friction) closing the rehearse rerun tax: a heavy
|
|
12
|
+
rehearsal now yields its verdict vector, per-gate reasons, and full raw output
|
|
13
|
+
from one execution plus file reads.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`architect rehearse` writes a durable transcript and prints a bounded
|
|
18
|
+
summary.** Previously the CLI wrote an unbounded report to stdout only, so
|
|
19
|
+
every re-view of a run — a tail window, a grep filter, a verdict count —
|
|
20
|
+
re-ran the whole gate suite. Every non-EMPTY rehearsal now writes its full
|
|
21
|
+
report (each gate's command, dir, exit code, verdict, reason, complete
|
|
22
|
+
stdout+stderr, and scope-asymmetry findings) to a uniquely named file under
|
|
23
|
+
`build/rehearse/` and prints that path; the terminal report itself is now
|
|
24
|
+
bounded — one line per gate carrying its AC label, gate id, verdict, and
|
|
25
|
+
exit code regardless of how many lines the command spans, inline output
|
|
26
|
+
capped to the last 20 lines with an elision marker naming the transcript —
|
|
27
|
+
and always ends with a summary block (one line per gate, a tally by
|
|
28
|
+
verdict, and the transcript path) positioned last, so a `tail` of stdout
|
|
29
|
+
captures the whole outcome.
|
|
30
|
+
- **`execute_gates` dedups identical executions.** Gates that share an
|
|
31
|
+
identical `(cmd, resolved dir)` pair now execute once; each is still
|
|
32
|
+
evaluated against its own `expect` and classified independently, the
|
|
33
|
+
group's effective timeout is the largest any member declares, and the
|
|
34
|
+
report/transcript name the shared execution so byte-identical output
|
|
35
|
+
doesn't read as a copy-paste bug. `run_gates` (judge time) shares this for
|
|
36
|
+
free — dedup lives in `execute_gates`, the one instrument both call.
|
|
37
|
+
- **docs/RELEASING.md.** The release process, retraced from the v7.0.0
|
|
38
|
+
landing: preconditions, the release-prep commit, the human-run PR/merge and
|
|
39
|
+
signed-tag steps, what the tag-push automation covers, and local-install
|
|
40
|
+
verification (including the stale-binstub PATH hazard).
|
|
41
|
+
|
|
8
42
|
## [7.0.0] - 2026-08-14
|
|
9
43
|
|
|
10
44
|
Three iterations of the Architect Loop run against this repo's own tooling, each
|
|
@@ -595,16 +595,17 @@ module Space::Architect
|
|
|
595
595
|
text = path.read
|
|
596
596
|
|
|
597
597
|
gates = parse_gates(text)
|
|
598
|
-
repo, base_dir, gate_results, scope_report =
|
|
598
|
+
repo, base_dir, gate_results, scope_report, transcript =
|
|
599
599
|
if gates.empty?
|
|
600
|
-
[nil, nil, [], nil]
|
|
600
|
+
[nil, nil, [], nil, nil]
|
|
601
601
|
else
|
|
602
602
|
r = resolve_rehearsal_repo(iteration, text, gates)
|
|
603
603
|
dir = r ? space.path.join("repos", r) : nil
|
|
604
604
|
raise Space::Core::Error, "directory does not exist: #{dir}" if dir && !dir.exist?
|
|
605
605
|
results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil)
|
|
606
606
|
.map { |g| g.merge(rehearsal: classify_rehearsal(g)) }
|
|
607
|
-
|
|
607
|
+
scope = scope_asymmetry_report(results, text)
|
|
608
|
+
[r, dir, results, scope, write_rehearsal_transcript(entry, r, dir, results, scope, now)]
|
|
608
609
|
end
|
|
609
610
|
|
|
610
611
|
digest = gates_digest(text)
|
|
@@ -617,7 +618,8 @@ module Space::Architect
|
|
|
617
618
|
end
|
|
618
619
|
|
|
619
620
|
{ iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results,
|
|
620
|
-
empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report
|
|
621
|
+
empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report,
|
|
622
|
+
transcript: transcript }
|
|
621
623
|
end
|
|
622
624
|
|
|
623
625
|
# Emit grounding reads for the architect's SessionStart hook.
|
|
@@ -1252,28 +1254,35 @@ module Space::Architect
|
|
|
1252
1254
|
# same shell (capture_with_timeout's Process.spawn), same GateEvaluator,
|
|
1253
1255
|
# same timeout handling (I09/AC3). repo_root/lane are nil outside a lane
|
|
1254
1256
|
# context (rehearsal never has one — it always runs in the repo checkout).
|
|
1257
|
+
#
|
|
1258
|
+
# I01: gates sharing an identical (resolved cmd, resolved dir) pair execute
|
|
1259
|
+
# once; the shared capture is fanned back out to every member, each still
|
|
1260
|
+
# evaluated against its own `expect` and classified independently. A
|
|
1261
|
+
# group's effective timeout is the largest any member declares, so no
|
|
1262
|
+
# duplicate is cut short by another's shorter budget.
|
|
1255
1263
|
def execute_gates(gates, base_dir:, lane:, repo_root:)
|
|
1256
|
-
gates.map do |gate|
|
|
1264
|
+
resolved = gates.map do |gate|
|
|
1257
1265
|
g = gate.transform_keys(&:to_s)
|
|
1258
|
-
dir =
|
|
1259
|
-
if (cwd = g["cwd"])
|
|
1260
|
-
gate_cwd = space.path.join(cwd)
|
|
1261
|
-
if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
|
|
1262
|
-
base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
|
|
1263
|
-
else
|
|
1264
|
-
gate_cwd
|
|
1265
|
-
end
|
|
1266
|
-
else
|
|
1267
|
-
base_dir
|
|
1268
|
-
end
|
|
1266
|
+
dir = resolve_gate_dir(g, base_dir: base_dir, lane: lane, repo_root: repo_root)
|
|
1269
1267
|
raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
|
|
1268
|
+
[g, dir]
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
groups = resolved.group_by { |g, dir| [g["cmd"], dir.to_s] }
|
|
1272
|
+
captures = groups.transform_values do |members|
|
|
1273
|
+
timeout = members.map { |g, _| g["timeout"] || DEFAULT_GATE_TIMEOUT }.max
|
|
1274
|
+
sample_g, dir = members.first
|
|
1275
|
+
capture_with_timeout(sample_g["cmd"], dir: dir, timeout: timeout).merge(timeout: timeout)
|
|
1276
|
+
end
|
|
1270
1277
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1278
|
+
resolved.map do |g, dir|
|
|
1279
|
+
key = [g["cmd"], dir.to_s]
|
|
1280
|
+
captured = captures[key]
|
|
1281
|
+
siblings = groups[key].map { |gg, _| gg["id"] } - [g["id"]]
|
|
1273
1282
|
|
|
1274
1283
|
if captured[:timed_out]
|
|
1275
1284
|
status = :fail
|
|
1276
|
-
reason = "timed out after #{
|
|
1285
|
+
reason = "timed out after #{captured[:timeout]}s"
|
|
1277
1286
|
else
|
|
1278
1287
|
ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
|
|
1279
1288
|
status = ev.pass? ? :pass : :fail
|
|
@@ -1282,10 +1291,69 @@ module Space::Architect
|
|
|
1282
1291
|
|
|
1283
1292
|
{ id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
|
|
1284
1293
|
stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
|
|
1285
|
-
dir: dir, status: status, reason: reason, timed_out: captured[:timed_out]
|
|
1294
|
+
dir: dir, status: status, reason: reason, timed_out: captured[:timed_out],
|
|
1295
|
+
shared_with: siblings }
|
|
1296
|
+
end
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
# The directory a single gate executes in: its own `cwd` (remapped into the
|
|
1300
|
+
# lane worktree when it falls under repo_root) or the run's base_dir.
|
|
1301
|
+
def resolve_gate_dir(g, base_dir:, lane:, repo_root:)
|
|
1302
|
+
return base_dir unless (cwd = g["cwd"])
|
|
1303
|
+
|
|
1304
|
+
gate_cwd = space.path.join(cwd)
|
|
1305
|
+
if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
|
|
1306
|
+
base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
|
|
1307
|
+
else
|
|
1308
|
+
gate_cwd
|
|
1286
1309
|
end
|
|
1287
1310
|
end
|
|
1288
1311
|
|
|
1312
|
+
# I01: the durable, unbounded counterpart to the CLI's bounded terminal
|
|
1313
|
+
# report — one file per non-EMPTY rehearsal under build/rehearse/, holding
|
|
1314
|
+
# every gate's full command, dir, exit code, verdict, reason, and complete
|
|
1315
|
+
# stdout+stderr, plus the scope-asymmetry findings. The CLI prints this
|
|
1316
|
+
# path so a rerun is never needed just to see output the terminal trimmed.
|
|
1317
|
+
def write_rehearsal_transcript(entry, repo, base_dir, gate_results, scope_report, now)
|
|
1318
|
+
dir = space.path.join("build", "rehearse")
|
|
1319
|
+
FileUtils.mkdir_p(dir)
|
|
1320
|
+
path = dir.join("#{iteration_id(entry)}-#{now.strftime('%Y%m%dT%H%M%S.%6N')}.md")
|
|
1321
|
+
|
|
1322
|
+
lines = ["# Rehearsal transcript — #{entry['name']} — #{now.iso8601}"]
|
|
1323
|
+
lines << (repo ? "Repo: #{repo} (#{base_dir})" : "Each gate ran in its own declared cwd")
|
|
1324
|
+
|
|
1325
|
+
gate_results.each do |g|
|
|
1326
|
+
lines << ""
|
|
1327
|
+
lines << "## #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} — #{g[:rehearsal].to_s.upcase} (exit #{g[:exit_code].inspect})"
|
|
1328
|
+
lines << "cmd: #{g[:cmd]}"
|
|
1329
|
+
lines << "dir: #{g[:dir]}"
|
|
1330
|
+
lines << "reason: #{g[:reason]}" unless g[:reason].to_s.empty?
|
|
1331
|
+
lines << "shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)" if g[:shared_with]&.any?
|
|
1332
|
+
lines << "--- stdout ---"
|
|
1333
|
+
lines << g[:stdout].to_s
|
|
1334
|
+
lines << "--- stderr ---"
|
|
1335
|
+
lines << g[:stderr].to_s
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1338
|
+
if scope_report && (scope_report[:findings].any? || scope_report[:not_analyzable].any?)
|
|
1339
|
+
lines << ""
|
|
1340
|
+
lines << "## Scope-asymmetry check (grep-family gates only)"
|
|
1341
|
+
scope_report[:findings].each do |f|
|
|
1342
|
+
lines << "── #{f[:id]} (#{f[:repo]}): pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
|
|
1343
|
+
if f[:outside_lanes].any?
|
|
1344
|
+
lines << "OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
|
|
1345
|
+
f[:outside_lanes].each { |file| lines << " #{file}" }
|
|
1346
|
+
end
|
|
1347
|
+
lines << "also matches (within a declared lane's touch set): #{f[:within_lanes].join(', ')}" if f[:within_lanes].any?
|
|
1348
|
+
lines << "0 files elsewhere match" if f[:outside_lanes].empty? && f[:within_lanes].empty?
|
|
1349
|
+
end
|
|
1350
|
+
scope_report[:not_analyzable].each { |na| lines << "not analyzed — #{na[:id]}: #{na[:reason]}" }
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
path.write("#{lines.join("\n")}\n")
|
|
1354
|
+
path
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1289
1357
|
# I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
|
|
1290
1358
|
# `cwd` of their own — only those gates need one, and only they are ambiguous
|
|
1291
1359
|
# when the drafted ```lanes``` block names more than one repo. A gate with its
|
|
@@ -262,17 +262,29 @@ module Space::Architect
|
|
|
262
262
|
|
|
263
263
|
private
|
|
264
264
|
|
|
265
|
+
# I01: inline output is bounded — one identity line per gate, ≤ 20
|
|
266
|
+
# lines of tail output — because the full report always lands in the
|
|
267
|
+
# transcript #render_gate points at. The summary block is emitted last
|
|
268
|
+
# (after the disclaimer) so a `tail` of stdout captures it whole.
|
|
265
269
|
def render_rehearsal(result)
|
|
266
270
|
if result[:empty]
|
|
267
271
|
reason = result[:placeholder] ? "the scaffold placeholder '#{ArchitectProject::AC1_PLACEHOLDER}' with no active gate" : "no gates drafted yet"
|
|
268
272
|
terminal.say "EMPTY — #{reason}. Nothing to rehearse; the pre-freeze look is still stamped."
|
|
269
|
-
|
|
270
|
-
terminal.say
|
|
271
|
-
|
|
272
|
-
render_scope_asymmetry(result[:scope_asymmetry])
|
|
273
|
+
terminal.say ""
|
|
274
|
+
terminal.say discrimination_note
|
|
275
|
+
return
|
|
273
276
|
end
|
|
277
|
+
|
|
278
|
+
terminal.say rehearsal_header(result)
|
|
279
|
+
result[:gates].each { |g| render_gate(g, result[:transcript]) }
|
|
280
|
+
render_scope_asymmetry(result[:scope_asymmetry])
|
|
274
281
|
terminal.say ""
|
|
275
|
-
terminal.say
|
|
282
|
+
terminal.say discrimination_note
|
|
283
|
+
render_summary(result)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def discrimination_note
|
|
287
|
+
"Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
|
|
276
288
|
end
|
|
277
289
|
|
|
278
290
|
# #90/AC7: with a single defaultable repo, name it as before; a cross-repo
|
|
@@ -286,17 +298,45 @@ module Space::Architect
|
|
|
286
298
|
end
|
|
287
299
|
end
|
|
288
300
|
|
|
289
|
-
|
|
301
|
+
# I01/AC "bounded terminal report": identity (AC label, gate id, verdict,
|
|
302
|
+
# exit code) always lands on one stdout line, however many lines `cmd`
|
|
303
|
+
# spans — the command itself is shown as at most its first line,
|
|
304
|
+
# truncated; the full command lives in the transcript.
|
|
305
|
+
CMD_LINE_MAX = 100
|
|
306
|
+
OUTPUT_LINE_CAP = 20
|
|
307
|
+
|
|
308
|
+
def render_gate(g, transcript_path)
|
|
290
309
|
terminal.say ""
|
|
291
|
-
terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]}: #{g[:cmd]} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
|
|
310
|
+
terminal.say "── #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]}: #{truncated_cmd(g[:cmd])} (exit #{g[:exit_code].inspect}) [#{g[:rehearsal].to_s.upcase}]"
|
|
292
311
|
terminal.say " dir: #{terminal.path(g[:dir])}"
|
|
293
312
|
terminal.say " reason: #{g[:reason]}" unless g[:reason].to_s.empty?
|
|
313
|
+
if g[:shared_with]&.any?
|
|
314
|
+
terminal.say " shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)"
|
|
315
|
+
end
|
|
294
316
|
if g[:rehearsal] == :broken
|
|
295
317
|
terminal.say " BROKEN is advisory, not certain — a correct RED can look broken (e.g. a file the lane " \
|
|
296
318
|
"hasn't written yet). Confirm before treating it as a defect."
|
|
297
319
|
end
|
|
298
|
-
|
|
299
|
-
|
|
320
|
+
render_gate_output(g, transcript_path)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def truncated_cmd(cmd)
|
|
324
|
+
first, *rest = cmd.to_s.each_line.map(&:chomp)
|
|
325
|
+
clipped = first.length > CMD_LINE_MAX ? "#{first[0, CMD_LINE_MAX]}…" : first
|
|
326
|
+
rest.any? || clipped != first ? "#{clipped} …" : clipped
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def render_gate_output(g, transcript_path)
|
|
330
|
+
combined = [g[:stdout], g[:stderr]].map { |s| s.to_s.rstrip }.reject(&:empty?).join("\n")
|
|
331
|
+
return if combined.empty?
|
|
332
|
+
|
|
333
|
+
lines = combined.lines(chomp: true)
|
|
334
|
+
if lines.size > OUTPUT_LINE_CAP
|
|
335
|
+
terminal.say lines.last(OUTPUT_LINE_CAP).join("\n")
|
|
336
|
+
terminal.say " … #{lines.size - OUTPUT_LINE_CAP} more line(s) elided — full output in #{terminal.path(transcript_path)}"
|
|
337
|
+
else
|
|
338
|
+
terminal.say lines.join("\n")
|
|
339
|
+
end
|
|
300
340
|
end
|
|
301
341
|
|
|
302
342
|
# I12/AC3-AC4: reports scope asymmetry only — never affects RED/GREEN/
|
|
@@ -325,6 +365,22 @@ module Space::Architect
|
|
|
325
365
|
report[:not_analyzable].each { |na| terminal.say " #{na[:id]}: #{na[:reason]}" }
|
|
326
366
|
end
|
|
327
367
|
|
|
368
|
+
# I01: always-on, positioned last so a `tail` of stdout captures it
|
|
369
|
+
# whole — one line per gate (AC label, gate id, verdict; reason
|
|
370
|
+
# appended for RED/BROKEN), a tally by verdict, and the transcript path.
|
|
371
|
+
def render_summary(result)
|
|
372
|
+
terminal.say ""
|
|
373
|
+
terminal.say "Summary:"
|
|
374
|
+
result[:gates].each do |g|
|
|
375
|
+
line = " #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} [#{g[:rehearsal].to_s.upcase}]"
|
|
376
|
+
line += " — #{g[:reason]}" if %i[red broken].include?(g[:rehearsal]) && !g[:reason].to_s.empty?
|
|
377
|
+
terminal.say line
|
|
378
|
+
end
|
|
379
|
+
tally = result[:gates].group_by { |g| g[:rehearsal] }.transform_values(&:size)
|
|
380
|
+
terminal.say " #{%i[green red broken].filter_map { |v| "#{v.to_s.upcase}: #{tally[v]}" if tally[v] }.join(', ')}"
|
|
381
|
+
terminal.say " transcript: #{terminal.path(result[:transcript])}"
|
|
382
|
+
end
|
|
383
|
+
|
|
328
384
|
def render_record(result)
|
|
329
385
|
return "> Rehearsed #{result[:iteration]} — no gates drafted; nothing to record." if result[:empty]
|
|
330
386
|
|
data/lib/space_core/version.rb
CHANGED
data/skill/architect/SKILL.md
CHANGED
|
@@ -381,9 +381,13 @@ architect-read.
|
|
|
381
381
|
that measures nothing; **BROKEN**, a 127 / syntax error / timeout —
|
|
382
382
|
advisory, because a correct RED can look broken (`grep -q x` on a file the
|
|
383
383
|
lane will write exits 2): the tool names the suspicion, you confirm;
|
|
384
|
-
**EMPTY**, no gates or an untouched placeholder.
|
|
385
|
-
|
|
386
|
-
|
|
384
|
+
**EMPTY**, no gates or an untouched placeholder. Every non-EMPTY run writes
|
|
385
|
+
a full transcript (every gate's command, dir, exit code, verdict, reason,
|
|
386
|
+
complete stdout+stderr) under `build/rehearse/`, prints its path, and ends
|
|
387
|
+
with a bounded summary — one line per gate plus a tally — so a rerun is
|
|
388
|
+
never needed just to re-see output the terminal already trimmed. `--record`
|
|
389
|
+
emits a paste-able provenance block for the AC preamble. It reports; which
|
|
390
|
+
GREENs are guards and which are defects stays your call.
|
|
387
391
|
|
|
388
392
|
Then run `architect freeze <name>`. Freeze requires a fresh rehearsal stamp —
|
|
389
393
|
`rehearse` records one in `space.yaml`, keyed to the gates block's content, so
|