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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d521b0afec00484bfba5b35e26e9f0a603807fb75f3569fa61b0a0b5ff9b3ab
4
- data.tar.gz: d21ecd2d8dd8458b0ec2e89d4cc2ee45a5a70eafc53955b8d2b3c365b55efe3b
3
+ metadata.gz: 17559f544d11b36996a3e8f927a8e4c9d2db3f178687c726fee14793c7ec6de4
4
+ data.tar.gz: 5b7c00fc0748f05e5c557339d011f3583816399a2261c79ae45e93e3f9e78ae5
5
5
  SHA512:
6
- metadata.gz: e27a4154359d6f41370287eb86eeb2881d415dd0ce7e5d6c3c3f241ff338f168a7393456d63faa5e3970e3a72eb927ae4db13d4f407e50e320d90ed54c79dc7e
7
- data.tar.gz: 024047dcd39a0de008284fc256c3f7fb08ea8585e0cd0cae53f0e990b70f9e47341325cf92668ef632bf76ef2e34e5d082ccd98eba94af52c4f659c251afd9f8
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
- [r, dir, results, scope_asymmetry_report(results, text)]
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
- effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
1272
- captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
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 #{effective}s"
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
- else
270
- terminal.say rehearsal_header(result)
271
- result[:gates].each { |g| render_gate(g) }
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 "Discrimination report only — this runs and reports; it never judges whether these gates are good, bad, or ready."
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
- def render_gate(g)
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
- terminal.say g[:stdout].rstrip unless g[:stdout].strip.empty?
299
- terminal.say g[:stderr].rstrip unless g[:stderr].strip.empty?
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "7.0.0"
5
+ VERSION = "7.1.0"
6
6
  end
7
7
  end
@@ -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. `--record` emits a
385
- paste-able provenance block for the AC preamble. It reports; which GREENs
386
- are guards and which are defects stays your call.
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
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: 7.0.0
4
+ version: 7.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs