space-architect 7.0.0 → 8.0.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: b63abfece4c105ef5b2befd0d8c4e4301d8d5cade10570cd2f41bd0641b8c4be
4
+ data.tar.gz: 6b932ae13952dad48f81a2ee68ed52b7cee28ef8edc4a99e7a06f23952c4f107
5
5
  SHA512:
6
- metadata.gz: e27a4154359d6f41370287eb86eeb2881d415dd0ce7e5d6c3c3f241ff338f168a7393456d63faa5e3970e3a72eb927ae4db13d4f407e50e320d90ed54c79dc7e
7
- data.tar.gz: 024047dcd39a0de008284fc256c3f7fb08ea8585e0cd0cae53f0e990b70f9e47341325cf92668ef632bf76ef2e34e5d082ccd98eba94af52c4f659c251afd9f8
6
+ metadata.gz: 3cbb55f4fab850bc9fb7b4cf55bb318bf846d54c3f78a399c50b76d325ddbcd12ad52072d224d75a25b6e5830c8fffa6d7e6f51ad522aae231bb2b374ac3980e
7
+ data.tar.gz: 4adfa615512c8f6652b85d5948c0a58c9851d30711fc2a1a7f870060601bb6fe0fab9af4ade0c8eebfb5e93a67bf60d1d6516e12fcd0ba96595d530049e0d0da
data/CHANGELOG.md CHANGED
@@ -5,6 +5,94 @@ 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
+ ## [8.0.0] - 2026-10-09
9
+
10
+ ### Changed
11
+
12
+ - **pi is the only dispatch harness.** The `claude-code` and `opencode`
13
+ backends are deleted, along with everything that only served them: the
14
+ per-harness `DEFAULT_MODELS` map (one `Harness::DEFAULT_MODEL`:
15
+ `accounts/fireworks/models/glm-5p3-flash`), the three-way thinking-level
16
+ dispatch, the `--allowed-tools`/`--append-allowed-tools` flags and their
17
+ #89 resolution machinery (the frozen lanes block records `name`/`repo`/
18
+ `touch` only), `dispatch --as-job` with its `--host`/`--token`/
19
+ `--backend-url`/`--job-model`/`--api-key-ref` flags, and the backward-
20
+ compat `Dispatcher` wrapper. The `Harness.for` seam stays: any name but
21
+ `pi` — including a stored legacy harness in an old space.yaml — raises an
22
+ actionable error naming `pi` as the only valid harness. The push kwargs
23
+ (`--push-url`/`--push-token`/`--push-host`) and `--quiet` now apply to pi
24
+ dispatches, with liveness lines parsed from pi's own JSONL stream.
25
+ - **`PiHarness` reaches parity with the old default backend:** a liveness
26
+ line per run (first assistant-role JSONL event carrying `model`; OK when
27
+ it matches the pin, WARN on no growth / no assistant model within the
28
+ budget / pinned-vs-streamed mismatch; incremental scan, `--quiet`
29
+ suppressible), stdout tee through a pipe to the run log and — when push
30
+ is wired — streamed to the ingest endpoint, and TERM→grace→KILL timeout
31
+ escalation unchanged.
32
+ - **A vendored builder-guard extension ships with every dispatch.**
33
+ `lib/space_architect/pi/builder-guard.ts` is copied byte-for-byte into the
34
+ run's build dir and injected via `pi -e`, denying git-write subcommands
35
+ (parsed per command segment — `git -C <path> commit` is caught, `git log
36
+ --grep=commit` passes) and `bash -n`-unparseable commands before they run.
37
+ The deny reason tells the builder what was denied and what to do instead;
38
+ the architect CLI owns all commits.
39
+ - **Research runs run on pi too.** The research supervisor constructs a
40
+ `PiHarness` per run (sessions under `build/research/<id>/`), passes the
41
+ same vendored guard, and defaults to the pi default model; read-only
42
+ remains prompt-enforced. `run.detached` research classification keys off
43
+ pi's terminal assistant `stopReason` instead of a stream-json `result`
44
+ event.
45
+
46
+ ### Fixed
47
+
48
+ - **`architect research wait` works on pi runs.** `Research::Mux` and
49
+ `Research::Renderer` now speak pi's JSONL event vocabulary natively (the
50
+ last claude-code stream-json dialect in the gem): the tail breaks on
51
+ `agent_settled`, `wait` classifies via the last assistant `stopReason` plus
52
+ `agent_settled.aborted` and the final assistant text (no final text →
53
+ `:failed` "no final assistant text"), and the report is the final assistant
54
+ message's joined text blocks. The shared pi-shape predicates live in one
55
+ new module, `Research::PiEvents` (last assistant message/stopReason, joined
56
+ text, run span from `session.timestamp` → last assistant `timestamp`,
57
+ `turn_end` count) — `Supervisor#classify` consumes it too. The renderer's
58
+ §5.3 verbosity ladder, `--thinking`, and `--jsonl` passthrough are
59
+ unchanged, fed by pi events (`message_end`, `tool_execution_start/end`)
60
+ with the terminal line built from native facts (no fabricated fields).
61
+
62
+ ## [7.1.0] - 2026-08-16
63
+
64
+ One iteration of the Architect Loop (I01, space
65
+ 20260816-architect-rehearse-friction) closing the rehearse rerun tax: a heavy
66
+ rehearsal now yields its verdict vector, per-gate reasons, and full raw output
67
+ from one execution plus file reads.
68
+
69
+ ### Added
70
+
71
+ - **`architect rehearse` writes a durable transcript and prints a bounded
72
+ summary.** Previously the CLI wrote an unbounded report to stdout only, so
73
+ every re-view of a run — a tail window, a grep filter, a verdict count —
74
+ re-ran the whole gate suite. Every non-EMPTY rehearsal now writes its full
75
+ report (each gate's command, dir, exit code, verdict, reason, complete
76
+ stdout+stderr, and scope-asymmetry findings) to a uniquely named file under
77
+ `build/rehearse/` and prints that path; the terminal report itself is now
78
+ bounded — one line per gate carrying its AC label, gate id, verdict, and
79
+ exit code regardless of how many lines the command spans, inline output
80
+ capped to the last 20 lines with an elision marker naming the transcript —
81
+ and always ends with a summary block (one line per gate, a tally by
82
+ verdict, and the transcript path) positioned last, so a `tail` of stdout
83
+ captures the whole outcome.
84
+ - **`execute_gates` dedups identical executions.** Gates that share an
85
+ identical `(cmd, resolved dir)` pair now execute once; each is still
86
+ evaluated against its own `expect` and classified independently, the
87
+ group's effective timeout is the largest any member declares, and the
88
+ report/transcript name the shared execution so byte-identical output
89
+ doesn't read as a copy-paste bug. `run_gates` (judge time) shares this for
90
+ free — dedup lives in `execute_gates`, the one instrument both call.
91
+ - **docs/RELEASING.md.** The release process, retraced from the v7.0.0
92
+ landing: preconditions, the release-prep commit, the human-run PR/merge and
93
+ signed-tag steps, what the tag-push automation covers, and local-install
94
+ verification (including the stale-binstub PATH hazard).
95
+
8
96
  ## [7.0.0] - 2026-08-14
