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 +4 -4
- data/CHANGELOG.md +88 -0
- data/README.md +5 -3
- data/lib/space_architect/architect_project.rb +128 -200
- data/lib/space_architect/cli/architect.rb +114 -105
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/harness.rb +97 -344
- data/lib/space_architect/pi/builder-guard.ts +103 -0
- data/lib/space_architect/research/mux.rb +47 -32
- data/lib/space_architect/research/pi_events.rb +70 -0
- data/lib/space_architect/research/renderer.rb +39 -39
- data/lib/space_architect/research/supervisor.rb +35 -14
- data/lib/space_architect/research.rb +1 -6
- data/lib/space_architect.rb +0 -1
- data/lib/space_core/version.rb +1 -1
- data/skill/architect/SKILL.md +34 -26
- data/skill/architect/dispatch.md +137 -115
- data/skill/architect/research.md +14 -8
- data/skill/architect-research/SKILL.md +18 -11
- data/skill/architect-vocabulary/SKILL.md +1 -1
- metadata +3 -2
- data/lib/space_architect/dispatcher.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b63abfece4c105ef5b2befd0d8c4e4301d8d5cade10570cd2f41bd0641b8c4be
|
|
4
|
+
data.tar.gz: 6b932ae13952dad48f81a2ee68ed52b7cee28ef8edc4a99e7a06f23952c4f107
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 `
|
|
371
|
-
|
|
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
|
-
|
|
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"] || "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
977
|
+
guard_path: guard_path, force: force, err: err)
|
|
984
978
|
|
|
985
|
-
# Stamp launch time and the resolved harness/model/effort
|
|
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.
|
|
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"]
|
|
1000
|
-
l["harness"]
|
|
1001
|
-
l["model"]
|
|
1002
|
-
l["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
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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::
|
|
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
|
-
|
|
1272
|
-
|
|
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 #{
|
|
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
|
-
#
|
|
1622
|
-
#
|
|
1623
|
-
#
|
|
1624
|
-
#
|
|
1625
|
-
#
|
|
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"] || "
|
|
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
|
|
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.
|
|
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
|
|
1652
|
-
#
|
|
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"] || "
|
|
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"]
|