From b18b14839c9bd5b51a1e4718c10dea6eeddd9a72 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 13:47:38 +0200 Subject: [PATCH 01/36] [claude/vc-workflow] fix(voc): loopback product default, sane aicx PATH, review polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review threads on PR #54: - DEFAULT_SERVER was a tailnet address on the leptos reload port (100.82.232.70:3025) baked into the shipped binary; now the canonical product origin 127.0.0.1:3024 (server_config DEFAULT_BIND_HOST/PORT, vc-server DEFAULT_ADDR). VC_SERVER_URL / --server still route to any remote or tailnet server — an operator choice, not a compiled-in host - help text, snapshot and contract tests follow the same default - fetch_state parses the state envelope once (runs_from_envelope) - transcript pane: server failures read "server error: …", not "donor: …" - transcript tail rendering: plain slice of the last 40 lines - memory.rs: aicx runs with a sanitized PATH (absolute, non-empty entries; system fallback) so an implicit-cwd lookup can never pick a stray aicx - pre-commit: `--` after semgrep args so a staged path starting with `-` is always a target, never a flag - docs: AGENT_OPS + INSTALLED_RUNTIME_CAPSULE state the real worker host token ({label}-{short8}-w, WORKER_HOST_SUFFIX) instead of `-workers` - cargo test 133 passed; spawn worker tests 6 passed; semgrep accepts `--` Authored-By: claude session_id: dacccf9f-b20b-41b7-9a72-ec9edc38c73a time: 2026-08-18T13:47:49+02:00 runtime: interactive --- docs/runtime/AGENT_OPS.md | 15 +++++++---- docs/runtime/INSTALLED_RUNTIME_CAPSULE.md | 2 +- scripts/hooks/pre-commit | 4 +-- vibecrafted-app/tui-agent/src/config.rs | 2 +- vibecrafted-app/tui-agent/src/lib.rs | 2 +- vibecrafted-app/tui-agent/src/memory.rs | 26 +++++++++++++++++-- vibecrafted-app/tui-agent/src/observe.rs | 14 +++++++--- vibecrafted-app/tui-agent/src/ui.rs | 9 ++++--- .../tests/mission_control_snapshots.rs | 2 +- .../tui-agent/tests/state_contract.rs | 24 ++++++++--------- 10 files changed, 69 insertions(+), 31 deletions(-) diff --git a/docs/runtime/AGENT_OPS.md b/docs/runtime/AGENT_OPS.md index 4d3bec55..ecb01a80 100644 --- a/docs/runtime/AGENT_OPS.md +++ b/docs/runtime/AGENT_OPS.md @@ -254,11 +254,16 @@ seat unless `VIBECRAFTED_WORKER_SESSION` is set. 1. `VIBECRAFTED_WORKER_SESSION` if set — explicit override wins (any name, including one that matches the operator seat). -2. Else `"-workers"` (SPAWN_ROOT / VIBECRAFTED_ROOT / cwd) - — the per-project worker host (e.g. `vibecrafted-workers`, - `vc-frame-workers`), **always** suffixed. The suffix is dash-joined so the - name stays a single token across argv, shell quoting and session-listing - matches (2026-08-17; it used to be space-joined). Bare `basename(--root)` is the +2. Else the workspace-bound worker host `"{label}-{workspace_short8}-w"` + resolved through the workspace catalog (`workspace_catalog.py:: +worker_host_session_name`, `WORKER_HOST_SUFFIX = "-w"`); emergency + fallback `"-w"` only when the catalog cannot open + (SPAWN_ROOT / VIBECRAFTED_ROOT / cwd). **Always** suffixed. The suffix is a + short dash-joined token so the name stays one argv element across shell + quoting and session-listing matches AND fits the macOS `sockaddr_un` + budget (104 bytes; the older `{label}-{short} workers` form overflowed it — + `legacy_worker_host_session_name()` keeps that token for WES attach only). + See `docs/runtime/WORKSPACE_IDENTITY.md`. Bare `basename(--root)` is the operator's own interactive card in the rail and is never a worker target, so the dispatcher seat plays no part in host resolution. diff --git a/docs/runtime/INSTALLED_RUNTIME_CAPSULE.md b/docs/runtime/INSTALLED_RUNTIME_CAPSULE.md index 60bfd016..2f0cf2b6 100644 --- a/docs/runtime/INSTALLED_RUNTIME_CAPSULE.md +++ b/docs/runtime/INSTALLED_RUNTIME_CAPSULE.md @@ -121,7 +121,7 @@ that is a symlink or is owned by another user is refused, in which case vc-frame keeps its own default rather than accepting a hostile socket home. Worker host session names are single-token for the same reason -(`{label}-{workspace_short}-workers`, see `docs/runtime/WORKSPACE_IDENTITY.md`). +(`{label}-{workspace_short}-w`, see `docs/runtime/WORKSPACE_IDENTITY.md`). ### Install failures are visible diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index 146109cd..bbc896bb 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -54,10 +54,10 @@ SEMGREP_ARGS=(scan --config auto --error --quiet --exclude-rule html.security.au # pysemgrep (jsonschema/rpds clash). Isolate the scanner. if command -v semgrep >/dev/null 2>&1; then git diff --cached --name-only --diff-filter=ACM -z | \ - env -u PYTHONPATH -u PYTHONHOME xargs -0 semgrep "${SEMGREP_ARGS[@]}" + env -u PYTHONPATH -u PYTHONHOME xargs -0 semgrep "${SEMGREP_ARGS[@]}" -- elif command -v uvx >/dev/null 2>&1; then git diff --cached --name-only --diff-filter=ACM -z | \ - env -u PYTHONPATH -u PYTHONHOME xargs -0 uvx semgrep "${SEMGREP_ARGS[@]}" + env -u PYTHONPATH -u PYTHONHOME xargs -0 uvx semgrep "${SEMGREP_ARGS[@]}" -- else echo "[warn] semgrep unavailable; skipping" fi diff --git a/vibecrafted-app/tui-agent/src/config.rs b/vibecrafted-app/tui-agent/src/config.rs index e80aab71..801150f8 100644 --- a/vibecrafted-app/tui-agent/src/config.rs +++ b/vibecrafted-app/tui-agent/src/config.rs @@ -239,7 +239,7 @@ fn print_help() { println!(); println!("Options:"); println!(" --view observe|full Default observe: server-backed live board + AICX memory"); - println!(" --server Vibecrafted Server origin (default: VC_SERVER_URL or http://100.82.232.70:3025)"); + println!(" --server Vibecrafted Server origin (default: VC_SERVER_URL or http://127.0.0.1:3024)"); println!(" --state-root Control-plane state root under VIBECRAFTED_HOME"); println!(" --deck Command deck binary or script to launch workflows"); println!(" --root Workspace root passed through to launched workflows"); diff --git a/vibecrafted-app/tui-agent/src/lib.rs b/vibecrafted-app/tui-agent/src/lib.rs index bd0daa41..9d6a0d22 100644 --- a/vibecrafted-app/tui-agent/src/lib.rs +++ b/vibecrafted-app/tui-agent/src/lib.rs @@ -787,7 +787,7 @@ mod tests { launch_runtime: LaunchRuntime::Terminal, terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: crate::observe::ConsoleView::Full, }, state: ControlPlaneState::empty("/tmp/state"), diff --git a/vibecrafted-app/tui-agent/src/memory.rs b/vibecrafted-app/tui-agent/src/memory.rs index 95a26507..b0396187 100644 --- a/vibecrafted-app/tui-agent/src/memory.rs +++ b/vibecrafted-app/tui-agent/src/memory.rs @@ -6,6 +6,28 @@ use std::process::{Command, Stdio}; use std::time::Duration; +/// PATH for the `aicx` subprocess: the inherited entries minus anything that +/// is an implicit current-directory lookup (empty segments, relative paths), +/// with the system set as the fallback when nothing sane survives. +fn sane_tool_path() -> String { + let inherited = std::env::var("PATH").unwrap_or_default(); + let mut entries: Vec<&str> = inherited + .split(':') + .filter(|entry| !entry.is_empty() && entry.starts_with('/')) + .collect(); + entries.dedup(); + if entries.is_empty() { + return "/usr/local/bin:/usr/bin:/bin".to_string(); + } + entries.join(":") +} + +fn aicx() -> Command { + let mut command = Command::new("aicx"); + command.env("PATH", sane_tool_path()); + command +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MemoryState { pub project: String, @@ -22,7 +44,7 @@ pub fn default_project(launch_root: &std::path::Path) -> String { } pub fn load_continuity(project: &str) -> MemoryState { - let output = Command::new("aicx") + let output = aicx() .args(["continuity", "show", "-p", project, "-H", "24"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -68,7 +90,7 @@ pub fn load_continuity(project: &str) -> MemoryState { } pub fn launch_wizard(project: &str) -> anyhow::Result<()> { - let status = Command::new("aicx") + let status = aicx() .args(["wizard", "--view", "search", "-p", project]) .status()?; if !status.success() { diff --git a/vibecrafted-app/tui-agent/src/observe.rs b/vibecrafted-app/tui-agent/src/observe.rs index eb44b651..b62eb1a1 100644 --- a/vibecrafted-app/tui-agent/src/observe.rs +++ b/vibecrafted-app/tui-agent/src/observe.rs @@ -7,7 +7,11 @@ use serde::Deserialize; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -pub const DEFAULT_SERVER: &str = "http://100.82.232.70:3025"; +/// Canonical product origin — the loopback bind vc-server and server_config +/// default to (`127.0.0.1:3024`; 3025 is only the leptos reload port). A +/// remote or tailnet server is an operator choice expressed through +/// `VC_SERVER_URL` / `--server`, never a host address baked into the binary. +pub const DEFAULT_SERVER: &str = "http://127.0.0.1:3024"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConsoleView { @@ -164,6 +168,10 @@ pub fn age_label(started_at: &str, now: SystemTime) -> String { pub fn parse_state_json(bytes: &[u8], now: SystemTime) -> anyhow::Result> { let envelope: StateEnvelope = serde_json::from_slice(bytes)?; + Ok(runs_from_envelope(envelope, now)) +} + +fn runs_from_envelope(envelope: StateEnvelope, now: SystemTime) -> Vec { let mut runs = Vec::new(); for dto in envelope.active_runs.into_iter().chain(envelope.stalled_runs) { let run_id = dto.run_id.unwrap_or_default(); @@ -180,7 +188,7 @@ pub fn parse_state_json(bytes: &[u8], now: SystemTime) -> anyhow::Result anyhow::Result<(String, Vec)> { @@ -189,7 +197,7 @@ pub fn fetch_state(origin: &str) -> anyhow::Result<(String, Vec)> { let body = response.into_string()?; let envelope: StateEnvelope = serde_json::from_str(&body)?; let generated = envelope.generated_at.clone().unwrap_or_default(); - let runs = parse_state_json(body.as_bytes(), SystemTime::now())?; + let runs = runs_from_envelope(envelope, SystemTime::now()); Ok((generated, runs)) } diff --git a/vibecrafted-app/tui-agent/src/ui.rs b/vibecrafted-app/tui-agent/src/ui.rs index 6f43d87b..07ffcec2 100644 --- a/vibecrafted-app/tui-agent/src/ui.rs +++ b/vibecrafted-app/tui-agent/src/ui.rs @@ -172,7 +172,7 @@ fn draw_observe(frame: &mut Frame, area: Rect, app: &App) { let mut body = Vec::new(); if let Some(error) = &app.observe.error { body.push(Line::from(Span::styled( - format!("donor: {error}"), + format!("server error: {error}"), Style::default().fg(Color::Yellow), ))); body.push(Line::from("")); @@ -195,7 +195,10 @@ fn draw_observe(frame: &mut Frame, area: Rect, app: &App) { Style::default().fg(Color::DarkGray), ))); } else { - for line in app.observe.transcript.lines().rev().take(40).collect::>().into_iter().rev() { + // Last 40 lines, in file order. + let lines: Vec<&str> = app.observe.transcript.lines().collect(); + let tail = &lines[lines.len().saturating_sub(40)..]; + for line in tail { body.push(Line::from(line.to_string())); } } @@ -1660,7 +1663,7 @@ mod tests { launch_runtime: LaunchRuntime::Terminal, terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: crate::observe::ConsoleView::Full, }, state: ControlPlaneState::empty("/tmp/state"), diff --git a/vibecrafted-app/tui-agent/tests/mission_control_snapshots.rs b/vibecrafted-app/tui-agent/tests/mission_control_snapshots.rs index cc5f0815..b96c1d8a 100644 --- a/vibecrafted-app/tui-agent/tests/mission_control_snapshots.rs +++ b/vibecrafted-app/tui-agent/tests/mission_control_snapshots.rs @@ -272,7 +272,7 @@ fn mission_app(state: MissionControlState) -> App { launch_runtime: LaunchRuntime::Terminal, terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, }, state: ControlPlaneState::empty("/fixture/state"), diff --git a/vibecrafted-app/tui-agent/tests/state_contract.rs b/vibecrafted-app/tui-agent/tests/state_contract.rs index 7e07960e..79d99f57 100644 --- a/vibecrafted-app/tui-agent/tests/state_contract.rs +++ b/vibecrafted-app/tui-agent/tests/state_contract.rs @@ -807,7 +807,7 @@ fn mux_health_deep_actions_surface_per_known_service() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -949,7 +949,7 @@ fn mux_status_lines_render_healthy_and_attention_headers() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1133,7 +1133,7 @@ fn deep_controls_expose_attach_resume_and_artifacts() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1222,7 +1222,7 @@ fn native_artifact_viewer_reads_files_and_clipboard_payload_prefers_resume_comma terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1276,7 +1276,7 @@ fn empty_state_detail_lines_offer_human_quick_start() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1327,7 +1327,7 @@ fn prompt_lines_include_human_kind_copy_and_command_preview() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1379,7 +1379,7 @@ fn tab_navigation_wraps_and_dispatch_focus_tracks_selected_field() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1461,7 +1461,7 @@ fn tab_labels_surface_monitor_dispatch_and_controls_context() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1541,7 +1541,7 @@ async fn queue_scope_and_search_filter_the_visible_run_list() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }) @@ -1575,7 +1575,7 @@ fn changing_launch_kind_reorients_the_operator_into_dispatch() { terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }, @@ -1980,7 +1980,7 @@ async fn mission_control_focus_wraps_across_seven_panels() { launch_runtime: LaunchRuntime::Terminal, terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }) @@ -2012,7 +2012,7 @@ async fn mission_queue_preselects_matching_deep_action_for_controls_handoff() { launch_runtime: LaunchRuntime::Terminal, terminal_binary: "vc-frame".into(), tick_rate: Duration::from_millis(250), - server: "http://100.82.232.70:3025".into(), + server: "http://127.0.0.1:3024".into(), view: voc::observe::ConsoleView::Full, no_verify_gate: false, }) From bcbfc7760af99e6acb047321db6a58e0e0f8d9a5 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 13:48:26 +0200 Subject: [PATCH 02/36] [claude/vc-workflow] style: settle formatter output after the review-thread commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruff wrapped the resume-help assertion; prettier had de-indented a list item whose code span broke across lines — rephrased so the span stays on one line Authored-By: claude session_id: dacccf9f-b20b-41b7-9a72-ec9edc38c73a time: 2026-08-18T13:48:35+02:00 runtime: interactive --- docs/runtime/AGENT_OPS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/runtime/AGENT_OPS.md b/docs/runtime/AGENT_OPS.md index ecb01a80..20849be3 100644 --- a/docs/runtime/AGENT_OPS.md +++ b/docs/runtime/AGENT_OPS.md @@ -255,8 +255,8 @@ seat unless `VIBECRAFTED_WORKER_SESSION` is set. 1. `VIBECRAFTED_WORKER_SESSION` if set — explicit override wins (any name, including one that matches the operator seat). 2. Else the workspace-bound worker host `"{label}-{workspace_short8}-w"` - resolved through the workspace catalog (`workspace_catalog.py:: -worker_host_session_name`, `WORKER_HOST_SUFFIX = "-w"`); emergency + resolved through the workspace catalog (`worker_host_session_name` in + `workspace_catalog.py`, `WORKER_HOST_SUFFIX = "-w"`); emergency fallback `"-w"` only when the catalog cannot open (SPAWN_ROOT / VIBECRAFTED_ROOT / cwd). **Always** suffixed. The suffix is a short dash-joined token so the name stays one argv element across shell From cf20aa4ea9a4e4ded470e2fe7d8b96c935d2f329 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 14:20:40 +0200 Subject: [PATCH 03/36] [claude/interactive] docs(roadmap): backlog for the next scaffold - make release leaves a prunable ghost worktree entry (snapshot2, 2026-08-11): reap on success, prune in trap, contract test - distribution payload must be symlink-free (3.7.0 shipped 4 symlink entries incl. a stray HOME pointer); fold in-repo aliases into the same cut - vibecrafted_core/__init__.py barrel drives every non-breaking cycle in the loctree audit; own wave, full gates Authored-By: claude session_id: e7734be6-36b7-4e62-a2b2-0af69f9d32e2 time: 2026-08-18T14:20:40+02:00 runtime: interactive --- docs/ROADMAP_4.1.1.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/ROADMAP_4.1.1.md b/docs/ROADMAP_4.1.1.md index 2062e8f6..c8707369 100644 --- a/docs/ROADMAP_4.1.1.md +++ b/docs/ROADMAP_4.1.1.md @@ -71,3 +71,33 @@ same immutable pack path and digest. - a second durable control plane; - provider-specific summaries that fork repository truth; - replacing native provider resume when an exact resumable session exists. + +## Backlog for the next scaffold (not 4.1.1 scope) + +Items surfaced by ground truth on 2026-08-18; each is a small scaffold cut, +none is a release blocker. + +1. **`make release` must reap its snapshot worktree.** The release target + builds from a detached snapshot worktree under `$TMPDIR` (donor tree stays + untouched — see the dirty-donor rule). The tmp directory is removed, but + the `.git/worktrees/` entry survives; `vc-git` then reports a + prunable ghost (observed: `snapshot2` from 2026-08-11 pointing at a + deleted `.tmpQUEtCY/snapshot`). Add `git worktree remove --force` on the + success path and `git worktree prune` in the trap, plus a contract test + that a release run leaves `git worktree list` at exactly one entry. +2. **Symlink-free distribution payload as a gate, not a hope.** The 3.7.0 + tarball shipped 4 symlink entries (`vetcoders.zsh -> vetcoders.sh`, + `docs/install.sh -> ../install.sh`, a stray `.antigravitycli/.json` + pointing into an operator `$HOME`), which breaks Windows extraction and + `core.symlinks=false` clones. The portable channel now builds outside the + tree; verify on 4.x that the payload carries zero symlinks and add a + `find -type l` gate beside the env-secret gate. Fold the + in-repo aliases (`runtime`, `skills`, `docs/install.sh`, + `vibecrafted_core/config/vc-frame -> ../../../config/vc-frame`) into that + cut: a package must not depend on repo layout above itself. +3. **Import direction around `vibecrafted_core/__init__.py`.** Loctree audit + (health 93) shows every non-breaking cycle (1 structural, 3 diamond, + 17 lazy) rooted in the package barrel re-exporting from modules that + import back from the package. One deliberate cut on the hub (188 external + importers) clears roughly 40% of the audit list; needs its own wave with + the full Python gates, not a drive-by. From 833f770e7a3ae576f0fb3166d6793dd7326313db Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 14:34:56 +0200 Subject: [PATCH 04/36] [claude/vc-scaffold] docs(roadmap): scaffold 4.2.0 and correct the 4.1.1 release-worktree item - docs/ROADMAP_4.2.0.md: repo-facing summary of the 9-cut plan (W0 acceptance, W1 release/install seams, W2 Workspaces/app, W3 core imports); plan package lives under ~/.vibecrafted/artifacts/vetcoders/vibecrafted/2026_0818/plans/roadmap-4.2.0/ - docs/ROADMAP_4.1.1.md: item 1 reframed from 'make release leaves a ghost worktree' to 'dirty donors are a release feature' after falsification (no script ever ran git worktree add; the ghost was a manual workaround) Authored-By: claude session_id: e7734be6-36b7-4e62-a2b2-0af69f9d32e2 time: 2026-08-18T14:34:56+02:00 runtime: interactive --- docs/ROADMAP_4.1.1.md | 21 +++++++++++--------- docs/ROADMAP_4.2.0.md | 45 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 docs/ROADMAP_4.2.0.md diff --git a/docs/ROADMAP_4.1.1.md b/docs/ROADMAP_4.1.1.md index c8707369..c64d8a17 100644 --- a/docs/ROADMAP_4.1.1.md +++ b/docs/ROADMAP_4.1.1.md @@ -72,19 +72,22 @@ same immutable pack path and digest. - provider-specific summaries that fork repository truth; - replacing native provider resume when an exact resumable session exists. -## Backlog for the next scaffold (not 4.1.1 scope) +## Backlog for the next scaffold (not 4.1.1 scope) — scaffolded as ROADMAP_4.2.0 Items surfaced by ground truth on 2026-08-18; each is a small scaffold cut, none is a release blocker. -1. **`make release` must reap its snapshot worktree.** The release target - builds from a detached snapshot worktree under `$TMPDIR` (donor tree stays - untouched — see the dirty-donor rule). The tmp directory is removed, but - the `.git/worktrees/` entry survives; `vc-git` then reports a - prunable ghost (observed: `snapshot2` from 2026-08-11 pointing at a - deleted `.tmpQUEtCY/snapshot`). Add `git worktree remove --force` on the - success path and `git worktree prune` in the trap, plus a contract test - that a release run leaves `git worktree list` at exactly one entry. +1. **Dirty donors are a release feature, not an operator ritual.** `make release` + refuses dirty donors (`../vc-terminal`, `../vc-frame`); the operator hand-rolls + `git worktree add --detach` snapshots to get past it, and the ghost entry + `snapshot2` (2026-08-11, pointing at a deleted `$TMPDIR/.tmpQUEtCY/snapshot`) + was the residue — no script in either repo ever created a worktree + (`git log --all -S'worktree add' -- scripts Makefile` → docs only). Add + `--snapshot-donors` to `scripts/build-vibecrafted-release.sh`: create detached + worktrees at donor HEADs inside the build work dir, record the SHAs in the + receipt, reap with `git worktree remove --force` + `prune` in the trap, and a + contract test that a run leaves `git worktree list` at exactly one entry. + Superseded by ROADMAP_4.2.0 cut W1-b. 2. **Symlink-free distribution payload as a gate, not a hope.** The 3.7.0 tarball shipped 4 symlink entries (`vetcoders.zsh -> vetcoders.sh`, `docs/install.sh -> ../install.sh`, a stray `.antigravitycli/.json` diff --git a/docs/ROADMAP_4.2.0.md b/docs/ROADMAP_4.2.0.md new file mode 100644 index 00000000..ee33018b --- /dev/null +++ b/docs/ROADMAP_4.2.0.md @@ -0,0 +1,45 @@ +# Vibecrafted 4.2.0 roadmap — measured truths, finished seams + +Status: planned (scaffolded 2026-08-18). Not part of the 4.1.0 release contract. + +Plan package (atlas · falsification · tracker · DRIVER · 9 briefs · manifest): +`/Users/polyversai/.vibecrafted/artifacts/vetcoders/vibecrafted/2026_0818/plans/roadmap-4.2.0/` +Drive it from `DRIVER.md` there; this file is the repo-facing summary. + +## Thesis + +4.1.0 shipped two channels (DMG + portable) and a durable Workspace identity, but +several truths are still asserted rather than measured, and three product seams +are visibly unfinished. 4.2.0 turns each into a verifier-earned `[x]` or an honest +`[?]`. Only a delivery-verifier flips `[~]→[x]`. + +## Waves + +| Wave | Cut | Title | Vector | Repo | +| ---- | ---- | ----------------------------------------------- | --------- | ---------------------- | +| W0 | W0-a | Verify 4.1.0 payloads symlink/.env/HOME-free | recon | vibecrafted | +| W0 | W0-b | `resume --run-id` e2e on the installed build | e2e | vibecrafted | +| W0 | W0-c | LIVE RUNS dashboard runtime acceptance | e2e | vibecrafted | +| W1 | W1-a | Remove in-repo alias symlinks (Windows-safe) | implement | vibecrafted | +| W1 | W1-b | Donor snapshots as a release feature | implement | vibecrafted | +| W1 | W1-c | Serve `install.ps1` | implement | vibecrafted-io | +| W2 | W2-a | Workspaces surface in the vc-frame session rail | implement | vc-frame | +| W2 | W2-b | Vibecrafted.app boundary + chrome polish | implement | vibecrafted + vc-frame | +| W3 | W3-a | Core `__init__` import direction | stabilize | vibecrafted | + +Order: W0 (parallel, read-only) → W1 (parallel, disjoint files) → W2 (parallel) → +W3 (after W1-a). Every wave ends at an operator button (push / PR / deploy / install). + +## Decisions + +1. Repo tree is symlink-free; projections are produced by installer/packers, never linked. +2. Dirty donors are a release feature (`--snapshot-donors`), not an operator ritual. +3. Windows gets a served entry point (`/install.ps1`, WSL2 hand-off), not a native install. +4. vc-frame shows Workspaces (catalog, `workspace_id`), not physical session names. +5. Runtime acceptance on the installed build is a cut (W0), not a footnote. + +## Explicit non-goals + +Native Windows runtime · a second control plane · new vc-frame features beyond the +rail and the 2026-08-16 chrome asks · rewriting the release scripts · any push, +merge, or deploy performed by an agent. From 63e5e8d3d22cc80fa6ae1b093b081634f9662007 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 14:46:08 +0200 Subject: [PATCH 05/36] [claude/vc-scaffold] docs(roadmap): rescope W1-a after pre-flight - aliases were already removed in 4d1f1d11 (#47); W1-a becomes the symlink-free regression guard + Windows-clone smoke (stabilize) Authored-By: claude session_id: e7734be6-36b7-4e62-a2b2-0af69f9d32e2 time: 2026-08-18T14:46:07+02:00 runtime: interactive --- docs/ROADMAP_4.2.0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP_4.2.0.md b/docs/ROADMAP_4.2.0.md index ee33018b..9fcf3116 100644 --- a/docs/ROADMAP_4.2.0.md +++ b/docs/ROADMAP_4.2.0.md @@ -20,7 +20,7 @@ are visibly unfinished. 4.2.0 turns each into a verifier-earned `[x]` or an hone | W0 | W0-a | Verify 4.1.0 payloads symlink/.env/HOME-free | recon | vibecrafted | | W0 | W0-b | `resume --run-id` e2e on the installed build | e2e | vibecrafted | | W0 | W0-c | LIVE RUNS dashboard runtime acceptance | e2e | vibecrafted | -| W1 | W1-a | Remove in-repo alias symlinks (Windows-safe) | implement | vibecrafted | +| W1 | W1-a | Symlink-free tree: guard + Windows-clone smoke | stabilize | vibecrafted | | W1 | W1-b | Donor snapshots as a release feature | implement | vibecrafted | | W1 | W1-c | Serve `install.ps1` | implement | vibecrafted-io | | W2 | W2-a | Workspaces surface in the vc-frame session rail | implement | vc-frame | @@ -32,7 +32,7 @@ W3 (after W1-a). Every wave ends at an operator button (push / PR / deploy / ins ## Decisions -1. Repo tree is symlink-free; projections are produced by installer/packers, never linked. +1. Repo tree is symlink-free (landed in #47); a contract test + Windows-clone smoke guard it; projections are produced by installer/packers, never linked. 2. Dirty donors are a release feature (`--snapshot-donors`), not an operator ritual. 3. Windows gets a served entry point (`/install.ps1`, WSL2 hand-off), not a native install. 4. vc-frame shows Workspaces (catalog, `workspace_id`), not physical session names. From 69101f2cc64ee8c5b29b6064f24882d86b18c24c Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 14:48:21 +0200 Subject: [PATCH 06/36] [claude/vc-scaffold] docs(roadmap): align stop points with vc-operator/AUTONOMY.md - branch pushes and PR creation are the supervisor's; merge into trunk, deploys and host installs remain operator buttons Authored-By: claude session_id: e7734be6-36b7-4e62-a2b2-0af69f9d32e2 time: 2026-08-18T14:48:21+02:00 runtime: interactive --- docs/ROADMAP_4.2.0.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP_4.2.0.md b/docs/ROADMAP_4.2.0.md index 9fcf3116..c8b9caa7 100644 --- a/docs/ROADMAP_4.2.0.md +++ b/docs/ROADMAP_4.2.0.md @@ -28,7 +28,7 @@ are visibly unfinished. 4.2.0 turns each into a verifier-earned `[x]` or an hone | W3 | W3-a | Core `__init__` import direction | stabilize | vibecrafted | Order: W0 (parallel, read-only) → W1 (parallel, disjoint files) → W2 (parallel) → -W3 (after W1-a). Every wave ends at an operator button (push / PR / deploy / install). +W3 (after W1-a). Every wave ends at an operator button (merge / deploy / install). ## Decisions @@ -41,5 +41,5 @@ W3 (after W1-a). Every wave ends at an operator button (push / PR / deploy / ins ## Explicit non-goals Native Windows runtime · a second control plane · new vc-frame features beyond the -rail and the 2026-08-16 chrome asks · rewriting the release scripts · any push, -merge, or deploy performed by an agent. +rail and the 2026-08-16 chrome asks · rewriting the release scripts · merges into trunk, +deploys, or host installs performed by an agent (branch pushes and PR creation are the supervisor's; canonical list: vc-operator/AUTONOMY.md). From 838165d630f330076ee1702ff5a09d44fb2b0f6a Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 15:04:38 +0200 Subject: [PATCH 07/36] [claude/vc-implement] fix(install): make the docs shim runnable on a fresh clone - add tests/tui/test_repo_symlink_free.py: no tracked mode 120000 entry, the five historical alias paths stay regular files/dirs or absent, and a core.symlinks=false clone (the Windows default) still runs both entrypoints - fix docs/install.sh: it exec'd ../install.sh directly, but the repository file carries no executable bit by design (stated in scripts/build-portable-release.sh:86-88), so the shim died with 126 on every fresh clone; it now execs bash explicitly, matching the packer contract - roadmap 4.2.0 cut W1-a: the guard #47 never shipped with the alias removal Authored-By: claude session_id: 3da2a070-904b-4d8c-9e40-d3a94d10e3fa time: 2026-08-18T15:12:00+02:00 runtime: worker --- docs/install.sh | 10 +- tests/tui/test_repo_symlink_free.py | 146 ++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/tui/test_repo_symlink_free.py diff --git a/docs/install.sh b/docs/install.sh index e5305ae2..541b1a94 100755 --- a/docs/install.sh +++ b/docs/install.sh @@ -1,5 +1,13 @@ #!/bin/sh set -eu +# Doc-relative shim for the canonical installer one directory up. +# +# `bash` is explicit on purpose. `scripts/build-portable-release.sh` states the +# contract: "The entrypoint is `bash install.sh`, not `./install.sh`: the packer +# canonicalises modes and the repository file carries no executable bit, so do +# not test for one." A bare `exec "$script_dir/../install.sh"` inherits that +# missing bit and dies with 126 on every fresh clone — which is precisely the +# surface a Windows user meets first. script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) -exec "$script_dir/../install.sh" "$@" +exec bash "$script_dir/../install.sh" "$@" diff --git a/tests/tui/test_repo_symlink_free.py b/tests/tui/test_repo_symlink_free.py new file mode 100644 index 00000000..78e5b4f1 --- /dev/null +++ b/tests/tui/test_repo_symlink_free.py @@ -0,0 +1,146 @@ +"""The tracked tree must stay symlink-free, and a Windows clone must still run. + +BORN FROM (roadmap 4.2.0, cut W1-a): five alias symlinks lived in the tracked +tree until `4d1f1d11` (#47) removed them — `runtime`, `skills`, +`docs/install.sh`, `vibecrafted-core/vibecrafted_core/config/vc-frame` and +`.../runtime/shell/vetcoders.zsh`. Nothing guarded the removal. A Windows clone +(`core.symlinks=false`, the default without Developer Mode) materializes a +tracked symlink as a *text file holding the target path*, and bsdtar refuses +some of them outright, so a single re-added alias makes a fresh clone dead on +arrival while every macOS gate stays green. + +The doctrine these tests enforce (roadmap 4.2.0 D1): the repository tree is +symlink-free; projections such as the installed generation's top-level +`runtime/` are *produced by copy*, never linked. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + +GIT_SYMLINK_MODE = "120000" + +# The five paths that were symlinks before #47. Each must now be a regular +# file, a real directory, or absent — never a symlink again. +HISTORICAL_ALIAS_PATHS = ( + "docs/install.sh", + "runtime", + "skills", + "vibecrafted-core/vibecrafted_core/config/vc-frame", + "vibecrafted-core/vibecrafted_core/runtime/shell/vetcoders.zsh", +) + + +def _git(*args: str, cwd: Path = REPO_ROOT) -> str: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout + + +def _tracked_symlinks() -> list[str]: + entries = [] + for line in _git("ls-files", "-s").splitlines(): + if not line: + continue + mode, _, remainder = line.partition(" ") + if mode == GIT_SYMLINK_MODE: + entries.append(remainder.split("\t", 1)[-1]) + return entries + + +def test_tracked_tree_carries_no_symlinks() -> None: + offenders = _tracked_symlinks() + assert offenders == [], ( + "the tracked tree must stay symlink-free (roadmap 4.2.0 D1); " + f"mode {GIT_SYMLINK_MODE} entries found: {offenders}. " + "Produce the projection by copy in the installer/packers instead." + ) + + +@pytest.mark.parametrize("relative", HISTORICAL_ALIAS_PATHS) +def test_historical_alias_paths_are_real_or_absent(relative: str) -> None: + path = REPO_ROOT / relative + if not path.exists() and not path.is_symlink(): + return + assert not path.is_symlink(), ( + f"{relative} is a symlink again; it was removed in 4d1f1d11 (#47) " + "because Windows clones and bsdtar cannot carry it" + ) + assert path.is_file() or path.is_dir() + + +def test_windows_clone_without_symlink_support_keeps_entrypoints_runnable( + tmp_path: Path, +) -> None: + """`core.symlinks=false` is the Windows default; the shims must survive it. + + A clone made this way is exactly what a Windows user gets: any tracked + symlink would arrive as a text file with the target path inside, and + executing it would fail. Both entrypoints below were symlinks before #47. + """ + + clone = tmp_path / "windows-clone" + subprocess.run( + [ + "git", + "clone", + "--quiet", + "--no-hardlinks", + "--depth", + "1", + "--single-branch", + "--no-tags", + "-c", + "core.symlinks=false", + f"file://{REPO_ROOT}", + str(clone), + ], + check=True, + capture_output=True, + text=True, + ) + + materialized = [ + entry + for entry in clone.rglob("*") + if entry.is_symlink() and ".git" not in entry.relative_to(clone).parts + ] + assert materialized == [], f"clone still carries symlinks: {materialized}" + + install_shim = clone / "docs/install.sh" + assert install_shim.is_file() and not install_shim.is_symlink() + help_run = subprocess.run( + ["bash", str(install_shim), "--help"], + cwd=clone, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + assert help_run.returncode == 0, help_run.stderr[-2000:] + assert "Usage: install.sh" in help_run.stdout + + zsh = shutil.which("zsh") + if zsh is None: # pragma: no cover - zsh is present on macOS and CI + pytest.skip("zsh is not available on this host") + shim = clone / "vibecrafted-core/vibecrafted_core/runtime/shell/vetcoders.zsh" + assert shim.is_file() and not shim.is_symlink() + source_run = subprocess.run( + [zsh, "-c", f"source {shim}"], + cwd=clone, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + assert source_run.returncode == 0, source_run.stderr[-2000:] From cd13e1ca83762e02ff68c9a9ddc5aa4c08b81c37 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 15:22:55 +0200 Subject: [PATCH 08/36] [claude/vc-implement] feat(release): snapshot dirty donors instead of hand-rolling worktrees - add scripts/lib/donor-snapshot.sh: detached worktree at each donor HEAD plus a reaper that goes through git worktree remove --force and prune, so the ghost registration of 2026-08-11 cannot happen again - add --snapshot-donors to scripts/build-vibecrafted-release.sh; the flag loop replaces the single-argument case so it composes with the mode flags, and the snapshots are materialised inside build_product so --notarize-only never touches a donor - fold donor_snapshot_reap into the release cleanup trap - fix scripts/lib/keychain-session.sh trap chaining: _ks_trap_cleanup returns the triggering status on purpose, and under set -e that non-zero return tore the shell down before the caller's chained handler ran; measured on a real failed release, which skipped its own reaper and left both registrations - resolve donor roots with canonical_dir: the remap prefixes were built as "$REPO_ROOT/../vc-frame", and a prefix carrying .. never matches textually, which is why the shipped 4.1.0 binaries carry the living checkout path while /usr/src/vc-frame and /usr/src/vc-terminal appear in none of them - add tests/tui/test_donor_snapshot.py: real scratch repos, dirty donor, trap path, ghost reclaim, and the subshell regression that the walk-around caught - pass RELEASE_FLAGS through the Makefile release targets; document the flag, the cold rebuild cost and the correct worktree-count check Authored-By: claude session_id: 3da2a070-904b-4d8c-9e40-d3a94d10e3fa time: 2026-08-18T15:26:00+02:00 runtime: worker --- Makefile | 11 +- docs/RELEASE_CHECKLIST.md | 29 ++++ scripts/build-vibecrafted-release.sh | 89 +++++++++-- scripts/lib/donor-snapshot.sh | 97 ++++++++++++ scripts/lib/keychain-session.sh | 19 ++- tests/tui/test_donor_snapshot.py | 224 +++++++++++++++++++++++++++ tests/tui/test_release_contract.py | 66 ++++++++ 7 files changed, 511 insertions(+), 24 deletions(-) create mode 100644 scripts/lib/donor-snapshot.sh create mode 100644 tests/tui/test_donor_snapshot.py diff --git a/Makefile b/Makefile index 5c5630fc..2d0afe7a 100644 --- a/Makefile +++ b/Makefile @@ -65,18 +65,21 @@ vibecrafted: install RELEASE_SCRIPT := scripts/build-vibecrafted-release.sh PORTABLE_SCRIPT := scripts/build-portable-release.sh KEYS ?= $(HOME)/.keys +# Extra builder flags, e.g. RELEASE_FLAGS=--snapshot-donors to build from +# detached worktrees at each donor HEAD instead of refusing a dirty donor. +RELEASE_FLAGS ?= app: - @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" --app-only' + @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" --app-only $(RELEASE_FLAGS)' dmg dmg-signed release-local: - @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" --no-notarize' + @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" --no-notarize $(RELEASE_FLAGS)' notarize: - @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" --notarize-only' + @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" --notarize-only $(RELEASE_FLAGS)' release: - @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)"' + @zsh -ic 'cd "$(CURDIR)" && KEYS="$(KEYS)" exec bash "$(RELEASE_SCRIPT)" $(RELEASE_FLAGS)' # The portable channel needs no signing identity and no notary account: it is a # provenance-bound source distribution, so it builds anywhere git and python3 do. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index e802bf0c..217e6a89 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -90,6 +90,35 @@ make semgrep What this proves: the version file, the donors, and the source gates agree before you spend an hour in notarization. +### When a donor is dirty and you are not going to clean it + +The Living Tree keeps donors dirty on purpose, and the builder refuses a dirty +donor because a receipt must not bind a SHA that could move mid-build. Do **not** +hand-roll `git worktree add --detach` into a temp dir: that is where the ghost +registration of 2026-08-11 came from. Use the flag instead: + +```bash +make dmg RELEASE_FLAGS=--snapshot-donors +# or directly: +bash scripts/build-vibecrafted-release.sh --no-notarize --snapshot-donors +``` + +It creates a detached worktree at each donor's HEAD under +`build/unified-release/donor-snapshots/`, builds from those, and reaps them from +the same trap that ends the signing keychain — on success, on failure and on +Ctrl-C. The donor's own working tree, index and stashes are never touched, and +the receipt still binds the donor HEAD, because that is what the snapshot is. + +Two things to know before you use it: the snapshot starts from a **cold cargo +target directory**, so the build is a full rebuild; and the check afterwards is +that each donor is back to the worktree count it had _before_ the build, not +that it has exactly one — donors legitimately carry other agents' worktrees. + +```bash +git -C ../vc-frame worktree list # same entries as before the build +git -C ../vc-terminal worktree list +``` + ## 3. Build, sign, notarize ```bash diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index 0d7d1a79..df71a0c3 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -1,9 +1,54 @@ #!/usr/bin/env bash set -euo pipefail +log() { printf '\n==> %s\n' "$*"; } +die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; } +require() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +# A --remap-path-prefix whose prefix still contains `..` never matches the path +# the compiler actually sees, because the match is textual. The donor roots used +# to be plain concatenations ("$REPO_ROOT/../vc-terminal"), so both donor remaps +# silently missed every file: measured on the shipped 4.1.0 payload +# (Vibecrafted_4.1.0-20260817-237d2814.dmg, roadmap 4.2.0 cut W0-a), the strings +# `/usr/src/vc-frame` and `/usr/src/vc-terminal` are ABSENT from every binary +# while `/Volumes/<...>/vc-frame` and `/Volumes/<...>/vc-terminal` are present in +# Contents/Helpers/vc-frame, Contents/MacOS/Vibecrafted, Contents/MacOS/voc and +# the bundled alacritty. Resolve the donor roots; never concatenate them. +canonical_dir() { + local target="$1" + (cd "$target" >/dev/null 2>&1 && pwd) || die "missing donor directory: $target" +} + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TERMINAL_REPO="${VIBECRAFTED_TERMINAL_REPO:-$REPO_ROOT/../vc-terminal}" -FRAME_REPO="${VIBECRAFTED_FRAME_REPO:-$REPO_ROOT/../vc-frame}" + +MODE="release" +SNAPSHOT_DONORS=0 +for argument in "$@"; do + case "$argument" in + --app-only) MODE="app" ;; + --no-notarize) MODE="dmg" ;; + --notarize-only) MODE="notarize" ;; + --snapshot-donors) SNAPSHOT_DONORS=1 ;; + *) + echo "usage: $0 [--app-only|--no-notarize|--notarize-only] [--snapshot-donors]" >&2 + exit 2 + ;; + esac +done + +# The donor is where the source lives; the repo is what we compile. They differ +# only under --snapshot-donors, where the repo becomes a detached worktree at the +# donor HEAD so a dirty Living Tree donor can still produce an honest receipt. +TERMINAL_DONOR="$(canonical_dir "${VIBECRAFTED_TERMINAL_REPO:-$REPO_ROOT/../vc-terminal}")" +FRAME_DONOR="$(canonical_dir "${VIBECRAFTED_FRAME_REPO:-$REPO_ROOT/../vc-frame}")" +DONOR_SNAPSHOT_ROOT="$REPO_ROOT/build/unified-release/donor-snapshots" +if (( SNAPSHOT_DONORS )); then + TERMINAL_REPO="$DONOR_SNAPSHOT_ROOT/vc-terminal" + FRAME_REPO="$DONOR_SNAPSHOT_ROOT/vc-frame" +else + TERMINAL_REPO="$TERMINAL_DONOR" + FRAME_REPO="$FRAME_DONOR" +fi ICON_SOURCE="${VIBECRAFTED_ICON_SOURCE:-$TERMINAL_REPO/assets/icon/vc-terminal-icon.png}" ICON_REFERENCE="${VIBECRAFTED_ICON_REFERENCE:-$TERMINAL_REPO/assets/icon/terminal.png}" DIST_DIR="${VIBECRAFTED_RELEASE_DIR:-$REPO_ROOT/dist}" @@ -29,7 +74,6 @@ CERT_PASSWORD_FILE="$KEYS/cert_password.txt" SIGNING_KEY="$KEYS/vibecrafted-signing.key" NOTARY_ENV="$KEYS/.notary.env" BUILD_NUMBER="${BUILD_NUMBER:-$(date -u +%Y%m%d%H%M%S)}" -MODE="release" SIGNING_IDENTITY="" TEMP_KEYCHAIN_PATH="" SIGNING_KEYCHAIN_LABEL="vibecrafted-signing-$$" @@ -37,19 +81,7 @@ CODESIGN_KEYCHAIN_ARGS=() export MACOSX_DEPLOYMENT_TARGET=14.0 # Release payloads must not remember the operator account, Cargo registry, or # living checkout locations through Rust panic/debug metadata. -export RUSTFLAGS="--remap-path-prefix=$REPO_ROOT=/usr/src/vibecrafted --remap-path-prefix=$TERMINAL_REPO=/usr/src/vc-terminal --remap-path-prefix=$FRAME_REPO=/usr/src/vc-frame --remap-path-prefix=$HOME=/usr/src/operator-home" - -case "${1:-}" in - --app-only) MODE="app" ;; - --no-notarize) MODE="dmg" ;; - --notarize-only) MODE="notarize" ;; - "") ;; - *) echo "usage: $0 [--app-only|--no-notarize|--notarize-only]" >&2; exit 2 ;; -esac - -log() { printf '\n==> %s\n' "$*"; } -die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; } -require() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } +export RUSTFLAGS="--remap-path-prefix=$REPO_ROOT=/usr/src/vibecrafted --remap-path-prefix=$TERMINAL_DONOR=/usr/src/vc-terminal --remap-path-prefix=$FRAME_DONOR=/usr/src/vc-frame --remap-path-prefix=$TERMINAL_REPO=/usr/src/vc-terminal --remap-path-prefix=$FRAME_REPO=/usr/src/vc-frame --remap-path-prefix=$HOME=/usr/src/operator-home" # The ephemeral signing keychain is owned by scripts/lib/keychain-session.sh, # which arms its own EXIT/INT/TERM/HUP traps and chains onto whatever this @@ -61,8 +93,11 @@ require() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } # host-wide side effect for the entire duration of the release. # shellcheck source=/dev/null . "$REPO_ROOT/scripts/lib/keychain-session.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/donor-snapshot.sh" cleanup() { + donor_snapshot_reap || true keychain_session_end "$SIGNING_KEYCHAIN_LABEL" || true } trap cleanup EXIT INT TERM HUP @@ -188,7 +223,29 @@ remove_ambient_swift_rpath() { fi } +# Snapshots are materialised here, not at parse time: --notarize-only reuses an +# already assembled app and must not touch the donors at all. +materialize_donor_snapshots() { + (( SNAPSHOT_DONORS )) || return 0 + require git + log "Snapshotting donors at HEAD; their dirty working trees stay untouched" + # No command substitution here: it would run the snapshot in a subshell and + # the reaper would lose the record. See scripts/lib/donor-snapshot.sh. + local terminal_head frame_head + donor_snapshot_create "$TERMINAL_DONOR" "$TERMINAL_REPO" + terminal_head="$DONOR_SNAPSHOT_HEAD" + donor_snapshot_create "$FRAME_DONOR" "$FRAME_REPO" + frame_head="$DONOR_SNAPSHOT_HEAD" + log "vc-terminal snapshot at $terminal_head" + log "vc-frame snapshot at $frame_head" + # Every snapshot build starts from a cold target directory. That is the price + # of a receipt that binds a SHA nobody edited mid-build. + [[ -z "${VIBECRAFTED_RELEASE_FAIL_AFTER_SNAPSHOT:-}" ]] \ + || die "VIBECRAFTED_RELEASE_FAIL_AFTER_SNAPSHOT is set; failing on purpose so the reaper is exercised" +} + build_product() { + materialize_donor_snapshots require_clean_repo "$REPO_ROOT" vibecrafted require_clean_repo "$TERMINAL_REPO" vc-terminal require_clean_repo "$FRAME_REPO" vc-frame diff --git a/scripts/lib/donor-snapshot.sh b/scripts/lib/donor-snapshot.sh new file mode 100644 index 00000000..3ec2c807 --- /dev/null +++ b/scripts/lib/donor-snapshot.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ============================================================================ +# donor-snapshot.sh — build a release from a detached snapshot of a donor repo +# that is allowed to stay dirty +# ============================================================================ +# BORN FROM (2026-08-11): `build-vibecrafted-release.sh` refuses a dirty donor +# with "FATAL: is dirty; release receipts refuse moving source". That +# refusal is correct — a receipt that binds a SHA must not have been built from +# a tree that moved underneath it. But on the Living Tree a dirty donor is the +# NORMAL state, so the operator hand-rolled the way around it: +# +# git -C ../vc-frame worktree add --detach "$TMPDIR/.tmpQUEtCY/snapshot" HEAD +# +# and then the temp dir vanished before the worktree was removed. What was left +# behind was a ghost registration in the donor's `.git/worktrees/snapshot2` +# pointing at a path that no longer exists — `git worktree list` lied for a +# week, and nothing in either repo's history ever ran `worktree add` from a +# script, so there was no reaper to blame. +# +# This file makes that move a feature with a reaper attached: +# +# - the snapshot is a DETACHED worktree at the donor's HEAD, so the donor's +# dirty files, index and stashes are never read, moved or touched; +# - the snapshot is clean by construction, so the existing dirty-donor gate +# passes honestly instead of being bypassed; +# - `git_sha` on the snapshot IS the donor's HEAD, so the receipt keeps binding +# the exact source revision it claims; +# - every created snapshot is recorded, and the reaper removes the worktree +# THROUGH GIT (`worktree remove --force`) and then prunes, so no ghost +# registration can survive — on success and on failure alike, because the +# caller runs the reaper from a trap armed for EXIT INT TERM HUP. +# +# The reaper is idempotent: double delivery of a signal is harmless, and it +# never runs anything that could mutate the donor's index or stash list. + +# Records of live snapshots, one "\t" per entry. +DONOR_SNAPSHOTS=() +# The HEAD the most recent snapshot was taken at. Exported because its only +# reader is the script that sources this file, not this file itself. +export DONOR_SNAPSHOT_HEAD="" + +# donor_snapshot_create +# Sets DONOR_SNAPSHOT_HEAD to the donor HEAD the snapshot was taken at. +# +# It deliberately does NOT print the SHA for `head="$(donor_snapshot_create ...)"` +# to capture. MEASURED 2026-08-18 during this cut's own walk-around: with the +# printing shape, the release created both worktrees and then reaped NOTHING, +# because command substitution runs the function in a SUBSHELL — the +# `DONOR_SNAPSHOTS+=(...)` below mutated a copy that died with the subshell, and +# the parent entered its trap with an empty record list. Seven green unit tests +# had missed it because they called the function directly. A reaper that cannot +# see what it must reap is exactly how the 2026-08-11 ghost was born, so the +# recording side effect must happen in the caller's own shell. Read the result +# out of DONOR_SNAPSHOT_HEAD. +donor_snapshot_create() { + local donor="$1" path="$2" head + + git -C "$donor" rev-parse --git-dir >/dev/null 2>&1 \ + || { printf 'FATAL: %s is not a git repository\n' "$donor" >&2; return 1; } + head="$(git -C "$donor" rev-parse HEAD)" + + # Clear any residue from an earlier interrupted run before adding: a stale + # registration for this exact path would make `worktree add` refuse. + git -C "$donor" worktree prune >/dev/null 2>&1 || true + if [[ -e "$path" ]]; then + git -C "$donor" worktree remove --force "$path" >/dev/null 2>&1 || rm -rf "$path" + git -C "$donor" worktree prune >/dev/null 2>&1 || true + fi + + mkdir -p "$(dirname "$path")" + git -C "$donor" worktree add --detach --quiet "$path" "$head" >/dev/null + + DONOR_SNAPSHOTS+=("$donor"$'\t'"$path") + DONOR_SNAPSHOT_HEAD="$head" +} + +# donor_snapshot_reap — remove every snapshot this process created. +# Safe to call more than once and safe to call when nothing was created. +donor_snapshot_reap() { + local record donor path + (( ${#DONOR_SNAPSHOTS[@]} == 0 )) && return 0 + # The expansion MUST stay quoted. Each record holds "\t", and an + # unquoted "${DONOR_SNAPSHOTS[@]}" word-splits on the tab in IFS: `record` + # would then be the donor alone, `path` would resolve to the donor too, and + # the fallback below would have been pointed at the donor repository itself. + for record in "${DONOR_SNAPSHOTS[@]}"; do + [[ -n "$record" ]] || continue + donor="${record%%$'\t'*}" + path="${record#*$'\t'}" + if ! git -C "$donor" worktree remove --force "$path" >/dev/null 2>&1; then + # Belt and braces: never recursively delete the donor, and never a root. + [[ -n "$path" && "$path" != "$donor" && "$path" != "/" ]] && rm -rf "$path" + fi + git -C "$donor" worktree prune >/dev/null 2>&1 || true + done + DONOR_SNAPSHOTS=() +} diff --git a/scripts/lib/keychain-session.sh b/scripts/lib/keychain-session.sh index 196c7b73..8607e73a 100755 --- a/scripts/lib/keychain-session.sh +++ b/scripts/lib/keychain-session.sh @@ -349,11 +349,22 @@ _ks_arm_traps() { # was already registered, read back a few lines above, and it has to be # baked into the new handler at arm time. Deferring it would splice the # variable's value at signal time, when it no longer holds anything. + # + # `|| true` is load-bearing, not defensive noise. _ks_trap_cleanup ends with + # `return $rc` so it PRESERVES the status that triggered the trap — which + # means on any failed run it returns non-zero, and under `set -eu` (every + # release script here) a non-zero command inside a trap handler tears the + # shell down on the spot. The caller's chained handler then never runs. + # MEASURED 2026-08-18: a release that died on purpose after taking donor + # worktree snapshots skipped its own reaper entirely and left both + # registrations behind — the very ghost the chaining exists to prevent. + # An EXIT trap that does not call `exit` cannot change the script's exit + # status, so swallowing the status here costs nothing. case "$sig" in - EXIT) trap "_ks_trap_cleanup${body:+; $body}" EXIT ;; - INT) trap "_ks_trap_cleanup; ${body:-exit 130}" INT ;; - TERM) trap "_ks_trap_cleanup; ${body:-exit 143}" TERM ;; - HUP) trap "_ks_trap_cleanup; ${body:-exit 129}" HUP ;; + EXIT) trap "_ks_trap_cleanup || true${body:+; $body}" EXIT ;; + INT) trap "_ks_trap_cleanup || true; ${body:-exit 130}" INT ;; + TERM) trap "_ks_trap_cleanup || true; ${body:-exit 143}" TERM ;; + HUP) trap "_ks_trap_cleanup || true; ${body:-exit 129}" HUP ;; esac done } diff --git a/tests/tui/test_donor_snapshot.py b/tests/tui/test_donor_snapshot.py new file mode 100644 index 00000000..ea14eba6 --- /dev/null +++ b/tests/tui/test_donor_snapshot.py @@ -0,0 +1,224 @@ +"""Runtime proof for `scripts/lib/donor-snapshot.sh`. + +These tests drive the real shell functions against real scratch git repositories +— no text assertions, no mocks. What they must prove is the thing the 2026-08-11 +incident got wrong: a hand-rolled `git worktree add --detach` into a temp dir +left a ghost registration in the donor when the temp dir disappeared first, and +`git worktree list` lied about it afterwards. + +The contract under test: + +1. a snapshot of a DIRTY donor is clean and sits exactly at the donor HEAD, so + the release's dirty-donor gate passes honestly and the receipt still binds + the SHA it claims; +2. the donor's own dirty files, index and stash list are never touched; +3. the reaper removes the worktree through git and prunes, so `worktree list` + is back to one entry — on the success path AND from a trap on failure; +4. residue from an interrupted earlier run (path deleted behind git's back) is + reclaimed instead of blocking the next build. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +LIBRARY = REPO_ROOT / "scripts/lib/donor-snapshot.sh" + + +def _git(*args: str, cwd: Path) -> str: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _make_donor(root: Path) -> Path: + """A donor repository in the state the Living Tree actually keeps them in.""" + + root.mkdir(parents=True) + _git("init", "--quiet", "--initial-branch", "main", cwd=root) + _git("config", "user.email", "agents@vetcoders.io", cwd=root) + _git("config", "user.name", "donor", cwd=root) + (root / "committed.txt").write_text("committed\n", encoding="utf-8") + _git("add", "committed.txt", cwd=root) + _git("commit", "--quiet", "-m", "seed", cwd=root) + + # Dirty, exactly the way a donor under active work is dirty. + (root / "committed.txt").write_text("edited by another agent\n", encoding="utf-8") + (root / "scratch.txt").write_text("untracked\n", encoding="utf-8") + return root + + +def _run_driver(script: str, tmp_path: Path) -> subprocess.CompletedProcess[str]: + driver = tmp_path / "driver.sh" + driver.write_text( + f'#!/usr/bin/env bash\nset -euo pipefail\n. "{LIBRARY}"\n{script}\n', + encoding="utf-8", + ) + return subprocess.run( + ["bash", str(driver)], + check=False, + capture_output=True, + text=True, + timeout=120, + ) + + +def _worktree_count(donor: Path) -> int: + listing = _git("worktree", "list", cwd=donor) + return len([line for line in listing.splitlines() if line.strip()]) + + +def test_snapshot_of_a_dirty_donor_is_clean_and_binds_the_donor_head( + tmp_path: Path, +) -> None: + donor = _make_donor(tmp_path / "donor") + snapshot = tmp_path / "work/donor-snapshots/donor" + head = _git("rev-parse", "HEAD", cwd=donor) + + assert _git("status", "--porcelain", "--untracked-files=normal", cwd=donor), ( + "the fixture must be dirty or it proves nothing" + ) + + result = _run_driver( + f'donor_snapshot_create "{donor}" "{snapshot}"\n' + 'printf "%s\\n" "$DONOR_SNAPSHOT_HEAD"\n', + tmp_path, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == head + + assert snapshot.is_dir() + assert _git("rev-parse", "HEAD", cwd=snapshot) == head + assert _git("status", "--porcelain", "--untracked-files=all", cwd=snapshot) == "", ( + "the snapshot must be clean so the dirty-donor gate passes honestly" + ) + # The dirty donor content stayed exactly where it was. + assert (donor / "scratch.txt").exists() + assert (donor / "committed.txt").read_text(encoding="utf-8") == ( + "edited by another agent\n" + ) + # ... and the snapshot carries the COMMITTED bytes, not the edited ones. + assert (snapshot / "committed.txt").read_text(encoding="utf-8") == "committed\n" + assert not (snapshot / "scratch.txt").exists() + + +def test_reaper_returns_the_donor_to_a_single_worktree(tmp_path: Path) -> None: + donor = _make_donor(tmp_path / "donor") + snapshot = tmp_path / "work/donor-snapshots/donor" + + result = _run_driver( + f'donor_snapshot_create "{donor}" "{snapshot}" >/dev/null\n' + f'test "$(git -C "{donor}" worktree list | wc -l)" -eq 2\n' + "donor_snapshot_reap\n" + "donor_snapshot_reap # idempotent\n", + tmp_path, + ) + assert result.returncode == 0, result.stderr + assert _worktree_count(donor) == 1 + assert not snapshot.exists() + assert _git("stash", "list", cwd=donor) == "" + + +def test_trap_reaps_when_the_build_dies_after_snapshotting(tmp_path: Path) -> None: + """The failure path is the one the 2026-08-11 ghost came from.""" + + donor = _make_donor(tmp_path / "donor") + snapshot = tmp_path / "work/donor-snapshots/donor" + + result = _run_driver( + "trap 'donor_snapshot_reap || true' EXIT INT TERM HUP\n" + f'donor_snapshot_create "{donor}" "{snapshot}" >/dev/null\n' + 'printf "boom\\n" >&2\n' + "exit 1\n", + tmp_path, + ) + assert result.returncode == 1 + assert _worktree_count(donor) == 1, ( + "a failed build must not leave a worktree registration behind" + ) + assert not snapshot.exists() + + +def test_residue_from_an_interrupted_run_is_reclaimed(tmp_path: Path) -> None: + """Exactly the ghost: the snapshot path vanished behind git's back.""" + + donor = _make_donor(tmp_path / "donor") + snapshot = tmp_path / "work/donor-snapshots/donor" + + first = _run_driver( + f'donor_snapshot_create "{donor}" "{snapshot}" >/dev/null', tmp_path + ) + assert first.returncode == 0, first.stderr + + # The temp dir disappears; git still believes the worktree exists. + subprocess.run(["rm", "-rf", str(snapshot)], check=True) + assert _worktree_count(donor) == 2, "fixture must reproduce the ghost" + + second = _run_driver( + f'donor_snapshot_create "{donor}" "{snapshot}" >/dev/null\n' + "donor_snapshot_reap\n", + tmp_path, + ) + assert second.returncode == 0, second.stderr + assert _worktree_count(donor) == 1 + assert not snapshot.exists() + + +@pytest.mark.parametrize( + "needle", + ( + "worktree add --detach", + "worktree remove --force", + "worktree prune", + ), +) +def test_library_reaps_through_git_not_through_rm(needle: str) -> None: + assert needle in LIBRARY.read_text(encoding="utf-8") + + +def test_the_record_is_made_in_the_callers_shell_not_a_subshell( + tmp_path: Path, +) -> None: + """REGRESSION 2026-08-18, caught by a real release run, not by a unit test. + + `donor_snapshot_create` used to print the SHA so the caller could write + `head="$(donor_snapshot_create ...)"`. Command substitution runs the + function in a subshell: `DONOR_SNAPSHOTS+=(...)` mutated a copy that died + with the subshell, the parent entered its trap with an empty record list, + and a deliberately failed release left BOTH donor worktrees registered — + the exact ghost this library exists to prevent. The driver below mirrors the + builder's real call shape. + """ + + donor = _make_donor(tmp_path / "donor") + snapshot = tmp_path / "work/donor-snapshots/donor" + + result = _run_driver( + "trap 'donor_snapshot_reap || true' EXIT INT TERM HUP\n" + f'donor_snapshot_create "{donor}" "{snapshot}"\n' + 'head="$DONOR_SNAPSHOT_HEAD"\n' + 'test -n "$head"\n' + 'test "${#DONOR_SNAPSHOTS[@]}" -eq 1\n' + "exit 1\n", + tmp_path, + ) + assert result.returncode == 1, result.stderr + assert _worktree_count(donor) == 1 + assert not snapshot.exists() + + +def test_builder_never_captures_the_snapshot_through_command_substitution() -> None: + builder = (REPO_ROOT / "scripts/build-vibecrafted-release.sh").read_text( + encoding="utf-8" + ) + assert "$(donor_snapshot_create" not in builder + assert 'terminal_head="$DONOR_SNAPSHOT_HEAD"' in builder + assert 'frame_head="$DONOR_SNAPSHOT_HEAD"' in builder diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index a5be9181..f51d7d75 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -376,3 +376,69 @@ def test_vc_release_skill_locks_four_mandatory_report_sections() -> None: "## Sign-off", ): assert heading in template_text + + +def test_dirty_donors_are_a_release_flag_with_a_reaper_not_a_manual_ritual() -> None: + """`--snapshot-donors` must build from detached worktrees and always reap. + + Roadmap 4.2.0 D2. Before this flag the operator hand-rolled + `git worktree add --detach` into a temp dir; the dir disappeared first and + left a ghost registration in the donor for a week. + """ + + builder = (REPO_ROOT / "scripts/build-vibecrafted-release.sh").read_text( + encoding="utf-8" + ) + library = (REPO_ROOT / "scripts/lib/donor-snapshot.sh").read_text(encoding="utf-8") + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + + assert "--snapshot-donors) SNAPSHOT_DONORS=1 ;;" in builder + assert '. "$REPO_ROOT/scripts/lib/donor-snapshot.sh"' in builder + # The reaper runs from the same trap that ends the keychain session, so it + # fires on success, on error, and on Ctrl-C during a notarization wait. + assert "donor_snapshot_reap || true" in builder + assert "trap cleanup EXIT INT TERM HUP" in builder + assert "materialize_donor_snapshots" in builder + assert "VIBECRAFTED_RELEASE_FAIL_AFTER_SNAPSHOT" in builder + + # Reaping goes through git; `rm -rf` alone is what creates ghosts. + assert "worktree add --detach" in library + assert "worktree remove --force" in library + assert "worktree prune" in library + + # Without the flag the refusal is unchanged: a receipt must not be built + # from a tree that can move underneath it. + assert 'die "$label is dirty; release receipts refuse moving source"' in builder + + assert "RELEASE_FLAGS ?=" in makefile + assert "--app-only $(RELEASE_FLAGS)" in makefile + assert "--no-notarize $(RELEASE_FLAGS)" in makefile + + +def test_donor_remap_prefixes_are_resolved_never_concatenated() -> None: + """A `..` inside a --remap-path-prefix never matches; measured on 4.1.0. + + In `Vibecrafted_4.1.0-20260817-237d2814.dmg` the strings `/usr/src/vc-frame` + and `/usr/src/vc-terminal` are absent from every shipped binary while the + living checkout path is present, because both donor prefixes were built as + `"$REPO_ROOT/../vc-frame"` and the compiler matches prefixes textually. + """ + + builder = (REPO_ROOT / "scripts/build-vibecrafted-release.sh").read_text( + encoding="utf-8" + ) + + assert "canonical_dir()" in builder + assert ( + 'TERMINAL_DONOR="$(canonical_dir "${VIBECRAFTED_TERMINAL_REPO:-$REPO_ROOT/../vc-terminal}")"' + in builder + ) + assert ( + 'FRAME_DONOR="$(canonical_dir "${VIBECRAFTED_FRAME_REPO:-$REPO_ROOT/../vc-frame}")"' + in builder + ) + assert "--remap-path-prefix=$TERMINAL_DONOR=/usr/src/vc-terminal" in builder + assert "--remap-path-prefix=$FRAME_DONOR=/usr/src/vc-frame" in builder + # The snapshot roots are compiled too when --snapshot-donors is used. + assert "--remap-path-prefix=$TERMINAL_REPO=/usr/src/vc-terminal" in builder + assert "--remap-path-prefix=$FRAME_REPO=/usr/src/vc-frame" in builder From e9f47da1f8c795f41b6f0d8576e19c7f97136dda Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 15:26:06 +0200 Subject: [PATCH 09/36] [claude/vc-implement] test(install): guard the two install.ps1 copies against drift - assert the framework install.ps1 and vibecrafted-io/site/public/install.ps1 are byte-identical when the site repo is checked out beside this one - the 404 on https://vibecrafted.io/install.ps1 is not a pipeline limit: the site repo's deploy branch (origin/main, 2026-04-14) never received the 2026-08-14 commit that added the asset, and /install.sh answers 200 - roadmap 4.2.0 cut W1-c; serving still needs an operator merge and deploy Authored-By: claude session_id: 3da2a070-904b-4d8c-9e40-d3a94d10e3fa time: 2026-08-18T15:40:00+02:00 runtime: worker --- tests/tui/test_release_contract.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index f51d7d75..d8e52357 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -1,10 +1,13 @@ from __future__ import annotations +import hashlib import json import subprocess import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[2] RELEASE_PAGE = "https://github.com/vetcoders/vibecrafted/releases/latest" @@ -442,3 +445,26 @@ def test_donor_remap_prefixes_are_resolved_never_concatenated() -> None: # The snapshot roots are compiled too when --snapshot-donors is used. assert "--remap-path-prefix=$TERMINAL_REPO=/usr/src/vc-terminal" in builder assert "--remap-path-prefix=$FRAME_REPO=/usr/src/vc-frame" in builder + + +def test_windows_entry_point_does_not_drift_between_its_two_copies() -> None: + """`install.ps1` lives here and in vibecrafted-io; two copies means drift. + + Roadmap 4.2.0 cut W1-c. The site copy is what a Windows user would fetch + over HTTPS, so the moment the two differ the served script is a lie about + this repository. Measured 2026-08-18: identical, and + `https://vibecrafted.io/install.ps1` still answers 404 — not because the + pipeline drops non-HTML assets (`/install.sh` answers 200) but because the + site repo's deploy branch has not received the commit that added it. + """ + + framework = REPO_ROOT / "install.ps1" + served = REPO_ROOT.parent / "vibecrafted-io/site/public/install.ps1" + assert framework.is_file() + if not served.is_file(): + pytest.skip("vibecrafted-io is not checked out beside this repository") + + digest = hashlib.sha256(framework.read_bytes()).hexdigest() + assert digest == hashlib.sha256(served.read_bytes()).hexdigest(), ( + "install.ps1 drifted between the framework repo and the served site copy" + ) From 01e5e18a0cdcbbe6d0626ae50cd20cd924348123 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 15:53:02 +0200 Subject: [PATCH 10/36] [claude/vc-implement] refactor(core): import siblings by module path, not through the barrel - __init__.py __getattr__ imported its lazy modules with `from . import X`, and `.` is this very file, so the importer graph recorded a structural self-cycle on the package barrel; the absolute form names the owning module - run_triage.py and vc_frame_delivery.py bound a sibling module object the same way, which put the barrel in the middle of three diamond cycles - runtime behaviour is unchanged: the package still initialises before any submodule, and the lazy exports stay lazy; what changes is that the graph now names the module that actually owns each symbol - measured with loct on this branch: cycles 0 breaking / 1 structural / 3 diamond / 4 total before, 0 / 0 / 0 / 0 after; health 74 to 80 - gates: test_package_api_contract.py untouched and green (103 passed with doctor and control_plane), mypy clean on all three, semgrep clean, and `import vibecrafted_core, vibecrafted_acp.server` still resolves Authored-By: claude session_id: 3da2a070-904b-4d8c-9e40-d3a94d10e3fa time: 2026-08-18T15:56:00+02:00 runtime: worker --- vibecrafted-core/vibecrafted_core/__init__.py | 19 +++++++++++++------ .../vibecrafted_core/run_triage.py | 4 +++- .../vibecrafted_core/vc_frame_delivery.py | 8 +++++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/vibecrafted-core/vibecrafted_core/__init__.py b/vibecrafted-core/vibecrafted_core/__init__.py index d7ea5b21..5e5f301a 100644 --- a/vibecrafted-core/vibecrafted_core/__init__.py +++ b/vibecrafted-core/vibecrafted_core/__init__.py @@ -182,29 +182,36 @@ def __getattr__(name: str) -> Any: if module_name is None: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + # Each branch imports its sibling BY MODULE PATH. The obvious spelling here + # is `from . import workflow`, and it is what this chain used to say — but + # `.` is the package barrel, i.e. this very file, so every static importer + # graph recorded `__init__.py -> __init__.py` and reported a structural + # self-cycle that has no load-order meaning. The absolute form names the + # module that actually owns the symbol. Runtime behaviour is unchanged: + # these imports still happen lazily, only when __getattr__ is reached. module: Any if module_name == ".workflow": - from . import workflow + from vibecrafted_core import workflow module = workflow elif module_name == ".continuity": - from . import continuity + from vibecrafted_core import continuity module = continuity elif module_name == ".delivery": - from . import delivery + from vibecrafted_core import delivery module = delivery elif module_name == ".events": - from . import events + from vibecrafted_core import events module = events elif module_name == ".control_plane": - from . import control_plane + from vibecrafted_core import control_plane module = control_plane elif module_name == ".settlement_ledger": - from . import settlement_ledger + from vibecrafted_core import settlement_ledger module = settlement_ledger else: # pragma: no cover - _LAZY_EXPORTS is the whitelist. diff --git a/vibecrafted-core/vibecrafted_core/run_triage.py b/vibecrafted-core/vibecrafted_core/run_triage.py index 56c7d997..47ac34f1 100644 --- a/vibecrafted-core/vibecrafted_core/run_triage.py +++ b/vibecrafted-core/vibecrafted_core/run_triage.py @@ -51,7 +51,9 @@ from pathlib import Path from typing import Any, Protocol, TypeGuard -from . import run_mutation as run_mutation_module +# Module-path import on purpose; see the note in vc_frame_delivery.py. +import vibecrafted_core.run_mutation as run_mutation_module + from .run_mutation import ( RunMetaMutationError, mutate_run_meta, diff --git a/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py index 77d59fae..6999f7a8 100644 --- a/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py +++ b/vibecrafted-core/vibecrafted_core/vc_frame_delivery.py @@ -25,7 +25,13 @@ from datetime import datetime, timezone from pathlib import Path -from . import vc_frame_staging as _vc_frame_staging +# Imported by module path, not `from . import ...`: the relative form binds the +# sibling through the package barrel, and every static importer graph then +# records an edge back into `vibecrafted_core/__init__.py`. Runtime behaviour is +# identical (the package still initialises first); what changes is that the +# graph names the module that actually owns these symbols. +import vibecrafted_core.vc_frame_staging as _vc_frame_staging + from .frontier_assets import vc_frame_config_source from .runtime_paths import vibecrafted_tools_home, xdg_config_home from .vc_frame_staging import ( From 4918c7fb95d89af4872e7a1437e57317f7ed5259 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 15:55:34 +0200 Subject: [PATCH 11/36] [claude/vc-implement] fix(core): keep the barrel self-cycle fix past the formatter - the previous commit spelled the lazy imports `import vibecrafted_core.X as X` and ruff PLR0402 rewrote every one of them back into `from vibecrafted_core import X` inside the pre-commit hook, which is the barrel again; measured right after: structural cycles back to 1, health 80 down to 78 - use the bare dotted form `import vibecrafted_core.X` plus an attribute read, which ruff leaves alone; re-measured 0 breaking / 0 structural / 0 diamond and health back to 80, with `ruff check` and `ruff format --check` clean - lazy exports, package API contract (39 passed) and mypy all unchanged Authored-By: claude session_id: 3da2a070-904b-4d8c-9e40-d3a94d10e3fa time: 2026-08-18T16:03:00+02:00 runtime: worker --- vibecrafted-core/vibecrafted_core/__init__.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/vibecrafted-core/vibecrafted_core/__init__.py b/vibecrafted-core/vibecrafted_core/__init__.py index 5e5f301a..3ba0b5f4 100644 --- a/vibecrafted-core/vibecrafted_core/__init__.py +++ b/vibecrafted-core/vibecrafted_core/__init__.py @@ -189,31 +189,37 @@ def __getattr__(name: str) -> Any: # self-cycle that has no load-order meaning. The absolute form names the # module that actually owns the symbol. Runtime behaviour is unchanged: # these imports still happen lazily, only when __getattr__ is reached. + # + # The spelling is `import vibecrafted_core.X`, not `import ... as X`: ruff's + # PLR0402 rewrites an alias that repeats the last component back into + # `from vibecrafted_core import X`, which is the barrel again and brings the + # self-cycle straight back. Measured after the first attempt: the formatter + # undid it in the pre-commit hook. module: Any if module_name == ".workflow": - from vibecrafted_core import workflow + import vibecrafted_core.workflow - module = workflow + module = vibecrafted_core.workflow elif module_name == ".continuity": - from vibecrafted_core import continuity + import vibecrafted_core.continuity - module = continuity + module = vibecrafted_core.continuity elif module_name == ".delivery": - from vibecrafted_core import delivery + import vibecrafted_core.delivery - module = delivery + module = vibecrafted_core.delivery elif module_name == ".events": - from vibecrafted_core import events + import vibecrafted_core.events - module = events + module = vibecrafted_core.events elif module_name == ".control_plane": - from vibecrafted_core import control_plane + import vibecrafted_core.control_plane - module = control_plane + module = vibecrafted_core.control_plane elif module_name == ".settlement_ledger": - from vibecrafted_core import settlement_ledger + import vibecrafted_core.settlement_ledger - module = settlement_ledger + module = vibecrafted_core.settlement_ledger else: # pragma: no cover - _LAZY_EXPORTS is the whitelist. raise AttributeError(f"module {__name__!r} has no lazy module for {name!r}") From df9b6337e9a3fa2019bf2383768879e82ee3430e Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 15:58:02 +0200 Subject: [PATCH 12/36] [claude/vc-implement] docs(roadmap): record what the implement stage measured - add the landed-SHA table for the nine 4.2.0 cuts with their real states - record the seven findings that changed the plan: the fresh-clone exit 126, the donor remap prefixes that never matched, the trap chain that dropped the caller's cleanup, vibecrafted.io still serving 3.7.0, the answered menu question, the missing session-manager permissions, and the five tests that were already red Authored-By: claude session_id: 3da2a070-904b-4d8c-9e40-d3a94d10e3fa time: 2026-08-18T16:20:00+02:00 runtime: worker --- docs/ROADMAP_4.2.0.md | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/ROADMAP_4.2.0.md b/docs/ROADMAP_4.2.0.md index c8b9caa7..c862de04 100644 --- a/docs/ROADMAP_4.2.0.md +++ b/docs/ROADMAP_4.2.0.md @@ -38,6 +38,55 @@ W3 (after W1-a). Every wave ends at an operator button (merge / deploy / install 4. vc-frame shows Workspaces (catalog, `workspace_id`), not physical session names. 5. Runtime acceptance on the installed build is a cut (W0), not a footnote. +## Implement stage — what landed, 2026-08-18 + +Stage `implement` ran as a single worker (no fleet), so every cut below carries the +executing agent's own authorship, not the brief's planned assignee. + +| Cut | State | Landed SHA(s) | Measured result | +| ---- | ----- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W0-a | `[!]` | recon only, no commit | Portable payload clean (0 symlinks, 0 `.env`, no operator identity). **DMG is not:** `Contents/Helpers/vc-frame` and `Contents/MacOS/Vibecrafted` carry `/Users//.cargo/...`, and four binaries carry the living checkout path. | +| W0-b | `[~]` | recon only, no commit | Installed runtime `4.1.0+g237d2814` contains `18dea346`; `resume --run-id ` fails loudly (exit 1, names the id). Live resume of a real run not exercised from a headless worker. | +| W0-c | `[!]` | recon only, no commit | `catalog.json` present, schema valid, 48 workspaces — but **22 of 48 ids are UUIDv4, including this repo's** (`bda366e0-…-45f1-…`). The plan's UUIDv7 premise is false. | +| W1-a | `[~]` | `838165d6` | Guard added; the clone smoke found a real break and fixed it (see below). | +| W1-b | `[~]` | `cd13e1ca` | `--snapshot-donors` + reaper; proved on the real donors, failure path included. | +| W1-c | `[~]` | `e9f47da1` | Parity guarded. The 404 is a stale deploy branch, not a pipeline limit. | +| W2-a | `[ ]` | — | Not implemented; premise falsified (see W0-c) and acceptance is GUI-only. | +| W2-b | `[~]` | — | Chrome asks already landed in vc-frame `76048ca54`; the menu question is answered below. | +| W3-a | `[~]` | `01e5e18a`, `4918c7fb` | Import cycles 4 → **0**; loctree health 74 → **80**. | + +### Measured findings that changed the plan + +1. **`docs/install.sh` died with exit 126 on every fresh clone.** After #47 it became a + real shim that `exec`s `../install.sh`, but the repository file carries no executable + bit by design — `scripts/build-portable-release.sh:86-88` states exactly that. The shim + now execs `bash` explicitly. +2. **The 4.1.0 DMG leaks operator paths.** `--remap-path-prefix` ran (`/usr/src/operator-home` + is present) yet `/usr/src/vc-frame` and `/usr/src/vc-terminal` are present in _no_ binary: + the donor prefixes were built as `"$REPO_ROOT/../vc-frame"`, and a prefix containing `..` + never matches textually. Fixed in `cd13e1ca`; the remaining `.cargo/registry` leak in + `Contents/Helpers/vc-frame` is unexplained and needs its own cut. +3. **`keychain-session.sh` silently dropped the caller's cleanup on any failed release.** + `_ks_trap_cleanup` returns the triggering status by design, and under `set -e` a non-zero + command inside a trap tears the shell down before the chained handler runs. Measured on a + real failed release; fixed in `cd13e1ca`. +4. **`vibecrafted.io` is serving 3.7.0.** `https://vibecrafted.io/VERSION` answers `3.7.0` + and the served `install.sh` is the 3.7.0-era script. The site repo's deploy branch + (`origin/main`) is at 2026-04-14; the 4.1.0 hydration and `install.ps1` live only on an + unmerged branch. The `/install.ps1` 404 is one symptom of that, not the problem. +5. **"Open Console" is not a mislabelled terminal.** The tray menu already carries both + `Open Console` (the Swift main window) and `Open vc-terminal`. Renaming the first would + produce two terminal-sounding items. Kept; a clearer word than "Console" for the Swift + window is a naming call for the operator. +6. **`session-manager` requests no plugin permissions at all** (`request_permission` appears + only in `status-bar` and the test fixture), so W2-a cannot read the catalog from inside the + WASM sandbox without a new consent prompt. The smaller blast radius is a host-side + projection onto `SessionInfo` (`zellij-utils/src/data.rs:1824`). +7. **Five tests were already red before this stage** and stayed red: three + `test_research_launcher.py` settle timeouts, `test_vibecrafted_launcher.py::test_dashboard_subcommand_launches_repo_owned_vc_frame_layout`, + and `vibecrafted-core/tests/test_aicx_session_chain.py::test_resume_pack_never_selects_native_even_with_same_agent`. + All four/one confirmed against a clean `HEAD`. + ## Explicit non-goals Native Windows runtime · a second control plane · new vc-frame features beyond the From 85cebab5f89459c7926c17343a700e677e16c498 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 16:48:53 +0200 Subject: [PATCH 13/36] [claude/vc-workflow] fix(release): refuse payloads that name the build host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review P1-01 said the `.cargo/registry` leak comes from prebuilt WASM blobs. Measuring the shipped Vibecrafted_4.1.0-20260817-237d2814.dmg found FIVE producers, of which `--remap-path-prefix` reaches exactly one: - Contents/Helpers/vc-frame — 411x $HOME, from git-tracked assets/plugins/*.wasm pulled in by include_bytes! while `make release-binary` builds --no-plugins - Contents/MacOS/Vibecrafted — 21x $HOME/.cargo/.../ring-0.17.14/crypto/..., emitted by cc-rs, which reads CFLAGS and not RUSTFLAGS - Contents/MacOS/Vibecrafted — 51x the checkout root, from Swift sources and xcodebuild DerivedData intermediates - runtime/python/lib/python3.12/_sysconfigdata__darwin_darwin.py — 27x the ephemeral uv seed directory - runtime/python-site/bin/jsonschema — a shebang naming that same seed dir; not only a leak, a script that cannot run anywhere but this laptop Five producers, five levers, so the primary defence is producer-agnostic: - scripts/payload_hygiene.py streams the finished payload and refuses any build-host literal. No allowlist. 2955 files in 1.7s. - both release channels gate before they sign or publish; `make payload-hygiene ARTIFACT=<...>` asks the same question of artifacts already on disk - the first standalone run missed 277 leaks in the bundled alacritty because it did not know the donors; the entry point now resolves them, and a test pins it Producers closed, with measurements: - plugins rebuilt inside the donor snapshot under the release remaps: all 14 blobs go 276 -> 0 occurrences of $HOME, 14 -> 0 of the checkout root. Only ever against a snapshot, never the living donor; the snapshot's resulting dirty set is exactly zellij-utils/assets/plugins/, which is the whole of the new require_clean_repo allowance - remap order reversed to broadest-first: rustc applies the LAST match, so the trailing $HOME entry would have shadowed every specific root on any host whose checkout lives under $HOME. Duplicate donor pair dropped - CFLAGS/CXXFLAGS get -ffile-prefix-map; xcodebuild gets -debug-prefix-map - python-site/bin deleted, interpreter seed path normalised - default_command_deck and find_tray_icon no longer probe CARGO_MANIFEST_DIR in release builds. That path EXISTS on the build host, so the shipped binary preferred the developer's checkout on the one machine the release gets walked around on - five tracked files carried the operator's real checkout into the portable tarball verbatim; a host-independent test now guards the whole tree Gates: unified-product-contract-gate green (606 passed/18 skipped, 83 passed/1 skipped, self-test PASS); 32 release+hygiene contract tests; clippy --workspace --all-targets -D warnings clean (fixed a pre-existing derivable_impls red); cargo test --workspace green; shellcheck and semgrep clean. Not verified: the Swift and cc-rs prefix maps need a full signed release to confirm. The gate is what makes that non-optional — the next release fails loudly if they did not work. Authored-By: claude session_id: 0658cbd4-0648-48d5-af3e-f24fcb6a3e54 time: 2026-08-18T16:48:32+02:00 runtime: worker --- Makefile | 19 ++- docs/design/agents-workshop/Layout-2.md | 2 +- docs/design/agents-workshop/Layout-5.md | 2 +- docs/design/agents-workshop/_render.py | 4 +- docs/design/agents-workshop/preview.html | 4 +- scripts/build-portable-release.sh | 11 ++ scripts/build-vibecrafted-release.sh | 193 +++++++++++++++++++++- scripts/lib/payload-hygiene.sh | 62 +++++++ scripts/payload-hygiene-artifact.sh | 92 +++++++++++ scripts/payload_hygiene.py | 183 +++++++++++++++++++++ tests/tui/test_payload_hygiene.py | 200 +++++++++++++++++++++++ tests/tui/test_release_contract.py | 173 +++++++++++++++++++- vibecrafted-app/mux-agent/src/tray.rs | 13 +- vibecrafted-app/tui-agent/src/config.rs | 24 ++- vibecrafted-app/tui-agent/src/observe.rs | 11 +- 15 files changed, 961 insertions(+), 32 deletions(-) create mode 100644 scripts/lib/payload-hygiene.sh create mode 100755 scripts/payload-hygiene-artifact.sh create mode 100755 scripts/payload_hygiene.py create mode 100644 tests/tui/test_payload_hygiene.py diff --git a/Makefile b/Makefile index 2d0afe7a..53313580 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ CARGO_BUILD_ROOT ?= $(INSTALLER_CACHE_HOME)/vibecrafted/build/$(INSTALLER_HOST_T # in-tree cache is never read or written by install lanes. export PYTHONPYCACHEPREFIX ?= $(INSTALLER_CACHE_HOME)/vibecrafted/pycache-$(INSTALLER_HOST_TAG) -.PHONY: help help-dev vibecrafted app dmg dmg-signed release-local notarize release portable publish-release gui-install wizard wizard-dev check test test-core test-skills test-install test-parity test-vc-frame test-iterm2-migrate test-memex test-aicx-sync test-hammerspoon test-keychain-session dispatch-test unified-product-contract-gate install install-auto install-all install-python-tools install-bundle-tools install-tools install-tools-held install-vendored-binaries install-app-binaries install-hammerspoon skills helpers setup-dev dry-run doctor list update uninstall restore migrate migrate-dry init-hooks seed-commit-msg-hooks bundle bundle-check foundations foundations-check semgrep version version-show version-bump bump-patch bump-minor bump-major iterm-plugin iterm-plugin-refresh iterm-plugin-show iterm-plugin-uninstall iterm-plugin-migrate demo demo-full commit-safe test-race-protection skill-new server server-build build-server-release server-check server-test install-server install-server-payload install-server-service server-smoke +.PHONY: help help-dev vibecrafted app dmg dmg-signed release-local notarize release portable publish-release gui-install wizard wizard-dev check test test-core test-skills test-install test-parity test-vc-frame test-iterm2-migrate test-memex test-aicx-sync test-hammerspoon test-keychain-session dispatch-test unified-product-contract-gate payload-hygiene install install-auto install-all install-python-tools install-bundle-tools install-tools install-tools-held install-vendored-binaries install-app-binaries install-hammerspoon skills helpers setup-dev dry-run doctor list update uninstall restore migrate migrate-dry init-hooks seed-commit-msg-hooks bundle bundle-check foundations foundations-check semgrep version version-show version-bump bump-patch bump-minor bump-major iterm-plugin iterm-plugin-refresh iterm-plugin-show iterm-plugin-uninstall iterm-plugin-migrate demo demo-full commit-safe test-race-protection skill-new server server-build build-server-release server-check server-test install-server install-server-payload install-server-service server-smoke help: @printf "\n" @@ -86,6 +86,23 @@ release: portable: @bash "$(PORTABLE_SCRIPT)" +# Ask an artifact that ALREADY EXISTS whether it names the build host. Both +# release scripts run this gate before they sign or publish, but a release is +# expensive and the artifacts from before the gate existed are still on disk — +# so the same question has to be answerable without a rebuild. +# +# make payload-hygiene ARTIFACT=dist/Vibecrafted.app +# make payload-hygiene ARTIFACT=dist/Vibecrafted_4.1.0-20260817-237d2814.dmg +# +# A .dmg is mounted read-only and detached again; nothing is written anywhere. +PAYLOAD_HYGIENE_SCRIPT := scripts/payload-hygiene-artifact.sh +ARTIFACT ?= +payload-hygiene: + @test -n "$(ARTIFACT)" || { \ + printf 'usage: make payload-hygiene ARTIFACT=\n' >&2; \ + exit 2; } + @bash "$(PAYLOAD_HYGIENE_SCRIPT)" "$(ARTIFACT)" + publish-release: @zsh -ic 'cd "$(CURDIR)" && exec bash scripts/publish-vibecrafted-release.sh' diff --git a/docs/design/agents-workshop/Layout-2.md b/docs/design/agents-workshop/Layout-2.md index 0f6472a3..48262810 100644 --- a/docs/design/agents-workshop/Layout-2.md +++ b/docs/design/agents-workshop/Layout-2.md @@ -33,7 +33,7 @@ SESSIONS 5 ┌ grok · vibecrafted ──────────── · resume-codex │ ┌ ❯ Nowy agent ────────────────────────────────────────────────────────────── [Anuluj] ┐ │ 04 ◉ vc-release │ │ ▸ agent [agy] [claude] [codex] «grok» [junie] │ │ · Start here │ │ rytual [init] «resume» [operator] [partner] │ │ - ◉ Agents │ │ sciezka /Volumes/vc-workspace/vetcoders/vibecrafted-suite/vibecrafted │ │ + ◉ Agents │ │ sciezka /srv/vetcoders/vibecrafted │ │ · shell │ │ Enter = interaktywny panel na tym tabie. Nie mux. Nie headless. │ │ · voc │ └─ ↑/↓ wiersz · ←/→ chip · spacja · enter · esc ───────────────────────────────┘ │ 05 ○ vibecrafted-vc_│ │ diff --git a/docs/design/agents-workshop/Layout-5.md b/docs/design/agents-workshop/Layout-5.md index ff6d3164..ed125e34 100644 --- a/docs/design/agents-workshop/Layout-5.md +++ b/docs/design/agents-workshop/Layout-5.md @@ -33,7 +33,7 @@ SESSIONS 5 ┌ grok · vibecrafted ──────────── · resume-codex │ ┌ ❯ Nowy dispatch ─────────────────────────────────────────────────────────── [Anuluj] ┐ │ 04 ◉ vc-release │ │ ▸ agent [agy] [claude] [codex] «grok» [junie] │ │ · Start here │ │ rytual [init] «resume» [operator] [partner] │ │ - ◉ Agents │ │ sciezka /Volumes/vc-workspace/vetcoders/vibecrafted-suite/vibecrafted │ │ + ◉ Agents │ │ sciezka /srv/vetcoders/vibecrafted │ │ · shell │ │ Enter = HEADLESS worker. Bez TTY. Widać go na serwerze / w voc. │ │ · voc │ └─ ten sam chassis co Nowy agent · inne narodziny ─────────────────────────────────────┘ │ 05 ○ vibecrafted-vc_│ │ diff --git a/docs/design/agents-workshop/_render.py b/docs/design/agents-workshop/_render.py index 55361f3a..eba5600c 100755 --- a/docs/design/agents-workshop/_render.py +++ b/docs/design/agents-workshop/_render.py @@ -225,7 +225,7 @@ def chips(items: list[str], selected: str) -> str: def mark(name: str) -> str: return "▸" if focus == name else " " - path = "/Volumes/vc-workspace/vetcoders/vibecrafted-suite/vibecrafted" + path = "/srv/vetcoders/vibecrafted" if focus == "path": path += "█" rows_inner = [ @@ -254,7 +254,7 @@ def box_new_dispatch() -> list[str]: rows_inner = [ " ▸ agent [agy] [claude] [codex] «grok» [junie]", " rytual [init] «resume» [operator] [partner]", - " sciezka /Volumes/vc-workspace/vetcoders/vibecrafted-suite/vibecrafted", + " sciezka /srv/vetcoders/vibecrafted ", " Enter = HEADLESS worker. Bez TTY. Widać go na serwerze / w voc.", ] title = "┌ ❯ Nowy dispatch " diff --git a/docs/design/agents-workshop/preview.html b/docs/design/agents-workshop/preview.html index 0717740d..6fbb93f1 100644 --- a/docs/design/agents-workshop/preview.html +++ b/docs/design/agents-workshop/preview.html @@ -82,7 +82,7 @@ · resume-codex │ ┌ ❯ Nowy agent ────────────────────────────────────────────────────────────── [Anuluj] ┐ │ 04 ◉ vc-release │ │ ▸ agent [agy] [claude] [codex] «grok» [junie] │ │ · Start here │ │ rytual [init] «resume» [operator] [partner] │ │ - ◉ Agents │ │ sciezka /Volumes/vc-workspace/vetcoders/vibecrafted-suite/vibecrafted │ │ + ◉ Agents │ │ sciezka /srv/vetcoders/vibecrafted │ │ · shell │ │ Enter = interaktywny panel na tym tabie. Nie mux. Nie headless. │ │ · voc │ └─ ↑/↓ wiersz · ←/→ chip · spacja · enter · esc ───────────────────────────────┘ │ 05 ○ vibecrafted-vc_│ │ @@ -187,7 +187,7 @@ · resume-codex │ ┌ ❯ Nowy dispatch ─────────────────────────────────────────────────────────── [Anuluj] ┐ │ 04 ◉ vc-release │ │ ▸ agent [agy] [claude] [codex] «grok» [junie] │ │ · Start here │ │ rytual [init] «resume» [operator] [partner] │ │ - ◉ Agents │ │ sciezka /Volumes/vc-workspace/vetcoders/vibecrafted-suite/vibecrafted │ │ + ◉ Agents │ │ sciezka /srv/vetcoders/vibecrafted │ │ · shell │ │ Enter = HEADLESS worker. Bez TTY. Widać go na serwerze / w voc. │ │ · voc │ └─ ten sam chassis co Nowy agent · inne narodziny ─────────────────────────────────────┘ │ 05 ○ vibecrafted-vc_│ │ diff --git a/scripts/build-portable-release.sh b/scripts/build-portable-release.sh index ea75fac0..143f1b92 100755 --- a/scripts/build-portable-release.sh +++ b/scripts/build-portable-release.sh @@ -91,6 +91,17 @@ bash -n "$VERIFY_DIR/$ARCHIVE_ROOT_NAME/install.sh" bash "$VERIFY_DIR/$ARCHIVE_ROOT_NAME/install.sh" --help >/dev/null \ || die "packed install.sh cannot print its own usage" +# This channel ships a projection of a commit rather than compiled bytes, so it +# has always been the cleaner of the two — measured on the 4.1.0 tarball, the +# only `/Users|/home` matches were documentation placeholders. That is a fact +# about one build, not a property of the channel: the packer's allowlist can +# grow, and a generated file can arrive carrying an absolute path. Ask the +# extracted tree the same question the DMG channel is asked. +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/payload-hygiene.sh" +log "asserting the packed payload does not name the build host" +assert_payload_is_anonymous "$VERIFY_DIR/$ARCHIVE_ROOT_NAME" "$PORTABLE_NAME" + ( cd "$DIST_DIR" if command -v shasum >/dev/null 2>&1; then diff --git a/scripts/build-vibecrafted-release.sh b/scripts/build-vibecrafted-release.sh index df71a0c3..63fc93f3 100755 --- a/scripts/build-vibecrafted-release.sh +++ b/scripts/build-vibecrafted-release.sh @@ -80,8 +80,56 @@ SIGNING_KEYCHAIN_LABEL="vibecrafted-signing-$$" CODESIGN_KEYCHAIN_ARGS=() export MACOSX_DEPLOYMENT_TARGET=14.0 # Release payloads must not remember the operator account, Cargo registry, or -# living checkout locations through Rust panic/debug metadata. -export RUSTFLAGS="--remap-path-prefix=$REPO_ROOT=/usr/src/vibecrafted --remap-path-prefix=$TERMINAL_DONOR=/usr/src/vc-terminal --remap-path-prefix=$FRAME_DONOR=/usr/src/vc-frame --remap-path-prefix=$TERMINAL_REPO=/usr/src/vc-terminal --remap-path-prefix=$FRAME_REPO=/usr/src/vc-frame --remap-path-prefix=$HOME=/usr/src/operator-home" +# living checkout locations through compiler metadata. +# +# ORDER IS LOAD-BEARING. rustc applies the LAST matching --remap-path-prefix. +# MEASURED 2026-08-18: +# rustc --remap-path-prefix=$T=/usr/src/OUTER \ +# --remap-path-prefix=$T/inner=/usr/src/INNER $T/inner/main.rs +# reports /usr/src/INNER/main.rs, and swapping the two arguments reports +# /usr/src/OUTER/inner/main.rs. So the list runs BROADEST FIRST: +# * $HOME must precede the checkout and the donors. It used to be last, which +# is correct only by accident on this host — every repository happens to +# live on /Volumes. On any operator whose checkout sits under $HOME, the +# trailing $HOME entry would win and every specific root would be dead. +# * the donor snapshots live under $REPO_ROOT/build/..., so they must follow +# $REPO_ROOT or they would be rewritten as /usr/src/vibecrafted/build/... +# +# The snapshot pair is emitted only when it exists. Without --snapshot-donors +# TERMINAL_REPO IS TERMINAL_DONOR, and the duplicate pair merely pinned its own +# redundancy into the contract test. +PATH_REMAPS=( + "$HOME=/usr/src/operator-home" + "$REPO_ROOT=/usr/src/vibecrafted" + "$TERMINAL_DONOR=/usr/src/vc-terminal" + "$FRAME_DONOR=/usr/src/vc-frame" +) +if (( SNAPSHOT_DONORS )); then + PATH_REMAPS+=( + "$TERMINAL_REPO=/usr/src/vc-terminal" + "$FRAME_REPO=/usr/src/vc-frame" + ) +fi +RUSTFLAGS="" +FILE_PREFIX_MAP="" +SWIFT_PREFIX_MAP="" +for mapping in "${PATH_REMAPS[@]}"; do + RUSTFLAGS+="${RUSTFLAGS:+ }--remap-path-prefix=$mapping" + FILE_PREFIX_MAP+="${FILE_PREFIX_MAP:+ }-ffile-prefix-map=$mapping" + SWIFT_PREFIX_MAP+="${SWIFT_PREFIX_MAP:+ }-debug-prefix-map $mapping" +done +export RUSTFLAGS +# cc-rs compiles the C half of crates such as `ring`, and rustc's remap never +# sees those translation units. MEASURED on the shipped 4.1.0 DMG: +# Contents/MacOS/Vibecrafted carried 21 occurrences of +# $HOME/.cargo/registry/src/.../ring-0.17.14/crypto/... clang's +# -ffile-prefix-map is the same instrument on the C side. +export CFLAGS="${CFLAGS:+$CFLAGS }$FILE_PREFIX_MAP" +export CXXFLAGS="${CXXFLAGS:+$CXXFLAGS }$FILE_PREFIX_MAP" +# The Swift host is built by xcodebuild, which reads none of the above. Same +# payload, 51 occurrences of the checkout root from Swift source locations and +# DerivedData intermediates. Passed to xcodebuild as build settings below. +export SWIFT_PREFIX_MAP # The ephemeral signing keychain is owned by scripts/lib/keychain-session.sh, # which arms its own EXIT/INT/TERM/HUP traps and chains onto whatever this @@ -95,6 +143,8 @@ export RUSTFLAGS="--remap-path-prefix=$REPO_ROOT=/usr/src/vibecrafted --remap-pa . "$REPO_ROOT/scripts/lib/keychain-session.sh" # shellcheck source=/dev/null . "$REPO_ROOT/scripts/lib/donor-snapshot.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/payload-hygiene.sh" cleanup() { donor_snapshot_reap || true @@ -154,10 +204,82 @@ LC_ALL=C file -b "$SPOT_MONO_FONT" \ prepare_signing_identity git_sha() { git -C "$1" rev-parse HEAD; } + +# require_clean_repo