9
97
 
10
98
  Three iterations of the Architect Loop run against this repo's own tooling, each
data/README.md CHANGED
@@ -343,7 +343,8 @@ architect integrate my-feature --lanes lane-a # merge passing lanes → proje
343
343
 
344
344
  ### Streaming builder output 📡
345
345
 
346
- `architect dispatch` can push the builder's stream-json to an ingest server for
346
+ `architect dispatch` can push the builder's pi JSONL event stream to an ingest
347
+ server for
347
348
  live viewing:
348
349
 
349
350
  ```sh
@@ -367,8 +368,9 @@ can be combined with `--detach` (the push tees the live pipe in-process).
367
368
  ### Research lanes 🔭
368
369
 
369
370
  When an iteration needs facts the repo doesn't already have, fan out parallel
370
- **read-only** research lanes — detached `claude -p` researchers (no
371
- Edit/Write/Bash) that you supervise:
371
+ **read-only** research lanes — detached `pi -p --mode json` researchers
372
+ (read-only by prompt contract; the supervisor-injected guard denies git writes)
373
+ that you supervise:
372
374
 
373
375
  ```sh
374
376
  architect research dispatch 01-official-api.prompt.md 02-changelog.prompt.md
@@ -241,13 +241,6 @@ module Space::Architect
241
241
  lanes = s["lanes"] || []
242
242
  declared.each do |d|
243
243
  fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
244
- # #89/AC3-AC4: the frozen lane's own tool grant, reviewable like every other
245
- # boundary. Declared-only, like touch_set — worktree_add never touches these
246
- # keys, so a re-materialize preserves them without threading them through
247
- # recorded_lane_fields (unlike harness/model, which worktree_add resolves and
248
- # overwrites on every call).
249
- fields["allowed_tools"] = d["allowed_tools"] if d["allowed_tools"]
250
- fields["append_allowed_tools"] = d["append_allowed_tools"] if d["append_allowed_tools"]
251
244
  existing = lanes.find { |l| l["name"] == d["name"] }
252
245
  existing ? existing.merge!(fields) : lanes << fields
253
246
  end
@@ -595,16 +588,17 @@ module Space::Architect
595
588
  text = path.read
596
589
 
597
590
  gates = parse_gates(text)
598
- repo, base_dir, gate_results, scope_report =
591
+ repo, base_dir, gate_results, scope_report, transcript =
599
592
  if gates.empty?
600
- [nil, nil, [], nil]
593
+ [nil, nil, [], nil, nil]
601
594
  else
602
595
  r = resolve_rehearsal_repo(iteration, text, gates)
603
596
  dir = r ? space.path.join("repos", r) : nil
604
597
  raise Space::Core::Error, "directory does not exist: #{dir}" if dir && !dir.exist?
605
598
  results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil)
606
599
  .map { |g| g.merge(rehearsal: classify_rehearsal(g)) }
607
- [r, dir, results, scope_asymmetry_report(results, text)]
600
+ scope = scope_asymmetry_report(results, text)
601
+ [r, dir, results, scope, write_rehearsal_transcript(entry, r, dir, results, scope, now)]
608
602
  end
609
603
 
610
604
  digest = gates_digest(text)
@@ -617,7 +611,8 @@ module Space::Architect
617
611
  end
618
612
 
619
613
  { iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results,
620
- empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report }
614
+ empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report,
615
+ transcript: transcript }
621
616
  end
622
617
 
623
618
  # Emit grounding reads for the architect's SessionStart hook.
@@ -839,7 +834,7 @@ module Space::Architect
839
834
  variants: variant_lanes.map do |l|
840
835
  {
841
836
  name: l["name"],
842
- harness: l["harness"] || "claude-code",
837
+ harness: l["harness"] || "pi",
843
838
  model: l["model"],
844
839
  effort: l["effort"],
845
840
  base_sha: l["base_sha"],
@@ -938,9 +933,8 @@ module Space::Architect
938
933
  end
939
934
  end
940
935
 
941
- def dispatch(iteration, lane, model: nil, max_turns: 200,
942
- claude_bin: nil, harness: nil, opencode_bin: nil, effort: nil,
943
- allowed_tools: nil, append_allowed_tools: nil, force: false, quiet: false,
936
+ def dispatch(iteration, lane, model: nil, max_turns: 200, bin: nil, harness: nil, effort: nil,
937
+ force: false, quiet: false,
944
938
  detach: false, push_url: nil, push_token: nil, push_host: nil, run_creator: nil,
945
939
  push_client: nil, timeout: nil, prompt: nil, now: Time.now)
946
940
  raise Space::Core::Error, "Specify --push-host or --push-url, not both" if push_host && push_url
@@ -957,12 +951,6 @@ module Space::Architect
957
951
 
958
952
  resolved_harness, resolved_model, resolved_effort =
959
953
  resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
960
- resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
961
- resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
962
- err.puts(tools_provenance) if replace_tools || append_tools
963
-
964
- raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
965
- if push_host && resolved_harness != "claude-code"
966
954
 
967
955
  id = iteration_id(entry)
968
956
  wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
@@ -977,31 +965,31 @@ module Space::Architect
977
965
  # and the CLI owns the canonical copy — byte-for-byte, like variant_add.
978
966
  copy_and_validate_prompt!(prompt, prompt_path)
979
967
 
980
- bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
968
+ # Vendor the builder guard into the lane's build dir (idempotent overwrite) and
969
+ # inject it via -e on every dispatch — blocking and detached alike. The guard
970
+ # denies git-write and bash-unparseable commands before evaluation; the builder
971
+ # itself never needs to remember to load it.
972
+ guard_path = build_dir.join("builder-guard.ts")
973
+ FileUtils.cp(vendored_guard_path, guard_path)
974
+
981
975
  harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin,
982
976
  config_dir: build_dir, effort: resolved_effort,
983
- allowed_tools: resolved_allowed_tools, force: force, err: err)
977
+ guard_path: guard_path, force: force, err: err)
984
978
 
985
- # Stamp launch time and the resolved harness/model/effort/allowed_tools onto the
979
+ # Stamp launch time and the resolved harness/model/effort onto the
986
980
  # lane entry: after every preflight validation has passed (a dispatch that raises
987
981
  # above records nothing) and before the blocking run or a detached dispatch
988
982
  # returns. A re-dispatch overwrites the prior values, so `architect status`
989
- # always reads what actually ran on the last dispatch. #89/AC8: the resolved
990
- # tool grant is recorded the same way, so a denial traces back to a grant a
991
- # human can read — replace_tools/append_tools are the pre-composition components
992
- # (not the harness's expanded ClaudeCodeHarness.resolve_tools result), so a later
993
- # bare re-dispatch re-resolves from the same components instead of compounding.
983
+ # always reads what actually ran on the last dispatch.
994
984
  update_architect_block do |b|
995
985
  (b["iterations"] || []).each do |s|
996
986
  next unless s["name"] == iteration
997
987
  (s["lanes"] || []).each do |l|
998
988
  next unless l["name"] == lane
999
- l["dispatched_at"] = now.iso8601
1000
- l["harness"] = resolved_harness
1001
- l["model"] = resolved_model
1002
- l["effort"] = resolved_effort if resolved_effort
1003
- l["allowed_tools"] = replace_tools if replace_tools
1004
- l["append_allowed_tools"] = append_tools if append_tools
989
+ l["dispatched_at"] = now.iso8601
990
+ l["harness"] = resolved_harness
991
+ l["model"] = resolved_model
992
+ l["effort"] = resolved_effort if resolved_effort
1005
993
  end
1006
994
  end
1007
995
  b
@@ -1026,96 +1014,30 @@ module Space::Architect
1026
1014
 
1027
1015
  run_kwargs = { prompt_path: prompt_path, run_log_path: run_log_path, chdir: wt_path }
1028
1016
  run_kwargs[:timeout] = timeout if timeout
1029
- if resolved_harness == "claude-code"
1030
- run_kwargs[:push_url] = push_url if push_url
1031
- run_kwargs[:push_token] = push_token if push_token
1032
- run_kwargs[:push_client] = push_client if push_client
1033
- run_kwargs[:err] = err if quiet
1034
- end
1017
+ run_kwargs[:push_url] = push_url if push_url
1018
+ run_kwargs[:push_token] = push_token if push_token
1019
+ run_kwargs[:push_client] = push_client if push_client
1020
+ run_kwargs[:err] = err if quiet
1035
1021
  exit_code = harness_obj.run(**run_kwargs)
1036
1022
 
1037
1023
  result = { exit_code: exit_code, run_log: run_log_path, report: report_path, worktree: wt_path }
1038
1024
  result[:prompt_copied] = prompt_path if prompt
1039
- result[:timed_out] = true if exit_code == Harness::ClaudeCodeHarness::TIMEOUT_EXIT_CODE
1025
+ result[:timed_out] = true if exit_code == Harness::PiHarness::TIMEOUT_EXIT_CODE
1040
1026
  result[:created_run_id] = created_run_id if created_run_id
1041
1027
  result[:push_url] = push_url if push_url
1042
1028
  result
1043
1029
  end
1044
1030
  end
1045
1031
 
1046
- # Submit the lane's builder run as a job to the space-server's queue instead of
1047
- # running it locally: the sandboxed executor mounts the lane worktree + the repo
1048
- # checkout at their identical host absolute paths (so the worktree's gitdir
1049
- # pointer resolves) and runs the harness itself — no local run.jsonl, the
1050
- # transcript lives server-side. jobs_client: is the injectable seam (mirrors
1051
- # dispatch's run_creator:).
1052
- def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
1053
- max_turns: 200, effort: nil, allowed_tools: nil, append_allowed_tools: nil,
1054
- force: false, quiet: false,
1055
- job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now)
1056
- err = quiet ? File.open(File::NULL, "w") : $stderr
1057
-
1058
- entry = slice_entry(iteration)
1059
- lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
1060
- raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
1061
- lane_entry = ensure_lane_materialized(iteration, lane)
1062
-
1063
- resolved_harness, resolved_model, resolved_effort =
1064
- resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
1065
- raise Space::Core::Error, "--as-job only supports the claude-code harness (lane '#{lane}' resolves to '#{resolved_harness}')" \
1066
- unless resolved_harness == "claude-code"
1067
- raise Space::Core::Error, "--job-model is required with --as-job" unless job_model
1068
-
1069
- # #89: same replace/append resolution as the local dispatch path — the sandboxed
1070
- # executor still runs `claude -p` with harness_args server-side.
1071
- resolved_allowed_tools, replace_tools, append_tools, tools_provenance =
1072
- resolve_allowed_tools(lane_entry, allowed_tools: allowed_tools, append_allowed_tools: append_allowed_tools)
1073
- err.puts(tools_provenance) if replace_tools || append_tools
1074
-
1075
- id = iteration_id(entry)
1076
- wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
1077
- raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
1078
-
1079
- build_dir = space.path.join("build", "#{id}-#{lane}")
1080
- prompt_path = build_dir.join("prompt.md")
1081
- copy_and_validate_prompt!(prompt, prompt_path)
1082
-
1083
- repo_path = space.path.join("repos", lane_entry["repo"])
1084
- harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
1085
- effort: resolved_effort, allowed_tools: resolved_allowed_tools,
1086
- force: force, err: err)
1087
-
1088
- spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir,
1089
- repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url,
1090
- job_model: job_model, api_key_ref: api_key_ref, harness_args: harness_obj.builder_args)
1091
-
1092
- client = jobs_client || JobsClient.new(host, token)
1093
- job_id = client.create(spec)
1094
-
1095
- # Dispatch bookkeeping, mirroring the local path's dispatched_at stamp: a
1096
- # re-dispatch overwrites the prior job_id, so `architect status` always
1097
- # reflects the last dispatch regardless of mode.
1098
- update_architect_block do |b|
1099
- (b["iterations"] || []).each do |s|
1100
- next unless s["name"] == iteration
1101
- (s["lanes"] || []).each do |l|
1102
- next unless l["name"] == lane
1103
- l["dispatched_at"] = now.iso8601
1104
- l["job_id"] = job_id
1105
- end
1106
- end
1107
- b
1108
- end
1109
-
1110
- result = { job_id: job_id, spec: spec }
1111
- result[:prompt_copied] = prompt_path if prompt
1112
- result
1113
- end
1114
-
1115
1032
  private
1116
1033
 
1117
1034
  attr_reader :space
1118
1035
 
1036
+ # The vendored builder-guard extension shipped in the gem (lib/space_architect/pi/).
1037
+ def vendored_guard_path
1038
+ Pathname.new(__dir__).join("pi/builder-guard.ts")
1039
+ end
1040
+
1119
1041
  # Compose a commit message. Without a custom message, the canonical default
1120
1042
  # (unchanged). With one, a short canonical prefix keeps the loop's commit
1121
1043
  # taxonomy grep-able while the author's first line owns the subject; any
@@ -1252,28 +1174,35 @@ module Space::Architect
1252
1174
  # same shell (capture_with_timeout's Process.spawn), same GateEvaluator,
1253
1175
  # same timeout handling (I09/AC3). repo_root/lane are nil outside a lane
1254
1176
  # context (rehearsal never has one — it always runs in the repo checkout).
1177
+ #
1178
+ # I01: gates sharing an identical (resolved cmd, resolved dir) pair execute
1179
+ # once; the shared capture is fanned back out to every member, each still
1180
+ # evaluated against its own `expect` and classified independently. A
1181
+ # group's effective timeout is the largest any member declares, so no
1182
+ # duplicate is cut short by another's shorter budget.
1255
1183
  def execute_gates(gates, base_dir:, lane:, repo_root:)
1256
- gates.map do |gate|
1184
+ resolved = gates.map do |gate|
1257
1185
  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
1186
+ dir = resolve_gate_dir(g, base_dir: base_dir, lane: lane, repo_root: repo_root)
1269
1187
  raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
1188
+ [g, dir]
1189
+ end
1190
+
1191
+ groups = resolved.group_by { |g, dir| [g["cmd"], dir.to_s] }
1192
+ captures = groups.transform_values do |members|
1193
+ timeout = members.map { |g, _| g["timeout"] || DEFAULT_GATE_TIMEOUT }.max
1194
+ sample_g, dir = members.first
1195
+ capture_with_timeout(sample_g["cmd"], dir: dir, timeout: timeout).merge(timeout: timeout)
1196
+ end
1270
1197
 
1271
- effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
1272
- captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
1198
+ resolved.map do |g, dir|
1199
+ key = [g["cmd"], dir.to_s]
1200
+ captured = captures[key]
1201
+ siblings = groups[key].map { |gg, _| gg["id"] } - [g["id"]]
1273
1202
 
1274
1203
  if captured[:timed_out]
1275
1204
  status = :fail
1276
- reason = "timed out after #{effective}s"
1205
+ reason = "timed out after #{captured[:timeout]}s"
1277
1206
  else
1278
1207
  ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
1279
1208
  status = ev.pass? ? :pass : :fail
@@ -1282,8 +1211,67 @@ module Space::Architect
1282
1211
 
1283
1212
  { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
1284
1213
  stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
1285
- dir: dir, status: status, reason: reason, timed_out: captured[:timed_out] }
1214
+ dir: dir, status: status, reason: reason, timed_out: captured[:timed_out],
1215
+ shared_with: siblings }
1216
+ end
1217
+ end
1218
+
1219
+ # The directory a single gate executes in: its own `cwd` (remapped into the
1220
+ # lane worktree when it falls under repo_root) or the run's base_dir.
1221
+ def resolve_gate_dir(g, base_dir:, lane:, repo_root:)
1222
+ return base_dir unless (cwd = g["cwd"])
1223
+
1224
+ gate_cwd = space.path.join(cwd)
1225
+ if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
1226
+ base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
1227
+ else
1228
+ gate_cwd
1229
+ end
1230
+ end
1231
+
1232
+ # I01: the durable, unbounded counterpart to the CLI's bounded terminal
1233
+ # report — one file per non-EMPTY rehearsal under build/rehearse/, holding
1234
+ # every gate's full command, dir, exit code, verdict, reason, and complete
1235
+ # stdout+stderr, plus the scope-asymmetry findings. The CLI prints this
1236
+ # path so a rerun is never needed just to see output the terminal trimmed.
1237
+ def write_rehearsal_transcript(entry, repo, base_dir, gate_results, scope_report, now)
1238
+ dir = space.path.join("build", "rehearse")
1239
+ FileUtils.mkdir_p(dir)
1240
+ path = dir.join("#{iteration_id(entry)}-#{now.strftime('%Y%m%dT%H%M%S.%6N')}.md")
1241
+
1242
+ lines = ["# Rehearsal transcript — #{entry['name']} — #{now.iso8601}"]
1243
+ lines << (repo ? "Repo: #{repo} (#{base_dir})" : "Each gate ran in its own declared cwd")
1244
+
1245
+ gate_results.each do |g|
1246
+ lines << ""
1247
+ lines << "## #{g[:ac].empty? ? "(gate)" : g[:ac]} #{g[:id]} — #{g[:rehearsal].to_s.upcase} (exit #{g[:exit_code].inspect})"
1248
+ lines << "cmd: #{g[:cmd]}"
1249
+ lines << "dir: #{g[:dir]}"
1250
+ lines << "reason: #{g[:reason]}" unless g[:reason].to_s.empty?
1251
+ lines << "shared execution with: #{g[:shared_with].join(', ')} (identical cmd+dir, captured once)" if g[:shared_with]&.any?
1252
+ lines << "--- stdout ---"
1253
+ lines << g[:stdout].to_s
1254
+ lines << "--- stderr ---"
1255
+ lines << g[:stderr].to_s
1256
+ end
1257
+
1258
+ if scope_report && (scope_report[:findings].any? || scope_report[:not_analyzable].any?)
1259
+ lines << ""
1260
+ lines << "## Scope-asymmetry check (grep-family gates only)"
1261
+ scope_report[:findings].each do |f|
1262
+ lines << "── #{f[:id]} (#{f[:repo]}): pattern #{f[:pattern].inspect} searched #{f[:paths].join(', ')}"
1263
+ if f[:outside_lanes].any?
1264
+ lines << "OUTSIDE ANY LANE'S TOUCH SET — no lane may legally fix these:"
1265
+ f[:outside_lanes].each { |file| lines << " #{file}" }
1266
+ end
1267
+ lines << "also matches (within a declared lane's touch set): #{f[:within_lanes].join(', ')}" if f[:within_lanes].any?
1268
+ lines << "0 files elsewhere match" if f[:outside_lanes].empty? && f[:within_lanes].empty?
1269
+ end
1270
+ scope_report[:not_analyzable].each { |na| lines << "not analyzed — #{na[:id]}: #{na[:reason]}" }
1286
1271
  end
1272
+
1273
+ path.write("#{lines.join("\n")}\n")
1274
+ path
1287
1275
  end
1288
1276
 
1289
1277
  # I09/AC4, #90/AC5-AC6: resolve the repo to default for gates that declare no
@@ -1617,23 +1605,23 @@ module Space::Architect
1617
1605
  end
1618
1606
 
1619
1607
  # Resolve harness + model by precedence: explicit value > (dispatch only) the
1620
- # lane's stored value > space.yaml project.harness/project.model > the
1621
- # per-harness sensible default (model only, keyed on the resolved harness).
1622
- # Shared by worktree_add and dispatch so both read the same defaults. A stored
1623
- # model is only honored when its stored harness still matches the resolved
1624
- # harness — a dispatch-time harness override drops the old harness's stored
1625
- # model instead of leaking it into the new harness's run.
1608
+ # lane's stored value > space.yaml project.harness/project.model > the pi
1609
+ # default model. Shared by worktree_add and dispatch so both read the same
1610
+ # defaults. A stored model is only honored when its stored harness still matches
1611
+ # the resolved harness — a dispatch-time harness override drops the old harness's
1612
+ # stored model instead of leaking it into the new harness's run. A stored
1613
+ # legacy harness name survives resolution (so a stale space.yaml
1614
+ # surfaces as Harness.for's actionable pi-only error, not a silent default).
1626
1615
  def resolve_harness_model(harness, model, stored_harness: nil, stored_model: nil)
1627
1616
  defaults = project_defaults
1628
- resolved_harness = (harness || stored_harness || defaults["harness"] || "claude-code").to_s
1617
+ resolved_harness = (harness || stored_harness || defaults["harness"] || "pi").to_s
1629
1618
  stored_model = nil unless stored_harness.nil? || stored_harness.to_s == resolved_harness
1630
- resolved_model = model || stored_model || defaults["model"] || Harness.default_model_for(resolved_harness)
1619
+ resolved_model = model || stored_model || defaults["model"] || Harness::DEFAULT_MODEL
1631
1620
  [resolved_harness, resolved_model]
1632
1621
  end
1633
1622
 
1634
1623
  # --prompt: the caller authors the lane prompt anywhere (a fresh scratch file) and
1635
- # the CLI owns the canonical copy — byte-for-byte, like variant_add. Shared by
1636
- # dispatch and dispatch_as_job so both refuse the same empty/stub prompt.
1624
+ # the CLI owns the canonical copy — byte-for-byte, like variant_add.
1637
1625
  def copy_and_validate_prompt!(prompt, prompt_path)
1638
1626
  if prompt
1639
1627
  src = Pathname.new(prompt)
@@ -1648,8 +1636,8 @@ module Space::Architect
1648
1636
  if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
1649
1637
  end
1650
1638
 
1651
- # Resolve harness/model/effort for a dispatch (local or --as-job), shared so the
1652
- # two dispatch paths can never drift on precedence or thinking-level translation.
1639
+ # Resolve harness/model/effort for a dispatch, so every dispatch path reads
1640
+ # precedence and thinking-level translation from one place.
1653
1641
  def resolve_dispatch_harness(lane_entry, model:, harness:, effort:, force:, err:)
1654
1642
  model, suffix_level = Harness.parse_model_suffix(model)
1655
1643
  resolved_harness, resolved_model = resolve_harness_model(harness, model,
@@ -1669,69 +1657,9 @@ module Space::Architect
1669
1657
  [resolved_harness, resolved_model, resolved_effort]
1670
1658
  end
1671
1659
 
1672
- # #89/AC1-AC5: resolve the claude-code --allowedTools grant for a dispatch (local or
1673
- # --as-job), shared so both paths resolve precedence identically. The CLI flag beats
1674
- # the lane's recorded allowed_tools:/append_allowed_tools: (frozen-declaration or a
1675
- # prior dispatch's stamp) — resolved independently for replace vs. append, so a flag
1676
- # on one axis and a lane value on the other both apply (Objective A). Meaningless for
1677
- # opencode/pi (no equivalent grant mechanism) — still resolved/recorded for visibility,
1678
- # but Harness.for only wires it into the claude-code branch's argv.
1679
- def resolve_allowed_tools(lane_entry, allowed_tools:, append_allowed_tools:)
1680
- replace, replace_from =
1681
- if allowed_tools
1682
- [allowed_tools, "--allowed-tools flag"]
1683
- elsif lane_entry["allowed_tools"]
1684
- [lane_entry["allowed_tools"], "lane's allowed_tools:"]
1685
- else
1686
- [nil, "default"]
1687
- end
1688
-
1689
- append, append_from =
1690
- if append_allowed_tools
1691
- [append_allowed_tools, "--append-allowed-tools flag"]
1692
- elsif lane_entry["append_allowed_tools"]
1693
- [lane_entry["append_allowed_tools"], "lane's append_allowed_tools:"]
1694
- end
1695
-
1696
- final = Harness::ClaudeCodeHarness.resolve_tools(replace: replace, append: append)
1697
- provenance = "allowed-tools: #{replace_from}#{append_from ? " + #{append_from}" : ""} → #{final}"
1698
- [final, replace, append, provenance]
1699
- end
1700
-
1701
- # Compose a dispatch --as-job spec per the space-server's job contract: prompt +
1702
- # workspace.dir (the lane worktree) + environment (deps/network/mounts/env or
1703
- # secrets) + harness (claude/backend/args) + provenance. mounts are src:dst with
1704
- # src == dst — the sandboxed executor requires the lane worktree AND the repo
1705
- # checkout mounted at their identical host absolute paths for the worktree's
1706
- # gitdir pointer to resolve.
1707
- def job_spec(iteration:, lane:, wt_path:, build_dir:, repo_path:, prompt_content:, backend_url:,
1708
- job_model:, api_key_ref:, harness_args:)
1709
- harness = { "type" => "claude", "backend" => { "base_url" => backend_url }, "args" => harness_args }
1710
- harness["model"] = job_model if job_model
1711
- harness["backend"]["api_key_ref"] = api_key_ref if api_key_ref
1712
-
1713
- environment = {
1714
- "env" => api_key_ref ? {} : { "ANTHROPIC_API_KEY" => "unused-for-keyless-backends" },
1715
- "deps" => ["git"],
1716
- "permissions" => {
1717
- "network" => true,
1718
- "mounts" => ["#{build_dir}:#{build_dir}", "#{repo_path}:#{repo_path}"]
1719
- }
1720
- }
1721
- environment["secrets"] = [{ "ref" => api_key_ref, "name" => "ANTHROPIC_API_KEY" }] if api_key_ref
1722
-
1723
- {
1724
- "prompt" => prompt_content,
1725
- "workspace" => { "dir" => wt_path.to_s },
1726
- "environment" => environment,
1727
- "harness" => harness,
1728
- "provenance" => { "space" => Space::Core::Slugger.slug(space.title), "iteration" => iteration, "lane" => lane }
1729
- }
1730
- end
1731
-
1732
1660
  def recorded_lane_fields(lane_entry)
1733
1661
  {
1734
- harness: lane_entry["harness"] || "claude-code",
1662
+ harness: lane_entry["harness"] || "pi",
1735
1663
  model: lane_entry["model"],
1736
1664
  variant: lane_entry["variant"] || false,
1737
1665
  effort: lane_entry["effort"]