diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6113fdf..ce2a58b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,25 +46,7 @@ jobs: run: | archive_name="vibecrafted-${{ steps.version.outputs.version }}" mkdir -p dist - tar -czf "dist/${archive_name}.tar.gz" \ - --exclude='./dist' \ - --exclude='./vibecrafted-framework.plugin' \ - --exclude='./.pytest_cache' \ - --exclude='./presence' \ - --exclude='./.github' \ - --exclude='./.git' \ - --exclude='./node_modules' \ - --exclude='./output' \ - --exclude='./.vibecrafted' \ - --exclude='./.DS_Store' \ - --exclude='./docs/index.html' \ - --exclude='./docs/pl' \ - --exclude='./docs/presence' \ - --exclude='./docs/CNAME' \ - --exclude='./docs/foundation' \ - --exclude='./docs/playground' \ - --exclude='./docs/install.sh' \ - -C . . + uv run python3 scripts/distribution_manifest.py archive --source . --output "dist/${archive_name}.tar.gz" --root-name "$archive_name" echo "archive=${archive_name}.tar.gz" >> "$GITHUB_OUTPUT" - name: Sign release artifacts diff --git a/Makefile b/Makefile index 8e02e777..47b7b071 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,8 @@ tui-installer: init-hooks # control plane matches the branded install surface; otherwise it falls # back to the built-in inline HTML. BUNDLE_DIR ?= +BUNDLE_VERSION := $(shell sed -n '1p' "$(VERSION_FILE)" 2>/dev/null | tr -d '[:space:]') +BUNDLE_ARCHIVE ?= $(SOURCE)/dist/vibecrafted-$(BUNDLE_VERSION).tar.gz wizard: init-hooks @if [ -n "$(BUNDLE_DIR)" ]; then \ @@ -246,18 +248,23 @@ list: bundle: @$(PYTHON) scripts/build_marketplace_bundle.py --output "$(SOURCE)/vibecrafted-framework.plugin" + @mkdir -p "$(dir $(BUNDLE_ARCHIVE))" + @$(PYTHON) scripts/distribution_manifest.py archive --source "$(SOURCE)" --output "$(BUNDLE_ARCHIVE)" --root-name "vibecrafted-$(BUNDLE_VERSION)" bundle-check: - @tmp_root="$${TMPDIR:-/tmp}"; \ + @set -e; \ + tmp_root="$${TMPDIR:-/tmp}"; \ tmp_bundle="$$(mktemp "$$tmp_root/vibecrafted-bundle.XXXXXX")"; \ - trap 'rm -f "$$tmp_bundle"' EXIT; \ + tmp_runtime="$$(mktemp -d "$$tmp_root/vibecrafted-runtime.XXXXXX")"; \ + tmp_archive="$$tmp_runtime/vibecrafted-$(BUNDLE_VERSION).tar.gz"; \ + trap 'rm -f "$$tmp_bundle"; rm -rf "$$tmp_runtime"' EXIT; \ $(PYTHON) scripts/build_marketplace_bundle.py --output "$$tmp_bundle"; \ - if cmp -s "$$tmp_bundle" "$(SOURCE)/vibecrafted-framework.plugin"; then \ - echo "Bundle is current."; \ - else \ - echo "Bundle drift detected. Run 'make bundle'."; \ - exit 1; \ - fi + test -s "$$tmp_bundle" || { echo "Marketplace bundle generation produced an empty artifact."; exit 1; }; \ + $(PYTHON) scripts/distribution_manifest.py archive --source "$(SOURCE)" --output "$$tmp_archive" --root-name "vibecrafted-$(BUNDLE_VERSION)"; \ + mkdir -p "$$tmp_runtime/extracted"; \ + tar -xzf "$$tmp_archive" -C "$$tmp_runtime/extracted"; \ + $(PYTHON) scripts/distribution_manifest.py check --root "$$tmp_runtime/extracted/vibecrafted-$(BUNDLE_VERSION)"; \ + echo "Marketplace bundle and runtime payload are valid." version version-show: @version="$$(sed -n '1p' "$(VERSION_FILE)" 2>/dev/null | tr -d '[:space:]')"; \ diff --git a/VERSION b/VERSION index 15a27998..1545d966 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.3.0 +3.5.0 diff --git a/config/vc-frame/config.kdl b/config/vc-frame/config.kdl index 68ec8ff2..0e716a84 100644 --- a/config/vc-frame/config.kdl +++ b/config/vc-frame/config.kdl @@ -48,7 +48,8 @@ theme "pastel" // ─── Shell & Display ──────────────────────────────────────────────────────────── default_shell "zsh" -default_layout "compact" +// compact chowal rail SESSIONS z nowego default.kdl (2026-07-07) +default_layout "default" copy_command "pbcopy" mouse_mode true pane_frames true diff --git a/docs/CONTRIBUTING-SKILLS.md b/docs/CONTRIBUTING-SKILLS.md index 9b6dd371..ceefb879 100644 --- a/docs/CONTRIBUTING-SKILLS.md +++ b/docs/CONTRIBUTING-SKILLS.md @@ -91,7 +91,7 @@ Optional but encouraged: | Key | When to use | | --------------- | ---------------------------------------------------------------------------------- | | `requires:` | Foundation tools or sibling skills this depends on. | -| `agent_target:` | If the skill is biased toward one agent (claude / codex / gemini). | +| `agent_target:` | If the skill is biased toward one agent (claude / codex / agy). | | `triggers:` | Explicit operator phrases as a YAML list, when the description prose is too dense. | The smoke gate at `tests/skill_loader_smoke.sh` only checks structural diff --git a/docs/INSTALL.md b/docs/INSTALL.md index d49accb0..47ca53c5 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -294,12 +294,27 @@ See [`docs/DOCKER.md`](DOCKER.md) for the full container workflow. ## Uninstall ```bash -make uninstall # remove skills + shell helpers -make restore # undo last install/uninstall step -rm -rf "$HOME/.vibecrafted" # nuclear option (removes EVERYTHING) +vibecrafted uninstall ``` -The installer leaves a transaction log at `~/.vibecrafted/install.log`. +Before consent, uninstall prints one inventory of every managed path it will +remove or edit and every path it will intentionally preserve. It removes staged +`vibecrafted-*` payloads, the `vibecrafted-current` link, launchers, managed +skills/views, helpers, shell lines, the start guide, and the installer log. +Unknown siblings in the runtime tools directory are retained. + +The restore kit survives teardown under +`~/.vibecrafted/backups/installer//`. The final receipt prints its +exact self-contained command, for example: + +```bash +python3 ~/.vibecrafted/backups/installer//restore.py +``` + +Runtime binaries and uv-owned tool environments are reported as preserved when +the installer cannot prove ownership; remove those separately with their named +owner if desired. Operator artifacts, control-plane history, and logs under +`~/.vibecrafted/` are retained intentionally and listed in the receipt. --- diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index 674f3954..421d7846 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -135,6 +135,19 @@ vibecrafted tui vibecrafted dispatch path/to/run.dispatch.toml --doctor ``` +Inside a dispatch plan, pin a model per cut to match its class — the pin rides +the plan into the launcher, and an unpinned cut falls back to the account +default: + +```toml +[[cuts]] +id = "refactor-parser" +agent = "codex" +workflow = "implement" +model = "gpt-5-codex" # pin the model to the cut's class; unset = account default +prompt = "..." +``` + `gui`, `tui`, `dashboard`, `dispatch`, and telemetry commands are real runtime surfaces. They are second-visit tools, not the first thing a new operator needs to memorize. diff --git a/docs/SUBMISSION_FORMS.md b/docs/SUBMISSION_FORMS.md index 29bbe59c..00bda505 100644 --- a/docs/SUBMISSION_FORMS.md +++ b/docs/SUBMISSION_FORMS.md @@ -290,7 +290,7 @@ Use these as the default copy/paste answers when a form asks for the common launch fields: | Field | Paste-ready answer | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Product name | `𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍.` | | Tagline | `Release engine for AI-built software.` | | Secondary line | `Ship AI-built software without the vibe hangover.` | @@ -299,8 +299,8 @@ launch fields: | Docs | `https://vibecrafted.io/en/quickstart/` | | Category | `Developer Tools`, `AI Agents`, `Release Engineering` | | Pricing | `Free for personal use and startups. Enterprise licensing available.` | -| Primary CTA | `curl -fsSL https://vibecrafted.io/install.sh | bash -s -- --gui` | -| Backup CTA | `curl -fsSL https://vibecrafted.io/install.sh | bash` | +| Primary CTA | `curl -fsSL https://vibecrafted.io/install.sh | bash -s -- --gui` | +| Backup CTA | `curl -fsSL https://vibecrafted.io/install.sh | bash` | | 160-char summary | `Vibecrafted hardens AI-generated repos through structural mapping, convergence loops, install audits, and launch-ready packaging.` | | 300-char summary | `Vibecrafted is the release engine for AI-built software. It takes the repo your agents already produced and drives it through perception, verification, convergence loops, install truth, and launch-readiness work until the product is fit to ship.` | diff --git a/docs/operator/FLEET_DISPATCH_RUNBOOK.md b/docs/operator/FLEET_DISPATCH_RUNBOOK.md index 5507369c..1e1d77fd 100644 --- a/docs/operator/FLEET_DISPATCH_RUNBOOK.md +++ b/docs/operator/FLEET_DISPATCH_RUNBOOK.md @@ -46,9 +46,9 @@ vibecrafted --file briefs/-_.md # e.g. vibecr vibecrafted --prompt '' ``` -- `agent` ∈ {codex, claude, gemini, junie, agy, grok}. Pick via the **why-matrix**: +- `agent` ∈ {codex, claude, agy, junie, grok}. Pick via the **why-matrix** (gemini deprecated; agy for Google-family) **codex = precision/surgery** (contract-gated refactors, exact edits), **claude = forensics/audit** - (deep unknowns, bug hunts), **gemini = radical reframing** (architecture leaps, simplification). + (deep unknowns, bug hunts), **agy = Google-family (antigravity rewire; gemini deprecated)** (architecture leaps, simplification). - Headless (non-TTY agent bash) **degrades to in-repo dispatch automatically**. A launch receipt prints `run_id` + report/transcript/meta paths — capture them. - **Disjoint file-domains → safe parallel dispatch** (Living Tree). Overlapping domains → sequence them. @@ -63,9 +63,20 @@ vibecrafted --prompt '' ## 5. Observe (metadata-first, not pane-first) -- `vibecrafted await --run-id ` (or `--last`) waits on completion + prints the summary. -- Poll durable artifacts: `~/.vibecrafted/artifacts////reports/*.meta.json` (status) - - `*.transcript.log` (live work). The fleet stays operable from artifacts even with no panes open. +- After dispatch, arm `vibecrafted await --run-id ` immediately, + supervisor-side. Control-plane JSON, report files, transcripts, panes, and + scheduled wakeups are diagnostic only, not wake signals. Hedging await with + ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, + do not normalize the hedge. See `docs/runtime/AGENT_OPS.md`. +- Liveness is always 3-signal: await verdict, terminal run meta, worker pid + dead, plus promised report presence. Two agreeing signals are enough to act; + three are required to declare done. Any disagreement means treat as live and + re-arm await. Known skew: rc=0-on-live and meta stuck `active`/`stalled` after + real completion. +- Durable artifacts (`*.meta.json`, `*.transcript.log`, report paths) are + diagnostic drilldowns after await is armed. The fleet stays operable from + artifacts even with no panes open, but artifact polling does not replace the + canonical await. - **Verify each cut** against its brief acceptance + run its gates (tests / clippy -D warnings / `make check`). Confirm the agent **committed its round**; if it left work uncommitted, flag the doctrine regression. **STOP is recovery, not surrender** — on stall/fail, issue a focused diff --git a/docs/runtime/AGENT_OPS.md b/docs/runtime/AGENT_OPS.md new file mode 100644 index 00000000..c928ca51 --- /dev/null +++ b/docs/runtime/AGENT_OPS.md @@ -0,0 +1,252 @@ +# Agent Ops — Runtime Failure Classes + +The agent-ops canon: failure classes of multi-agent runtimes, observed in +anger, with confirmed remediations. This is runtime mechanics, not identity or +naming — it documents how agents fail _inside this machinery_ (dispatcher, +no-await lifecycle, subagents, watchers) and what actually fixes it. + +## Supervisor Quickstart + +After dispatch, arm `vibecrafted await --run-id ` immediately, +supervisor-side. Control-plane JSON, report files, transcripts, panes, and +scheduled wakeups are diagnostic only, not wake signals. Hedging await with +ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, +do not normalize the hedge. + +Liveness is always a 3-signal decision before declaring a run done: confirm (1) +the await verdict, (2) terminal state in run meta, and (3) worker pid dead; when +a report path is promised, also confirm the report file exists and is non-empty. +Two agreeing signals are enough to act cautiously, three are required to declare +done. Any disagreement means "treat as live, re-arm await." + +Every class here earned its entry with at least two confirmed real-world +cases. Speculative classes do not belong in this file. + +## The shared principle + +> **Neither side may rely on a signal whose channel does not guarantee +> delivery.** + +Both classes below are the same root cause seen from opposite ends of the +relay: a worker waiting for a wake-up that will never come, and a supervisor +assuming a death notice that is never sent. The cure is always the same shape: +replace passive waiting with active verification on the side that can act. + +--- + +## Class 1 — Gate-nap („Drzemka na bramce") + +_3 confirmed cases, prview-rs session 2026-07-02/03 (Monika). Canonical +description by Monika._ + +### Symptom + +A worker-subagent gets a task with a quality gate (cargo test / build / +release gate, 2–8 min). Instead of waiting for the result, it launches the +gate in the background (`run_in_background` / monitor pattern) and ends its +turn with "waiting for the completion signal". The dispatcher receives +`task-notification: completed` with that "waiting" as the result — the work +hangs half-done (commits exist, but no report/push/replies), and the worker +never comes back on its own. + +### Mechanism: wake asymmetry in the harness + +The main loop is automatically re-invoked when its background task completes. +A subagent is NOT. For a subagent, end of turn = end of its decision process — +the signal from its own background task has nobody left to wake. The worker +imitates a pattern it sees the orchestrator use (and which is correct _for the +orchestrator_), not knowing that for a subagent it is a trap with no exit. + +### Why a ban in the brief is not enough + +Case #3 had the explicit ban ("gates synchronously, never in background") and +broke it anyway. Affordance beats prohibition: the flag is visible in the +tool, the gate is long, and the pattern looks like good practice. + +### Confirmed remediation (3/3) + +Resume the stopped agent with an EXPLANATION OF THE MECHANICS, not a bare +"continue": + +> "The gate signal will NEVER reach you — you are a subagent, background +> notifications do not wake you. Read the result directly now (Read the +> output file / re-run the command in the foreground) and finish everything +> in this turn." + +Context is preserved; the worker finishes without loss. A soft "finish up" is +too weak — case #2 fell asleep again after one; only the explanation of _why +waiting is futile_ worked. + +### Prevention (strongest first) + +1. **Harness/hook**: PreToolUse hook on subagents' `run_in_background=true` — + preferably **auto-degrade to foreground with a message** ("degraded to + foreground: you are a subagent"), not a hard block. A hard block on an + 8-minute gate can kill the worker on turn timeouts instead; degradation + teaches the mechanics as a side effect (levels 1 and 3 in one shot). +2. **Dispatcher watchdog**: a `completed` notification whose result matches + `/czekam|wait.*(monitor|event)/i` → automatic resume with the standard + mechanics message. +3. **Template preamble** in dispatch/skill worker contracts: "You are a + subagent: background completions will never wake you. Never end your turn + waiting." Explanation of mechanics > bare prohibition (evidence: case #3 + vs. the effective resume). + +**Implemented in this runtime**: level 3 is wired mechanically — every worker +prompt the runtime composes carries `WORKER_SIGNAL_DISCIPLINE` +(`workflow_runtime.py`, injected by both `workflow._runtime_prompt` and +`workflow_runtime._child_prompt`, so dispatched workers AND supervised +marbles/polarize/research children get the mechanics explanation in their +contract). Levels 1–2 remain harness-side proposals. + +### Cost when it hits + +~3 × 10–20 min delay per case plus manual intervention; zero lost work +(commits were always on disk — only the final leg hung: report/push/replies). + +--- + +## Class 2 — Report-on-death gap + +_3 confirmed cases, vibecrafted vc-ship flights 2026-07-02/03 (claude +supervising codex workers)._ + +### Symptom + +A dispatched worker dies silently at or near startup; the dispatcher never +notices. In no-await lifecycle mode the run's meta stays in a live-looking +state and the supervisor (or its watcher) waits on a report that will never +be written. Observed death modes: + +- `codex exec` dies right after `task_started` (rollout truncated at ~22 KB; + transport failure, not disk) — dispatcher hung blind for 37 minutes. +- `codex` dies on `failed to refresh available models: timeout` (transcript + ~244 bytes) — a 3-hour watcher budget expired on a corpse. + +### Mechanism + +In no-await mode nothing owns the child's death: the ephemeral spawn launcher +exits by design, the dispatcher tracks the report file, and process death +emits no report. The absence of a signal is indistinguishable from slow work +unless someone actively checks liveness. + +### Confirmed remediation (3/3) + +Operator-verb sequence, no manual state surgery: + +``` +vibecrafted ship interrupt # trace: stop_accepted +vibecrafted ship fallback --stage # baton rewinds WITH cargo +vibecrafted ship approve [--force] # --force only when the cargo gate + # correctly flags the unwritten report +``` + +No baton cargo is lost; the stage relaunches with the full report trail. + +### Prevention + +1. **Supervisor-side watchers, never worker-side** (see patterns below) with + explicit early-death detection: transcript < 5 KB and silent for 10 min = + startup death; do not wait out the full stage budget. +2. **Terminal-state check** alongside the report poll: read `meta.json` state + (`failed` / `process_dead` / `contract_failed` / `ghost`), don't infer from + transcript growth alone. +3. **Systemic cut (implemented, on-read)**: `control_plane.run_liveness()` + runs the dispatch reconciler for one run and answers with OS-level truth; + `LifecycleSupervisor.status()` joins it for the current stage as + `stage_worker` with the actionable flag `worker_dead_without_report` — + surfaced by `vibecrafted ship status`, `vc_lifecycle_status` (MCP), and + anything else reading the status projection. +4. **Systemic cut (implemented, push-side)**: every lifecycle stage launch + hands the dispatcher its `state.json` address (`--lifecycle-state`, wired + through `WorkflowLaunchSpec.lifecycle_state_path`). On worker failure + (nonzero exit or broken artifact contract) the dispatcher calls + `lifecycle_runner.record_stage_worker_exit`, which annotates the matching + stage with `worker_exit` and — for the current stage of a still-`launching` + run — mirrors it top-level as `stage_worker_exit`. Purely passive readers + of raw `state.json` (the Rust server, dashboards) now see the death with + no status verb in the loop. Additive within `lifecycle.schema.v1`; healthy + exits write nothing, which also keeps the write too rare to race operator + verbs on the same file. + +--- + +## Class 3 — Premature/untrusted await (observability contract drift) + +### Symptom + +Supervising agents stop trusting `await` and hedge: they run the verb in the +background AND keep a manual sleep/ps/git monitor "because await can return +early". Every hedge is a doctrine violation (ad-hoc watchers) caused by a real +contract gap, not by agent paranoia. + +### Mechanism (four confirmed gaps, all fixed at the source) + +1. **A third private await loop**: `cli._agent_await`'s human path had its own + inline loop treating `--timeout` as an ABSOLUTE wall clock — it abandoned + demonstrably-working runs at 300 s. Fixed: the verb now blocks through the + one canonical `control_plane.await_run` (liveness-aware idle window), with + an `on_poll` callback for progress printing. There must be exactly ONE + await loop in the runtime; a new inline loop is this class reborn. +2. **Loop parents look dead while children work**: marbles/polarize rounds are + sequenced deterministically (next round fires on the previous child + PROCESS EXIT + artifact validation in `workflow_runtime.run_marbles`), but + each round is a separate run record (`--L`) linked only by + id prefix. The parent record freezes between rounds, so a parent-only + fingerprint fired false `idle_stall` mid-loop. Fixed: `await_run` + aggregates child-run movement and liveness into the parent's idle window. +3. **Delivered report ≠ return**: a no-await stage worker writes its report + and exits; `await_run` knew nothing about reports, so `ship await` idled a + full window on the corpse and returned a misleading + `timed_out: idle_stall` + `report_written: true`. Fixed: a non-empty + report (`report_path` argument, else the run's `latest_report`) returns + `completed` with `reason: report_delivered` on the first poll. +4. **rc=0-on-live / inverse meta lag**: field evidence from 2026-07-10 showed + both directions of signal skew. `await` could return rc=0 while the worker + was still alive, and completed workers could leave run meta stuck + `active`/`stalled` with `exit_code: null` after writing the report and + exiting. Fixed: success requires worker-dead terminal evidence — terminal + meta state or delivered report — and live-worker disagreement keeps await + armed. Known failure mode if it recurs: rc=0-on-live or meta-active-after- + completion is a Class 3 bug; use the 3-signal rule and re-arm await. + +### The rule + +The runtime hands out a run id; `await`/`observe` on that id must ALWAYS be +the whole answer. If an agent feels the need to double-guard await with a +manual monitor, treat that as a Class 3 bug report against the runtime — fix +the contract, do not normalize the hedge. + +The operational verdict is deliberately redundant: await verdict, terminal run +meta, and worker pid death must converge before "done" leaves the supervisor. +Report presence is a fourth promised-artifact check, not a replacement for +liveness. Two agreeing signals can justify recovery or cautious next action; +three signals are the bar for done. + +--- + +## Supervisor watcher patterns (battle-tested, two full vc-ship flights) + +- Poll the stage **report file** (`[ -s "$REPORT" ]`), paired with the + `meta.json` terminal-state check above. Report file appearing = stage done; + terminal state = stage dead. Waiting on anything else is guesswork. +- **Marbles parent runs have NO transcript.log** — the loop spawns children + with their own transcripts under `reports/marbles/-children/`. Watch + children-dir growth or the report file; a parent-transcript stall check is + a false alarm (confirmed twice). +- Watchers live with the supervisor (main loop), never inside a subagent — + that is Class 1 waiting to happen. +- Budget every watcher, and on silent expiry verify liveness before declaring + a stall; on noisy expiry check for the early-death signature first. + +## Provenance + +- Class 1: prview-rs, session 2026-07-02/03, cases #1–#3, remediation and + canonical description by Monika. +- Class 2: vibecrafted, vc-ship flights `life-ship-260702-123238-24000` + (v3.3.0) and `life-ship-260702-202338-58000` (lifecycle.schema.v1), + supervision by claude, session `2603026d-0c40-4ca9-af91-e2ab74256926`. +- Class 3: operator report 2026-07-05 (agents hedging + `codex await --run-id marb-260705-164001-84000` with parallel manual + monitors) + live evidence from the vc-frame redesign flight (`ship await` + idle-stalling on a delivered review report); fixes by claude, same session. diff --git a/docs/runtime/CONTRACT.md b/docs/runtime/CONTRACT.md index 956b0794..17ac86ab 100644 --- a/docs/runtime/CONTRACT.md +++ b/docs/runtime/CONTRACT.md @@ -171,7 +171,7 @@ Every Markdown artifact generated by ANY skill (`vc-agents`, `vc-scaffold`, `vc- ```yaml --- run_id: -agent: +agent: # gemini deprecated skill: project: status: @@ -251,13 +251,15 @@ PLAN="$VIBECRAFTED_HOME/artifacts////plans/.md" bash $VIBECRAFTED_ROOT/runtime/scripts/claude_spawn.sh "$PLAN" --mode implement ``` -### Gemini +### Agy (Google Antigravity; gemini deprecated) ```bash PLAN="$VIBECRAFTED_HOME/artifacts////plans/.md" -bash $VIBECRAFTED_ROOT/runtime/scripts/gemini_spawn.sh "$PLAN" --mode implement +bash $VIBECRAFTED_ROOT/runtime/scripts/agy_spawn.sh "$PLAN" --mode implement ``` +Gemini CLI paths are deprecated and removed from active launchers. Existing historical references preserved only in marked docs/audits. + If these tools are unavailable, stop pretending spawn is correctly configured and say so explicitly. --- diff --git a/docs/runtime/LIFECYCLE.md b/docs/runtime/LIFECYCLE.md index 78705dd4..1c9dd7cf 100644 --- a/docs/runtime/LIFECYCLE.md +++ b/docs/runtime/LIFECYCLE.md @@ -145,6 +145,153 @@ Stage workers steer the lifecycle through their report YAML frontmatter operator-accepted gaps) and reads the live report frontmatter in no-await mode. Absent or invalid values read as unknown, never as a fake zero. +## Lifecycle contract: `vibecrafted.lifecycle.v1` + +Lifecycle `state.json` is now a versioned external contract, not an internal +accident. Every fresh state document carries: + +```json +{ "schema": "vibecrafted.lifecycle.v1" } +``` + +The machine-readable JSON Schema ships inside the core package at +`vibecrafted_core/schemas/lifecycle.schema.v1.json` and is exposed to MCP +clients as the `vibecrafted://lifecycle/schema` resource. + +Within v1, changes are additive only: existing keys keep their names, types, +and semantics. A breaking lifecycle state or worker-report frontmatter change +requires a `vibecrafted.lifecycle.v2` schema and a parallel compatibility +period. The single writer remains Python +(`vibecrafted_core.lifecycle_runner` / `lifecycle_control`); Rust +`control-core`, HTTP endpoints, MCP, Codescribe, and Pensieve are readers or +operator-command surfaces only. + +The v1 state contract covers: + +- top-level run identity and paths: `schema`, `run_id`, `workflow`, `agent`, + `root`, `status`, `state_path`, `report_path`, `transcript_path`; +- execution shape: `await_stages`, `parent_run_id`, `spec`, `manifest`, + `context_atlas`, `supervisor`, `human_controls`; +- lifecycle state: `operator_actions`, `baton`, `stages`, `next_stage`, + `error`; +- DoU state: `dou_index`, `accepted_dou`, `accepted_dou_findings`. + +Worker report frontmatter is the steering side of the same contract: + +- `next_stage: ` — requested next manifest stage; +- `next_agent: ` — requested baton holder; +- `dou_index: ` — open DoU findings, where `0` is launch-ready; +- `status: ` — worker report metadata. Current runtime steering reads + `next_stage`, `next_agent`, and `dou_index`; do not treat `status` as a + transition control until the runtime explicitly does. + +### Codescribe consumer recipe + +Codescribe drives lifecycle runs through the umbrella MCP verbs: +`vc_lifecycle_runs`, `vc_lifecycle_status`, `vc_lifecycle_approve`, +`vc_lifecycle_interrupt`, `vc_lifecycle_force_audit`, +`vc_lifecycle_accept_dou`, and `vc_lifecycle_fallback`. Read +`vc_lifecycle_status.result.schema` before relying on fields, and fetch +`vibecrafted://lifecycle/schema` when the client needs the full contract. +Use the operator verbs for mutation; never write `state.json` directly. + +### Pensieve consumer recipe + +Pensieve reads lifecycle truth through the Rust read-model and HTTP endpoints: +`/api/control/lifecycle` for summaries and +`/api/control/lifecycle/{run_id}` for the full nested state. Branch on +`schema == "vibecrafted.lifecycle.v1"` before interpreting lifecycle-specific +fields. The server is a reader/projection layer; it must not become a second +lifecycle writer. + +### Consumer proof packet + +Use this packet when handing the v1 contract to Codescribe, Pensieve, or another +reader. It is repo-local proof, not live external acceptance: release still +needs one captured read from each real consumer environment. + +Contract identifiers: + +- State schema id: `vibecrafted.lifecycle.v1` +- Packaged schema path: `vibecrafted_core/schemas/lifecycle.schema.v1.json` +- MCP schema resource: `vibecrafted://lifecycle/schema` +- HTTP summaries: `GET /api/control/lifecycle` +- HTTP detail: `GET /api/control/lifecycle/{run_id}` + +MCP status payload shape: + +```json +{ + "result": { + "run_id": "smoke-life", + "schema": "vibecrafted.lifecycle.v1", + "workflow": "vc-ship", + "status": "completed", + "next_stage": "release" + } +} +``` + +HTTP summary payload shape: + +```json +{ + "count": 1, + "lifecycle_runs": [ + { + "run_id": "smoke-life", + "schema": "vibecrafted.lifecycle.v1", + "workflow": "vc-ship", + "current_stage": "hydrate", + "next_stage": "release", + "dou_readiness": "zero" + } + ] +} +``` + +HTTP detail payload shape: + +```json +{ + "run_id": "smoke-life", + "schema": "vibecrafted.lifecycle.v1", + "baton": { + "next_stage": "release" + }, + "stages": [ + { + "id": "hydrate", + "status": "completed" + } + ], + "dou_index": { + "value": 0 + } +} +``` + +Repo gates that currently prove the contract surface: + +```bash +.venv/bin/pytest vibecrafted-core/tests -q +.venv/bin/pytest vibecrafted-mcp/tests -q +make server-check +make server-test +make server-smoke +``` + +Release-time live acceptance checklist: + +- Codescribe reads `vibecrafted://lifecycle/schema` and records + `vc_lifecycle_status.result.schema == "vibecrafted.lifecycle.v1"` from its + own MCP client runtime. +- Pensieve reads `/api/control/lifecycle` and + `/api/control/lifecycle/{run_id}` from its own control-core or HTTP runtime + and records `schema == "vibecrafted.lifecycle.v1"` in both payloads. +- Neither consumer writes `state.json`; all mutations go through lifecycle + operator verbs. + Boundaries: - do not edit `state.json` by hand without recording why in the report or diff --git a/docs/runtime/README.md b/docs/runtime/README.md index 7622032f..db0947ba 100644 --- a/docs/runtime/README.md +++ b/docs/runtime/README.md @@ -10,6 +10,10 @@ For the canonical product lifecycle — the read/write cadence of the `vc-ship` pipeline, the component architecture, and the async supervision model — see [`LIFECYCLE.md`](./LIFECYCLE.md). +For the agent-ops canon — runtime failure classes of the multi-agent +machinery (gate-nap, report-on-death) with confirmed remediations and +supervisor watcher patterns — see [`AGENT_OPS.md`](./AGENT_OPS.md). + --- ## Runtime Today diff --git a/docs/runtime/RULES.md b/docs/runtime/RULES.md new file mode 100644 index 00000000..df08a5e4 --- /dev/null +++ b/docs/runtime/RULES.md @@ -0,0 +1,3 @@ +# Runtime Rule Files + +The canonical agent rule files live in `vibecrafted-core/vibecrafted_core/skills/`: `VERIFICATION_RULE.md` defines the walk-around verification contract, and `LIVING_TREE_RULE.md` defines the shared-checkout discipline. The Polish Living Tree variant lives at `vibecrafted-core/vibecrafted_core/skills/pl/LIVING_TREE_RULE.md`. Runtime docs may point to these files, but the source of truth stays in the skills tree. diff --git a/install.sh b/install.sh index 8345eb7b..8c2f1d1f 100644 --- a/install.sh +++ b/install.sh @@ -572,9 +572,14 @@ fi [[ -n "$source_dir" ]] || die "Could not find extracted source directory" incoming_dir="$tools_dir/.incoming-$safe_ref-$$" +manifest_helper="$source_dir/scripts/distribution_manifest.py" +[[ -f "$manifest_helper" ]] || die "Distribution manifest missing: $manifest_helper" rm -rf "$incoming_dir" -mv "$source_dir" "$incoming_dir" +python3 "$manifest_helper" stage \ + --source "$source_dir" \ + --destination "$incoming_dir" \ + --mirror >/dev/null rm -rf "$staged_dir" mv "$incoming_dir" "$staged_dir" ln -sfn "$staged_dir" "$current_link" diff --git a/install.toml b/install.toml index 972b7d40..b225238f 100644 --- a/install.toml +++ b/install.toml @@ -135,7 +135,7 @@ additional_tools = "Additional tools" [diagnostics.commands] foundations = ["loctree-mcp", "aicx-mcp", "prview", "screenscribe"] toolchains = ["python3", "node", "git", "rsync"] -agents = ["claude", "codex", "gemini", "agy", "junie", "grok"] +agents = ["claude", "codex", "agy", "junie", "grok"] additional_tools = ["mise", "starship", "atuin", "zoxide"] [diagnostics.paths] @@ -149,9 +149,9 @@ default = "none" available = ["wezterm", "vc-apprt", "locterm", "microsandbox"] [runtime.picking.research] -default_agents = ["claude", "codex", "gemini"] +default_agents = ["claude", "codex", "agy"] fallback_agents = ["grok", "junie", "agy"] -reason = "Research needs three reliable independent workers. Claude, Codex, and Gemini are the mainstream packaged defaults; per-user runtime picks can override this in ~/.config/vibecrafted/config.toml without reinstalling." +reason = "Research needs three reliable independent workers. Claude, Codex, and Agy are the packaged defaults; per-user runtime picks can override this in ~/.config/vibecrafted/config.toml without reinstalling." [runtime.horses.wezterm] label = "WezTerm" diff --git a/plugins/iterm2/tests/test_iterm2_osc.py b/plugins/iterm2/tests/test_iterm2_osc.py index bf980a6e..2ff41a1c 100644 --- a/plugins/iterm2/tests/test_iterm2_osc.py +++ b/plugins/iterm2/tests/test_iterm2_osc.py @@ -164,8 +164,7 @@ def test_ftcs_command_lifecycle() -> None: def test_remote_host_format() -> None: assert ( - osc.remote_host("tester", "host-a") - == "\x1b]1337;RemoteHost=tester@host-a\x07" + osc.remote_host("tester", "host-a") == "\x1b]1337;RemoteHost=tester@host-a\x07" ) diff --git a/pyproject.toml b/pyproject.toml index 69ea29c2..89d70849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,3 +22,7 @@ exclude = [ ] ignore_missing_imports = true follow_imports = "skip" + +[tool.pytest.ini_options] +addopts = "--import-mode=importlib" +pythonpath = ["vibecrafted-core"] diff --git a/scripts/distribution_manifest.py b/scripts/distribution_manifest.py new file mode 100644 index 00000000..f6b05359 --- /dev/null +++ b/scripts/distribution_manifest.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Build and verify the complete Vibecrafted runtime payload. + +The repository is a development surface. A distribution is an allowlisted +projection of it: required runtime paths must exist, development artifacts are +never copied, and symlinks must stay inside the payload. +""" + +from __future__ import annotations + +import argparse +import gzip +import os +import shutil +import sys +import tarfile +import tempfile +from pathlib import Path +from typing import Iterable + + +REQUIRED_FILES = ( + "VERSION", + "LICENSE", + "README.md", + "Makefile", + "install.sh", + "install.ps1", + "install.toml", + "scripts/distribution_manifest.py", + "scripts/vetcoders_install.py", + "scripts/runtime_paths.py", + "scripts/vibecrafted", + "vibecrafted-core/pyproject.toml", + "vibecrafted-core/vibecrafted_core/VERSION", + "vibecrafted-core/vibecrafted_core/deck/vibecrafted", + "vibecrafted-mcp/pyproject.toml", + "plugins/iterm2/pyproject.toml", + "vibecrafted-app/Cargo.toml", + "vibecrafted-server/Cargo.toml", +) + +REQUIRED_DIRECTORIES = ( + "bin", + "config", + "docs", + "plugins", + "runtime/scripts", + "runtime/shell/lib", + "scripts/installer", + "skills", + "templates", + "tools", + "vibecrafted-app", + "vibecrafted-core/vibecrafted_core/runtime", + "vibecrafted-core/vibecrafted_core/skills", + "vibecrafted-mcp", + "vibecrafted-server", + "vibecrafted-vm", + "workflows", +) + +# A directory name alone does not prove that its runtime survived packaging. +# Keep one stable, executable-or-documenting sentinel for every required +# surface so an empty subtree can never pass the complete-runtime gate. +REQUIRED_SURFACE_FILES = { + "bin": "bin/vc-workflow", + "config": "config/README.md", + "docs": "docs/INSTALL.md", + "plugins": "plugins/iterm2/README.md", + "runtime/scripts": "runtime/scripts/README.md", + "runtime/shell/lib": "runtime/shell/lib/core.sh", + "scripts/installer": "scripts/installer/pyproject.toml", + "skills": "skills/vc-init/SKILL.md", + "templates": "templates/hooks/install.sh", + "tools": "tools/README.md", + "vibecrafted-app": "vibecrafted-app/Cargo.toml", + "vibecrafted-core/vibecrafted_core/runtime": ( + "vibecrafted-core/vibecrafted_core/runtime/README.md" + ), + "vibecrafted-core/vibecrafted_core/skills": ( + "vibecrafted-core/vibecrafted_core/skills/LIVING_TREE_RULE.md" + ), + "vibecrafted-mcp": "vibecrafted-mcp/pyproject.toml", + "vibecrafted-server": "vibecrafted-server/Cargo.toml", + "vibecrafted-vm": "vibecrafted-vm/Containerfile", + "workflows": "workflows/MARBLES.md", +} + +ALLOWED_TOP_LEVEL = frozenset( + { + "VERSION", + "LICENSE", + "README.md", + "CHANGELOG.md", + "SECURITY.md", + "Makefile", + "install.sh", + "install.ps1", + "install.toml", + "pyproject.toml", + "plugin.json", + "vibecrafted-framework.plugin", + "bin", + "config", + "docs", + "plugins", + "runtime", + "scripts", + "skills", + "templates", + "tools", + "vibecrafted-app", + "vibecrafted-core", + "vibecrafted-mcp", + "vibecrafted-server", + "vibecrafted-vm", + "workflows", + } +) + +FORBIDDEN_COMPONENTS = frozenset( + { + ".DS_Store", + ".backup", + ".build", + ".circleci", + ".coverage", + ".devcontainer", + ".dockerignore", + ".git", + ".github", + ".gitignore", + ".gitlab", + ".junie", + ".legacy-state-agency", + ".loctignore", + ".loctree", + ".mypy_cache", + ".next", + ".prettierignore", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "AGENTS.md", + "Cargo.lock", + "CONTRIBUTING.md", + "DerivedData", + "Pipfile.lock", + "__pycache__", + "__tests__", + "build", + "coverage.xml", + "dist", + "node_modules", + "package-lock.json", + "pnpm-lock.yaml", + "poetry.lock", + "target", + "test", + "tests", + "uv.lock", + "yarn.lock", + } +) + +FORBIDDEN_SUFFIXES = (".pyc", ".pyo", ".swp", "~") + + +class ManifestError(ValueError): + """The requested payload cannot satisfy the distribution contract.""" + + +def _relative_path(value: str | Path) -> Path: + relative = Path(value) + if relative.is_absolute() or ".." in relative.parts: + raise ManifestError(f"unsafe relative path: {value}") + return relative + + +def path_is_forbidden(relative: str | Path) -> bool: + relative_path = _relative_path(relative) + if not relative_path.parts: + return True + return any(part in FORBIDDEN_COMPONENTS for part in relative_path.parts) or ( + relative_path.name.endswith(FORBIDDEN_SUFFIXES) + ) + + +def path_is_included(relative: str | Path) -> bool: + relative_path = _relative_path(relative) + return bool( + relative_path.parts + and relative_path.parts[0] in ALLOWED_TOP_LEVEL + and not path_is_forbidden(relative_path) + ) + + +def _symlink_error(root: Path, path: Path) -> str | None: + if not path.is_symlink(): + return None + raw_target = os.readlink(path) + target = Path(raw_target) + relative = path.relative_to(root) + if target.is_absolute(): + return f"symlink escapes payload: {relative} -> {raw_target}" + resolved_root = root.resolve() + resolved_target = (path.parent / target).resolve(strict=False) + try: + target_relative = resolved_target.relative_to(resolved_root) + except ValueError: + return f"symlink escapes payload: {relative} -> {raw_target}" + if not path_is_included(target_relative): + return f"symlink targets excluded path: {relative} -> {raw_target}" + return None + + +def _required_errors(root: Path) -> list[str]: + errors = [] + for relative in REQUIRED_FILES: + if not (root / relative).is_file(): + errors.append(f"missing required path: {relative}") + for relative in REQUIRED_DIRECTORIES: + if not (root / relative).is_dir(): + errors.append(f"missing required path: {relative}") + for surface, relative in REQUIRED_SURFACE_FILES.items(): + if not (root / relative).is_file(): + errors.append(f"missing required runtime content: {surface} -> {relative}") + return errors + + +def _walk_entries(root: Path) -> Iterable[Path]: + for current, directory_names, file_names in os.walk(root, followlinks=False): + current_path = Path(current) + directory_names.sort() + file_names.sort() + for name in directory_names: + yield current_path / name + for name in file_names: + yield current_path / name + directory_names[:] = [ + name + for name in directory_names + if not path_is_forbidden((current_path / name).relative_to(root)) + ] + + +def validate_payload(root: str | Path) -> None: + payload_root = Path(root) + if not payload_root.is_dir(): + raise ManifestError(f"payload root is not a directory: {payload_root}") + + errors = _required_errors(payload_root) + for path in _walk_entries(payload_root): + relative = path.relative_to(payload_root) + if path_is_forbidden(relative): + errors.append(f"forbidden path: {relative}") + continue + if relative.parts[0] not in ALLOWED_TOP_LEVEL: + errors.append(f"unexpected top-level path: {relative}") + continue + if error := _symlink_error(payload_root, path): + errors.append(error) + + if errors: + raise ManifestError("\n".join(sorted(set(errors)))) + + +def _validate_source(root: Path) -> None: + errors = _required_errors(root) + for path in _walk_entries(root): + relative = path.relative_to(root) + if not path_is_included(relative): + continue + if error := _symlink_error(root, path): + errors.append(error) + if errors: + raise ManifestError("\n".join(sorted(set(errors)))) + + +def _remove_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + + +def _copy_included(source_root: Path, source: Path, destination: Path) -> None: + relative = source.relative_to(source_root) + if not path_is_included(relative): + return + if source.is_symlink(): + if error := _symlink_error(source_root, source): + raise ManifestError(error) + if destination.exists() or destination.is_symlink(): + _remove_path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.symlink_to(os.readlink(source)) + return + if source.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + for child in sorted(source.iterdir(), key=lambda item: item.name): + _copy_included(source_root, child, destination / child.name) + return + if source.is_file(): + if destination.exists() and destination.is_dir(): + _remove_path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + +def stage_payload( + source: str | Path, destination: str | Path, *, mirror: bool = False +) -> None: + source_root = Path(source).resolve() + destination_root = Path(destination) + if not source_root.is_dir(): + raise ManifestError(f"source root is not a directory: {source_root}") + _validate_source(source_root) + + if mirror and (destination_root.exists() or destination_root.is_symlink()): + _remove_path(destination_root) + destination_root.mkdir(parents=True, exist_ok=True) + for item in sorted(source_root.iterdir(), key=lambda entry: entry.name): + _copy_included(source_root, item, destination_root / item.name) + validate_payload(destination_root) + + +def _normalized_tar_info(info: tarfile.TarInfo) -> tarfile.TarInfo: + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + info.mtime = 0 + info.pax_headers = {} + return info + + +def _write_archive(payload_root: Path, output: Path, root_name: str) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as raw_output: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw_output, mtime=0) as gz: + with tarfile.open(fileobj=gz, mode="w", format=tarfile.PAX_FORMAT) as tar: + root_info = tarfile.TarInfo(root_name) + root_info.type = tarfile.DIRTYPE + root_info.mode = 0o755 + tar.addfile(_normalized_tar_info(root_info)) + for path in _walk_entries(payload_root): + relative = path.relative_to(payload_root) + archive_name = f"{root_name}/{relative.as_posix()}" + info = _normalized_tar_info( + tar.gettarinfo(str(path), arcname=archive_name) + ) + if info.isreg(): + with path.open("rb") as source_file: + tar.addfile(info, source_file) + else: + tar.addfile(info) + + +def create_archive(source: str | Path, output: str | Path, *, root_name: str) -> Path: + if not root_name or root_name in {".", ".."} or "/" in root_name: + raise ManifestError(f"unsafe archive root name: {root_name!r}") + output_path = Path(output) + with tempfile.TemporaryDirectory(prefix="vibecrafted-payload-") as temporary: + payload_root = Path(temporary) / root_name + stage_payload(source, payload_root, mirror=True) + _write_archive(payload_root, output_path, root_name) + return output_path + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + check = subparsers.add_parser("check", help="Validate a staged payload") + check.add_argument("--root", required=True, type=Path) + + stage = subparsers.add_parser("stage", help="Create an allowlisted payload") + stage.add_argument("--source", required=True, type=Path) + stage.add_argument("--destination", required=True, type=Path) + stage.add_argument("--mirror", action="store_true") + + archive = subparsers.add_parser("archive", help="Create a validated tarball") + archive.add_argument("--source", required=True, type=Path) + archive.add_argument("--output", required=True, type=Path) + archive.add_argument("--root-name", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + try: + if args.command == "check": + validate_payload(args.root) + print(f"Payload valid: {args.root}") + elif args.command == "stage": + stage_payload(args.source, args.destination, mirror=args.mirror) + print(f"Payload staged: {args.destination}") + elif args.command == "archive": + archive = create_archive( + args.source, + args.output, + root_name=args.root_name, + ) + print(f"Archive built: {archive}") + else: # pragma: no cover - argparse owns command validation. + raise ManifestError(f"unknown command: {args.command}") + except (ManifestError, OSError) as exc: + print(str(exc), file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install-foundations.sh b/scripts/install-foundations.sh index b30a0809..6d445afc 100755 --- a/scripts/install-foundations.sh +++ b/scripts/install-foundations.sh @@ -29,7 +29,6 @@ VCFRAME_INSTALL_URL="${VCFRAME_INSTALL_URL:-https://vibecrafted.io/install.sh}" AGENT_PACKAGES=( "claude:@anthropic-ai/claude-code" "codex:@openai/codex" - "gemini:@google/gemini-cli" "junie:@jetbrains/junie" "grok:@xai-official/grok" ) diff --git a/scripts/installer_gui.py b/scripts/installer_gui.py index a130d3e9..b45d1a9b 100644 --- a/scripts/installer_gui.py +++ b/scripts/installer_gui.py @@ -57,7 +57,7 @@ FOUNDATION_COMMANDS = ("loctree-mcp", "aicx-mcp", "prview", "screenscribe") BUNDLED_BIN_NAMES = ("aicx-mcp", "aicx", "loctree-mcp", "loctree", "loct", "prview") TOOLCHAIN_COMMANDS = ("python3", "node", "git", "rsync") -AGENT_COMMANDS = ("claude", "codex", "gemini", "agy", "junie", "grok") +AGENT_COMMANDS = ("claude", "codex", "agy", "junie", "grok") ADDITIONAL_TOOL_COMMANDS = ("mise", "starship", "atuin", "zoxide") @@ -458,11 +458,7 @@ def _resolve_site_dist(self, explicit: str | None) -> Path | None: ) if root.name == "vibecrafted" or (root / "VERSION").is_file(): candidates.append( - vibecrafted_home() - / "vc-runtime" - / "vibecrafted-io" - / "site" - / "dist" + vibecrafted_home() / "vc-runtime" / "vibecrafted-io" / "site" / "dist" ) # Legacy fallback: honor a pre-existing $HOME/Libraxis checkout. candidates.append( @@ -543,7 +539,7 @@ def preflight_payload(self) -> dict[str, Any]: "control_plane": control_plane, "launcher_defaults": { "workflows": ["workflow", "research", "review", "marbles"], - "agents": ["claude", "codex", "gemini", "agy", "junie", "grok"], + "agents": ["claude", "codex", "agy", "junie", "grok"], "runtimes": ["headless", "terminal", "visible"], }, "status": self.status_payload(), diff --git a/scripts/version_bump.py b/scripts/version_bump.py index 1af29fe4..0b4b87f4 100644 --- a/scripts/version_bump.py +++ b/scripts/version_bump.py @@ -10,6 +10,11 @@ SEMVER_RE = re.compile( r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$" ) +PROJECT_VERSION_RE = re.compile( + r'^(?P\s*version\s*=\s*")(?P[^"]+)(?P".*)$' +) +PYPROJECT_RELATIVE = Path("vibecrafted-core/pyproject.toml") +PACKAGED_VERSION_RELATIVE = Path("vibecrafted-core/vibecrafted_core/VERSION") def _parse_version(value: str) -> tuple[int, int, int]: @@ -40,23 +45,96 @@ def resolve_next_version(current: str, requested: str) -> str: return f"{major}.{minor}.{patch}" +def _project_version(text: str) -> str: + in_project = False + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_project = stripped == "[project]" + continue + if in_project and (match := PROJECT_VERSION_RE.match(line)): + return match.group("version") + raise ValueError("pyproject.toml has no [project] version declaration") + + +def _replace_project_version(text: str, version: str) -> str: + in_project = False + output: list[str] = [] + replaced = False + for line in text.splitlines(keepends=True): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_project = stripped == "[project]" + if in_project and not replaced: + body = line.rstrip("\r\n") + newline = line[len(body) :] + if match := PROJECT_VERSION_RE.match(body): + line = ( + f"{match.group('prefix')}{version}{match.group('suffix')}{newline}" + ) + replaced = True + output.append(line) + if not replaced: + raise ValueError("pyproject.toml has no [project] version declaration") + return "".join(output) + + +def _version_projections(version_file: Path) -> tuple[Path, Path] | None: + project_root = version_file.parent + pyproject = project_root / PYPROJECT_RELATIVE + packaged = project_root / PACKAGED_VERSION_RELATIVE + existing = (pyproject.exists(), packaged.exists()) + if not any(existing): + return None + if not all(existing): + missing = pyproject if not existing[0] else packaged + raise ValueError(f"version declaration missing: {missing}") + return pyproject, packaged + + +def update_version_declarations(version_file: Path, requested: str) -> tuple[str, str]: + current = version_file.read_text(encoding="utf-8").strip() + next_version = resolve_next_version(current, requested) + projections = _version_projections(version_file) + + updates = {version_file: next_version + "\n"} + if projections is not None: + pyproject, packaged = projections + pyproject_text = pyproject.read_text(encoding="utf-8") + declared = { + version_file: current, + pyproject: _project_version(pyproject_text), + packaged: packaged.read_text(encoding="utf-8").strip(), + } + drift = {path: value for path, value in declared.items() if value != current} + if drift: + details = ", ".join(f"{path}={value}" for path, value in drift.items()) + raise ValueError( + f"Version drift detected; expected {current} in every declaration: {details}" + ) + updates[pyproject] = _replace_project_version(pyproject_text, next_version) + updates[packaged] = next_version + "\n" + + for path, content in updates.items(): + path.write_text(content, encoding="utf-8") + return current, next_version + + def main() -> int: parser = argparse.ArgumentParser( - description="Bump the root VERSION file.", + description="Bump VERSION and every packaged version declaration.", ) parser.add_argument("version", help="{patch|minor|major|x.y.z}") parser.add_argument("--file", default="VERSION", help="VERSION file path") args = parser.parse_args() version_file = Path(args.file) - current = version_file.read_text(encoding="utf-8").strip() try: - next_version = resolve_next_version(current, args.version) - except ValueError as exc: + current, next_version = update_version_declarations(version_file, args.version) + except (OSError, ValueError) as exc: print(str(exc), file=sys.stderr) return 2 - version_file.write_text(next_version + "\n", encoding="utf-8") print(f"Bumped: v{current} -> v{next_version}") return 0 diff --git a/scripts/vetcoders_install.py b/scripts/vetcoders_install.py index c90a4f04..0bf3cda7 100755 --- a/scripts/vetcoders_install.py +++ b/scripts/vetcoders_install.py @@ -33,9 +33,11 @@ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple try: + _distribution_manifest = importlib.import_module("distribution_manifest") _installer_brand = importlib.import_module("installer_brand") _runtime_paths = importlib.import_module("runtime_paths") except ModuleNotFoundError: # pragma: no cover - import path depends on entrypoint + _distribution_manifest = importlib.import_module("scripts.distribution_manifest") _installer_brand = importlib.import_module("scripts.installer_brand") _runtime_paths = importlib.import_module("scripts.runtime_paths") @@ -47,12 +49,17 @@ brand_separator = getattr(_installer_brand, "separator") brand_version_line = getattr(_installer_brand, "version_line") read_version_file = getattr(_runtime_paths, "read_version_file") +vibecrafted_backups_home = getattr(_runtime_paths, "vibecrafted_backups_home") vibecrafted_launcher_bin = getattr(_runtime_paths, "vibecrafted_launcher_bin") vibecrafted_runtime_home = getattr(_runtime_paths, "vibecrafted_runtime_home") vibecrafted_runtime_bin = getattr(_runtime_paths, "vibecrafted_runtime_bin") vibecrafted_tools_home = getattr(_runtime_paths, "vibecrafted_tools_home") vibecrafted_home = getattr(_runtime_paths, "vibecrafted_home") +xdg_data_home = getattr(_runtime_paths, "xdg_data_home") xdg_config_home = getattr(_runtime_paths, "xdg_config_home") +stage_distribution_payload = getattr(_distribution_manifest, "stage_payload") +distribution_path_is_forbidden = getattr(_distribution_manifest, "path_is_forbidden") +DistributionManifestError = getattr(_distribution_manifest, "ManifestError") # --------------------------------------------------------------------------- # ANSI helpers @@ -550,6 +557,8 @@ def doctor_runtime_finding() -> "DoctorFinding": OLD_SKILL_PREFIX = "vetcoders-" OLD_HELPER_NAME = "vetcoders-skills.zsh" +SKILL_ROOT_RULE_FILES = ("VERIFICATION_RULE.md", "LIVING_TREE_RULE.md") +LOCALIZED_SKILL_RULE_DIRS = ("pl",) def _is_writable(path: Path) -> bool: @@ -564,8 +573,9 @@ def _is_writable(path: Path) -> bool: return False -AGENT_RUNTIMES = ["codex", "claude", "gemini", "agy", "junie", "grok"] +AGENT_RUNTIMES = ["codex", "claude", "agy", "junie", "grok"] SYMLINK_TARGETS = ["agents", "claude", "codex"] +# gemini kept in CHOICES only for legacy .gemini data dir compat (no active runtime) SYMLINK_TARGET_CHOICES = [*SYMLINK_TARGETS, "gemini", "agy", "junie", "grok"] # --------------------------------------------------------------------------- @@ -796,6 +806,18 @@ def detect_cargo() -> Optional[str]: return shutil.which("cargo") +def source_skills_root(repo_root: Path) -> Path: + skills_dir = repo_root / "skills" + if skills_dir.is_dir(): + return skills_dir + + packaged_skills_dir = repo_root / "vibecrafted-core" / "vibecrafted_core" / "skills" + if packaged_skills_dir.is_dir(): + return packaged_skills_dir + + return repo_root + + def get_framework_version(repo_root: Path) -> str: return read_version_file(repo_root) @@ -830,9 +852,7 @@ def get_repo_url(repo_root: Path) -> str: def discover_skills(repo_root: Path) -> List[Path]: """Find all default 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. skill directories.""" skills: List[Path] = [] - skills_dir = repo_root / "skills" - if not skills_dir.is_dir(): - skills_dir = repo_root / "vibecrafted-core" / "vibecrafted_core" / "skills" + skills_dir = source_skills_root(repo_root) if not skills_dir.exists() or not skills_dir.is_dir(): return skills @@ -850,6 +870,45 @@ def discover_skills(repo_root: Path) -> List[Path]: return skills +def iter_skill_root_rule_files(skills_root: Path) -> List[Tuple[Path, Path]]: + rule_files: List[Tuple[Path, Path]] = [] + + for filename in SKILL_ROOT_RULE_FILES: + source = skills_root / filename + if source.is_file(): + rule_files.append((source, Path(filename))) + + for localized_dir in LOCALIZED_SKILL_RULE_DIRS: + localized_root = skills_root / localized_dir + if not localized_root.is_dir(): + continue + for filename in SKILL_ROOT_RULE_FILES: + source = localized_root / filename + if source.is_file(): + rule_files.append((source, Path(localized_dir) / filename)) + + return rule_files + + +def sync_skill_root_rules( + skills_root: Path, store_path: Path, dry_run: bool = False +) -> List[Path]: + """Copy rule files that skill directories link to via ../RULE.md.""" + copied: List[Path] = [] + for source, relative_target in iter_skill_root_rule_files(skills_root): + target = store_path / relative_target + if not dry_run: + target.parent.mkdir(parents=True, exist_ok=True) + # When the skill store is a symlink back to the source checkout + # (portable CI wires vibecrafted-current -> vibecrafted-main), + # source and target resolve to the same inode; copy2 would raise + # shutil.SameFileError and the copy is a no-op, so skip it. + if not (target.exists() and source.resolve() == target.resolve()): + shutil.copy2(source, target) + copied.append(relative_target) + return copied + + def categorize_skill(name: str) -> str: """Return category key for a skill name.""" if name.startswith("vc-"): @@ -1113,11 +1172,12 @@ def render(): # Backup # --------------------------------------------------------------------------- -BACKUP_DIR = ".backup" +BACKUP_DIR = "backups/installer" def _backup_root(store_path: Path) -> Path: - return store_path / BACKUP_DIR + _ = store_path + return vibecrafted_backups_home() def _copy_path_to_backup(src: Path, dst: Path) -> None: @@ -1146,6 +1206,139 @@ def _restore_path_from_backup(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +@dataclass(frozen=True) +class ManagedPath: + kind: str + path: Path + action: str = "remove" + reason: str = "" + + +RESTORE_MANIFEST_FILE = "restore-manifest.json" +RESTORE_SCRIPT_FILE = "restore.py" + +_SELF_CONTAINED_RESTORE_SCRIPT = """#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path + + +def remove_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + + +def restore_path(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists() or destination.is_symlink(): + remove_path(destination) + if source.is_symlink(): + destination.symlink_to(os.readlink(source)) + elif source.is_dir(): + shutil.copytree(source, destination, symlinks=True) + elif source.is_file(): + shutil.copy2(source, destination) + + +backup_dir = Path(__file__).resolve().parent +manifest = json.loads((backup_dir / "restore-manifest.json").read_text(encoding="utf-8")) +restored = 0 +for item in manifest["items"]: + source = backup_dir / item["backup"] + destination = Path(item["path"]) + if source.exists() or source.is_symlink(): + restore_path(source, destination) + restored += 1 +print(f"Restored {restored} managed paths from {backup_dir}") +""" + + +def _path_present(path: Path) -> bool: + return path.exists() or path.is_symlink() + + +def _teardown_backup_records(inventory: Sequence[ManagedPath]) -> List[ManagedPath]: + candidates = [ + record + for record in inventory + if record.action in {"remove", "edit"} and _path_present(record.path) + ] + selected: List[ManagedPath] = [] + selected_roots: List[Path] = [] + for record in sorted(candidates, key=lambda item: len(item.path.parts)): + if record.path.is_symlink(): + selected.append(record) + continue + resolved = record.path.resolve(strict=False) + if any(resolved == root or root in resolved.parents for root in selected_roots): + continue + selected.append(record) + selected_roots.append(resolved) + return selected + + +def create_teardown_backup( + inventory: Sequence[ManagedPath], *, dry_run: bool = False +) -> Optional[str]: + records = _teardown_backup_records(inventory) + if not records: + return None + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + if dry_run: + return timestamp + + backup_root = vibecrafted_backups_home() + backup_dir = backup_root / timestamp + items_dir = backup_dir / "items" + items_dir.mkdir(parents=True, exist_ok=False) + manifest_items: List[Dict[str, str]] = [] + for index, record in enumerate(records): + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "-", record.path.name) or "root" + relative_backup = Path("items") / f"{index:04d}-{safe_name}" + _copy_path_to_backup(record.path, backup_dir / relative_backup) + manifest_items.append( + { + "kind": record.kind, + "path": str(record.path), + "backup": str(relative_backup), + } + ) + + manifest = { + "version": 1, + "created_at": datetime.now(timezone.utc).isoformat(), + "items": manifest_items, + } + (backup_dir / RESTORE_MANIFEST_FILE).write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + restore_script = backup_dir / RESTORE_SCRIPT_FILE + restore_script.write_text(_SELF_CONTAINED_RESTORE_SCRIPT, encoding="utf-8") + restore_script.chmod(0o755) + backup_root.mkdir(parents=True, exist_ok=True) + (backup_root / "latest").write_text(timestamp + "\n", encoding="utf-8") + return timestamp + + +def _restore_command(backup_timestamp: str) -> str: + script = vibecrafted_backups_home() / backup_timestamp / RESTORE_SCRIPT_FILE + return f"python3 {shlex_quote(str(script))}" + + +def shlex_quote(value: str) -> str: + """Shell-quote one path without adding a runtime dependency.""" + if not value: + return "''" + if re.fullmatch(r"[A-Za-z0-9_@%+=:,./-]+", value): + return value + return "'" + value.replace("'", "'\\''") + "'" + + def collect_orphaned_skills( store_path: Path, runtimes: List[str], current_bundle: Set[str] ) -> List[Tuple[str, Path]]: @@ -1987,12 +2180,12 @@ def collect_installed_launchers() -> List[Tuple[Path, Path]]: "claude-research", "claude-prompt", "claude-observe", - "gemini-implement", - "gemini-plan", - "gemini-review", - "gemini-research", - "gemini-prompt", - "gemini-observe", + "agy-implement", + "agy-plan", + "agy-review", + "agy-research", + "agy-prompt", + "agy-observe", "agy-implement", "agy-plan", "agy-review", @@ -2006,9 +2199,9 @@ def collect_installed_launchers() -> List[Tuple[Path, Path]]: "junie-prompt", "junie-observe", "skills-sync", - "gemini-keychain-set", - "gemini-keychain-get", - "gemini-keychain-clear", + "agy-keychain-set", + "agy-keychain-get", + "agy-keychain-clear", ] @@ -2124,29 +2317,6 @@ def report_helper_conflicts( _RSYNC_EXCLUDES = {".DS_Store", ".backup", ".loctree"} -_CONTROL_PLANE_EXCLUDES = { - ".DS_Store", - ".git", - ".legacy-state-agency", - ".loctree", - ".pytest_cache", - ".venv", - "__pycache__", - # Regenerable build artifacts — never belong in the staged control-plane - # mirror. Without these the mirror balloons (a Swift-app DerivedData/build - # tree took vibecrafted-local to 47G and filled the disk, which surfaced as - # rsync "No space left on device" during install). - "node_modules", - "target", - "dist", - "build", - "DerivedData", - ".build", - ".next", - ".air", - ".mypy_cache", - ".ruff_cache", -} def _copytree_skill(src: Path, dst: Path, mirror: bool = False) -> None: @@ -2170,6 +2340,12 @@ def rsync_skill( """Sync a single skill directory. Uses rsync when available, shutil otherwise.""" if dry_run: return + # A symlinked store (portable CI wires vibecrafted-current -> the source + # checkout) makes src and dst the same directory. rsync would churn, and the + # shutil fallback would copy a file onto itself (or rmtree the source under + # --mirror); skip the self-sync entirely. + if dst.exists() and src.resolve() == dst.resolve(): + return dst.mkdir(parents=True, exist_ok=True) if shutil.which("rsync"): cmd = [ @@ -2207,71 +2383,32 @@ def _remove_path(path: Path) -> None: shutil.rmtree(path) -def _clear_dir_contents(path: Path) -> None: - if not path.exists(): - return - for child in path.iterdir(): - _remove_path(child) - - -def _copy_control_plane_contents(src: Path, dst: Path, mirror: bool = False) -> None: - """Pure-Python fallback for staged control-plane sync.""" - if mirror: - _clear_dir_contents(dst) - dst.mkdir(parents=True, exist_ok=True) - - for item in src.iterdir(): - if item.name in _CONTROL_PLANE_EXCLUDES: - continue - - target = dst / item.name - if item.is_symlink(): - if target.exists() or target.is_symlink(): - _remove_path(target) - target.symlink_to(os.readlink(item)) - elif item.is_dir(): - if target.exists() and not target.is_dir(): - _remove_path(target) - _copy_control_plane_contents(item, target, mirror=False) - elif item.is_file(): - if target.exists() and target.is_dir(): - _remove_path(target) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(item, target) - - def sync_control_plane_tree( src: Path, dst: Path, dry_run: bool = False, mirror: bool = False ) -> None: - """Sync the staged source tree used by installed launchers and helpers.""" + """Atomically replace the staged source tree used by installed launchers.""" if dry_run: return - dst.mkdir(parents=True, exist_ok=True) - if shutil.which("rsync"): - # --copy-dirlinks: materialise dir-symlinks (the dev compat-shims at - # top-level runtime/ and skills/ that point into the packaged tree) as - # real directories in the staged mirror. Without it, rsync tries to - # replace the destination's existing real dirs with symlinks and fails - # with exit 23 ("could not make way for new symlink: runtime/skills"). - cmd = ["rsync", "-a", "--copy-dirlinks"] - for name in sorted(_CONTROL_PLANE_EXCLUDES): - cmd += ["--exclude", name] - if mirror: - cmd.append("--delete") - cmd += [str(src) + "/", str(dst) + "/"] - # Capture rsync stderr — do NOT discard it. When this sync fails the - # operator needs the real reason (exit 23 "could not make way", exit - # 11/12 "No space left on device", a dangling symlink, a permission - # error), not an opaque "could not refresh staged tools". - subprocess.run( - cmd, - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - ) - else: - _copy_control_plane_contents(src, dst, mirror=mirror) + _ = mirror # staged runtime is always an exact distribution payload + staging = dst.parent / f".{dst.name}.staging-{os.getpid()}" + previous = dst.parent / f".{dst.name}.previous-{os.getpid()}" + if staging.exists() or staging.is_symlink(): + _remove_path(staging) + if previous.exists() or previous.is_symlink(): + _remove_path(previous) + try: + stage_distribution_payload(src, staging, mirror=True) + if dst.exists() or dst.is_symlink(): + dst.rename(previous) + staging.rename(dst) + if previous.exists() or previous.is_symlink(): + _remove_path(previous) + except Exception: + if staging.exists() or staging.is_symlink(): + _remove_path(staging) + if not dst.exists() and previous.exists(): + previous.rename(dst) + raise def _staged_sync_failure_detail(exc: Exception) -> str: @@ -2371,9 +2508,7 @@ def _transfer_relative_files(root: Path) -> List[Path]: return [] files: List[Path] = [] for item in sorted(root.rglob("*")): - if any( - part in _CONTROL_PLANE_EXCLUDES for part in item.relative_to(root).parts - ): + if distribution_path_is_forbidden(item.relative_to(root)): continue if item.is_file() or item.is_symlink(): files.append(item.relative_to(root)) @@ -3875,7 +4010,7 @@ def run_doctor(store_path: Path, state: InstallState) -> List[DoctorFinding]: ) # 7d. Agent CLI availability - for agent_name in ("claude", "codex", "gemini"): + for agent_name in ("claude", "codex", "agy"): agent_bin = shutil.which(agent_name) if agent_bin: findings.append( @@ -4491,12 +4626,14 @@ def _cmd_install_verbose(args: argparse.Namespace, repo_root: Path) -> int: if not dry_run: store_path.mkdir(parents=True, exist_ok=True) - skills_dir = repo_root / "skills" if (repo_root / "skills").is_dir() else repo_root + skills_dir = source_skills_root(repo_root) for name in selected_skills: src = skills_dir / name dst = store_path / name print(f" {dim('->')} {name}") rsync_skill(src, dst, dry_run=dry_run, mirror=mirror) + for rule in sync_skill_root_rules(skills_dir, store_path, dry_run=dry_run): + print(f" {dim('->')} {rule}") print() # --- Execute: staged control plane --- @@ -4524,6 +4661,8 @@ def _cmd_install_verbose(args: argparse.Namespace, repo_root: Path) -> int: if not dry_run: rt_skills.mkdir(parents=True, exist_ok=True) print(f" {cyan(rt)} -> {rt_skills}") + for rule in sync_skill_root_rules(skills_dir, rt_skills, dry_run=dry_run): + print(f" {dim('->')} {rule}") for name in selected_skills: default = store_path / name link = rt_skills / name @@ -4921,9 +5060,7 @@ def _cmd_install_compact(args: argparse.Namespace, repo_root: Path) -> int: print("Installing skills:") if not dry_run: store_path.mkdir(parents=True, exist_ok=True) - skills_dir = ( - repo_root / "skills" if (repo_root / "skills").is_dir() else repo_root - ) + skills_dir = source_skills_root(repo_root) # One live counter line (§6.6), per-skill detail stays in the log. total_skills = len(selected_skills) for idx, name in enumerate(selected_skills, 1): @@ -4936,6 +5073,8 @@ def _cmd_install_compact(args: argparse.Namespace, repo_root: Path) -> int: out, dim(frame), "Skills", f"installing {idx}/{total_skills}" ) rsync_skill(src, dst, dry_run=dry_run, mirror=mirror) + for rule in sync_skill_root_rules(skills_dir, store_path, dry_run=dry_run): + print(f" -> {rule}") print() print("Refreshing staged control plane:") @@ -4943,7 +5082,11 @@ def _cmd_install_compact(args: argparse.Namespace, repo_root: Path) -> int: current_tools = refresh_current_tools( repo_root, shared_home, dry_run=dry_run, mirror=mirror ) - except (OSError, subprocess.CalledProcessError) as exc: + except ( + OSError, + subprocess.CalledProcessError, + DistributionManifestError, + ) as exc: print(f" FAILED: {_staged_sync_failure_detail(exc)}") _clear_compact_status(out) err_line( @@ -4975,6 +5118,8 @@ def _cmd_install_compact(args: argparse.Namespace, repo_root: Path) -> int: if not dry_run: rt_skills.mkdir(parents=True, exist_ok=True) print(f" {rt} -> {rt_skills}") + for rule in sync_skill_root_rules(skills_dir, rt_skills, dry_run=dry_run): + print(f" -> {rule}") for name in selected_skills: default = store_path / name link = rt_skills / name @@ -5419,6 +5564,229 @@ def cmd_layout(args: argparse.Namespace) -> int: # --------------------------------------------------------------------------- +def _managed_tools_entry(path: Path) -> bool: + return ( + path.name == "vibecrafted-current" + or path.name.startswith("vibecrafted-") + or path.name.startswith(".incoming-") + ) + + +def _build_uninstall_inventory( + *, + shared_home: Path, + store_path: Path, + state_file: Path, + skill_names: Sequence[str], + runtimes: Sequence[str], + helper_paths: Sequence[Path], + launchers: Sequence[Tuple[Path, Path]], + rc_cleanup_targets: Sequence[Path], +) -> List[ManagedPath]: + records: List[ManagedPath] = [] + seen: Dict[str, int] = {} + + def add(kind: str, path: Path, action: str = "remove", reason: str = "") -> None: + normalized = path.expanduser() + if action != "remove-if-empty" and not _path_present(normalized): + return + key = str(normalized) + existing = seen.get(key) + if existing is not None: + if records[existing].action == "preserve" and action != "preserve": + records[existing] = ManagedPath(kind, normalized, action, reason) + return + seen[key] = len(records) + records.append(ManagedPath(kind, normalized, action, reason)) + + resolved_store = store_path.resolve(strict=False) + managed_tools_root = vibecrafted_tools_home().resolve(strict=False) + legacy_store_root = (shared_home / "skills").resolve(strict=False) + store_is_managed = _is_subpath(resolved_store, managed_tools_root) or _is_subpath( + resolved_store, legacy_store_root + ) + if store_is_managed: + for name in skill_names: + add("shared-skill", store_path / name) + add("install-state", state_file) + elif _path_present(store_path): + add( + "external-store", + resolved_store, + "preserve", + "current link resolves outside the managed tools root", + ) + for runtime in runtimes: + runtime_skills = Path.home() / f".{runtime}" / "skills" + for name in skill_names: + add("agent-view", runtime_skills / name) + for helper in helper_paths: + add("shell-helper", helper) + for _launcher_dir, launcher in launchers: + add("launcher", launcher) + for rcfile in rc_cleanup_targets: + add("shell-rc", rcfile, "edit", "remove Vibecrafted-managed lines only") + + add("install-log", shared_home / "install.log") + add("start-guide", start_here_path()) + + tools_roots = [vibecrafted_tools_home(), shared_home / "tools"] + unique_tools_roots: List[Path] = [] + for tools_root in tools_roots: + if tools_root in unique_tools_roots: + continue + unique_tools_roots.append(tools_root) + if tools_root.is_dir(): + for entry in sorted(tools_root.iterdir(), key=lambda item: item.name): + if _managed_tools_entry(entry): + add("staged-payload", entry) + else: + add( + "tools-sibling", + entry, + "preserve", + "not a Vibecrafted-managed payload name", + ) + add( + "tools-root", + tools_root, + "remove-if-empty", + "shared parent remains when unrelated entries exist", + ) + + runtime_bin = vibecrafted_runtime_bin() + if runtime_bin.is_dir(): + children = sorted(runtime_bin.iterdir(), key=lambda item: item.name) + if children: + for child in children: + add( + "runtime-bin", + child, + "preserve", + "binary ownership is product-managed outside installer state", + ) + else: + add("runtime-bin", runtime_bin, "preserve", "empty runtime binary root") + + runtime_home = vibecrafted_runtime_home() + if runtime_home.is_dir(): + for child in sorted(runtime_home.iterdir(), key=lambda item: item.name): + if child in {vibecrafted_tools_home(), runtime_bin}: + continue + add( + "runtime-data", + child, + "preserve", + "runtime data is not proven installer-owned", + ) + + uv_tools_root = Path( + os.environ.get("UV_TOOL_DIR", str(xdg_data_home() / "uv" / "tools")) + ).expanduser() + for name in ("vibecrafted", "vibecrafted-core", "vibecrafted-mcp"): + add( + "uv-tool", + uv_tools_root / name, + "preserve", + "uv owns this environment; remove it with `uv tool uninstall`", + ) + + for name in ("artifacts", "control_plane", "logs"): + add( + "operator-data", + shared_home / name, + "preserve", + "operator history/data is retained intentionally", + ) + return records + + +def _print_uninstall_inventory(inventory: Sequence[ManagedPath]) -> None: + print(bold("Managed teardown inventory:")) + for record in inventory: + verb = { + "remove": "remove", + "edit": "edit", + "remove-if-empty": "remove if empty", + "preserve": "preserve", + }[record.action] + suffix = f" — {record.reason}" if record.reason else "" + print(f" {verb:15} {record.kind}: {record.path}{suffix}") + print() + + +def _edit_rc_file(record: ManagedPath, *, dry_run: bool) -> Tuple[bool, str]: + rcfile = record.path + if not _is_writable(rcfile): + return False, "locked; launcher/source hints remain" + content = rcfile.read_text(encoding="utf-8") + changed = False + for line, comment in _uninstall_rc_entries(): + content, removed = _strip_rc_entry(content, line, comment) + changed = changed or removed > 0 + if changed and not dry_run: + rcfile.write_text(content, encoding="utf-8") + return changed, "" + + +def _apply_uninstall_inventory( + inventory: Sequence[ManagedPath], *, dry_run: bool +) -> Tuple[List[ManagedPath], List[ManagedPath], List[str]]: + applied: List[ManagedPath] = [] + preserved = [record for record in inventory if record.action == "preserve"] + failures: List[str] = [] + + removals = sorted( + (record for record in inventory if record.action == "remove"), + key=lambda item: (-len(item.path.parts), str(item.path)), + ) + for record in removals: + if not _path_present(record.path): + continue + if dry_run: + applied.append(record) + continue + try: + _remove_path(record.path) + applied.append(record) + except OSError as exc: + failures.append(f"{record.path}: {exc}") + + for record in (item for item in inventory if item.action == "edit"): + try: + changed, reason = _edit_rc_file(record, dry_run=dry_run) + except OSError as exc: + failures.append(f"{record.path}: {exc}") + continue + if changed: + applied.append(record) + elif reason: + preserved.append(ManagedPath(record.kind, record.path, "preserve", reason)) + + for record in (item for item in inventory if item.action == "remove-if-empty"): + if not record.path.is_dir(): + continue + try: + is_empty = not any(record.path.iterdir()) + if not is_empty: + preserved.append( + ManagedPath( + record.kind, + record.path, + "preserve", + "contains intentionally preserved or unrelated entries", + ) + ) + elif dry_run: + applied.append(record) + else: + record.path.rmdir() + applied.append(record) + except OSError as exc: + failures.append(f"{record.path}: {exc}") + return applied, preserved, failures + + def cmd_uninstall(args: argparse.Namespace) -> int: shared_home = vibecrafted_home() store_path = _canonical_store_path(shared_home) @@ -5434,20 +5802,15 @@ def cmd_uninstall(args: argparse.Namespace) -> int: # fall back to discovery heuristics only when we don't have installer state. if state.helper_files: helper_paths = [Path(p) for p in state.helper_files if Path(p).exists()] - backup_helper_entries = state.helper_files elif has_state and not (state.skills or state.runtimes or state.launcher_entries): helper_paths = [] - backup_helper_entries = None else: helper_paths = [hf for hf in (helper_file, legacy_file) if hf.exists()] - backup_helper_entries = None if state.launcher_entries: launchers = _parse_manifest_launchers(state.launcher_entries) - backup_launcher_entries = state.launcher_entries else: launchers = collect_installed_launchers() - backup_launcher_entries = None rc_cleanup_targets = [ Path.home() / rcname @@ -5463,34 +5826,43 @@ def cmd_uninstall(args: argparse.Namespace) -> int: else [rt for rt in SYMLINK_TARGET_CHOICES if runtime_skills_dir(rt).exists()] ) + inventory = _build_uninstall_inventory( + shared_home=shared_home, + store_path=store_path, + state_file=state_file, + skill_names=skill_names, + runtimes=runtimes, + helper_paths=helper_paths, + launchers=launchers, + rc_cleanup_targets=rc_cleanup_targets, + ) + has_work = any( + record.action in {"remove", "edit"} + or ( + record.action == "remove-if-empty" + and record.path.is_dir() + and not any(record.path.iterdir()) + ) + for record in inventory + ) + print(f"\n{bold('𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. Uninstall')}\n") - if not (skill_names or launchers or helper_paths or rc_cleanup_targets): + if not has_work: print( dim( - "Nothing to uninstall — no tracked skills, launchers, helpers, or shell hooks found." + "Nothing to uninstall — no managed payloads, skills, launchers, helpers, or shell hooks found." ) ) + preserved = [record for record in inventory if record.action == "preserve"] + if preserved: + print(" Preserved intentionally:") + for record in preserved: + print(f" {record.path} — {record.reason}") + print() return 0 - if skill_names: - print(f" Will remove {len(skill_names)} skills from:") - print(f" Store: {store_path}") - for rt in runtimes: - print(f" Symlinks: $VIBECRAFTED_ROOT/.{rt}/skills/") - if launchers: - print(" Will remove launcher commands from:") - for launcher_bin_dir in _launcher_bin_dirs(): - print(f" Launchers: {launcher_bin_dir}") - if helper_paths: - print(" Will remove helper files:") - for hf in helper_paths: - print(f" Helper: {hf}") - if rc_cleanup_targets: - print(" Will clean shell startup files:") - for rcfile in rc_cleanup_targets: - print(f" RC: {rcfile}") - print() + _print_uninstall_inventory(inventory) if _IS_TTY and not dry_run: if not ask_yn("Remove the installed 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. bundle?", default=False): @@ -5498,164 +5870,50 @@ def cmd_uninstall(args: argparse.Namespace) -> int: return 0 print() - # Backup before removing - print(bold("Saving current state...")) - backup_ts = create_backup( - store_path, - runtimes, - skill_names, - launcher_entries=backup_launcher_entries, - helper_entries=backup_helper_entries, - dry_run=dry_run, - ) - if backup_ts: - print(f" {OK} Backup saved: {_backup_root(store_path) / backup_ts}") - print(f" {dim('Use `make restore` to undo this uninstall.')}") - print() - - # Remove symlinks from per-runtime dirs - print(bold("Removing agent views...")) - for rt in runtimes: - rt_skills = Path.home() / f".{rt}" / "skills" - if not rt_skills.exists(): - continue - for name in skill_names: - link = rt_skills / name - if link.exists() or link.is_symlink(): - if dry_run: - print(f" {dim('rm')} {link}") - else: - if link.is_symlink(): - link.unlink() - elif link.is_dir(): - shutil.rmtree(link) - print(f" {dim('-')} {rt}/{name}") - print() - - # Remove skills from shared store - print(bold("Removing shared skills...")) - for name in skill_names: - skill_path = store_path / name - if skill_path.exists(): - if dry_run: - print(f" {dim('rm -rf')} {skill_path}") - else: - shutil.rmtree(skill_path) - print(f" {dim('-')} {name}") - print() - - # Remove shell helpers - any_helper = bool(helper_paths) - if any_helper: - print(bold("Removing shell helpers...")) - for hf in helper_paths: - if dry_run: - print(f" {dim('rm')} {hf}") - else: - hf.unlink() - print(f" {dim('-')} {hf}") - print() - - if launchers: - print(bold("Removing launcher commands...")) - for _launcher_bin_dir, entry in launchers: - if dry_run: - print(f" {dim('rm')} {entry}") - else: - if entry.is_symlink() or entry.is_file(): - entry.unlink(missing_ok=True) - elif entry.is_dir(): - shutil.rmtree(entry) - print(f" {dim('-')} {entry}") - - if not dry_run: - for launcher_bin_dir in _launcher_bin_dirs(): - if launcher_bin_dir.exists() and not any(launcher_bin_dir.iterdir()): - try: - launcher_bin_dir.rmdir() - print(f" {dim('-')} {launcher_bin_dir} (empty)") - except OSError: - pass - print() - - # Remove framework artifacts - artifacts_to_remove = [ - shared_home / "install.log", - start_here_path(), - vibecrafted_tools_home() / "vibecrafted-current", - ] - removed_artifacts = False - for art in artifacts_to_remove: - if art.exists() or art.is_symlink(): - if not removed_artifacts: - print(bold("Removing framework artifacts...")) - removed_artifacts = True - if dry_run: - print(f" {dim('rm')} {art}") - else: - if art.is_symlink() or art.is_file(): - art.unlink(missing_ok=True) - elif art.is_dir(): - shutil.rmtree(art) - print(f" {dim('-')} {art}") - + backup_ts = None if not dry_run: - tools_dir = shared_home / "tools" - if tools_dir.exists() and not any(tools_dir.iterdir()): - try: - tools_dir.rmdir() - if not removed_artifacts: - print(bold("Removing framework artifacts...")) - removed_artifacts = True - print(f" {dim('-')} {tools_dir} (empty)") - except OSError: - pass - if removed_artifacts: + print(bold("Saving external restore kit...")) + backup_ts = create_teardown_backup(inventory) + if backup_ts: + print(f" {OK} {_backup_root(store_path) / backup_ts}") print() - # Always scrub launcher PATH/source hints even if the helper files were already gone. - cleaned_rc_files = 0 - for rcname in (".zshrc", ".bashrc"): - rcfile = Path.home() / rcname - if not rcfile.exists(): - continue - content = rcfile.read_text() - changed = False - for line, comment in _uninstall_rc_entries(): - if line not in content and (not comment or f"# {comment}" not in content): - continue - if not _is_writable(rcfile): - print( - f" {WARN} {rcfile} is locked — cannot remove launcher/source hints" - ) - break - if dry_run: - print(f" {dim('remove source line from')} {rcfile}") - changed = True - continue - content, removed = _strip_rc_entry(content, line, comment) - changed = changed or removed > 0 - if changed and not dry_run: - rcfile.write_text(content) - print(f" {dim('-')} source line from {rcfile}") - cleaned_rc_files += 1 - elif changed: - cleaned_rc_files += 1 - if cleaned_rc_files: + applied, preserved, failures = _apply_uninstall_inventory( + inventory, dry_run=dry_run + ) + if dry_run: + print("Would remove or edit:") + for record in applied: + print(f" {record.kind}: {record.path}") + if preserved: + print("Preserved intentionally:") + for record in preserved: + print(f" {record.kind}: {record.path} — {record.reason}") print() + return 0 - # Remove manifest - state_file = store_path / STATE_FILE - if state_file.exists(): - if dry_run: - print(f" {dim('rm')} {state_file}") - else: - state_file.unlink() + if failures: + print(red(bold("Uninstall incomplete."))) + for failure in failures: + print(f" {failure}") + if backup_ts: + print(f" Restore with: {_restore_command(backup_ts)}") + print() + return 1 - print(green(bold("Removed."))) + print(green(bold("Removed managed paths:"))) + for record in applied: + print(f" {record.kind}: {record.path}") + if preserved: + print("Preserved intentionally:") + for record in preserved: + print(f" {record.kind}: {record.path} — {record.reason}") if backup_ts: - print(dim(f" Backup at: {_backup_root(store_path) / backup_ts}")) - print(dim(" Run 'make restore' to undo.")) + backup_path = _backup_root(store_path) / backup_ts + print(f"Backup preserved: {backup_path}") + print("Restore:") + print(f" {_restore_command(backup_ts)}") + print(green(bold("Uninstall complete."))) print() return 0 @@ -5688,6 +5946,18 @@ def cmd_restore(args: argparse.Namespace) -> int: print(f" Restoring from backup: {bold(ts)}") print() + teardown_manifest = backup_dir / RESTORE_MANIFEST_FILE + teardown_restore = backup_dir / RESTORE_SCRIPT_FILE + if teardown_manifest.is_file() and teardown_restore.is_file(): + manifest = json.loads(teardown_manifest.read_text(encoding="utf-8")) + if dry_run: + for item in manifest.get("items", []): + print(f" {dim('restore')} {item.get('path', '')}") + print() + return 0 + result = subprocess.run([sys.executable, str(teardown_restore)], check=False) + return result.returncode + restored = 0 # Restore skills in store diff --git a/scripts/vibecrafted b/scripts/vibecrafted index 941e1424..9c13d5ce 100755 --- a/scripts/vibecrafted +++ b/scripts/vibecrafted @@ -52,7 +52,7 @@ _cyan='\033[36m' _red='\033[31m' _reset='\033[0m' -_agents=(claude codex gemini agy junie grok) +_agents=(claude codex agy junie grok) _modes=(implement research review plan prompt observe await stop) _skills=(audit decorate delegate dou followup hydrate implement intents justdo marbles ownership partner polarize prune release research review scaffold workflow) @@ -149,7 +149,8 @@ PY _has_agent() { local candidate="${1:-}" case "$candidate" in - claude|codex|gemini|agy|junie|grok) return 0 ;; + claude|codex|agy|junie|grok) return 0 ;; + gemini) return 1 ;; # deprecated - see migration error below *) return 1 ;; esac } @@ -505,7 +506,7 @@ _print_ship_path() { cmd_skill_help() { local skill="$1" - local agent="${2:-}" + local agent="${2:-}" local wrapper="vc-$skill" local display_skill="$skill" local version="" @@ -796,7 +797,7 @@ cmd_resume_help() { printf ' Resume an existing agent session.\n' printf '\n' printf '%bUsage:%b\n' "$_bold" "$_reset" - printf ' vibecrafted resume --session [flags]\n' + printf ' vibecrafted resume --session [flags]\n' printf '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' vibecrafted resume claude --session abc123\n' @@ -813,8 +814,8 @@ cmd_init_help() { printf ' Start an interactive repository orientation session with an agent.\n' printf '\n' printf '%bUsage:%b\n' "$_bold" "$_reset" - printf ' vibecrafted init [claude|codex|gemini|agy|junie|grok]\n' - printf ' vc-init [claude|codex|gemini|agy|junie|grok]\n' + printf ' vibecrafted init [claude|codex|agy|junie|grok]\n' + printf ' vc-init [claude|codex|agy|junie|grok]\n' printf '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' vibecrafted init claude\n' @@ -910,8 +911,8 @@ cmd_telemetry_help() { printf ' Smoke the marbles telemetry path through the installed command deck.\n' printf '\n' printf '%bUsage:%b\n' "$_bold" "$_reset" - printf ' telemetry smoke [--agent ] [--count ] [--root ] [--no-watch|--watch]\n' - printf ' vibecrafted telemetry smoke [--agent ] [--count ] [--root ] [--no-watch|--watch]\n' + printf ' telemetry smoke [--agent ] [--count ] [--root ] [--no-watch|--watch]\n' + printf ' vibecrafted telemetry smoke [--agent ] [--count ] [--root ] [--no-watch|--watch]\n' printf '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' telemetry smoke --count 1 --no-watch\n' @@ -1026,6 +1027,11 @@ cmd_telemetry_smoke() { shift done + if [[ "$agent" == "gemini" ]]; then + printf '%b✗ gemini CLI is deprecated.%b Use agy (Google Antigravity CLI) instead.\n' "$_red" "$_reset" >&2 + printf ' Example: vibecrafted workflow agy --prompt "..." \n' >&2 + return 1 + fi _has_agent "$agent" || { printf '%b✗%b Unknown agent: %s\n' "$_red" "$_reset" "$agent" >&2 return 1 @@ -1568,10 +1574,15 @@ cmd_help() { case "$topic" in ""|-h|--help) ;; - claude|codex|gemini|agy|junie|grok) + claude|codex|agy|junie|grok) cmd_agent_help "$topic" return ;; + gemini) + printf '%b✗ gemini CLI is deprecated.%b Use agy (Google Antigravity CLI) instead.\n' "$_red" "$_reset" >&2 + printf ' Example: vibecrafted workflow agy --prompt "..." \n' >&2 + return 1 + ;; init) cmd_init_help return @@ -1681,7 +1692,7 @@ cmd_help() { printf ' scaffold → implement → review → workflow → followup → marbles → audit → polarize → dou → hydrate → release\n' printf ' %b14 more skills: vibecrafted help --all%b\n' "$_dim" "$_reset" printf '\n' - printf '%bAgents:%b claude · codex · gemini · agy · junie · grok\n' "$_bold" "$_reset" + printf '%bAgents:%b claude · codex · agy · junie · grok\n' "$_bold" "$_reset" printf '\n' printf '%bExamples:%b\n' "$_bold" "$_reset" printf ' vibecrafted init claude\n' @@ -1757,7 +1768,7 @@ cmd_help_full() { printf ' %bvibecrafted tui%b Rust operator console over shared state\n' "$_cyan" "$_reset" printf ' %bvibecrafted server%b Manage local control-plane viewer server\n' "$_cyan" "$_reset" printf '\n' - printf ' Agents: %bclaude%b · %bcodex%b · %bgemini%b · %bagy%b · %bjunie%b · %bgrok%b\n' "$_bold" "$_reset" "$_bold" "$_reset" "$_bold" "$_reset" "$_bold" "$_reset" "$_bold" "$_reset" "$_bold" "$_reset" + printf '%b Agents: claude · codex · agy · junie · grok%b\n' "$_bold" "$_reset" printf '\n' printf '%b=== ADDITIONAL SKILLS ===%b\n' "$_bold" "$_reset" printf '\n' @@ -1809,7 +1820,7 @@ cmd_help_full() { printf '\n' printf ' vibecrafted codex implement .vibecrafted/plans/my-plan.md\n' printf ' vibecrafted claude research .vibecrafted/plans/my-plan.md\n' - printf ' vibecrafted gemini review .vibecrafted/plans/my-plan.md\n' + printf ' vibecrafted agy review .vibecrafted/plans/my-plan.md # (gemini deprecated)\n' printf ' vibecrafted codex observe --last\n' printf ' vibecrafted resume claude --session --prompt "Continue the fix"\n' printf '\n' @@ -1937,6 +1948,11 @@ cmd_core_workflow_skill() { cmd_init() { local agent="${1:-claude}" _prepend_repo_bin_path + if [[ "$agent" == "gemini" ]]; then + printf '%b✗ gemini CLI is deprecated.%b Use agy (Google Antigravity CLI) instead.\n' "$_red" "$_reset" >&2 + printf ' Example: vibecrafted workflow agy --prompt "..." \n' >&2 + return 1 + fi _has_agent "$agent" || { printf '%b✗%b Unknown agent: %s\n' "$_red" "$_reset" "$agent" >&2 return 1 @@ -1945,7 +1961,6 @@ cmd_init() { case "$agent" in claude) command -v claude >/dev/null 2>&1 || return 1 ;; codex) command -v codex >/dev/null 2>&1 || return 1 ;; - gemini) command -v gemini >/dev/null 2>&1 || return 1 ;; agy) command -v agy >/dev/null 2>&1 || return 1 ;; junie) command -v junie >/dev/null 2>&1 || return 1 ;; grok) command -v grok >/dev/null 2>&1 || return 1 ;; @@ -2316,12 +2331,22 @@ _post_update_banner() { cmd_update() { local makefile="" repo_source="" installed_ver="" channel_ref="main" local force=0 - for _u_arg in "$@"; do + local _u_arg="" + while (( $# )); do + _u_arg="$1" case "$_u_arg" in --force|-f) force=1 ;; - --ref) : ;; # consumed below + --ref) + shift + if [[ $# -eq 0 || "${1:-}" == --* ]]; then + printf '%b✗%b --ref requires a channel value.\n' "$_red" "$_reset" >&2 + return 2 + fi + channel_ref="$1" + ;; --ref=*) channel_ref="${_u_arg#--ref=}" ;; esac + shift done repo_source="$(_repo_source_root 2>/dev/null || true)" @@ -2353,7 +2378,7 @@ cmd_update() { done if [[ -n "$makefile" ]]; then - make -C "$(dirname "$makefile")" update + make -C "$(dirname "$makefile")" update BRANCH="$channel_ref" update_status=$? _post_update_banner "$installed_ver" return "$update_status" @@ -2409,6 +2434,11 @@ cmd_resume() { return 1 } shift || true + if [[ "$agent" == "gemini" ]]; then + printf '%b✗ gemini CLI is deprecated.%b Use agy (Google Antigravity CLI) instead.\n' "$_red" "$_reset" >&2 + printf ' Example: vibecrafted workflow agy --prompt "..." \n' >&2 + return 1 + fi _has_agent "$agent" || { printf '%b✗%b Unknown agent: %s\n' "$_red" "$_reset" "$agent" >&2 return 1 diff --git a/tests/agent_dispatch_test.py b/tests/agent_dispatch_test.py index be067c12..770dbea4 100644 --- a/tests/agent_dispatch_test.py +++ b/tests/agent_dispatch_test.py @@ -113,6 +113,25 @@ def test_tier_family_buckets(): assert agent_dispatch.tier_family("nope") == "unknown" +@pytest.mark.parametrize( + "model,shape,max_files,max_parallel", + [ + ("gpt-5.6-sol", "coherent", 8, 3), + ("grok-build", "coherent", 8, 3), + ("claude-sonnet-4-7", "bounded", 4, 2), + ("claude-haiku-4", "surgical", 2, 1), + ("unknown-model", "surgical", 1, 1), + ], +) +def test_dispatch_granularity_is_model_aware_and_conservative( + model, shape, max_files, max_parallel +): + policy = agent_dispatch.dispatch_granularity(model) + assert policy.shape == shape + assert policy.max_files_per_cut == max_files + assert policy.max_parallel_cuts == max_parallel + + # --------------------------------------------------------------------------- # check_parity # --------------------------------------------------------------------------- diff --git a/tests/portable/run.sh b/tests/portable/run.sh index 8889343f..0e51c8f0 100755 --- a/tests/portable/run.sh +++ b/tests/portable/run.sh @@ -95,7 +95,7 @@ bash -n \ "$repo_root/runtime/scripts/common.sh" \ "$repo_root/runtime/scripts/codex_spawn.sh" \ "$repo_root/runtime/scripts/claude_spawn.sh" \ - "$repo_root/runtime/scripts/gemini_spawn.sh" + "$repo_root/runtime/scripts/agy_spawn.sh" # Shell helpers are bash-compatible; verify with bash -n bash -n "$repo_root/runtime/shell/vetcoders.sh" # If zsh is available, also verify zsh syntax @@ -148,7 +148,7 @@ log "install smoke into clean HOME" HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" \ bash "$repo_root/runtime/scripts/install.sh" \ --source "$repo_root" \ - --tool codex --tool claude --tool gemini \ + --tool codex --tool claude --tool agy \ --with-shell --write-shell-rc # Stage the uv-tool launcher shim. The granular installer wires @@ -164,19 +164,17 @@ HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" \ require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/codex_spawn.sh" require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/claude_spawn.sh" -require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/gemini_spawn.sh" +require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/agy_spawn.sh" require_file "$home_dir/.local/bin/vibecrafted" require_symlink "$home_dir/.local/bin/vc-help" require_symlink "$home_dir/.local/bin/vc-marbles" -# Skill-symlink fan-out follows SYMLINK_TARGETS (agents, claude, codex). Gemini -# is a first-class spawn runtime (gemini_spawn.sh above) but an opt-in skill -# target (SYMLINK_TARGET_CHOICES), so the default install does not wire -# .gemini/skills/vc-agents — only codex and claude are asserted here. +# Skill-symlink fan-out follows SYMLINK_TARGETS (agents, claude, codex, agy). +# gemini is deprecated; no gemini_spawn.sh is shipped. require_symlink "$home_dir/.codex/skills/vc-agents" require_symlink "$home_dir/.claude/skills/vc-agents" require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/codex_spawn.sh" require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/claude_spawn.sh" -require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/gemini_spawn.sh" +require_file "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/agy_spawn.sh" # Canonical + compat helper locations require_file "$config_dir/vetcoders/vc-skills.sh" require_file "$config_dir/zsh/vc-skills.zsh" @@ -254,47 +252,40 @@ echo '{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool echo '{"type":"result","result":"Fake Claude final handoff"}' EOF_CLAUDE -cat > "$fake_bin/gemini" <<'EOF_GEMINI' +cat > "$fake_bin/agy" <<'EOF_AGY' #!/usr/bin/env bash set -euo pipefail -output_format="text" -while [[ $# -gt 0 ]]; do - case "$1" in - -o|--output-format) shift; output_format="${1:-text}" ;; - esac - shift || true -done -if [[ "$output_format" == "stream-json" ]]; then - printf '{"type":"init","session_id":"fake-gemini-001","model":"fake-model"}\n' - printf '{"type":"tool_use","tool_name":"Read","tool_id":"read_1"}\n' - printf '{"type":"tool_result","tool_id":"read_1","status":"success","output":"file content"}\n' - printf '{"type":"message","role":"assistant","content":"fake gemini stdout","delta":true}\n' - printf '{"type":"result","status":"success","stats":{"input_tokens":50,"output_tokens":5,"duration_ms":100,"tool_calls":1}}\n' -else - echo 'fake gemini stdout' -fi -EOF_GEMINI +echo 'fake agy stdout' +# minimal model line for telemetry smoke if transcript present +printf 'model: agy-test-model\n' >> "${SPAWN_TRANSCRIPT:-/dev/null}" 2>/dev/null || true +EOF_AGY -chmod +x "$fake_bin/codex" "$fake_bin/claude" "$fake_bin/gemini" +chmod +x "$fake_bin/codex" "$fake_bin/claude" "$fake_bin/agy" -common_env=(HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" PATH="$fake_bin:$PATH") +common_env=( + HOME="$home_dir" + XDG_CONFIG_HOME="$config_dir" + PATH="$fake_bin:$PATH" + VIBECRAFTED_RUN_ID="" + VIBECRAFTED_PROMPT_ID="" +) log "headless spawn smoke" env "${common_env[@]}" bash "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/codex_spawn.sh" --mode plan --runtime headless --root "$work_repo" "$work_repo/.vibecrafted/plans/test.md" env "${common_env[@]}" bash "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/claude_spawn.sh" --mode review --runtime headless --root "$work_repo" "$work_repo/.vibecrafted/plans/test.md" -env "${common_env[@]}" bash "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/gemini_spawn.sh" --mode implement --runtime headless --root "$work_repo" "$work_repo/.vibecrafted/plans/test.md" +env "${common_env[@]}" bash "$home_dir/.local/share/vibecrafted/tools/vibecrafted-current/runtime/scripts/agy_spawn.sh" --mode implement --runtime headless --root "$work_repo" "$work_repo/.vibecrafted/plans/test.md" codex_meta="$(find "$work_repo/.vibecrafted/reports" -maxdepth 1 -type f -name '*_codex.meta.json' | sort | tail -n 1)" claude_meta="$(find "$work_repo/.vibecrafted/reports" -maxdepth 1 -type f -name '*_claude.meta.json' | sort | tail -n 1)" -gemini_meta="$(find "$work_repo/.vibecrafted/reports" -maxdepth 1 -type f -name '*_gemini.meta.json' | sort | tail -n 1)" +agy_meta="$(find "$work_repo/.vibecrafted/reports" -maxdepth 1 -type f -name '*_agy.meta.json' | sort | tail -n 1)" require_file "$codex_meta" require_file "$claude_meta" -require_file "$gemini_meta" +require_file "$agy_meta" [[ "$(wait_for_meta "$codex_meta")" == "completed" ]] || die "codex spawn did not complete" [[ "$(wait_for_meta "$claude_meta")" == "completed" ]] || die "claude spawn did not complete" -[[ "$(wait_for_meta "$gemini_meta")" == "completed" ]] || die "gemini spawn did not complete" +[[ "$(wait_for_meta "$agy_meta")" == "completed" ]] || die "agy spawn did not complete" codex_report="$(python3 - "$codex_meta" <<'PY' import json, sys @@ -308,7 +299,7 @@ with open(sys.argv[1], 'r', encoding='utf-8') as fh: print(json.load(fh)['report']) PY )" -gemini_report="$(python3 - "$gemini_meta" <<'PY' +agy_report="$(python3 - "$agy_meta" <<'PY' import json, sys with open(sys.argv[1], 'r', encoding='utf-8') as fh: print(json.load(fh)['report']) @@ -326,7 +317,7 @@ with open(sys.argv[1], 'r', encoding='utf-8') as fh: print(json.load(fh)['transcript']) PY )" -gemini_transcript="$(python3 - "$gemini_meta" <<'PY' +agy_transcript="$(python3 - "$agy_meta" <<'PY' import json, sys with open(sys.argv[1], 'r', encoding='utf-8') as fh: print(json.load(fh)['transcript']) @@ -335,10 +326,10 @@ PY require_file "$codex_report" require_file "$claude_report" -require_file "$gemini_report" +require_file "$agy_report" require_file "$codex_transcript" require_file "$claude_transcript" -require_file "$gemini_transcript" +require_file "$agy_transcript" assert_contains "$codex_report" 'Fake Codex Report' assert_matches "$codex_report" 'run_id: plan-[0-9]{6}' assert_contains "$codex_report" 'prompt_id: test_' @@ -347,18 +338,17 @@ assert_contains "$codex_report" 'prompt_id: test_' # of the old "completed without writing a standalone report file" placeholder. # The fake claude emits that final handoff above, so assert it was salvaged. assert_contains "$claude_report" 'Fake Claude final handoff' -assert_contains "$gemini_report" 'fake gemini' +assert_contains "$agy_report" 'fake agy' assert_matches "$codex_transcript" '\[[0-9]{2}:[0-9]{2}:[0-9]{2} \$ ls\]' assert_matches "$codex_transcript" '\[[0-9]{2}:[0-9]{2}:[0-9]{2}\] tokens: 100 in / 10 out' assert_matches "$claude_transcript" '\[[0-9]{2}:[0-9]{2}:[0-9]{2}\] session: fake-claude-001' assert_matches "$claude_transcript" '\[[0-9]{2}:[0-9]{2}:[0-9]{2} Read\]' -assert_matches "$gemini_transcript" '\[[0-9]{2}:[0-9]{2}:[0-9]{2}\] session: fake-gemini-001' -assert_matches "$gemini_transcript" '\[[0-9]{2}:[0-9]{2}:[0-9]{2} Read\]' +assert_matches "$agy_transcript" 'agy-test-model' jq -e '.prompt_id != null and (.prompt_id | startswith("test_"))' "$codex_meta" >/dev/null || die "codex meta missing prompt_id" jq -e '.run_id | test("^plan-[0-9]{6}-[0-9]+$")' "$codex_meta" >/dev/null || die "codex meta missing plan run_id" jq -e '.run_id | test("^rvew-[0-9]{6}-[0-9]+$")' "$claude_meta" >/dev/null || die "claude meta missing review run_id" -jq -e '.run_id | test("^impl-[0-9]{6}-[0-9]+$")' "$gemini_meta" >/dev/null || die "gemini meta missing implement run_id" +jq -e '.run_id | test("^impl-[0-9]{6}-[0-9]+$")' "$agy_meta" >/dev/null || die "agy meta missing implement run_id" jq -e '.loop_nr == 0' "$codex_meta" >/dev/null || die "codex meta missing loop_nr" jq -e '.framework_version != null and .framework_version != ""' "$codex_meta" >/dev/null || die "codex meta missing framework_version" jq -e '.completed_at != null and .duration_s != null' "$codex_meta" >/dev/null || die "codex meta missing completion telemetry" @@ -366,7 +356,10 @@ jq -e '.liveness == "terminal"' "$codex_meta" >/dev/null || die "codex meta miss log "launcher resume smoke" resume_capture="$workspace/resume-codex.txt" -env HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" PATH="$fake_bin:$PATH" FAKE_CODEX_CAPTURE="$resume_capture" \ +env -u VIBECRAFTED_RUN_ID -u VIBECRAFTED_OPERATOR_SESSION \ + -u VC_FRAME -u VC_FRAME_PANE_ID -u VC_FRAME_SESSION_NAME \ + -u ZELLIJ -u ZELLIJ_PANE_ID -u ZELLIJ_SESSION_NAME \ + HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" PATH="$fake_bin:$PATH" FAKE_CODEX_CAPTURE="$resume_capture" \ "$home_dir/.local/bin/vibecrafted" resume codex --session fake-session-001 --prompt "resume smoke" require_file "$resume_capture" assert_contains "$resume_capture" 'resume' @@ -376,7 +369,7 @@ assert_contains "$resume_capture" 'resume smoke' log "helper bash smoke" # shellcheck disable=SC2016 env HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" PATH="$home_dir/.local/bin:$fake_bin:$PATH" \ - bash -c 'source "${XDG_CONFIG_HOME:-$HOME/.config}/vetcoders/vc-skills.sh"; command -v codex-implement >/dev/null && command -v claude-implement >/dev/null && command -v gemini-implement >/dev/null && command -v vc-marbles >/dev/null && command -v skills-sync >/dev/null && echo helper-ok' \ + bash -c 'source "${XDG_CONFIG_HOME:-$HOME/.config}/vetcoders/vc-skills.sh"; command -v codex-implement >/dev/null && command -v claude-implement >/dev/null && command -v agy-implement >/dev/null && command -v vc-marbles >/dev/null && command -v skills-sync >/dev/null && echo helper-ok' \ | grep -Fq 'helper-ok' || die 'bash helper layer not loaded' log "skill helper telemetry smoke" # shellcheck disable=SC2016 @@ -403,7 +396,7 @@ if command -v zsh >/dev/null 2>&1; then log "helper zsh smoke (bonus)" # shellcheck disable=SC2016 env HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" PATH="$home_dir/.local/bin:$fake_bin:$PATH" \ - zsh -c 'source "${XDG_CONFIG_HOME:-$HOME/.config}/zsh/vc-skills.zsh"; command -v codex-implement >/dev/null && command -v claude-implement >/dev/null && command -v gemini-implement >/dev/null && command -v vc-marbles >/dev/null && command -v skills-sync >/dev/null && echo helper-ok' \ + zsh -c 'source "${XDG_CONFIG_HOME:-$HOME/.config}/zsh/vc-skills.zsh"; command -v codex-implement >/dev/null && command -v claude-implement >/dev/null && command -v agy-implement >/dev/null && command -v vc-marbles >/dev/null && command -v skills-sync >/dev/null && echo helper-ok' \ | grep -Fq 'helper-ok' || die 'zsh helper layer not loaded' fi @@ -425,7 +418,8 @@ chmod +x "$fake_bin/rsync" sync_output="$(env HOME="$home_dir" XDG_CONFIG_HOME="$config_dir" PATH="$fake_bin:$PATH" bash "$repo_root/runtime/scripts/skills_sync.sh" fakehost --source "$repo_root" --dry-run)" grep -q "Syncing skills from" <<<"$sync_output" || die "Sync dry-run failed to start" -grep -q "rsync .* --dry-run" <<<"$sync_output" || die "Sync dry-run didn't pass dry-run to rsync" +grep -q '^ rsync ' <<<"$sync_output" || die "Sync dry-run didn't print planned rsync commands" +! grep -q '^rsync ' <<<"$sync_output" || die "Sync dry-run executed rsync instead of printing it" # shellcheck disable=SC2016 # matching literal $HOME in sync output, not expanding grep -q '\$HOME/.local/share/vibecrafted/tools/vibecrafted-current/skills' <<<"$sync_output" || die "Sync dry-run didn't target the staged canonical skill store" # shellcheck disable=SC2016 # matching literal $HOME in sync output, not expanding diff --git a/tests/server_smoke.sh b/tests/server_smoke.sh index c2e3df8f..25cac104 100755 --- a/tests/server_smoke.sh +++ b/tests/server_smoke.sh @@ -136,6 +136,7 @@ EOF_LIFE_TRANSCRIPT cat > "$VIBECRAFTED_HOME/control_plane/lifecycle_runs/smoke-life/state.json" < No text = launcher.read_text(encoding="utf-8") assert "SPAWN_AGENT=agy" in text - assert "agy --print --dangerously-skip-permissions --add-dir" in text + assert "agy --dangerously-skip-permissions --add-dir" in text assert "--print-timeout 30m" in text - assert " < " in text - assert '"$(cat ' not in text + assert '--print "$(cat ' in text + assert "agy --print --dangerously-skip-permissions" not in text assert "Agy completed without writing a standalone report file" in text assert "Agy failed before writing a standalone report file" in text assert "pipeline_status=65" not in text @@ -93,16 +93,37 @@ def test_junie_spawn_dry_run_uses_project_task_contract(tmp_path: Path) -> None: assert "junie --project=" in text assert "--task=" in text assert "--skip-update-check" in text + assert "--input-format=text" in text + assert "--output-format=json-stream" in text + assert "--output-format=text" not in text + + +def test_junie_spawn_dry_run_pipes_json_stream_to_transcript(tmp_path: Path) -> None: + launcher = _dry_run_launcher(tmp_path, "junie") + text = launcher.read_text(encoding="utf-8") + + assert "junie --project=" in text + assert "--output-format=json-stream" in text + assert "2>&1 | tee -a $qtranscript" not in text + assert "tee -a" in text def test_grok_spawn_dry_run_uses_prompt_file_contract(tmp_path: Path) -> None: launcher = _dry_run_launcher(tmp_path, "grok") text = launcher.read_text(encoding="utf-8") + # Contract alignment (grok 0.2.97): --prompt-file delivery, permission, streaming-json + # for transcript capture (matches verified python _stdin_command lane), --cwd. assert "SPAWN_AGENT=grok" in text assert "grok --cwd" in text assert "--permission-mode bypassPermissions" in text assert "--prompt-file" in text + assert "--output-format streaming-json" in text + assert ( + "tee -a " in text + ) # transcript capture (expanded path in generated SPAWN_CMD) + assert "--prompt-file " in text + # resume flag shape covered in dedicated grok test below (source contract) def test_dry_run_meta_records_new_agents(tmp_path: Path) -> None: @@ -116,3 +137,61 @@ def test_dry_run_meta_records_new_agents(tmp_path: Path) -> None: meta_path = Path(meta_line.split("=", 1)[1].strip().strip("'\"")) payload = json.loads(meta_path.read_text(encoding="utf-8")) assert payload["agent"] == agent + + +def test_grok_resume_uses_resume_flag_not_session_id_and_streams_json() -> None: + """Regression on grok resume contract (grok 0.2.97): + - MUST use --resume (never -s/--session-id for resume per help) + - headless uses --output-format streaming-json for parseable transcript + - --single for prompt continuation; --permission-mode and --no-alt-screen + Source-of-truth is the grok case in marbles.sh (shell resume builder). + """ + marbles_path = ( + REPO_ROOT / "vibecrafted-core/vibecrafted_core/runtime/shell/lib/marbles.sh" + ) + src = marbles_path.read_text(encoding="utf-8") + + # Isolate the grok) case block + assert " grok)" in src + grok_block = src.split(" grok)")[1].split(";;")[0] + + # resume flag shape + assert "grok --resume " in grok_block + assert "--session-id" not in grok_block + assert "-s " not in grok_block and "--session-id=" not in grok_block + + # output format for capture + prompt delivery via --single in resume context + assert "--output-format streaming-json" in grok_block + assert ( + "--single " in grok_block + or "--single\n" in grok_block + or "--single " in grok_block + ) + + # other contract flags present in headless resume path + assert "--permission-mode bypassPermissions" in grok_block + assert "--no-alt-screen" in grok_block + assert "--cwd " in grok_block + + # Fixture-based unit test for spawn.py extraction against grok 0.2.97 streaming-json shape + # (no real grok call). Covers session-id (sessionId in end event) + JSON_TOKEN_PATTERNS usage. + from vibecrafted_core.spawn import _extract_session, _extract_tokens, _extract_cost + + # Realistic grok streaming-json transcript fragment (end event + usage if emitted; patterns are general) + grok_stream = ( + '{"type":"thought","data":"..."}\n' + '{"type":"text","data":"done"}\n' + '{"type":"end","stopReason":"EndTurn",' + '"sessionId":"019ec430-9888-78e3-8ca0-b29387444fdb",' + '"usage":{"input_tokens":42,"output_tokens":17,"cache_read_input_tokens":5}}\n' + ) + assert _extract_session(grok_stream) == "019ec430-9888-78e3-8ca0-b29387444fdb" + toks = _extract_tokens(grok_stream) + assert toks["input"] == 42 + assert toks["output"] == 17 + assert toks["cached_input"] == 5 + assert toks["total"] == 64 + # cost may be absent in raw stream (footer supplies); ensure no crash + assert _extract_cost(grok_stream) is None or isinstance( + _extract_cost(grok_stream), float + ) diff --git a/tests/tui/test_bundle.py b/tests/tui/test_bundle.py index effb63d3..067cfad3 100644 --- a/tests/tui/test_bundle.py +++ b/tests/tui/test_bundle.py @@ -37,7 +37,7 @@ def test_discover_bundle_skills_tracks_live_skill_surface() -> None: assert "vc-implement" in skill_names assert "vc-justdo" in skill_names # alias kept in bundle assert "vc-marbles" in skill_names - assert "vc-ship" not in skill_names + assert "vc-ship" in skill_names # lifecycle umbrella kept in bundle assert "vc-ownership" in skill_names assert "vc-screenscribe" in skill_names @@ -77,7 +77,7 @@ def test_write_bundle_uses_current_metadata_and_skill_inventory(tmp_path: Path) assert "skills/vc-implement/SKILL.md" in members assert "skills/vc-justdo/SKILL.md" in members # alias kept in bundle assert "skills/vc-marbles/SKILL.md" in members - assert "skills/vc-ship/SKILL.md" not in members + assert "skills/vc-ship/SKILL.md" in members assert "skills/vc-ownership/SKILL.md" in members assert "skills/vc-screenscribe/SKILL.md" in members assert "docs/RELEASE_KICKOFF.md" in members diff --git a/tests/tui/test_distribution_manifest.py b/tests/tui/test_distribution_manifest.py new file mode 100644 index 00000000..b7d90d49 --- /dev/null +++ b/tests/tui/test_distribution_manifest.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import subprocess +import sys +import tarfile +from pathlib import Path + +import pytest + +from scripts import distribution_manifest as manifest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + +EXPECTED_REQUIRED = { + "VERSION", + "LICENSE", + "README.md", + "Makefile", + "install.sh", + "install.ps1", + "install.toml", + "scripts/distribution_manifest.py", + "scripts/vetcoders_install.py", + "scripts/runtime_paths.py", + "scripts/vibecrafted", + "runtime/scripts", + "runtime/shell/lib", + "skills", + "vibecrafted-core/pyproject.toml", + "vibecrafted-core/vibecrafted_core/VERSION", + "vibecrafted-core/vibecrafted_core/deck/vibecrafted", + "vibecrafted-core/vibecrafted_core/runtime", + "vibecrafted-core/vibecrafted_core/skills", + "vibecrafted-mcp/pyproject.toml", + "plugins/iterm2/pyproject.toml", + "vibecrafted-app/Cargo.toml", + "vibecrafted-server/Cargo.toml", +} + + +def _minimal_payload(root: Path) -> None: + for relative in manifest.REQUIRED_FILES: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"fixture for {relative}\n", encoding="utf-8") + for relative in manifest.REQUIRED_DIRECTORIES: + (root / relative).mkdir(parents=True, exist_ok=True) + for relative in manifest.REQUIRED_SURFACE_FILES.values(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"runtime sentinel for {relative}\n", encoding="utf-8") + + +def test_manifest_names_complete_runtime_and_forbidden_junk() -> None: + declared = set(manifest.REQUIRED_FILES) | set(manifest.REQUIRED_DIRECTORIES) + + assert EXPECTED_REQUIRED <= declared + assert set(manifest.REQUIRED_SURFACE_FILES) == set(manifest.REQUIRED_DIRECTORIES) + assert { + ".DS_Store", + ".gitignore", + ".prettierignore", + ".dockerignore", + "package-lock.json", + "CONTRIBUTING.md", + ".loctree", + ".backup", + "tests", + ".github", + } <= manifest.FORBIDDEN_COMPONENTS + + +@pytest.mark.parametrize( + ("surface", "sentinel"), manifest.REQUIRED_SURFACE_FILES.items() +) +def test_validate_payload_rejects_empty_required_runtime_surface( + tmp_path: Path, surface: str, sentinel: str +) -> None: + payload = tmp_path / "payload" + _minimal_payload(payload) + sentinel_path = payload / sentinel + sentinel_path.unlink() + + with pytest.raises(manifest.ManifestError) as exc_info: + manifest.validate_payload(payload) + + assert f"missing required runtime content: {surface} -> {sentinel}" in str( + exc_info.value + ) + + +def test_forbidden_artifact_filter_is_safe_for_runtime_subtrees() -> None: + assert not manifest.path_is_forbidden("SKILL.md") + assert not manifest.path_is_forbidden("scripts/codex_spawn.sh") + assert manifest.path_is_forbidden("tests/test_spawn.py") + assert manifest.path_is_forbidden("scripts/__pycache__/helper.pyc") + + assert not manifest.path_is_included("SKILL.md") + assert manifest.path_is_included("skills/vc-init/SKILL.md") + + +def test_stage_payload_filters_junk_and_mirrors_destination(tmp_path: Path) -> None: + source = tmp_path / "source" + destination = tmp_path / "payload" + _minimal_payload(source) + (source / "scripts" / "keep.py").write_text("keep\n", encoding="utf-8") + (source / "scripts" / ".DS_Store").write_text("junk\n", encoding="utf-8") + (source / "scripts" / "tests").mkdir() + (source / "scripts" / "tests" / "test_dev.py").write_text( + "junk\n", encoding="utf-8" + ) + (source / ".gitignore").write_text("junk\n", encoding="utf-8") + destination.mkdir() + (destination / "orphan.txt").write_text("stale\n", encoding="utf-8") + + manifest.stage_payload(source, destination, mirror=True) + + manifest.validate_payload(destination) + assert (destination / "scripts" / "keep.py").is_file() + assert not (destination / "scripts" / ".DS_Store").exists() + assert not (destination / "scripts" / "tests").exists() + assert not (destination / ".gitignore").exists() + assert not (destination / "orphan.txt").exists() + + +def test_validate_payload_reports_missing_and_forbidden_paths(tmp_path: Path) -> None: + payload = tmp_path / "payload" + _minimal_payload(payload) + (payload / "VERSION").unlink() + (payload / "scripts" / "nested").mkdir() + (payload / "scripts" / "nested" / ".DS_Store").write_text( + "junk\n", encoding="utf-8" + ) + + with pytest.raises(manifest.ManifestError) as exc_info: + manifest.validate_payload(payload) + + message = str(exc_info.value) + assert "missing required path: VERSION" in message + assert "forbidden path: scripts/nested/.DS_Store" in message + + +def test_walk_entries_prunes_forbidden_directories_before_descending( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + payload = tmp_path / "payload" + payload.mkdir() + descended: list[str] = [] + + def fake_walk(root: Path, *, followlinks: bool): + assert root == payload + assert followlinks is False + directory_names = ["scripts", ".git", "node_modules"] + yield str(payload), directory_names, [] + descended.extend(directory_names) + + monkeypatch.setattr(manifest.os, "walk", fake_walk) + + assert list(manifest._walk_entries(payload)) == [ + payload / ".git", + payload / "node_modules", + payload / "scripts", + ] + assert descended == ["scripts"] + + +def test_stage_payload_rejects_symlink_that_escapes_source(tmp_path: Path) -> None: + source = tmp_path / "source" + _minimal_payload(source) + outside = tmp_path / "outside.txt" + outside.write_text("outside\n", encoding="utf-8") + (source / "scripts" / "escape").symlink_to(outside) + + with pytest.raises(manifest.ManifestError, match="symlink escapes payload"): + manifest.stage_payload(source, tmp_path / "payload", mirror=True) + + +def test_manifest_cli_check_is_loud_and_nonzero_for_junk(tmp_path: Path) -> None: + payload = tmp_path / "payload" + _minimal_payload(payload) + (payload / "package-lock.json").write_text("{}\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "distribution_manifest.py"), + "check", + "--root", + str(payload), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 2 + assert "forbidden path: package-lock.json" in result.stderr + + +def test_archive_has_one_safe_root_and_validated_payload(tmp_path: Path) -> None: + source = tmp_path / "source" + archive_path = tmp_path / "vibecrafted-9.8.7.tar.gz" + extracted = tmp_path / "extracted" + _minimal_payload(source) + (source / "scripts" / "keep.py").write_text("keep\n", encoding="utf-8") + (source / "scripts" / ".DS_Store").write_text("junk\n", encoding="utf-8") + + manifest.create_archive( + source, + archive_path, + root_name="vibecrafted-9.8.7", + ) + + with tarfile.open(archive_path, "r:gz") as archive: + names = archive.getnames() + assert names + assert all( + name == "vibecrafted-9.8.7" or name.startswith("vibecrafted-9.8.7/") + for name in names + ) + assert all( + not name.startswith("/") and ".." not in Path(name).parts for name in names + ) + assert not any(name.endswith(".DS_Store") for name in names) + archive.extractall(extracted, filter="data") + + payload = extracted / "vibecrafted-9.8.7" + manifest.validate_payload(payload) + assert (payload / "scripts" / "keep.py").is_file() + + +def test_archive_is_deterministic(tmp_path: Path) -> None: + source = tmp_path / "source" + first = tmp_path / "first.tar.gz" + second = tmp_path / "second.tar.gz" + _minimal_payload(source) + + manifest.create_archive(source, first, root_name="vibecrafted-test") + manifest.create_archive(source, second, root_name="vibecrafted-test") + + assert first.read_bytes() == second.read_bytes() diff --git a/tests/tui/test_frontier_resolution.py b/tests/tui/test_frontier_resolution.py index adf70451..7b3c6b78 100644 --- a/tests/tui/test_frontier_resolution.py +++ b/tests/tui/test_frontier_resolution.py @@ -189,6 +189,9 @@ def test_vc_dashboard_mixes_companion_vc_frame_config_with_repo_layout( env.pop("VC_FRAME", None) env.pop("VC_FRAME_PANE_ID", None) env.pop("VC_FRAME_SESSION_NAME", None) + env.pop("ZELLIJ", None) + env.pop("ZELLIJ_PANE_ID", None) + env.pop("ZELLIJ_SESSION_NAME", None) subprocess.run( ["bash", "-lc", f'source "{HELPER_SCRIPT}"; vc-dashboard vc-marbles'], @@ -235,6 +238,9 @@ def test_vc_dashboard_uses_base_run_id_session_without_layout_suffix( env.pop("VC_FRAME", None) env.pop("VC_FRAME_PANE_ID", None) env.pop("VC_FRAME_SESSION_NAME", None) + env.pop("ZELLIJ", None) + env.pop("ZELLIJ_PANE_ID", None) + env.pop("ZELLIJ_SESSION_NAME", None) # Scrub any operator-session context leaked from a running operator shell so # the dashboard resolves the run-id session rather than the ambient one. env.pop("VIBECRAFTED_OPERATOR_SESSION", None) diff --git a/tests/tui/test_install_bootstrap.py b/tests/tui/test_install_bootstrap.py index 954bb002..ebca6205 100644 --- a/tests/tui/test_install_bootstrap.py +++ b/tests/tui/test_install_bootstrap.py @@ -7,6 +7,7 @@ import shlex import signal import subprocess +import sys import tarfile import time from pathlib import Path @@ -20,6 +21,28 @@ def _write_executable(path: Path, body: str) -> None: path.chmod(0o755) +def _write_distribution_manifest_stub(source_dir: Path) -> None: + path = source_dir / "scripts" / "distribution_manifest.py" + path.write_text( + """#!/usr/bin/env python3 +from pathlib import Path +import shutil +import sys + +if sys.argv[1] == "check": + raise SystemExit(0) +if sys.argv[1] != "stage": + raise SystemExit(2) +source = Path(sys.argv[sys.argv.index("--source") + 1]) +destination = Path(sys.argv[sys.argv.index("--destination") + 1]) +if destination.exists(): + shutil.rmtree(destination) +shutil.copytree(source, destination, symlinks=True) +""", + encoding="utf-8", + ) + + def _run_with_tty( command: str, *, response: str | None = None, timeout: float = 10.0 ) -> tuple[int, str]: @@ -113,6 +136,16 @@ def test_install_sh_quiets_tar_xattr_noise_and_hides_make_directory_trace() -> N assert "make --no-print-directory -C" in text +def test_install_sh_stages_archives_through_distribution_manifest() -> None: + text = INSTALL_SH.read_text(encoding="utf-8") + + assert 'manifest_helper="$source_dir/scripts/distribution_manifest.py"' in text + assert '"$manifest_helper" stage' in text + assert '--source "$source_dir"' in text + assert '--destination "$incoming_dir"' in text + assert 'mv "$source_dir" "$incoming_dir"' not in text + + def test_install_sh_attended_pipe_requires_explicit_yes_before_staging( tmp_path: Path, ) -> None: @@ -126,6 +159,7 @@ def test_install_sh_attended_pipe_requires_explicit_yes_before_staging( (source_dir / "Makefile").write_text("install:\n\t@echo ok\n", encoding="utf-8") (scripts_dir / "placeholder").write_text("", encoding="utf-8") + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-main") @@ -176,6 +210,7 @@ def test_install_sh_yes_skips_attended_prompt_for_pipe_bootstrap( ) (scripts_dir / "placeholder").write_text("", encoding="utf-8") (scripts_dir / "vetcoders_install.py").write_text("# compact\n", encoding="utf-8") + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-main") @@ -234,6 +269,7 @@ def test_install_sh_runtime_flag_dispatches_staged_runtime_helper( 'printf "%s\\n" "$@" > "$RUNTIME_CAPTURE"\n', encoding="utf-8", ) + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-main") @@ -282,6 +318,7 @@ def test_install_sh_archive_install_runs_local_make_target(tmp_path: Path) -> No (source_dir / "Makefile").write_text("install:\n\t@echo ok\n", encoding="utf-8") (scripts_dir / "placeholder").write_text("", encoding="utf-8") + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-main") @@ -303,6 +340,7 @@ def test_install_sh_archive_install_runs_local_make_target(tmp_path: Path) -> No [ "#!/usr/bin/env bash", "set -euo pipefail", + f'if [[ "$1" == */distribution_manifest.py ]]; then exec {shlex.quote(sys.executable)} "$@"; fi', 'printf "unexpected\\n" > "$PYTHON_CAPTURE"', "exit 97", ] @@ -354,6 +392,7 @@ def test_install_sh_gui_bootstrap_runs_local_guided_installer(tmp_path: Path) -> (source_dir / "Makefile").write_text("install:\n\t@echo ok\n", encoding="utf-8") (scripts_dir / "installer_gui.py").write_text("# gui\n", encoding="utf-8") (scripts_dir / "placeholder").write_text("", encoding="utf-8") + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-main") @@ -375,6 +414,7 @@ def test_install_sh_gui_bootstrap_runs_local_guided_installer(tmp_path: Path) -> [ "#!/usr/bin/env bash", "set -euo pipefail", + f'if [[ "$1" == */distribution_manifest.py ]]; then exec {shlex.quote(sys.executable)} "$@"; fi', 'printf "%s\\n" "$@" > "$PYTHON_CAPTURE"', ] ) @@ -435,6 +475,7 @@ def _run_storytelling_bootstrap( (source_dir / "Makefile").write_text("install:\n\t@echo ok\n", encoding="utf-8") (source_dir / "VERSION").write_text("9.9.9-test\n", encoding="utf-8") (scripts_dir / "placeholder").write_text("", encoding="utf-8") + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-main") _write_executable( diff --git a/tests/tui/test_installer_restore.py b/tests/tui/test_installer_restore.py new file mode 100644 index 00000000..f8b463cd --- /dev/null +++ b/tests/tui/test_installer_restore.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import shlex +import subprocess +from argparse import Namespace +from pathlib import Path + +from scripts import vetcoders_install as installer + + +def _write_executable(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + + +def test_bundle_uninstall_removes_owned_payload_and_printed_restore_works( + tmp_path: Path, monkeypatch, capsys +) -> None: + home = tmp_path / "home" + crafted_home = home / ".vibecrafted" + runtime_home = home / ".local" / "share" / "vibecrafted" + tools_home = runtime_home / "tools" + payload = tools_home / "vibecrafted-3.4.0" + stale_payload = tools_home / "vibecrafted-stale" + incoming = tools_home / ".incoming-old" + unrelated = tools_home / "third-party-tool" + current = tools_home / "vibecrafted-current" + runtime_bin = runtime_home / "bin" + uv_tool = home / ".local" / "share" / "uv" / "tools" / "vibecrafted" + launcher = home / ".local" / "bin" / "vibecrafted" + helper = home / ".config" / "vetcoders" / "vc-skills.sh" + zshrc = home / ".zshrc" + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(crafted_home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_HOME", str(runtime_home)) + monkeypatch.setenv("VIBECRAFTED_TOOLS_HOME", str(tools_home)) + monkeypatch.setenv("VIBECRAFTED_RUNTIME_BIN", str(runtime_bin)) + monkeypatch.setenv("VIBECRAFTED_LAUNCHER_BIN", str(launcher.parent)) + monkeypatch.setattr(installer, "_IS_TTY", False) + monkeypatch.setattr(installer, "_known_bundle_names", lambda: ["vc-init"]) + + skill = payload / "skills" / "vc-init" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# init\n", encoding="utf-8") + (payload / "VERSION").write_text("3.4.0\n", encoding="utf-8") + installer.InstallState( + framework_version="3.4.0", + skills=["vc-init"], + runtimes=[], + launcher_entries=["local-bin/vibecrafted"], + helper_files=[str(helper)], + ).save(payload / "skills") + stale_payload.mkdir(parents=True) + incoming.mkdir(parents=True) + unrelated.mkdir(parents=True) + current.symlink_to(payload) + + _write_executable(launcher) + helper.parent.mkdir(parents=True) + helper.write_text("# helper\n", encoding="utf-8") + zshrc.write_text( + f"# 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. launcher\n{installer._launcher_path_line()}\n", + encoding="utf-8", + ) + runtime_bin.mkdir(parents=True) + _write_executable(runtime_bin / "vc-frame") + uv_tool.mkdir(parents=True) + (uv_tool / "receipt.toml").write_text("managed by uv\n", encoding="utf-8") + crafted_home.mkdir(parents=True, exist_ok=True) + (crafted_home / "install.log").write_text("log\n", encoding="utf-8") + (crafted_home / installer.START_HERE_FILE).write_text("guide\n", encoding="utf-8") + + assert installer.cmd_uninstall(Namespace(dry_run=False)) == 0 + output = capsys.readouterr().out + + assert not current.exists() and not current.is_symlink() + assert not payload.exists() + assert not stale_payload.exists() + assert not incoming.exists() + assert unrelated.is_dir() + assert (runtime_bin / "vc-frame").is_file() + assert uv_tool.is_dir() + assert not launcher.exists() + assert not helper.exists() + assert installer._launcher_path_line() not in zshrc.read_text(encoding="utf-8") + assert "Removed managed paths:" in output + assert "Preserved intentionally:" in output + assert str(unrelated) in output + assert str(runtime_bin / "vc-frame") in output + assert str(uv_tool) in output + assert "make restore" not in output + + restore_line = next( + line.strip() + for line in output.splitlines() + if line.strip().startswith("python3 ") + ) + restore_script = Path(shlex.split(restore_line)[1]) + assert restore_script.is_file() + restore_manifest = json.loads( + (restore_script.parent / "restore-manifest.json").read_text(encoding="utf-8") + ) + assert any(Path(item["path"]) == payload for item in restore_manifest["items"]) + + assert installer.cmd_uninstall(Namespace(dry_run=False)) == 0 + second_output = capsys.readouterr().out + assert "Nothing to uninstall" in second_output + assert "Removed managed paths:" not in second_output + + subprocess.run( + shlex.split(restore_line), check=True, text=True, capture_output=True + ) + + assert payload.is_dir() + assert current.is_symlink() + assert current.resolve() == payload + assert launcher.is_file() + assert helper.is_file() + assert installer._launcher_path_line() in zshrc.read_text(encoding="utf-8") + assert unrelated.is_dir() + + +def test_uninstall_never_recurses_through_current_link_into_checkout( + tmp_path: Path, monkeypatch, capsys +) -> None: + home = tmp_path / "home" + tools_home = home / ".local" / "share" / "vibecrafted" / "tools" + checkout = tmp_path / "checkout" + checkout_skill = checkout / "skills" / "vc-init" + current = tools_home / "vibecrafted-current" + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share")) + monkeypatch.setenv("VIBECRAFTED_HOME", str(home / ".vibecrafted")) + monkeypatch.setenv("VIBECRAFTED_TOOLS_HOME", str(tools_home)) + monkeypatch.setattr(installer, "_IS_TTY", False) + monkeypatch.setattr(installer, "_known_bundle_names", lambda: ["vc-init"]) + + checkout_skill.mkdir(parents=True) + (checkout_skill / "SKILL.md").write_text("# source checkout\n", encoding="utf-8") + tools_home.mkdir(parents=True) + current.symlink_to(checkout) + + assert installer.cmd_uninstall(Namespace(dry_run=False)) == 0 + output = capsys.readouterr().out + + assert checkout_skill.is_dir() + assert (checkout_skill / "SKILL.md").is_file() + assert not current.exists() and not current.is_symlink() + assert str(checkout / "skills") in output + assert "outside the managed tools root" in output diff --git a/tests/tui/test_installer_uninstall.py b/tests/tui/test_installer_uninstall.py index 76866525..20c9b72d 100644 --- a/tests/tui/test_installer_uninstall.py +++ b/tests/tui/test_installer_uninstall.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from argparse import Namespace from pathlib import Path @@ -91,10 +92,13 @@ def test_cmd_uninstall_removes_launchers_and_compat_pack_wrappers( assert not (store_path / "vc-init").exists() assert not (home / ".codex" / "skills" / "vc-init").exists() - backup_root = store_path / installer.BACKUP_DIR + backup_root = installer._backup_root(store_path) latest = (backup_root / "latest").read_text(encoding="utf-8").strip() - assert (backup_root / latest / "launchers" / "local-bin" / "marble-pack").exists() - assert (backup_root / latest / "launchers" / "local-bin" / "aicx-pack").exists() + manifest = json.loads( + (backup_root / latest / "restore-manifest.json").read_text(encoding="utf-8") + ) + backed_paths = {Path(item["path"]).name for item in manifest["items"]} + assert {"marble-pack", "aicx-pack"} <= backed_paths for launcher_bin_dir in installer._launcher_bin_dirs(): for removed_name in ( @@ -179,10 +183,14 @@ def test_cmd_uninstall_prefers_manifest_tracked_launchers_and_helpers( assert not (runtime_skills / "vc-init").exists() assert (local_bin / "unrelated-tool").exists() - backup_root = store_path / installer.BACKUP_DIR + backup_root = installer._backup_root(store_path) latest = (backup_root / "latest").read_text(encoding="utf-8").strip() - assert (backup_root / latest / "helpers" / "vc-skills.sh").exists() - assert not (backup_root / latest / "launchers" / "local-bin" / "vc-help").exists() + manifest = json.loads( + (backup_root / latest / "restore-manifest.json").read_text(encoding="utf-8") + ) + backed_paths = {Path(item["path"]).name for item in manifest["items"]} + assert "vc-skills.sh" in backed_paths + assert "vc-help" not in backed_paths def test_restore_roundtrip_recovers_launchers_and_runtime_symlinks( @@ -260,9 +268,12 @@ def test_cmd_uninstall_cleans_launcher_only_surface_without_manifest( assert not (launcher_bin_dir / "vc-workflow").exists() assert installer._launcher_path_line() not in zshrc.read_text(encoding="utf-8") - backup_root = store_path / installer.BACKUP_DIR + backup_root = installer._backup_root(store_path) latest = (backup_root / "latest").read_text(encoding="utf-8").strip() - assert (backup_root / latest / "launchers" / "local-bin" / "vibecrafted").exists() + manifest = json.loads( + (backup_root / latest / "restore-manifest.json").read_text(encoding="utf-8") + ) + assert any(Path(item["path"]).name == "vibecrafted" for item in manifest["items"]) def collect_names(entries: list[tuple[Path, Path]]) -> set[str]: diff --git a/tests/tui/test_keys.py b/tests/tui/test_keys.py index 56ed8846..c5fe639a 100644 --- a/tests/tui/test_keys.py +++ b/tests/tui/test_keys.py @@ -227,6 +227,26 @@ def test_install_launcher_dedupes_zshrc_path_entries_with_consent( assert wrapper_path.readlink() == Path("vibecrafted") +def test_non_python_launcher_wrappers_have_explicit_deck_verbs() -> None: + from vibecrafted_core import cli + + shell_wrappers = set(vetcoders_install.LAUNCHER_WRAPPERS) - set( + vetcoders_install.PYTHON_ENTRYPOINT_LAUNCHERS + ) + + assert set(cli.SHELL_WRAPPER_VERBS) == shell_wrappers + assert cli.SHELL_WRAPPER_VERBS == { + "telemetry": "telemetry", + "vc-dashboard": "dashboard", + "vc-dispatch": "dispatch", + "vc-help": "help", + "vc-init": "init", + "vc-justdo": "justdo", + "vc-resume": "resume", + "vc-start": "start", + } + + def test_install_launcher_replaces_old_blind_local_bin_path( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/tui/test_launcher_surface.py b/tests/tui/test_launcher_surface.py index ab6a72c2..a20e473d 100644 --- a/tests/tui/test_launcher_surface.py +++ b/tests/tui/test_launcher_surface.py @@ -27,6 +27,37 @@ def _run_launcher_help(tmp_path: Path, *args: str) -> str: return ANSI_RE.sub("", result.stdout) +def _run_launcher_update_with_stubs(tmp_path: Path, *args: str) -> tuple[str, str]: + home = tmp_path / "home" + bin_dir = tmp_path / "bin" + make_log = tmp_path / "make.log" + bin_dir.mkdir(parents=True) + (bin_dir / "curl").write_text("#!/usr/bin/env bash\nexit 22\n", encoding="utf-8") + (bin_dir / "make").write_text( + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" > "$MAKE_LOG"\nexit 0\n', + encoding="utf-8", + ) + (bin_dir / "curl").chmod(0o755) + (bin_dir / "make").chmod(0o755) + + env = os.environ.copy() + env["HOME"] = str(home) + env["XDG_CONFIG_HOME"] = str(home / ".config") + env["XDG_DATA_HOME"] = str(home / ".local" / "share") + env["MAKE_LOG"] = str(make_log) + env["PATH"] = f"{bin_dir}:{env['PATH']}" + + result = subprocess.run( + ["bash", "scripts/vibecrafted", "update", *args], + cwd=REPO_ROOT, + env=env, + check=True, + capture_output=True, + text=True, + ) + return ANSI_RE.sub("", result.stdout), make_log.read_text(encoding="utf-8") + + def test_compact_help_uses_release_engine_contract(tmp_path: Path) -> None: output = _run_launcher_help(tmp_path, "help") @@ -66,17 +97,22 @@ def test_implement_help_is_canonical_and_names_alias(tmp_path: Path) -> None: "Autonomous end-to-end implementation with followup and marbles built in." in output ) - assert ( - "vibecrafted implement [flags]" in output - ) - assert "vc-implement [flags]" in output - assert ( - "Alias: vibecrafted justdo [flags]" - in output - ) + assert "vibecrafted implement [flags]" in output + assert "vc-implement [flags]" in output + assert "Alias: vibecrafted justdo [flags]" in output assert 'vibecrafted implement codex --prompt "Ship the feature"' in output +def test_update_ref_pair_reaches_make_branch(tmp_path: Path) -> None: + output, make_invocation = _run_launcher_update_with_stubs( + tmp_path, "--ref", "preview" + ) + + assert "Vibecrafted Update" in output + assert "update" in make_invocation + assert "BRANCH=preview" in make_invocation + + def test_review_and_followup_help_stay_semantically_separate(tmp_path: Path) -> None: review = _run_launcher_help(tmp_path, "review", "--help") followup = _run_launcher_help(tmp_path, "followup", "--help") diff --git a/tests/tui/test_makefile_installer_contract.py b/tests/tui/test_makefile_installer_contract.py index bf9a5f0b..b9f2b479 100644 --- a/tests/tui/test_makefile_installer_contract.py +++ b/tests/tui/test_makefile_installer_contract.py @@ -6,6 +6,8 @@ import pytest +from scripts import vetcoders_install as installer + REPO_ROOT = Path(__file__).resolve().parents[2] @@ -104,6 +106,55 @@ def test_bundle_check_uses_portable_mktemp_template() -> None: assert 'mktemp "$$tmp_root/vibecrafted-bundle.XXXXXX.plugin"' not in text +def test_bundle_targets_use_distribution_manifest_for_runtime_archive() -> None: + text = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + bundle_block = text.split("\nbundle:\n", 1)[1].split("\nbundle-check:\n", 1)[0] + check_block = text.split("\nbundle-check:\n", 1)[1].split( + "\nversion version-show:", 1 + )[0] + + for block in (bundle_block, check_block): + assert "scripts/distribution_manifest.py archive" in block + assert "scripts/distribution_manifest.py check" in check_block + assert ( + "BUNDLE_ARCHIVE ?= $(SOURCE)/dist/vibecrafted-$(BUNDLE_VERSION).tar.gz" in text + ) + assert '--root-name "vibecrafted-$(BUNDLE_VERSION)"' in bundle_block + assert "build_marketplace_bundle.py" in bundle_block + assert "build_marketplace_bundle.py" in check_block + assert "cmp -s" not in check_block + assert 'test -s "$$tmp_bundle"' in check_block + assert check_block.lstrip().startswith("@set -e;") + + +def test_control_plane_staging_delegates_to_distribution_manifest( + monkeypatch, tmp_path: Path +) -> None: + source = tmp_path / "source" + destination = tmp_path / "destination" + source.mkdir() + seen: dict[str, object] = {} + + def fake_stage(src: Path, dst: Path, *, mirror: bool) -> None: + seen.update(source=src, destination=dst, mirror=mirror) + dst.mkdir(parents=True) + (dst / "payload.txt").write_text("validated\n", encoding="utf-8") + + monkeypatch.setattr(installer, "stage_distribution_payload", fake_stage) + + installer.sync_control_plane_tree(source, destination, mirror=True) + + assert seen["source"] == source + assert seen["destination"] != destination + assert Path(seen["destination"]).parent == destination.parent + assert seen["mirror"] is True + assert (destination / "payload.txt").read_text(encoding="utf-8") == "validated\n" + source_text = (REPO_ROOT / "scripts" / "vetcoders_install.py").read_text( + encoding="utf-8" + ) + assert "_CONTROL_PLANE_EXCLUDES" not in source_text + + def test_install_manifest_post_install_uses_mirror_sync() -> None: text = (REPO_ROOT / "install.toml").read_text(encoding="utf-8") @@ -114,6 +165,51 @@ def test_install_manifest_post_install_uses_mirror_sync() -> None: assert "make --no-print-directory install-python-tools" in text +def test_installer_copies_skill_rules_to_fresh_skills_root(tmp_path: Path) -> None: + source_skills = tmp_path / "source" / "skills" + install_skills = tmp_path / "install" / "skills" + runtime_skills = tmp_path / "home" / ".claude" / "skills" + installed_skill = install_skills / "vc-implement" + runtime_skill = runtime_skills / "vc-implement" + + (source_skills / "vc-implement").mkdir(parents=True) + (source_skills / "vc-implement" / "SKILL.md").write_text( + "See [Verification Rule](../VERIFICATION_RULE.md).\n" + "See [Living Tree Rule](../LIVING_TREE_RULE.md).\n", + encoding="utf-8", + ) + (source_skills / "VERIFICATION_RULE.md").write_text( + "# Verification\n", encoding="utf-8" + ) + (source_skills / "LIVING_TREE_RULE.md").write_text( + "# Living Tree\n", encoding="utf-8" + ) + (source_skills / "pl").mkdir() + (source_skills / "pl" / "LIVING_TREE_RULE.md").write_text( + "# Zywe Drzewo\n", encoding="utf-8" + ) + installed_skill.mkdir(parents=True) + runtime_skill.mkdir(parents=True) + + copied = installer.sync_skill_root_rules(source_skills, install_skills) + copied_again = installer.sync_skill_root_rules(source_skills, install_skills) + runtime_copied = installer.sync_skill_root_rules(source_skills, runtime_skills) + + assert copied == copied_again + assert runtime_copied == copied + assert Path("VERIFICATION_RULE.md") in copied + assert Path("LIVING_TREE_RULE.md") in copied + assert Path("pl/LIVING_TREE_RULE.md") in copied + assert (installed_skill / ".." / "VERIFICATION_RULE.md").is_file() + assert (installed_skill / ".." / "LIVING_TREE_RULE.md").is_file() + assert (runtime_skills / "VERIFICATION_RULE.md").is_file() + assert (runtime_skills / "LIVING_TREE_RULE.md").is_file() + assert (runtime_skill / ".." / "VERIFICATION_RULE.md").is_file() + assert (install_skills / "pl" / "LIVING_TREE_RULE.md").is_file() + assert (runtime_skills / "pl" / "LIVING_TREE_RULE.md").is_file() + assert not (install_skills / "pl" / "VERIFICATION_RULE.md").exists() + + def test_install_all_paths_do_not_install_shell_helpers_by_default() -> None: """New runtime contract: install-all installs tools and views, but does not wire legacy shell helpers or mutate shell rc files by default.""" diff --git a/tests/tui/test_operator_mode.py b/tests/tui/test_operator_mode.py index 82aaafdf..bdba2667 100644 --- a/tests/tui/test_operator_mode.py +++ b/tests/tui/test_operator_mode.py @@ -302,9 +302,118 @@ def test_vc_init_finds_bundled_vc_frame_and_creates_missing_operator_session( expected_session = _expected_operator_session() assert f"--session {expected_session} --new-session-with-layout" in payload assert f"--session {expected_session} action new-tab" in payload + assert f"run_id=interactive target={expected_session}/claude-init" in result.stdout + assert f"watch=vc-frame attach {expected_session}" in result.stdout assert "There is no active session!" not in result.stderr +def test_operator_spawn_success_prints_actionable_receipt(tmp_path: Path) -> None: + home = tmp_path / "home" + fake_bin = tmp_path / "bin" + capture_file = tmp_path / "vc_frame-args.txt" + home.mkdir() + fake_bin.mkdir() + _write_capture_command(fake_bin, "vc-frame", capture_file) + + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "PATH": f"{fake_bin}:{env.get('PATH', '')}", + "CAPTURE_FILE": str(capture_file), + "VIBECRAFTED_OPERATOR_SESSION": "receipt-session", + "VIBECRAFTED_ROOT": str(REPO_ROOT), + "VIBECRAFTED_RUN_ID": "impl-receipt-1", + } + ) + for name in ( + "VC_FRAME", + "VC_FRAME_PANE_ID", + "VC_FRAME_SESSION_NAME", + "ZELLIJ", + "ZELLIJ_SESSION_NAME", + "ZELLIJ_PANE_ID", + ): + env.pop(name, None) + + result = subprocess.run( + [ + "bash", + "--noprofile", + "--norc", + "-c", + ( + f'source "{HELPER_SCRIPT}"; ' + '_vetcoders_spawn_into_operator_session "codex-init" "true"' + ), + ], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert ( + "launch accepted: run_id=impl-receipt-1 " + "target=receipt-session/codex-init " + "watch=vc-frame attach receipt-session" + ) in result.stdout + assert "action\nnew-tab" in capture_file.read_text(encoding="utf-8") + + +def test_operator_spawn_failure_is_loud_and_preserves_status(tmp_path: Path) -> None: + home = tmp_path / "home" + fake_bin = tmp_path / "bin" + home.mkdir() + fake_bin.mkdir() + vc_frame = fake_bin / "vc-frame" + vc_frame.write_text("#!/usr/bin/env bash\nexit 37\n", encoding="utf-8") + vc_frame.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "PATH": f"{fake_bin}:{env.get('PATH', '')}", + "VIBECRAFTED_OPERATOR_SESSION": "receipt-session", + "VIBECRAFTED_ROOT": str(REPO_ROOT), + "VIBECRAFTED_RUN_ID": "impl-receipt-2", + } + ) + for name in ( + "VC_FRAME", + "VC_FRAME_PANE_ID", + "VC_FRAME_SESSION_NAME", + "ZELLIJ", + "ZELLIJ_SESSION_NAME", + "ZELLIJ_PANE_ID", + ): + env.pop(name, None) + + result = subprocess.run( + [ + "bash", + "--noprofile", + "--norc", + "-c", + ( + f'source "{HELPER_SCRIPT}"; ' + '_vetcoders_spawn_into_operator_session "marbles" "true"' + ), + ], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 37 + assert ( + "launch failed: run_id=impl-receipt-2 target=receipt-session/marbles status=37" + ) in result.stderr + + def test_vc_init_missing_vc_frame_message_has_fresh_install_path_hint( tmp_path: Path, ) -> None: @@ -336,6 +445,10 @@ def test_vc_init_missing_vc_frame_message_has_fresh_install_path_hint( assert result.returncode != 0 assert "vc-frame is required for the Vibecrafted operator runtime." in result.stderr + assert ( + "Run 'vc-start' first to create or attach the operator vc-frame session, then retry." + in result.stderr + ) assert ( f"Expected vc-frame on PATH or bundled at: {home}/.local/share/vibecrafted/bin/vc-frame" in result.stderr diff --git a/tests/tui/test_release_contract.py b/tests/tui/test_release_contract.py index 099ed77c..062cb90d 100644 --- a/tests/tui/test_release_contract.py +++ b/tests/tui/test_release_contract.py @@ -82,8 +82,14 @@ def test_release_archive_preserves_bundled_tool_slot() -> None: encoding="utf-8" ) - assert "tar -czf" in release_workflow - assert "--exclude='./tools/bin'" not in release_workflow + archive_step = release_workflow.split("- name: Build release archive", 1)[1].split( + "- name: Sign release artifacts", 1 + )[0] + assert "scripts/distribution_manifest.py archive" in archive_step + assert '--source . --output "dist/${archive_name}.tar.gz"' in archive_step + assert '--root-name "$archive_name"' in archive_step + assert "tar -czf" not in archive_step + assert "--exclude" not in archive_step assert "$SOURCE/tools/bin/-" in tools_readme diff --git a/tests/tui/test_research_launcher.py b/tests/tui/test_research_launcher.py index 6cc675e2..131f540c 100644 --- a/tests/tui/test_research_launcher.py +++ b/tests/tui/test_research_launcher.py @@ -28,6 +28,13 @@ def write_research_config(config_home: Path, agents: list[str]) -> None: ) +def write_runtime_research_yaml(vibecrafted_home: Path, agents: list[str]) -> None: + config_dir = vibecrafted_home / "config" + config_dir.mkdir(parents=True, exist_ok=True) + lanes = "\n".join(f" - agent: {agent}" for agent in agents) + (config_dir / "research.yaml").write_text(f"lanes:\n{lanes}\n", encoding="utf-8") + + def test_vc_research_help_is_pure_help() -> None: result = subprocess.run( ["bash", "-lc", f'source "{HELPER_SCRIPT}"; vc-research --help'], @@ -38,27 +45,41 @@ def test_vc_research_help_is_pure_help() -> None: assert result.returncode == 0 assert "Configurable triple-agent research swarm launcher" in result.stdout - assert "Do not pass an agent directly to vc-research." in result.stdout - assert "vc-research uno " in result.stdout + assert "Positional agents override the YAML lane set" in result.stdout + assert "uno " in result.stdout assert "Research swarm launched" not in result.stdout assert "command not found" not in result.stdout assert "command not found" not in result.stderr -def test_vc_research_rejects_agent_argument_without_helper_crash() -> None: +def test_vc_research_positional_agent_launches_one_lane(tmp_path: Path) -> None: + root = tmp_path / "repo" + root.mkdir() + crafted_home = tmp_path / "home" / ".vibecrafted" + env = os.environ.copy() + env["VIBECRAFTED_HOME"] = str(crafted_home) + env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) + env["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") + env["VETCODERS_SPAWN_RUNTIME"] = "headless" + result = subprocess.run( [ "bash", "-lc", - f'source "{HELPER_SCRIPT}"; vc-research codex --prompt "Check auth providers"', + ( + f'source "{HELPER_SCRIPT}"; ' + f'vc-research codex --runtime headless --root "{root}" ' + '--prompt "Check auth providers"' + ), ], - cwd=REPO_ROOT, + cwd=root, + env=env, capture_output=True, text=True, ) - assert result.returncode != 0 - assert "vc-research is a triple-agent swarm launcher" in result.stderr + assert result.returncode == 0, result.stderr + assert "Research override (codex) prepared" in result.stdout assert "command not found" not in result.stdout assert "command not found" not in result.stderr @@ -91,7 +112,7 @@ def test_vc_research_uno_launches_one_requested_agent(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stderr - assert "Research uno (codex) prepared" in result.stdout + assert "Research override (codex) prepared" in result.stdout assert "Research swarm prepared" not in result.stdout run_id_match = re.search(r"run_id=(rsch-[^)]+)", result.stdout) @@ -122,6 +143,43 @@ def test_vc_research_uno_launches_one_requested_agent(tmp_path: Path) -> None: assert "- Junie:" not in summary +def test_vc_research_reads_runtime_owned_yaml(tmp_path: Path) -> None: + root = tmp_path / "repo" + root.mkdir() + crafted_home = tmp_path / "home" / ".vibecrafted" + write_runtime_research_yaml(crafted_home, ["codex", "agy"]) + + env = os.environ.copy() + env["VIBECRAFTED_HOME"] = str(crafted_home) + env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) + env["XDG_CONFIG_HOME"] = str(tmp_path / "xdg") + env["VETCODERS_SPAWN_RUNTIME"] = "headless" + + result = subprocess.run( + [ + "bash", + "-lc", + ( + f'source "{HELPER_SCRIPT}"; ' + f'vc-research --runtime headless --root "{root}" --prompt "yaml lanes"' + ), + ], + cwd=root, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + run_dir_match = re.search(r"Run directory: (.+)", result.stdout) + assert run_dir_match is not None, result.stdout + run_dir = Path(run_dir_match.group(1)) + assert sorted(p.name for p in (run_dir / "logs").glob("*.meta.json")) == [ + "agy.meta.json", + "codex.meta.json", + ] + + def test_vc_research_generated_worker_prompts_do_not_leak_launcher_semantics( tmp_path: Path, ) -> None: @@ -225,7 +283,7 @@ def test_vc_research_uses_run_scoped_artifact_layout(tmp_path: Path) -> None: root.mkdir() crafted_home = tmp_path / "home" / ".vibecrafted" config_home = tmp_path / "xdg" - write_research_config(config_home, ["grok", "codex", "gemini"]) + write_research_config(config_home, ["grok", "codex", "agy"]) env = os.environ.copy() env["VIBECRAFTED_HOME"] = str(crafted_home) @@ -268,19 +326,19 @@ def test_vc_research_uses_run_scoped_artifact_layout(tmp_path: Path) -> None: assert run_dir.parent.name == "research" assert (run_dir / "summary.md").is_file() assert sorted(p.name for p in (run_dir / "logs").glob("*.meta.json")) == [ + "agy.meta.json", "codex.meta.json", - "gemini.meta.json", "grok.meta.json", ] assert sorted(p.name for p in (run_dir / "tmp").glob("*_launch.sh")) == [ + "agy_launch.sh", "codex_launch.sh", - "gemini_launch.sh", "grok_launch.sh", ] assert not list(run_dir.parent.parent.glob("reports/*rsch*.meta.json")) assert not list(run_dir.parent.parent.glob("tmp/vc-research-*")) - for agent in ("grok", "codex", "gemini"): + for agent in ("grok", "codex", "agy"): meta = json.loads((run_dir / "logs" / f"{agent}.meta.json").read_text()) assert meta["run_id"] == run_id assert meta["skill_code"] == "rsch" @@ -324,7 +382,7 @@ def test_runtime_picking_manifest_keeps_mainstream_default_researchers() -> None assert manifest["runtime"]["picking"]["research"]["default_agents"] == [ "claude", "codex", - "gemini", + "agy", ] assert "grok" in manifest["runtime"]["picking"]["research"]["fallback_agents"] diff --git a/tests/tui/test_research_terminal_safety.py b/tests/tui/test_research_terminal_safety.py index b04460c6..e71794fb 100644 --- a/tests/tui/test_research_terminal_safety.py +++ b/tests/tui/test_research_terminal_safety.py @@ -93,8 +93,8 @@ def test_vc_research_terminal_without_session_degrades_to_headless( def test_launcher_paths_recognise_every_supported_agent() -> None: - # The old regex matched only claude|codex|gemini while the default swarm - # is claude+codex+junie — the venv vc-research entrypoint could never + # The old regex matched only a subset of agents while the configured swarm + # can include every supported lane — the venv vc-research entrypoint could never # collect its own launchers and always exited 1 before spawning. output = "\n".join( [ @@ -104,9 +104,8 @@ def test_launcher_paths_recognise_every_supported_agent() -> None: " junie: /tmp/junie_launch.sh", " agy: /tmp/agy_launch.sh", " grok: /tmp/grok_launch.sh", - " gemini: /tmp/gemini_launch.sh", ] ) launchers = _launcher_paths(output) - assert sorted(launchers) == ["agy", "claude", "codex", "gemini", "grok", "junie"] + assert sorted(launchers) == ["agy", "claude", "codex", "grok", "junie"] assert launchers["junie"] == Path("/tmp/junie_launch.sh") diff --git a/tests/tui/test_runtime_helpers.py b/tests/tui/test_runtime_helpers.py index 1ef638d2..f5f7b694 100644 --- a/tests/tui/test_runtime_helpers.py +++ b/tests/tui/test_runtime_helpers.py @@ -504,7 +504,7 @@ def test_skill_dispatch_prints_launch_receipt(tmp_path: Path) -> None: in result.stdout ) assert ( - "await: vibecrafted claude await --run-id prun-010203-44444" + "await (ARM NOW, supervisor-side): vibecrafted claude await --run-id prun-010203-44444" in result.stdout ) diff --git a/tests/tui/test_skills_sync.py b/tests/tui/test_skills_sync.py index a577cf34..0a94b1ad 100644 --- a/tests/tui/test_skills_sync.py +++ b/tests/tui/test_skills_sync.py @@ -50,7 +50,6 @@ def test_skills_sync_with_shell_targets_canonical_helper_and_both_shells( ) stdout = result.stdout - log = log_file.read_text(encoding="utf-8") assert "Syncing optional shell helper layer to fakehost" in stdout assert "$HOME/.local/share/vibecrafted/tools/vibecrafted-current/skills" in stdout @@ -60,6 +59,49 @@ def test_skills_sync_with_shell_targets_canonical_helper_and_both_shells( assert "ssh fakehost ln -sfn" in stdout assert "Skipping remote $HOME/.bashrc update" not in stdout assert "Skipping remote $HOME/.zshrc update" not in stdout + assert "$HOME/.local/share/vibecrafted/tools/vibecrafted-local/skills" in stdout + assert "rsync" in stdout + assert ".bashrc" in stdout + assert ".zshrc" in stdout + assert not log_file.exists(), "dry-run must not execute ssh or rsync" + + +def test_skills_sync_with_shell_real_run_targets_canonical_helper_and_both_shells( + tmp_path: Path, +) -> None: + # Real run (no --dry-run) actually invokes the ssh/rsync shims; the shim log + # captures the exact commands, proving canonical targeting and that both + # shells are hit. Complements the dry-run test above, which only inspects + # the printed plan and asserts zero execution. + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + log_file = tmp_path / "sync.log" + + _write_stub_command(fake_bin, "ssh", f'printf "ssh:%s\\n" "$*" >> "{log_file}"') + _write_stub_command(fake_bin, "rsync", f'printf "rsync:%s\\n" "$*" >> "{log_file}"') + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}" + + subprocess.run( + [ + "bash", + str(SKILLS_SYNC), + "fakehost", + "--source", + str(REPO_ROOT), + "--with-shell", + "--no-verify", + ], + check=True, + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + ) + + log = log_file.read_text(encoding="utf-8") + assert "$HOME/.local/share/vibecrafted/tools/vibecrafted-local/skills" in log assert "$HOME/.vibecrafted/skills" not in log assert "_template" not in log diff --git a/tests/tui/test_spawn_common.py b/tests/tui/test_spawn_common.py index 439cba61..71e4e282 100644 --- a/tests/tui/test_spawn_common.py +++ b/tests/tui/test_spawn_common.py @@ -1720,7 +1720,7 @@ def test_generated_launcher_adds_uniform_artifact_closure(tmp_path: Path) -> Non assert payload["tokens_input"] == 10 assert payload["tokens_cached_input"] == 3 assert payload["tokens_output"] == 5 - assert payload["tokens_total"] == 15 + assert payload["tokens_total"] == 18 assert ( payload["resume_hint"] == f"Use `cd {root_dir} && vc-resume --session sess-abc-123` to continue work with this Agent." @@ -1733,7 +1733,7 @@ def test_generated_launcher_adds_uniform_artifact_closure(tmp_path: Path) -> Non assert "session_id: sess-abc-123" in text assert "tokens_input: 10" in text assert "tokens_output: 5" in text - assert "tokens_total: 15" in text + assert "tokens_total: 18" in text assert "cost_usd: unknown" in text assert "" in text assert "vc-resume --session sess-abc-123" in text diff --git a/tests/tui/test_staged_tools_sync.py b/tests/tui/test_staged_tools_sync.py index ba513787..fa547f5f 100644 --- a/tests/tui/test_staged_tools_sync.py +++ b/tests/tui/test_staged_tools_sync.py @@ -1,12 +1,13 @@ from __future__ import annotations -from argparse import Namespace import io -import shutil +from argparse import Namespace from pathlib import Path from scripts import vetcoders_install as installer +REPO_ROOT = Path(__file__).resolve().parents[2] + def _write_executable(path: Path, body: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -14,27 +15,10 @@ def _write_executable(path: Path, body: str) -> None: path.chmod(0o755) -def _write_minimal_source(root: Path, *, helper: str, launcher: str) -> None: - (root / "runtime" / "shell").mkdir(parents=True) - (root / "skills" / "vc-agents").mkdir(parents=True) - (root / "skills" / "vc-agents" / "SKILL.md").write_text( - "# vc-agents\n", encoding="utf-8" - ) +def _write_complete_source(root: Path, *, helper: str, launcher: str) -> None: + installer.stage_distribution_payload(REPO_ROOT, root, mirror=True) (root / "runtime" / "shell" / "vetcoders.sh").write_text(helper, encoding="utf-8") - (root / "scripts").mkdir(parents=True) _write_executable(root / "scripts" / "vibecrafted", launcher) - (root / "VERSION").write_text("1.5.0-test\n", encoding="utf-8") - - -def _hide_rsync(monkeypatch) -> None: - real_which = shutil.which - - def fake_which(name: str) -> str | None: - if name == "rsync": - return None - return real_which(name) - - monkeypatch.setattr(installer.shutil, "which", fake_which) class _TtyBuffer: @@ -112,7 +96,7 @@ def test_refresh_current_tools_mirrors_shadowing_files( old_target = runtime_tools / "vibecrafted-main" current_link = runtime_tools / "vibecrafted-current" - _write_minimal_source( + _write_complete_source( source, helper='printf "fresh helper\\n"\n', launcher='#!/usr/bin/env bash\nprintf "fresh launcher\\n"\n', @@ -126,9 +110,11 @@ def test_refresh_current_tools_mirrors_shadowing_files( 'printf "stale launcher\\n"\n', encoding="utf-8" ) (old_target / "obsolete.txt").write_text("delete me\n", encoding="utf-8") + stale_cache = old_target / "vibecrafted-core" / "vibecrafted_core" / "__pycache__" + stale_cache.mkdir(parents=True) + (stale_cache / "dispatcher.cpython-314.pyc").write_bytes(b"stale") current_link.parent.mkdir(parents=True, exist_ok=True) current_link.symlink_to(old_target) - _hide_rsync(monkeypatch) monkeypatch.setenv("HOME", str(tmp_path / "home")) monkeypatch.setenv("VIBECRAFTED_HOME", str(crafted_home)) @@ -145,6 +131,9 @@ def test_refresh_current_tools_mirrors_shadowing_files( encoding="utf-8" ) == '#!/usr/bin/env bash\nprintf "fresh launcher\\n"\n' assert not (old_target / "obsolete.txt").exists() + assert not ( + old_target / "vibecrafted-core" / "vibecrafted_core" / "__pycache__" + ).exists() def test_compact_install_refreshes_current_tools_from_local_checkout( @@ -157,7 +146,7 @@ def test_compact_install_refreshes_current_tools_from_local_checkout( old_target = runtime_tools / "vibecrafted-main" current_link = runtime_tools / "vibecrafted-current" - _write_minimal_source( + _write_complete_source( source, helper='printf "fresh installed helper\\n"\n', launcher='#!/usr/bin/env bash\nprintf "fresh installed launcher\\n"\n', @@ -204,8 +193,6 @@ def fail_runtime_entrypoints(*_args, **_kwargs): monkeypatch.setattr( installer, "_install_python_entrypoint_launchers", fail_runtime_entrypoints ) - _hide_rsync(monkeypatch) - exit_code = installer._cmd_install_compact( Namespace(dry_run=False, mirror=True, with_shell=False), source, @@ -218,3 +205,62 @@ def fail_runtime_entrypoints(*_args, **_kwargs): assert (current_link / "scripts" / "vibecrafted").read_text( encoding="utf-8" ) == '#!/usr/bin/env bash\nprintf "fresh installed launcher\\n"\n' + + +def _build_symlinked_skill_store(tmp_path: Path) -> tuple[Path, Path]: + """Wire vibecrafted-current -> vibecrafted-main so the skill store and the + install source resolve to the same inode (portable-CI staging shape).""" + main = tmp_path / "vibecrafted-main" + skills = main / "skills" + skills.mkdir(parents=True) + for filename in installer.SKILL_ROOT_RULE_FILES: + (skills / filename).write_text(f"{filename}\n", encoding="utf-8") + for localized in installer.LOCALIZED_SKILL_RULE_DIRS: + (skills / localized).mkdir(parents=True, exist_ok=True) + for filename in installer.SKILL_ROOT_RULE_FILES: + (skills / localized / filename).write_text( + f"{localized}/{filename}\n", encoding="utf-8" + ) + current = tmp_path / "vibecrafted-current" + current.symlink_to(main) + # source_skills_root(...).resolve() is what the installer passes as the + # source; the store comes from the unresolved current-link path -> same + # inode via two different string paths. + source = (current / "skills").resolve() + store = current / "skills" + return source, store + + +def test_sync_skill_root_rules_skips_same_inode_store(tmp_path: Path) -> None: + """Regression: a symlinked store (vibecrafted-current -> vibecrafted-main) + made copy2 raise shutil.SameFileError during the portable "skills and + launchers" phase. The sync must treat the self-copy as a no-op.""" + source, store = _build_symlinked_skill_store(tmp_path) + + copied = installer.sync_skill_root_rules(source, store, dry_run=False) + + # All rule files are still reported as synced (they already exist in place). + expected = {p for _src, p in installer.iter_skill_root_rule_files(source)} + assert set(copied) == expected + for filename in installer.SKILL_ROOT_RULE_FILES: + assert (store / filename).read_text(encoding="utf-8") == f"{filename}\n" + + +def test_rsync_skill_skips_same_inode_dir(tmp_path: Path, monkeypatch) -> None: + """Regression: the shutil fallback copied a skill dir onto itself (and under + --mirror rmtree'd the source) when the store symlinked back to the source.""" + source, store = _build_symlinked_skill_store(tmp_path) + # A skill living inside the skills dir: source/vc-demo (real) and + # store/vc-demo (via the current-link symlink) are the same inode. + src_skill = source / "vc-demo" + src_skill.mkdir() + (src_skill / "SKILL.md").write_text("demo\n", encoding="utf-8") + dst_skill = store / "vc-demo" + + # Force the pure-Python fallback path (no rsync) with mirror=True — the most + # destructive shape (rmtree of dst == the source) — and assert the source + # survives untouched. + monkeypatch.setattr(installer.shutil, "which", lambda _name: None) + installer.rsync_skill(src_skill, dst_skill, dry_run=False, mirror=True) + + assert (src_skill / "SKILL.md").read_text(encoding="utf-8") == "demo\n" diff --git a/tests/tui/test_uv_bootstrap.py b/tests/tui/test_uv_bootstrap.py index 3b71327d..a58aee77 100644 --- a/tests/tui/test_uv_bootstrap.py +++ b/tests/tui/test_uv_bootstrap.py @@ -29,6 +29,7 @@ import re import shutil import subprocess +import sys import tarfile from pathlib import Path @@ -45,6 +46,28 @@ def _write_executable(path: Path, body: str) -> None: path.chmod(0o755) +def _write_distribution_manifest_stub(source_dir: Path) -> None: + path = source_dir / "scripts" / "distribution_manifest.py" + path.write_text( + """#!/usr/bin/env python3 +from pathlib import Path +import shutil +import sys + +if sys.argv[1] == "check": + raise SystemExit(0) +if sys.argv[1] != "stage": + raise SystemExit(2) +source = Path(sys.argv[sys.argv.index("--source") + 1]) +destination = Path(sys.argv[sys.argv.index("--destination") + 1]) +if destination.exists(): + shutil.rmtree(destination) +shutil.copytree(source, destination, symlinks=True) +""", + encoding="utf-8", + ) + + # --------------------------------------------------------------------------- # P1-01: Makefile shell-boundary fix # --------------------------------------------------------------------------- @@ -273,7 +296,6 @@ def test_install_sh_reports_version_from_staged_tree(tmp_path: Path) -> None: fake_bin = tmp_path / "bin" home = tmp_path / "home" make_capture = tmp_path / "make-args.txt" - python_capture = tmp_path / "python-called.txt" scripts_dir.mkdir(parents=True) fake_bin.mkdir() @@ -285,6 +307,7 @@ def test_install_sh_reports_version_from_staged_tree(tmp_path: Path) -> None: ) (source_dir / "VERSION").write_text("9.9.9-test\n", encoding="utf-8") (scripts_dir / "placeholder").write_text("", encoding="utf-8") + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-main") @@ -296,8 +319,7 @@ def test_install_sh_reports_version_from_staged_tree(tmp_path: Path) -> None: ) _write_executable( fake_bin / "python3", - "#!/usr/bin/env bash\nset -euo pipefail\n" - 'printf "unexpected\\n" > "$PYTHON_CAPTURE"\nexit 97\n', + f'#!/usr/bin/env bash\nset -euo pipefail\nexec "{sys.executable}" "$@"\n', ) env = os.environ.copy() @@ -306,7 +328,6 @@ def test_install_sh_reports_version_from_staged_tree(tmp_path: Path) -> None: env["VIBECRAFTED_HOME"] = str(home / ".vibecrafted") env["PATH"] = f"{fake_bin}:/usr/bin:/bin:/usr/sbin:/sbin" env["MAKE_CAPTURE"] = str(make_capture) - env["PYTHON_CAPTURE"] = str(python_capture) result = subprocess.run( ["bash", str(INSTALL_SH), "--archive-file", str(archive_path), "install"], @@ -336,7 +357,6 @@ def test_install_sh_banner_falls_back_when_version_absent(tmp_path: Path) -> Non fake_bin = tmp_path / "bin" home = tmp_path / "home" make_capture = tmp_path / "make-args.txt" - python_capture = tmp_path / "python-called.txt" scripts_dir.mkdir(parents=True) fake_bin.mkdir() @@ -348,6 +368,7 @@ def test_install_sh_banner_falls_back_when_version_absent(tmp_path: Path) -> Non ) # No VERSION file on purpose. (scripts_dir / "placeholder").write_text("", encoding="utf-8") + _write_distribution_manifest_stub(source_dir) with tarfile.open(archive_path, "w:gz") as archive: archive.add(source_dir, arcname="vibecrafted-exotic") @@ -359,8 +380,7 @@ def test_install_sh_banner_falls_back_when_version_absent(tmp_path: Path) -> Non ) _write_executable( fake_bin / "python3", - "#!/usr/bin/env bash\nset -euo pipefail\n" - 'printf "unexpected\\n" > "$PYTHON_CAPTURE"\nexit 97\n', + f'#!/usr/bin/env bash\nset -euo pipefail\nexec "{sys.executable}" "$@"\n', ) env = os.environ.copy() @@ -369,7 +389,6 @@ def test_install_sh_banner_falls_back_when_version_absent(tmp_path: Path) -> Non env["VIBECRAFTED_HOME"] = str(home / ".vibecrafted") env["PATH"] = f"{fake_bin}:/usr/bin:/bin:/usr/sbin:/sbin" env["MAKE_CAPTURE"] = str(make_capture) - env["PYTHON_CAPTURE"] = str(python_capture) result = subprocess.run( ["bash", str(INSTALL_SH), "--archive-file", str(archive_path), "install"], diff --git a/tests/tui/test_vibecrafted_launcher.py b/tests/tui/test_vibecrafted_launcher.py index d38b490e..34a330dc 100644 --- a/tests/tui/test_vibecrafted_launcher.py +++ b/tests/tui/test_vibecrafted_launcher.py @@ -451,6 +451,21 @@ def test_init_codex_uses_interactive_tab_without_exec_mode(tmp_path: Path) -> No assert "codex exec" not in script_body +def test_init_gemini_returns_actionable_agy_migration() -> None: + result = subprocess.run( + ["bash", str(LAUNCHER), "init", "gemini"], + check=False, + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "gemini CLI is deprecated" in result.stderr + assert "Use agy" in result.stderr + assert "Unknown agent" not in result.stderr + + def test_vc_help_wrapper_symlink_renders_main_help(tmp_path: Path) -> None: wrapper = tmp_path / "vc-help" wrapper.symlink_to(LAUNCHER) @@ -486,7 +501,7 @@ def test_vc_help_wrapper_forwards_topic_help(tmp_path: Path) -> None: ) assert "Start an interactive repository orientation session" in result.stdout - assert "vc-init [claude|codex|gemini|agy|junie|grok]" in result.stdout + assert "vc-init [claude|codex|agy|junie|grok]" in result.stdout assert "Ship cycle:" not in result.stdout @@ -1113,8 +1128,8 @@ def test_gui_help_exposes_local_server_flags() -> None: @pytest.mark.parametrize( ("topic", "expected"), [ - ("init", "vc-init [claude|codex|gemini|agy|junie|grok]"), - ("vc-init", "vc-init [claude|codex|gemini|agy|junie|grok]"), + ("init", "vc-init [claude|codex|agy|junie|grok]"), + ("vc-init", "vc-init [claude|codex|agy|junie|grok]"), ("vc-review", 'vibecrafted review codex --prompt "Review PR #14"'), ("status", "vibecrafted stats"), ], @@ -1201,12 +1216,11 @@ def test_implement_help_is_the_canonical_autonomous_delivery_surface() -> None: assert "implement" in result.stdout assert "Autonomous end-to-end implementation" in result.stdout assert ( - "vibecrafted implement [flags]" - in result.stdout + "vibecrafted implement [flags]" in result.stdout ) - assert "vc-implement [flags]" in result.stdout + assert "vc-implement [flags]" in result.stdout assert ( - "Alias: vibecrafted justdo [flags]" + "Alias: vibecrafted justdo [flags]" in result.stdout ) @@ -1223,12 +1237,11 @@ def test_justdo_help_points_back_to_implement() -> None: assert "justdo" in result.stdout assert "Convenient alias for vc-implement" in result.stdout assert ( - "vibecrafted implement [flags]" - in result.stdout + "vibecrafted implement [flags]" in result.stdout ) - assert "vc-implement [flags]" in result.stdout + assert "vc-implement [flags]" in result.stdout assert ( - "Alias: vibecrafted justdo [flags]" + "Alias: vibecrafted justdo [flags]" in result.stdout ) @@ -1344,9 +1357,7 @@ def test_skill_wrapper_help_is_human_readable_without_agent( assert skill in result.stdout assert description in result.stdout - assert ( - f"{wrapper_name} [flags]" in result.stdout - ) + assert f"{wrapper_name} [flags]" in result.stdout @pytest.mark.parametrize( @@ -1953,6 +1964,83 @@ def test_resume_wrapper_symlink_forwards_session_and_prompt_to_agent( ] +def test_resume_wrapper_accepts_positional_session_id(tmp_path: Path) -> None: + """`vc-resume [prompt...]` works without --session.""" + home = tmp_path / "home" + fake_bin = tmp_path / "bin" + capture_file = tmp_path / "codex-args.txt" + wrapper = tmp_path / "vc-resume" + + home.mkdir() + fake_bin.mkdir() + wrapper.symlink_to(LAUNCHER) + _write_fake_agent(fake_bin, "codex", capture_file) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}" + env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) + env["VETCODERS_SPAWN_RUNTIME"] = "headless" + env["CAPTURE_FILE"] = str(capture_file) + + subprocess.run( + [ + "bash", + str(wrapper), + "codex", + "resume-session-456", + "Continue from wrapper", + ], + check=True, + cwd=REPO_ROOT, + env=env, + ) + + payload = capture_file.read_text(encoding="utf-8").splitlines() + assert payload == [ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "resume", + "resume-session-456", + "Continue from wrapper", + ] + + +def test_resume_wrapper_accepts_bare_positional_session_id(tmp_path: Path) -> None: + """`vc-resume ` with no prompt resumes that session.""" + home = tmp_path / "home" + fake_bin = tmp_path / "bin" + capture_file = tmp_path / "codex-args.txt" + wrapper = tmp_path / "vc-resume" + + home.mkdir() + fake_bin.mkdir() + wrapper.symlink_to(LAUNCHER) + _write_fake_agent(fake_bin, "codex", capture_file) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}" + env["VIBECRAFTED_ROOT"] = str(REPO_ROOT) + env["VETCODERS_SPAWN_RUNTIME"] = "headless" + env["CAPTURE_FILE"] = str(capture_file) + + subprocess.run( + ["bash", str(wrapper), "codex", "resume-session-789"], + check=True, + cwd=REPO_ROOT, + env=env, + ) + + payload = capture_file.read_text(encoding="utf-8").splitlines() + assert payload == [ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "resume", + "resume-session-789", + ] + + def test_vc_dashboard_wrapper_dispatches_to_dashboard(tmp_path: Path) -> None: """vc-dashboard wrapper (symlink) reaches cmd_dashboard, not run_skill.""" home = tmp_path / "home" @@ -2482,6 +2570,15 @@ def _fake_agent_script(agent: str, final_message: str, stream_json: bool) -> str " shift", ' task_text="${1:-}"', " ;;", + # agy >= 1.1 delivers the prompt as the value of --print (no stdin), + # so the fake agent must read it from there like the real CLI. + " --print)", + " shift", + ' task_text="${1:-}"', + " ;;", + " --print=*)", + ' task_text="${1#--print=}"', + " ;;", " esac", " shift || true", "done", @@ -2556,7 +2653,6 @@ def _fleet_salvage_env(tmp_path: Path, fake_bin: Path) -> dict[str, str]: @pytest.mark.parametrize( ("agent", "stream_json"), [ - ("gemini", True), ("agy", False), ("grok", False), ("junie", False), @@ -2595,7 +2691,6 @@ def test_remaining_launchers_salvage_final_message_on_missing_report_success( @pytest.mark.parametrize( ("agent", "stream_json", "exit_code"), [ - ("gemini", True, "11"), ("agy", False, "12"), ("grok", False, "13"), ("junie", False, "14"), @@ -2636,7 +2731,6 @@ def test_remaining_launchers_salvage_final_message_on_missing_report_failure( @pytest.mark.parametrize( ("agent", "stream_json"), [ - ("gemini", True), ("agy", False), ("grok", False), ("junie", False), diff --git a/vibecrafted-app/mux-agent/CHANGELOG.md b/vibecrafted-app/mux-agent/CHANGELOG.md index c8caa273..d41902ea 100644 --- a/vibecrafted-app/mux-agent/CHANGELOG.md +++ b/vibecrafted-app/mux-agent/CHANGELOG.md @@ -146,7 +146,7 @@ args.contains("rmcp-mux")` was a copy/paste; the second clause now ### Added -- Package metadata: `description`, `repository`, `homepage`, `documentation`, `readme`, `keywords`, `categories`, `license = "MIT OR Apache-2.0"`, and `authors = ["Maciej Gad ", "Monika Szymanska "]` in `Cargo.toml` for proper crates.io listing and discovery. +- Package metadata: `description`, `repository`, `homepage`, `documentation`, `readme`, `keywords`, `categories`, `license = "MIT OR Apache-2.0"`, and `authors = ["Vetcoders "]` in `Cargo.toml` for proper crates.io listing and discovery. ## 0.2.0 - 2025-11-24 diff --git a/vibecrafted-app/mux-agent/Cargo.toml b/vibecrafted-app/mux-agent/Cargo.toml index e46282ae..74294a92 100644 --- a/vibecrafted-app/mux-agent/Cargo.toml +++ b/vibecrafted-app/mux-agent/Cargo.toml @@ -2,7 +2,7 @@ name = "rmcp-mux" version = "0.4.0" edition.workspace = true -authors = ["Maciej Gad ", "Monika Szymanska "] +authors = ["Vetcoders "] description = "Transport multiplexer for Rust MCP servers — tray, proxy, and runtime supervisor" license.workspace = true repository = "https://github.com/Loctree/rmcp-mux" diff --git a/vibecrafted-core/pyproject.toml b/vibecrafted-core/pyproject.toml index 82082177..2f4b797b 100644 --- a/vibecrafted-core/pyproject.toml +++ b/vibecrafted-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vibecrafted" -version = "3.3.0" +version = "3.5.0" description = "Core Python library for Vibecrafted runtime and synthesis surfaces." readme = "README.md" requires-python = ">=3.11" @@ -49,9 +49,11 @@ build-backend = "hatchling.build" [tool.hatch.build] artifacts = [ + "vibecrafted_core/VERSION", "vibecrafted_core/runtime/**", "vibecrafted_core/skills/**", "vibecrafted_core/deck/vibecrafted", + "vibecrafted_core/schemas/**", ] [tool.hatch.build.targets.wheel] diff --git a/vibecrafted-core/tests/__init__.py b/vibecrafted-core/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/vibecrafted-core/tests/dispatch/fixtures/model-pin.dispatch.toml b/vibecrafted-core/tests/dispatch/fixtures/model-pin.dispatch.toml new file mode 100644 index 00000000..d1ca74f8 --- /dev/null +++ b/vibecrafted-core/tests/dispatch/fixtures/model-pin.dispatch.toml @@ -0,0 +1,33 @@ +schema = "vibecrafted.dispatch.v1" + +[meta] +name = "dispatch-model-pin" +repo = "/tmp/vibecrafted-dispatch-fixture" +reports_dir = "/tmp/vibecrafted-dispatch-fixture/reports" + +[[phases]] +title = "Foundation" +detail = "per-cut model pin smoke" + +[[cuts]] +id = "d1-pinned" +phase = "Foundation" +agent = "codex" +workflow = "implement" +model = "gpt-5-codex" +prompt = "Implement pinned cut {id} in {repo}." + + [[cuts.verify]] + run = "echo ok" + expect = { contains = "ok", exit_code = 0 } + +[[cuts]] +id = "d2-unpinned" +phase = "Foundation" +agent = "claude" +workflow = "implement" +prompt = "Implement unpinned cut {id} in {repo}." + + [[cuts.verify]] + run = "echo ok" + expect = { contains = "ok", exit_code = 0 } diff --git a/vibecrafted-core/tests/dispatch/test_schema.py b/vibecrafted-core/tests/dispatch/test_schema.py index 77bb129d..50166ebe 100644 --- a/vibecrafted-core/tests/dispatch/test_schema.py +++ b/vibecrafted-core/tests/dispatch/test_schema.py @@ -50,6 +50,24 @@ def test_stage0_maximum_fixture_is_accepted() -> None: assert dispatch.cuts[6].mutation == "" +def test_cut_model_pin_parses_and_defaults_empty_when_absent() -> None: + dispatch = load_dispatch(FIXTURES / "model-pin.dispatch.toml") + + assert dispatch.cuts[0].id == "d1-pinned" + assert dispatch.cuts[0].model == "gpt-5-codex" + # An unpinned cut carries no pin: empty string, plan still valid. + assert dispatch.cuts[1].id == "d2-unpinned" + assert dispatch.cuts[1].model == "" + + +def test_plan_without_any_model_pin_stays_valid() -> None: + # Backward-compatibility regression: the minimal fixture predates the + # `model` key; it must parse unchanged with an empty pin on every cut. + dispatch = load_dispatch(FIXTURES / "minimal.dispatch.toml") + + assert all(cut.model == "" for cut in dispatch.cuts) + + def test_prompt_rendering_substitutes_common_body_extra_and_empty_baton() -> None: dispatch = load_dispatch(FIXTURES / "minimal.dispatch.toml") prompt = render_cell_prompt(dispatch, dispatch.cuts[0]) diff --git a/vibecrafted-core/tests/dispatch/test_supervisor.py b/vibecrafted-core/tests/dispatch/test_supervisor.py index d9137816..1b0f4c44 100644 --- a/vibecrafted-core/tests/dispatch/test_supervisor.py +++ b/vibecrafted-core/tests/dispatch/test_supervisor.py @@ -16,6 +16,7 @@ STATE_VERIFIED, Dispatch, ) +from vibecrafted_core.dispatch import supervisor as supervisor_module from vibecrafted_core.dispatch.schema import parse_dispatch from vibecrafted_core.dispatch.supervisor import ( CellRun, @@ -170,6 +171,50 @@ def fake_popen(command: list[str], **kwargs: object) -> FakeProc: assert "/control_plane/runtime_runs/" in env["VIBECRAFTED_META_PATH"] +def test_workflow_cell_launcher_carries_cut_model_pin_into_spec( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + dispatch, _reports_dir, _artifacts_dir = build_dispatch( + tmp_path, + """ +[[cuts]] +id = "c1" +agent = "codex" +workflow = "implement" +model = "gpt-5-codex" +prompt = "pinned cut" + [[cuts.verify]] + run = "echo ok" + expect = { contains = "ok" } + +[[cuts]] +id = "c2" +agent = "claude" +workflow = "implement" +prompt = "unpinned cut" + [[cuts.verify]] + run = "echo ok" + expect = { contains = "ok" } +""", + ) + captured: dict[str, str] = {} + + def fake_launch_workflow(spec, _base_dir): + captured[spec.agent] = spec.model + return {"accepted": True, "run_id": "r", "pid": 1, "report": ""} + + monkeypatch.setattr(supervisor_module, "launch_workflow", fake_launch_workflow) + + launch = workflow_cell_launcher(dispatch, source_dir=tmp_path) + launch(dispatch.cuts[0], "pinned cut", "initial") + launch(dispatch.cuts[1], "unpinned cut", "initial") + + # The pinned cut forwards its model into the launch spec; the unpinned + # cut forwards an empty pin (account default is a deliberate non-decision). + assert captured["codex"] == "gpt-5-codex" + assert captured["claude"] == "" + + def test_passing_cuts_flip_to_verified_and_emit_artifacts(tmp_path: Path) -> None: dispatch, reports_dir, artifacts_dir = build_dispatch( tmp_path, diff --git a/vibecrafted-core/tests/lifecycle_schema_assertions.py b/vibecrafted-core/tests/lifecycle_schema_assertions.py new file mode 100644 index 00000000..73c9d254 --- /dev/null +++ b/vibecrafted-core/tests/lifecycle_schema_assertions.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from typing import Any + +from vibecrafted_core.lifecycle_runner import LIFECYCLE_SCHEMA_ID +from vibecrafted_core.package_resources import resource_path + + +def packaged_lifecycle_schema() -> dict[str, Any]: + path = resource_path("schemas", "lifecycle.schema.v1.json") + return json.loads(path.read_text(encoding="utf-8")) + + +def assert_lifecycle_state_matches_packaged_schema(state: dict[str, Any]) -> None: + schema = packaged_lifecycle_schema() + _assert_lifecycle_schema_identity(schema) + _validate_schema_value(state, schema, schema, "$") + + +def assert_worker_report_frontmatter_matches_packaged_schema( + frontmatter: dict[str, Any], +) -> None: + schema = packaged_lifecycle_schema() + _assert_lifecycle_schema_identity(schema) + _validate_schema_value( + frontmatter, + schema["$defs"]["worker_report_frontmatter"], + schema, + "$frontmatter", + ) + + +def _assert_lifecycle_schema_identity(schema: Mapping[str, Any]) -> None: + assert schema["$id"] == LIFECYCLE_SCHEMA_ID + assert schema["properties"]["schema"]["const"] == LIFECYCLE_SCHEMA_ID + + +def _validate_schema_value( + value: Any, + schema: Mapping[str, Any], + root: Mapping[str, Any], + path: str, +) -> None: + if "$ref" in schema: + _validate_schema_value( + value, + _resolve_ref(str(schema["$ref"]), root), + root, + path, + ) + return + + if "oneOf" in schema: + matches = 0 + errors: list[str] = [] + for option in schema["oneOf"]: + try: + _validate_schema_value(value, option, root, path) + except AssertionError as exc: + errors.append(str(exc)) + else: + matches += 1 + assert matches == 1, ( + f"{path}: expected exactly one oneOf match, got {matches}; {errors}" + ) + return + + if "const" in schema: + assert value == schema["const"], ( + f"{path}: expected const {schema['const']!r}, got {value!r}" + ) + + expected_types = schema.get("type") + if expected_types is not None: + _assert_json_type(value, expected_types, path) + + if "enum" in schema: + assert value in schema["enum"], ( + f"{path}: expected one of {schema['enum']!r}, got {value!r}" + ) + + if "minimum" in schema and value is not None: + assert value >= schema["minimum"], ( + f"{path}: expected >= {schema['minimum']}, got {value!r}" + ) + + if isinstance(value, Mapping): + missing = set(schema.get("required") or []) - set(value) + assert not missing, f"{path}: missing required keys {sorted(missing)}" + properties = schema.get("properties") or {} + for key, property_schema in properties.items(): + if key in value: + _validate_schema_value( + value[key], + property_schema, + root, + f"{path}.{key}", + ) + extra_keys = set(value) - set(properties) + additional_properties = schema.get("additionalProperties", True) + if additional_properties is False: + assert not extra_keys, ( + f"{path}: unexpected additional keys {sorted(extra_keys)}" + ) + elif isinstance(additional_properties, Mapping): + for key in extra_keys: + _validate_schema_value( + value[key], + additional_properties, + root, + f"{path}.{key}", + ) + + if isinstance(value, list) and "items" in schema: + if "minItems" in schema: + assert len(value) >= schema["minItems"], ( + f"{path}: expected at least {schema['minItems']} item(s), " + f"got {len(value)}" + ) + item_schema = schema["items"] + for index, item in enumerate(value): + _validate_schema_value(item, item_schema, root, f"{path}[{index}]") + + if isinstance(value, str) and "minLength" in schema: + assert len(value) >= schema["minLength"], ( + f"{path}: expected length >= {schema['minLength']}, got {len(value)}" + ) + + +def _resolve_ref(ref: str, root: Mapping[str, Any]) -> Mapping[str, Any]: + assert ref.startswith("#/"), f"unsupported schema ref: {ref}" + current: Any = root + for part in ref[2:].split("/"): + assert isinstance(current, Mapping), f"schema ref is not an object: {ref}" + current = current[part] + assert isinstance(current, Mapping), ( + f"schema ref does not resolve to an object: {ref}" + ) + return current + + +def _assert_json_type(value: Any, raw_types: str | Sequence[str], path: str) -> None: + expected_types = [raw_types] if isinstance(raw_types, str) else list(raw_types) + checks = { + "array": lambda item: isinstance(item, list), + "boolean": lambda item: isinstance(item, bool), + "integer": lambda item: isinstance(item, int) and not isinstance(item, bool), + "null": lambda item: item is None, + "number": lambda item: ( + isinstance(item, (int, float)) and not isinstance(item, bool) + ), + "object": lambda item: isinstance(item, Mapping), + "string": lambda item: isinstance(item, str), + } + assert any(checks[item](value) for item in expected_types if item in checks), ( + f"{path}: expected type {expected_types!r}, got {type(value).__name__}" + ) diff --git a/vibecrafted-core/tests/test_agent_stream.py b/vibecrafted-core/tests/test_agent_stream.py index 44e37368..64e7f2ae 100644 --- a/vibecrafted-core/tests/test_agent_stream.py +++ b/vibecrafted-core/tests/test_agent_stream.py @@ -1,6 +1,7 @@ from __future__ import annotations from vibecrafted_core.agent_stream import AgentStreamParser, resolve_default_model +from vibecrafted_core.telemetry import estimate_cost_usd def test_agent_stream_parser_extracts_claude_session_usage_cost_and_text() -> None: @@ -17,7 +18,8 @@ def test_agent_stream_parser_extracts_claude_session_usage_cost_and_text() -> No ), parser.feed_line( b'{"type":"result","result":"done","usage":{"input_tokens":10,' - b'"cache_read_input_tokens":3,"output_tokens":5},' + b'"cache_read_input_tokens":3,"cache_creation_input_tokens":2,' + b'"output_tokens":5},' b'"total_cost_usd":0.0123}\n' ), ] @@ -29,6 +31,7 @@ def test_agent_stream_parser_extracts_claude_session_usage_cost_and_text() -> No assert parser.model_id == "claude-opus-4-8" assert parser.tokens_input == 10 assert parser.tokens_cached_input == 3 + assert parser.tokens_cache_write == 2 assert parser.tokens_output == 5 assert parser.cost_usd == 0.0123 @@ -57,6 +60,7 @@ def test_agent_stream_parser_extracts_codex_thread_usage_and_text() -> None: assert parser.model_id == "gpt-5.3-codex" assert parser.tokens_input == 11 assert parser.tokens_cached_input == 4 + assert parser.tokens_cache_write is None assert parser.tokens_output == 6 @@ -121,6 +125,24 @@ def test_agent_stream_parser_renders_grok_thought_text_and_session() -> None: assert parser.session_id == "019ec430-9888-78e3-8ca0-b29387444fdb" +def test_agent_stream_parser_maps_real_grok_end_telemetry() -> None: + parser = AgentStreamParser("grok") + parser.feed_line( + b'{"type":"end","sessionId":"grok-session","usage":' + b'{"input_tokens":34113,"cache_read_input_tokens":2752,' + b'"output_tokens":151,"total_tokens":37016},"modelUsage":' + b'{"grok-build":{"inputTokens":34113,"outputTokens":151,' + b'"cacheReadInputTokens":2752,"modelCalls":1}}}\n' + ) + + assert parser.model_id == "grok-build" + assert parser.tokens_input == 34113 + assert parser.tokens_cached_input == 2752 + assert parser.tokens_output == 151 + assert parser.cost_usd == 0.034965 + assert parser.cost_source == "estimated:xai-api-2026-07" + + def test_agent_stream_parser_renders_junie_steps_without_none_noise() -> None: parser = AgentStreamParser("junie") @@ -142,6 +164,25 @@ def test_agent_stream_parser_renders_junie_steps_without_none_noise() -> None: assert parser.session_id == "session-junie-1" +def test_agent_stream_parser_maps_real_junie_nested_model_usage() -> None: + parser = AgentStreamParser("junie") + parser.feed_line( + b'{"kind":"SessionA2uxEvent","event":{"state":"IN_PROGRESS",' + b'"agentEvent":{"kind":"LlmResponseMetadataEvent","modelUsage":[' + b'{"model":"gpt-5.5","cost":0.045821,"inputTokens":807,' + b'"cacheInputTokens":49792,"cacheCreateTokens":0,' + b'"outputTokens":563,"time":0}]}}}\n' + ) + + assert parser.model_id == "gpt-5.5" + assert parser.tokens_input == 807 + assert parser.tokens_cached_input == 49792 + assert parser.tokens_cache_write == 0 + assert parser.tokens_output == 563 + assert parser.cost_usd == 0.045821 + assert parser.cost_source == "provider_reported" + + def test_agent_stream_parser_treats_agy_as_claude_family_text_stream() -> None: parser = AgentStreamParser("agy") @@ -179,3 +220,18 @@ def test_resolve_default_model_reads_codex_config(tmp_path, monkeypatch) -> None ) == "gpt-5.3-codex" ) + + +def test_cost_estimates_lock_user_reported_grok_and_codex_regressions() -> None: + assert estimate_cost_usd( + "grok-build", + tokens_input=123064, + tokens_cached_input=4200256, + tokens_output=21463, + ) == (1.006041, "estimated:xai-api-2026-07") + assert estimate_cost_usd( + "gpt-5.6-sol", + tokens_input=20901518, + tokens_cached_input=20536960, + tokens_output=48906, + ) == (116.24325, "estimated:openai-api-2026-07") diff --git a/vibecrafted-core/tests/test_agy_junie_pipeline.py b/vibecrafted-core/tests/test_agy_junie_pipeline.py index d5ee9d0b..fc94b6b0 100644 --- a/vibecrafted-core/tests/test_agy_junie_pipeline.py +++ b/vibecrafted-core/tests/test_agy_junie_pipeline.py @@ -40,14 +40,23 @@ def test_supervisor_defaults_and_sandbox_support_cover_agy_junie_grok() -> None: assert sandbox_supported("grok") is True assert _default_command("claude", "go")[:3] == ["claude", "--print", "--verbose"] assert _default_command("codex", "go")[:2] == ["codex", "exec"] - assert _default_command("gemini", "go")[:3] == ["gemini", "--yolo", "--prompt"] - agy_command = _default_command("agy", "go") - assert agy_command[:3] == [ - "bash", - "-lc", - "agy --print --dangerously-skip-permissions --add-dir . --print-timeout 30m '' <<< \"$1\"", + # gemini deprecated: must raise actionable migration error, never launch + try: + _ = _default_command("gemini", "go") + assert False, "gemini must raise deprecation" + except ValueError as e: + assert "deprecated" in str(e).lower() or "agy" in str(e).lower() + # agy >= 1.1: --print takes the prompt as its value; flags precede it. + assert _default_command("agy", "go") == [ + "agy", + "--dangerously-skip-permissions", + "--add-dir", + ".", + "--print-timeout", + "30m", + "--print", + "go", ] - assert agy_command[3:] == ["agy", "go"] assert _default_command("junie", "go")[:2] == ["junie", "--task"] assert _default_command("grok", "go")[:2] == ["grok", "--cwd"] assert "--single" in _default_command("grok", "go") diff --git a/vibecrafted-core/tests/test_cli.py b/vibecrafted-core/tests/test_cli.py index 57f79521..30d0c4b4 100644 --- a/vibecrafted-core/tests/test_cli.py +++ b/vibecrafted-core/tests/test_cli.py @@ -3,6 +3,8 @@ from pathlib import Path from types import SimpleNamespace +import pytest + from vibecrafted_core import cli @@ -22,12 +24,60 @@ def _accepted_launch_payload() -> dict[str, object]: } -def test_root_cli_vc_help_alias_returns_help_success(monkeypatch, capsys) -> None: - monkeypatch.setattr("sys.argv", ["vc-help"]) +def test_root_cli_without_command_returns_help_error(capsys) -> None: + assert cli.main([]) == 2 + + assert "Vibecrafted core command surface" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("wrapper", "verb"), + [ + ("telemetry", "telemetry"), + ("vc-dashboard", "dashboard"), + ("vc-dispatch", "dispatch"), + ("vc-help", "help"), + ("vc-init", "init"), + ("vc-justdo", "justdo"), + ("vc-resume", "resume"), + ("vc-start", "start"), + ], +) +def test_shell_wrapper_entrypoints_preserve_their_deck_verb( + monkeypatch, tmp_path: Path, wrapper: str, verb: str +) -> None: + tools_home = tmp_path / "tools" + deck = tools_home / "vibecrafted-current" / "scripts" / "vibecrafted" + deck.parent.mkdir(parents=True) + deck.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + seen: dict[str, object] = {} + + def fake_run(argv): + seen["argv"] = argv + return SimpleNamespace(returncode=0) + + monkeypatch.setenv("VIBECRAFTED_TOOLS_HOME", str(tools_home)) + monkeypatch.setattr("sys.argv", [wrapper, "sentinel"]) + monkeypatch.setattr("subprocess.run", fake_run) assert cli.main() == 0 + assert seen["argv"] == [str(deck), verb, "sentinel"] - assert "Vibecrafted core command surface" in capsys.readouterr().out + +def test_shell_wrapper_missing_deck_fails_loudly( + monkeypatch, tmp_path: Path, capsys +) -> None: + tools_home = tmp_path / "tools" + missing_deck = tmp_path / "missing" / "vibecrafted" + monkeypatch.setenv("VIBECRAFTED_TOOLS_HOME", str(tools_home)) + monkeypatch.setattr("sys.argv", ["vc-init", "codex"]) + monkeypatch.setattr(cli, "deck_path", lambda: missing_deck) + + assert cli.main() == 1 + assert ( + f"error: vc-init cannot find the runtime deck at {missing_deck}" + in capsys.readouterr().err + ) def test_version_reads_installed_package_never_delegates_to_deck( @@ -39,7 +89,11 @@ def test_version_reads_installed_package_never_delegates_to_deck( # inside a checkout it reports that checkout's version, not the installed one). # Make any deck subprocess fail loudly so a regression that re-delegates # `--version` cannot pass silently. - from vibecrafted_core import __version__ + expected = ( + (Path(__file__).resolve().parents[2] / "VERSION") + .read_text(encoding="utf-8") + .strip() + ) def _no_deck(*_args, **_kwargs): raise AssertionError("--version must not shell out to the deck") @@ -48,7 +102,7 @@ def _no_deck(*_args, **_kwargs): for flag in ("--version", "-v", "version"): assert cli.main([flag]) == 0 - assert capsys.readouterr().out.strip() == f"vibecrafted {__version__}" + assert capsys.readouterr().out.strip() == f"vibecrafted {expected}" def test_root_cli_accepts_justdo_alias(monkeypatch, capsys) -> None: @@ -69,6 +123,27 @@ def fake_launch(spec, source_dir): assert "VIBECRAFTED LAUNCH RECEIPT" in capsys.readouterr().out +def test_root_cli_research_agentless_and_positional_forms(monkeypatch, capsys) -> None: + seen = [] + + def fake_launch(spec, _source_dir): + seen.append(spec) + return _accepted_launch_payload() + + monkeypatch.setattr(cli, "launch_workflow", fake_launch) + + assert cli.main(["research", "--prompt", "map it"]) == 0 + assert cli.main(["research", "codex", "agy", "--prompt", "map it"]) == 0 + + assert seen[0].agent == "swarm" + assert seen[0].research_agents == () + assert seen[0].research_synthesizer == "" + assert seen[1].agent == "swarm" + assert seen[1].research_agents == ("codex", "agy") + assert seen[1].research_synthesizer == "codex" + assert "VIBECRAFTED LAUNCH RECEIPT" in capsys.readouterr().out + + def test_root_cli_launch_missing_work_prints_friendly_error( monkeypatch, capsys ) -> None: @@ -163,7 +238,8 @@ def test_root_cli_prints_full_launch_receipt(monkeypatch, capsys) -> None: "observe: vibecrafted codex observe --run-id impl-260613-145127-33000" in out ) assert ( - "await: vibecrafted codex await --run-id impl-260613-145127-33000" in out + "await (ARM NOW, supervisor-side): vibecrafted codex await --run-id impl-260613-145127-33000" + in out ) @@ -404,21 +480,33 @@ def test_root_cli_agent_observe_uses_codex_config_model( def test_root_cli_agent_await_accepts_receipt_command(monkeypatch, capsys) -> None: + run = { + "run_id": "impl-1", + "agent": "codex", + "state": "report_validated", + "skill": "implement", + "root": "/repo", + "artifact_ok": True, + "latest_report": "/tmp/report.md", + "latest_transcript": "/tmp/transcript.log", + } monkeypatch.setattr( cli, "sync_state", lambda: {"active_runs": [], "recent_runs": []} ) + monkeypatch.setattr(cli, "lookup_run", lambda _run_id: run) + # The verb must block through the ONE canonical loop — the CLI's own job + # is only the exit-code/print mapping of its verdict. monkeypatch.setattr( cli, - "lookup_run", - lambda run_id: { + "await_run", + lambda run_id, **_kwargs: { "run_id": run_id, - "agent": "codex", - "state": "report_validated", - "skill": "implement", - "root": "/repo", - "artifact_ok": True, - "latest_report": "/tmp/report.md", - "latest_transcript": "/tmp/transcript.log", + "found": True, + "completed": True, + "timed_out": False, + "reason": "terminal", + "worker_alive": False, + "run": run, }, ) @@ -450,6 +538,21 @@ def test_root_cli_agent_await_fails_dead_stale_worker( cli, "sync_state", lambda: {"active_runs": [], "recent_runs": []} ) monkeypatch.setattr(cli, "lookup_run", lambda _run_id: run) + # Dead + no movement is the canonical loop's idle_stall verdict — the CLI + # maps it to a nonzero exit with the final run status printed. + monkeypatch.setattr( + cli, + "await_run", + lambda run_id, **_kwargs: { + "run_id": run_id, + "found": True, + "completed": False, + "timed_out": True, + "reason": "idle_stall", + "worker_alive": False, + "run": run, + }, + ) rc = cli.main( [ @@ -466,11 +569,51 @@ def test_root_cli_agent_await_fails_dead_stale_worker( assert rc == 1 captured = capsys.readouterr() - assert "await: worker dead or stale" in captured.out + assert "await: timed out (idle_stall)" in captured.out assert "state: stalled" in captured.out assert "last useful line" in captured.out +def test_root_cli_agent_await_rejects_completed_payload_when_worker_alive( + monkeypatch, capsys +) -> None: + run = { + "run_id": "impl-live", + "agent": "codex", + "state": "running", + "liveness": "pid_alive", + "skill": "implement", + "root": "/repo", + "artifact_ok": True, + "latest_report": "/tmp/report.md", + "latest_transcript": "/tmp/transcript.log", + } + monkeypatch.setattr( + cli, "sync_state", lambda: {"active_runs": [], "recent_runs": []} + ) + monkeypatch.setattr(cli, "lookup_run", lambda _run_id: run) + monkeypatch.setattr( + cli, + "await_run", + lambda run_id, **_kwargs: { + "run_id": run_id, + "found": True, + "completed": True, + "timed_out": False, + "reason": "report_delivered", + "worker_alive": True, + "run": run, + }, + ) + + rc = cli.main(["codex", "await", "--run-id", "impl-live", "--timeout", "0"]) + + assert rc == 3 + captured = capsys.readouterr() + assert "non-terminal completion disagreement" in captured.err + assert "state: running" in captured.out + + def test_root_cli_doctor_routes_to_installer_doctor(monkeypatch, capsys) -> None: monkeypatch.setattr( cli.doctor_module, diff --git a/vibecrafted-core/tests/test_control_plane.py b/vibecrafted-core/tests/test_control_plane.py index ee8957a9..3bd7c0ca 100644 --- a/vibecrafted-core/tests/test_control_plane.py +++ b/vibecrafted-core/tests/test_control_plane.py @@ -1,8 +1,10 @@ from __future__ import annotations import datetime as dt +import fcntl import json import os +import time import uuid from pathlib import Path @@ -297,6 +299,69 @@ def test_lookup_run_uses_synced_snapshot( assert run["liveness"] == "pid_alive" +def test_scoped_lookup_is_lockless_while_global_lock_is_held( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A per-run lookup must not queue behind the shared control-plane lock. + + Regression for the flock migraine: one held global lock used to freeze every + per-run await/lookup for the full heartbeat window, mislabeling live workers + as stalled/pid_gone. The scoped path takes no lock, so it returns promptly + even while another holder owns the lock. + """ + home = tmp_path / ".vibecrafted" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + _write_meta( + home, + { + "run_id": "work-030303-99", + "status": "running", + "agent": "codex", + "mode": "workflow", + "root": str(tmp_path), + "updated_at": "2026-05-19T00:00:00+00:00", + "skill_code": "work", + "launcher_pid": os.getpid(), + "liveness": "pid_alive", + }, + ) + control_plane.control_plane_home().mkdir(parents=True, exist_ok=True) + + holder = control_plane._sync_lock_path().open("a+", encoding="utf-8") + fcntl.flock(holder.fileno(), fcntl.LOCK_EX) + try: + started = time.monotonic() + run = control_plane.lookup_run("work-030303-99") + elapsed = time.monotonic() - started + + # Lockless: returns without waiting out any lock budget. + assert elapsed < 1.0 + assert run is not None + assert run["agent"] == "codex" + assert run["launcher_pid"] == os.getpid() + + # The hot emit path (append_event / _append_event) must NOT take the + # global lock: a spawn/stop event lands even while the lock is held. + from vibecrafted_core import events + + events.append_event("lifecycle:active", "work-030303-99", "still moving") + control_plane.record_stop_transition( + "work-030303-99", accepted=False, reason="probe" + ) + stream = control_plane.event_stream_path().read_text(encoding="utf-8") + assert "still moving" in stream + assert "stop rejected" in stream + + # The board rebuild DOES take the lock — and now fails loud with a + # bounded budget instead of hanging forever. + monkeypatch.setenv("VIBECRAFTED_SYNC_LOCK_TIMEOUT_S", "0.2") + with pytest.raises(control_plane.ControlPlaneLockBusy): + control_plane.sync_state() + finally: + fcntl.flock(holder.fileno(), fcntl.LOCK_UN) + holder.close() + + def test_sync_state_reconciles_dead_launcher_to_stalled( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -900,6 +965,210 @@ def test_await_run_idle_stall_fires_when_worker_is_dead( assert payload["worker_alive"] is False +def test_await_run_returns_report_delivered_when_worker_is_gone( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Dead worker + non-empty report is the sealed no-await handoff. Await + must return ``report_delivered`` on the first poll instead of idling out a + full window on the corpse and answering with a misleading ``idle_stall`` + (which taught agents to distrust await and hedge with manual monitors).""" + home = tmp_path / ".vibecrafted" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_LIVENESS_STALE_HEARTBEAT_SECONDS", "100000") + report = tmp_path / "stage-report.md" + report.write_text("### Summary\ndelivered\n", encoding="utf-8") + _write_meta( + home, + { + "run_id": "revi-delivered-42", + "status": "running", + "agent": "junie", + "mode": "review", + "skill_code": "revi", + "root": str(tmp_path), + "updated_at": "2026-05-19T00:00:00+00:00", + "worker_pid": 999999999, + "worker_pgid": 999999999, + "liveness": "pid_alive", + }, + ) + + payload = control_plane.await_run( + "revi-delivered-42", + timeout_seconds=5, + interval_seconds=0.05, + hard_cap_seconds=5, + report_path=str(report), + ) + + assert payload["completed"] is True + assert payload["timed_out"] is False + assert payload["reason"] == "report_delivered" + assert payload["attempts"] == 1 + + +def test_await_run_report_alone_never_completes_a_live_worker( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The inverse guard (fleet bug: `codex await` exited 0 on a still-live + run): while the worker is ALIVE, a non-empty report never completes the + wait — it may be mid-write, or a stale leftover from a previous attempt on + the same announced path. Liveness holds the window until the hard cap.""" + import subprocess + import sys + + home = tmp_path / ".vibecrafted" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + report = tmp_path / "stage-report.md" + report.write_text("### Summary\nstale or mid-write\n", encoding="utf-8") + + worker = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + _write_meta( + home, + { + "run_id": "revi-live-writer-42", + "status": "running", + "agent": "junie", + "mode": "review", + "skill_code": "revi", + "root": str(tmp_path), + "updated_at": "2026-05-19T00:00:00+00:00", + "worker_pid": worker.pid, + "worker_pgid": worker.pid, + "liveness": "pid_alive", + }, + ) + + payload = control_plane.await_run( + "revi-live-writer-42", + timeout_seconds=0.2, + interval_seconds=0.05, + hard_cap_seconds=0.6, + report_path=str(report), + ) + finally: + worker.terminate() + worker.wait() + + assert payload["completed"] is False + assert payload["timed_out"] is True + assert payload["reason"] == "hard_cap" + assert payload["worker_alive"] is True + + +def test_await_run_terminal_looking_meta_never_completes_a_live_worker( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """rc=0-on-live guard: stale terminal-looking metadata cannot beat OS liveness.""" + import subprocess + import sys + + home = tmp_path / ".vibecrafted" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + + worker = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + _write_meta( + home, + { + "run_id": "impl-terminal-looking-live", + "status": "running", + "agent": "codex", + "mode": "implement", + "skill_code": "impl", + "root": str(tmp_path), + "updated_at": "2026-05-19T00:00:00+00:00", + "worker_pid": worker.pid, + "worker_pgid": worker.pid, + "exit_code": 0, + "liveness": "terminal", + }, + ) + + payload = control_plane.await_run( + "impl-terminal-looking-live", + timeout_seconds=0.2, + interval_seconds=0.05, + hard_cap_seconds=0.6, + ) + finally: + worker.terminate() + worker.wait() + + assert payload["completed"] is False + assert payload["timed_out"] is True + assert payload["reason"] == "hard_cap" + assert payload["worker_alive"] is True + + +def test_await_run_live_child_keeps_loop_parent_open_past_idle_window( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Marbles/polarize loop parents freeze between rounds — no transcript of + their own, sometimes no live pid — while the children do the real work in + separate ``-…-L`` records linked only by id prefix. A live + child must keep the parent's await open: the run stops only at the hard + cap, never on a false ``idle_stall`` mid-loop (the premature return that + made supervising agents double-guard await with manual monitors).""" + import subprocess + import sys + + home = tmp_path / ".vibecrafted" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + monkeypatch.setenv("VIBECRAFTED_LIVENESS_STALE_HEARTBEAT_SECONDS", "100000") + _write_meta( + home, + { + "run_id": "marb-loop-parent", + "status": "running", + "agent": "codex", + "mode": "marbles", + "skill_code": "marb", + "root": str(tmp_path), + "updated_at": "2026-05-19T00:00:00+00:00", + "worker_pid": 999999999, + "worker_pgid": 999999999, + "liveness": "pid_alive", + }, + ) + + child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + _write_meta( + home, + { + "run_id": "marb-loop-parent-marbles-L2", + "status": "running", + "agent": "codex", + "mode": "marbles", + "skill_code": "marb", + "root": str(tmp_path), + "updated_at": "2026-05-19T00:00:00+00:00", + "worker_pid": child.pid, + "worker_pgid": child.pid, + "liveness": "pid_alive", + }, + ) + + payload = control_plane.await_run( + "marb-loop-parent", + timeout_seconds=0.2, + interval_seconds=0.05, + hard_cap_seconds=0.8, + ) + finally: + child.terminate() + child.wait() + + # Stopped only by the absolute ceiling: the dead-pid parent survived many + # idle-window lengths because its live child kept resetting the deadline. + assert payload["timed_out"] is True + assert payload["reason"] == "hard_cap" + assert payload["worker_alive"] is True + assert payload["attempts"] >= 3 + + def test_sync_state_projects_event_stream_lifecycle( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -1040,3 +1309,46 @@ def test_block_run_lever_pins_terminal_blocked_state( assert run["operator_state"] == "blocked" assert run["health"] == "final" assert run["failure_card"] is not None + + +def test_run_liveness_projects_reconciled_worker_truth( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + import subprocess + import sys + + home = tmp_path / ".vibecrafted" + monkeypatch.setenv("VIBECRAFTED_HOME", str(home)) + + dead = subprocess.Popen([sys.executable, "-c", "pass"]) + dead.wait() + _write_meta( + home, + { + "run_id": "impl-dead-worker", + "status": "running", + "agent": "codex", + "mode": "workflow", + "skill_code": "impl", + "root": str(tmp_path), + "updated_at": "2026-05-19T00:00:00+00:00", + "worker_pid": dead.pid, + "worker_pgid": dead.pid, + "liveness": "pid_alive", + }, + ) + + payload = control_plane.run_liveness("impl-dead-worker") + + # The report-on-death gap: a startup corpse must be tellable from slow + # work by OS liveness, not inferred from an eternally-"running" status. + assert payload["found"] is True + assert payload["worker_alive"] is False + assert isinstance(payload["state"], str) + assert isinstance(payload["recovery_required"], bool) + + assert control_plane.run_liveness("no-such-run") == { + "run_id": "no-such-run", + "found": False, + } + assert control_plane.run_liveness("")["found"] is False diff --git a/vibecrafted-core/tests/test_control_plane_lock_doctrine.py b/vibecrafted-core/tests/test_control_plane_lock_doctrine.py new file mode 100644 index 00000000..d63cf4e6 --- /dev/null +++ b/vibecrafted-core/tests/test_control_plane_lock_doctrine.py @@ -0,0 +1,139 @@ +"""Architecture guard for the control-plane locking doctrine. + +INCIDENT (2026-07-12): a single global ``.sync.lock`` taken with an unbounded +``flock(LOCK_EX)`` serialized EVERY control-plane mutation of ALL runs. Because +the per-run hot path (lookup/await/heartbeat) and the append path (every +spawn/emit/stop event) all took that one lock, a fleet under load thundering- +herded: new dispatchers hung forever on an empty pane, and — the heartbeat +write being behind the same lock — a live-but-lock-starved worker could not +record liveness and was falsely declared stalled/pid_gone after 120s. + +The cure: the per-run path is scoped and lockless; the append path is a single +atomic ``O_APPEND`` write and takes NO lock; only the rare full-board rebuild in +``sync_state`` still takes the (now bounded) lock. + +This guard exists because the WRONG fix is the one "the rest of the world" and +training-data habit both suggest: wrap the append/hot path back in a global +mutex "for safety". That reintroduces the migraine. If you are here because this +test failed, do not delete it — the design is deliberate. Append-only JSONL with +O_APPEND is atomic per line; per-run snapshots are single-writer atomic via +os.replace. Neither needs the shared lock. See the fix commits and the Kronika. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import vibecrafted_core.control_plane as control_plane_module +import vibecrafted_core.events as events_module + +# The ONLY functions permitted to acquire the shared global lock. Everything +# else — especially the per-run and append/emit hot paths — must be lockless. +_SYNC_LOCK_ALLOWED_CALLERS = {"sync_state"} + +# Hot-path functions that must NEVER acquire the shared lock. Adding _sync_lock +# to any of these is the exact regression this guard blocks. +_HOT_PATH_LOCKLESS = { + "_append_event", + "append_event", + "record_stop_transition", + "_record_transition", + "lookup_run", + "await_run", +} + + +def _module_path(module) -> Path: + return Path(module.__file__) + + +def _enclosing_function(tree: ast.AST, target: ast.AST) -> str | None: + """Return the name of the nearest FunctionDef enclosing ``target``.""" + best: str | None = None + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for descendant in ast.walk(node): + if descendant is target: + best = node.name + return best + + +def _sync_lock_call_sites(tree: ast.AST) -> list[tuple[str | None, int]]: + sites: list[tuple[str | None, int]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = None + if isinstance(func, ast.Name): + name = func.id + elif isinstance(func, ast.Attribute): + name = func.attr + if name == "_sync_lock": + sites.append((_enclosing_function(tree, node), node.lineno)) + return sites + + +def test_sync_lock_is_only_acquired_by_the_board_rebuild() -> None: + """No caller outside the allowlist may take the shared control-plane lock. + + Guards against reintroducing the global-serialization migraine by wrapping + the per-run or append path back in the mutex. + """ + tree = ast.parse(_module_path(control_plane_module).read_text(encoding="utf-8")) + offenders = [ + f"{caller or ''}:{lineno}" + for caller, lineno in _sync_lock_call_sites(tree) + if caller not in _SYNC_LOCK_ALLOWED_CALLERS + ] + assert not offenders, ( + "control-plane lock doctrine violated: _sync_lock acquired outside " + f"{sorted(_SYNC_LOCK_ALLOWED_CALLERS)} at {offenders}. The per-run and " + "append/emit paths must stay lockless (see this file's docstring / the " + "2026-07-12 flock incident). Do not re-serialize them 'for safety'." + ) + + +def test_hot_path_functions_contain_no_sync_lock() -> None: + """The append/emit/per-run hot paths must not mention the shared lock.""" + tree = ast.parse(_module_path(control_plane_module).read_text(encoding="utf-8")) + violations: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name not in _HOT_PATH_LOCKLESS: + continue + for _caller, lineno in _sync_lock_call_sites(node): + violations.append(f"{node.name}:{lineno}") + assert not violations, ( + "hot-path function acquired _sync_lock (regression): " + f"{violations}. These must stay lockless." + ) + + +def test_events_append_is_lockless() -> None: + """events.append_event must not import or take the shared lock.""" + source = _module_path(events_module).read_text(encoding="utf-8") + tree = ast.parse(source) + assert not _sync_lock_call_sites(tree), ( + "events.append_event took _sync_lock — the append path must be a " + "lockless atomic O_APPEND write. Reverting this reintroduces the herd." + ) + + +def test_append_event_uses_atomic_o_append() -> None: + """The append primitive must use an O_APPEND write, not a lock or rewrite.""" + tree = ast.parse(_module_path(control_plane_module).read_text(encoding="utf-8")) + append_fn = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "_append_event" + ) + names = { + node.attr for node in ast.walk(append_fn) if isinstance(node, ast.Attribute) + } | {node.id for node in ast.walk(append_fn) if isinstance(node, ast.Name)} + assert "O_APPEND" in names, ( + "_append_event no longer uses an O_APPEND write. The atomic append is " + "what makes the emit path safe without a lock — keep it." + ) diff --git a/vibecrafted-core/tests/test_lifecycle_control.py b/vibecrafted-core/tests/test_lifecycle_control.py index 2e8026f0..8a5d3c98 100644 --- a/vibecrafted-core/tests/test_lifecycle_control.py +++ b/vibecrafted-core/tests/test_lifecycle_control.py @@ -11,6 +11,9 @@ LifecycleRunner, lifecycle_main, ) +from .lifecycle_schema_assertions import ( + assert_lifecycle_state_matches_packaged_schema, +) def _fake_launcher(tmp_path: Path): @@ -46,7 +49,7 @@ def _make_lifecycle_run( lambda *_args, **_kwargs: {"ok": True, "command": ["loct", "context"]}, ) runner = LifecycleRunner(launcher=_fake_launcher(tmp_path), awaiter=_fake_awaiter) - return asyncio.run( + state = asyncio.run( runner.run( LifecycleRunSpec( workflow_id=workflow_id, @@ -57,12 +60,20 @@ def _make_lifecycle_run( ) ) ) + assert_lifecycle_state_matches_packaged_schema(state) + return state def _reload_state(state: dict) -> dict: return json.loads(Path(state["state_path"]).read_text(encoding="utf-8")) +def _reload_contract_state(state: dict) -> dict: + reloaded = _reload_state(state) + assert_lifecycle_state_matches_packaged_schema(reloaded) + return reloaded + + def test_status_and_runs_surface_lifecycle_state( monkeypatch, tmp_path: Path, capsys ) -> None: @@ -115,7 +126,7 @@ def fake_run_lifecycle(spec: LifecycleRunSpec) -> dict: # The baton cargo: the scaffold report rides into the implement continuation. assert spec.previous_reports == (str(tmp_path / "scaffold.md"),) - reloaded = _reload_state(state) + reloaded = _reload_contract_state(state) actions = reloaded["operator_actions"] assert [action["action"] for action in actions] == ["approve_transition"] assert actions[0]["details"]["continuation_run_id"] == "life-cont-1" @@ -171,7 +182,7 @@ def fake_run_lifecycle(spec: LifecycleRunSpec) -> dict: == 0 ) assert len(launched) == 1 - reloaded = _reload_state(state) + reloaded = _reload_contract_state(state) details = reloaded["operator_actions"][0]["details"] assert details["forced_missing_reports"] == [str(scaffold_report)] @@ -210,7 +221,7 @@ def fake_stop_run(run_id: str, *, reason: str = "") -> dict: == 0 ) assert stopped == ["marbles-run"] - reloaded = _reload_state(state) + reloaded = _reload_contract_state(state) assert reloaded["status"] == "interrupted" assert [action["action"] for action in reloaded["operator_actions"]] == [ "interrupt_workflow" @@ -255,7 +266,7 @@ def test_force_audit_steers_baton_when_manifest_has_audit_stage( ) == 0 ) - reloaded = _reload_state(state) + reloaded = _reload_contract_state(state) assert reloaded["baton"]["next_stage"] == "audit" assert reloaded["baton"]["reason"] == "operator_forced_audit" details = reloaded["operator_actions"][0]["details"] @@ -286,7 +297,7 @@ def fake_run_lifecycle(spec: LifecycleRunSpec) -> dict: assert len(launched) == 1 assert launched[0].workflow_id == "vc-audit" assert launched[0].parent_run_id == state["run_id"] - reloaded = _reload_state(state) + reloaded = _reload_contract_state(state) details = reloaded["operator_actions"][0]["details"] assert details["mode"] == "dispatched_vc_audit" assert details["continuation_run_id"] == "life-audi-1" @@ -312,7 +323,7 @@ def test_fallback_validates_stage_and_steers_backwards( ) == 0 ) - reloaded = _reload_state(state) + reloaded = _reload_contract_state(state) assert reloaded["baton"]["next_stage"] == "polarize" assert reloaded["baton"]["reason"] == "operator_chose_fallback" @@ -332,7 +343,7 @@ def test_accept_dou_records_finding(monkeypatch, tmp_path: Path, capsys) -> None ) == 0 ) - reloaded = _reload_state(state) + reloaded = _reload_contract_state(state) assert reloaded["accepted_dou_findings"][0]["finding"].startswith( "install path unverified" ) @@ -375,3 +386,92 @@ def test_ship_and_wrapper_clis_route_control_verbs( assert lifecycle_main("vc-dou", ["runs", "--all", "--json"]) == 0 listed = json.loads(capsys.readouterr().out) assert {entry["workflow"] for entry in listed} == {"vc-dou", "vc-ship"} + + +def test_approve_honors_operator_stage_casting( + monkeypatch, tmp_path: Path, capsys +) -> None: + state = _make_lifecycle_run(tmp_path, monkeypatch, workflow_id="vc-ship") + state_file = Path(state["state_path"]) + payload = json.loads(state_file.read_text(encoding="utf-8")) + payload["spec"]["stage_agents"] = {"implement": "junie"} + payload["spec"]["stage_models"] = {"implement": "gpt-5.5"} + state_file.write_text(json.dumps(payload), encoding="utf-8") + + launched: list[LifecycleRunSpec] = [] + + def fake_run_lifecycle(spec: LifecycleRunSpec) -> dict: + launched.append(spec) + return {"run_id": "life-cast-1", "status": "launching"} + + monkeypatch.setattr( + "vibecrafted_core.lifecycle_control.run_lifecycle", fake_run_lifecycle + ) + + assert ( + lifecycle_control_main( + ["approve", state["run_id"], "--json"], workflow_id="vc-ship" + ) + == 0 + ) + # The operator's A-to-Z casting wins over the baton's next_agent for a + # stage it names, and the map rides into the continuation spec. + assert launched[0].agent == "junie" + assert launched[0].stage_agents == {"implement": "junie"} + assert launched[0].stage_models == {"implement": "gpt-5.5"} + capsys.readouterr() + + +def test_await_stage_reports_delivery_and_death( + monkeypatch, tmp_path: Path, capsys +) -> None: + state = _make_lifecycle_run(tmp_path, monkeypatch, workflow_id="vc-ship") + + awaited: list[str] = [] + forwarded_report_paths: list[str] = [] + + def fake_await_run(run_id: str, **kwargs) -> dict: + awaited.append(run_id) + forwarded_report_paths.append(str(kwargs.get("report_path") or "")) + return { + "completed": True, + "timed_out": False, + "reason": "terminal", + "worker_alive": False, + } + + monkeypatch.setattr( + "vibecrafted_core.lifecycle_control.control_plane_await_run", fake_await_run + ) + + # The seeded run's scaffold report exists -> a dead worker with a written + # report is the normal no-await handoff, not a death signal. + assert ( + lifecycle_control_main( + ["await", state["run_id"], "--idle", "1", "--json"], + workflow_id="vc-ship", + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert awaited # went through the runtime contract, not a sleep loop + # The stage report is the handoff: forwarding it lets await_run return + # `report_delivered` on the first poll instead of idling on the corpse. + assert forwarded_report_paths[0] == payload["report"] + assert payload["stage"] == "scaffold" + assert payload["report_written"] is True + assert payload["worker_dead_without_report"] is False + assert payload["next_stage"] == "implement" + + # Erase the report: same terminal await now means death-without-delivery. + Path(payload["report"]).unlink() + assert ( + lifecycle_control_main( + ["await", state["run_id"], "--idle", "1", "--json"], + workflow_id="vc-ship", + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["report_written"] is False + assert payload["worker_dead_without_report"] is True diff --git a/vibecrafted-core/tests/test_lifecycle_runner.py b/vibecrafted-core/tests/test_lifecycle_runner.py index 62055055..21d88d2f 100644 --- a/vibecrafted-core/tests/test_lifecycle_runner.py +++ b/vibecrafted-core/tests/test_lifecycle_runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import subprocess from pathlib import Path @@ -8,11 +9,17 @@ from vibecrafted_core import ship, wrappers from vibecrafted_core.lifecycle_runner import ( + LIFECYCLE_SCHEMA_ID, LifecycleRunSpec, LifecycleRunner, LifecycleSupervisor, ) from vibecrafted_core.workflows.model import WorkflowManifest, WorkflowStage +from .lifecycle_schema_assertions import ( + assert_lifecycle_state_matches_packaged_schema, + assert_worker_report_frontmatter_matches_packaged_schema, + packaged_lifecycle_schema, +) def test_lifecycle_runner_triggers_audit_after_marbles( @@ -63,6 +70,7 @@ def fake_awaiter(payload): ) assert calls == ["marbles", "audit"] + assert state["schema"] == LIFECYCLE_SCHEMA_ID assert loop_options == [(2, 4)] assert state["status"] == "completed" assert ( @@ -86,6 +94,130 @@ def fake_awaiter(payload): ) +def test_lifecycle_runner_propagates_polarize_loop_options( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setattr( + "vibecrafted_core.lifecycle_runner.load_context_atlas", + lambda *_args, **_kwargs: {"ok": True, "command": ["loct", "context"]}, + ) + loop_options: list[tuple[str, int | None, int | None]] = [] + + def fake_launcher(spec, _source_dir): + loop_options.append((spec.skill, spec.count, spec.depth)) + report = tmp_path / f"{spec.skill}.md" + report.write_text(f"{spec.skill} ok\n", encoding="utf-8") + return { + "accepted": True, + "run_id": f"{spec.skill}-run", + "report": str(report), + "transcript": str(tmp_path / f"{spec.skill}.log"), + "meta": str(tmp_path / f"{spec.skill}.json"), + } + + runner = LifecycleRunner( + launcher=fake_launcher, + awaiter=lambda payload: { + "completed": True, + "artifact_ok": True, + "report": payload["report"], + }, + ) + state = asyncio.run( + runner.run( + LifecycleRunSpec( + workflow_id="vc-polarize", + agent="codex", + prompt="cut excess", + root=str(tmp_path), + await_stages=True, + count=2, + depth=4, + ) + ) + ) + + assert loop_options == [("polarize", 2, 4)] + assert state["status"] == "completed" + + +def test_lifecycle_runner_stamps_packaged_schema_contract( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setattr( + "vibecrafted_core.lifecycle_runner.load_context_atlas", + lambda *_args, **_kwargs: {"ok": True, "command": ["loct", "context"]}, + ) + + def fake_launcher(spec, _source_dir): + report = tmp_path / f"{spec.skill}.md" + report.write_text(f"{spec.skill} ok\n", encoding="utf-8") + return { + "accepted": True, + "run_id": f"{spec.skill}-run", + "report": str(report), + "transcript": str(tmp_path / f"{spec.skill}.log"), + "meta": str(tmp_path / f"{spec.skill}.json"), + } + + def fake_awaiter(payload): + return { + "completed": True, + "artifact_ok": True, + "report": payload["report"], + } + + runner = LifecycleRunner(launcher=fake_launcher, awaiter=fake_awaiter) + state = asyncio.run( + runner.run( + LifecycleRunSpec( + workflow_id="vc-dou", + agent="codex", + prompt="validate contract", + root=str(tmp_path), + await_stages=True, + ) + ) + ) + written_state = json.loads(Path(state["state_path"]).read_text(encoding="utf-8")) + schema = packaged_lifecycle_schema() + + assert written_state["schema"] == LIFECYCLE_SCHEMA_ID + assert LifecycleSupervisor().status(written_state)["schema"] == LIFECYCLE_SCHEMA_ID + assert_lifecycle_state_matches_packaged_schema(written_state) + frontmatter = schema["$defs"]["worker_report_frontmatter"]["properties"] + assert set(frontmatter) == {"next_stage", "next_agent", "dou_index", "status"} + assert_worker_report_frontmatter_matches_packaged_schema( + { + "next_stage": "audit", + "next_agent": "codex", + "dou_index": 0, + "status": "completed", + } + ) + assert_worker_report_frontmatter_matches_packaged_schema({"dou_index": "0"}) + + wrong_action_shape = json.loads(json.dumps(written_state)) + wrong_action_shape["operator_actions"] = {} + with pytest.raises(AssertionError, match="operator_actions"): + assert_lifecycle_state_matches_packaged_schema(wrong_action_shape) + + wrong_stage_phase = json.loads(json.dumps(written_state)) + wrong_stage_phase["stages"][0]["phase"] = "execute" + with pytest.raises(AssertionError, match="phase"): + assert_lifecycle_state_matches_packaged_schema(wrong_stage_phase) + + missing_baton_cargo = json.loads(json.dumps(written_state)) + del missing_baton_cargo["baton"]["previous_reports"] + with pytest.raises(AssertionError, match="previous_reports"): + assert_lifecycle_state_matches_packaged_schema(missing_baton_cargo) + + with pytest.raises(AssertionError, match="dou_index"): + assert_worker_report_frontmatter_matches_packaged_schema({"dou_index": []}) + + def test_lifecycle_runner_honours_worker_requested_next_stage( monkeypatch, tmp_path: Path ) -> None: @@ -231,6 +363,7 @@ def test_lifecycle_runner_stage_agent_pin_overrides_baton_holder( phase="write", order=2, agent="gemini", + model="worker-tier", ), ), entry_stage="review", @@ -244,9 +377,11 @@ def test_lifecycle_runner_stage_agent_pin_overrides_baton_holder( lambda _id: {"id": manifest.id}, ) agents: list[str] = [] + models: list[str] = [] def fake_launcher(spec, _source_dir): agents.append(spec.agent) + models.append(spec.model) report = tmp_path / f"{spec.skill}-{len(agents)}.md" report.write_text(f"{spec.skill} ok\n", encoding="utf-8") return { @@ -268,7 +403,7 @@ def fake_awaiter(payload): LifecycleRunSpec( workflow_id="vc-pinned", agent="codex", - prompt="pin the writer", + prompt="---\nstage_models:\n review: frontier\n---\npin the writer", root=str(tmp_path), await_stages=True, ) @@ -276,6 +411,7 @@ def fake_awaiter(payload): ) assert agents == ["codex", "gemini"] + assert models == ["frontier", "worker-tier"] assert state["status"] == "completed" # The pin runs its own stage only; the baton holder stays with the launch agent. assert state["baton"]["next_agent"] == "codex" @@ -726,6 +862,7 @@ def fake_launcher(spec, _source_dir): status = supervisor.status(loaded) assert loaded["run_id"] == state["run_id"] + assert status["schema"] == LIFECYCLE_SCHEMA_ID assert status["workflow"] == "vc-dou" assert status["status"] == "launching" assert status["current_stage"] == "dou" @@ -858,6 +995,32 @@ def fake_run_lifecycle(spec: LifecycleRunSpec): assert captured[0].depth == 7 +def test_vc_polarize_wrapper_uses_lifecycle_runner_with_loop_options( + monkeypatch, tmp_path: Path +) -> None: + captured: list[LifecycleRunSpec] = [] + + def fake_run_lifecycle(spec: LifecycleRunSpec): + captured.append(spec) + return { + "run_id": "life-polarize-test", + "workflow": spec.workflow_id, + "status": "launching", + "state_path": str(tmp_path / "state.json"), + "report_path": str(tmp_path / "report.md"), + } + + monkeypatch.setattr( + "vibecrafted_core.lifecycle_runner.run_lifecycle", fake_run_lifecycle + ) + rc = wrappers.polarize_main(["codex", "--count", "5", "--depth", "7"]) + + assert rc == 0 + assert captured[0].workflow_id == "vc-polarize" + assert captured[0].count == 5 + assert captured[0].depth == 7 + + @pytest.mark.parametrize( ("wrapper_name", "workflow_id"), [ @@ -916,3 +1079,425 @@ def test_lifecycle_console_scripts_are_packaged() -> None: "vc-workflow", ): assert f"{name} = " in pyproject + + +def test_status_surfaces_stage_worker_death_without_report( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from vibecrafted_core import lifecycle_runner + + calls: list[str] = [] + + def fake_liveness(run_id: str) -> dict[str, object]: + calls.append(run_id) + return { + "run_id": run_id, + "found": True, + "state": "running", + "liveness": "pid_alive", + "worker_alive": False, + "recovery_required": False, + } + + monkeypatch.setattr(lifecycle_runner, "run_liveness", fake_liveness) + + report_path = tmp_path / "never-written.md" + state = { + "status": "launching", + "stages": [ + { + "id": "implement", + "launch": {"run_id": "impl-dead-1", "report": str(report_path)}, + } + ], + "baton": {}, + } + + stage_worker = LifecycleSupervisor().status(state)["stage_worker"] + + assert calls == ["impl-dead-1"] + assert stage_worker["worker_alive"] is False + assert stage_worker["report_written"] is False + # The actionable report-on-death signal: this stage will never deliver on + # its own — recover with interrupt/fallback/approve. + assert stage_worker["worker_dead_without_report"] is True + + # A dead worker that DID deliver its report is not a death signal — the + # normal no-await handoff looks exactly like this. + report_path.write_text("---\nstatus: completed\n---\ndone\n", encoding="utf-8") + stage_worker = LifecycleSupervisor().status(state)["stage_worker"] + assert stage_worker["report_written"] is True + assert stage_worker["worker_dead_without_report"] is False + + +def test_status_skips_stage_worker_liveness_when_run_is_terminal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from vibecrafted_core import lifecycle_runner + + def explode(_run_id: str) -> dict[str, object]: + raise AssertionError("terminal runs must not pay for a liveness sync") + + monkeypatch.setattr(lifecycle_runner, "run_liveness", explode) + + state = { + "status": "completed", + "stages": [{"id": "release", "launch": {"run_id": "rel-1"}}], + "baton": {}, + } + + assert LifecycleSupervisor().status(state)["stage_worker"] == {} + + +def test_record_stage_worker_exit_writes_push_side_death(tmp_path: Path) -> None: + from vibecrafted_core.lifecycle_runner import record_stage_worker_exit + + state_path = tmp_path / "state.json" + state = { + "status": "launching", + "stages": [ + {"id": "scaffold", "launch": {"run_id": "scaf-1"}}, + {"id": "implement", "launch": {"run_id": "impl-1"}}, + ], + } + state_path.write_text(json.dumps(state), encoding="utf-8") + + ok = record_stage_worker_exit( + state_path, + "impl-1", + {"state": "process_dead", "exit_code": 3, "artifact_ok": False}, + ) + + assert ok is True + reloaded = json.loads(state_path.read_text(encoding="utf-8")) + worker_exit = reloaded["stages"][1]["worker_exit"] + assert worker_exit["state"] == "process_dead" + assert worker_exit["exit_code"] == 3 + assert worker_exit["recorded_at"] + # The passive-reader alarm: current stage of a still-waiting run. + assert reloaded["stage_worker_exit"]["stage"] == "implement" + assert reloaded["stage_worker_exit"]["run_id"] == "impl-1" + + +def test_record_stage_worker_exit_keeps_history_quiet(tmp_path: Path) -> None: + from vibecrafted_core.lifecycle_runner import record_stage_worker_exit + + state_path = tmp_path / "state.json" + state = { + "status": "launching", + "stages": [ + {"id": "scaffold", "launch": {"run_id": "scaf-1"}}, + {"id": "implement", "launch": {"run_id": "impl-1"}}, + ], + } + state_path.write_text(json.dumps(state), encoding="utf-8") + + # A superseded stage (fallback/approve relaunched past it) is history: + # annotate the stage, do NOT raise the top-level alarm. + assert record_stage_worker_exit(state_path, "scaf-1", {"state": "failed"}) + reloaded = json.loads(state_path.read_text(encoding="utf-8")) + assert reloaded["stages"][0]["worker_exit"]["state"] == "failed" + assert "stage_worker_exit" not in reloaded + + # Unknown run and corrupt state must fail closed, never raise. + assert record_stage_worker_exit(state_path, "no-such-run", {}) is False + assert record_stage_worker_exit(state_path, "", {}) is False + assert record_stage_worker_exit(tmp_path / "missing.json", "impl-1", {}) is False + broken = tmp_path / "broken.json" + broken.write_text("{not json", encoding="utf-8") + assert record_stage_worker_exit(broken, "impl-1", {}) is False + + +def test_start_stage_carries_lifecycle_state_path_to_launch_spec( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setattr( + "vibecrafted_core.lifecycle_runner.load_context_atlas", + lambda *_args, **_kwargs: {"ok": True, "command": ["loct", "context"]}, + ) + seen_state_paths: list[str] = [] + + def fake_launcher(spec, _source_dir): + seen_state_paths.append(spec.lifecycle_state_path) + report = tmp_path / f"{spec.skill}.md" + report.write_text(f"{spec.skill} ok\n", encoding="utf-8") + return { + "accepted": True, + "run_id": f"{spec.skill}-run", + "report": str(report), + "transcript": str(tmp_path / f"{spec.skill}.log"), + "meta": str(tmp_path / f"{spec.skill}.json"), + } + + runner = LifecycleRunner( + launcher=fake_launcher, + awaiter=lambda payload: { + "completed": True, + "artifact_ok": True, + "report": payload["report"], + }, + ) + state = asyncio.run( + runner.run( + LifecycleRunSpec( + workflow_id="vc-marbles", + agent="codex", + prompt="close the gaps", + root=str(tmp_path), + await_stages=True, + ) + ) + ) + + # Every stage launch tells the dispatcher which lifecycle state.json it + # belongs to — the push-side report-on-death write-back address. + assert seen_state_paths + assert all(path == state["state_path"] for path in seen_state_paths) + + +def test_vc_ship_default_runtime_prefers_visible_tabs( + monkeypatch, tmp_path: Path +) -> None: + captured: list[LifecycleRunSpec] = [] + + def fake_run_lifecycle(spec: LifecycleRunSpec): + captured.append(spec) + return { + "run_id": "life-ship-test", + "workflow": spec.workflow_id, + "status": "launching", + "state_path": str(tmp_path / "state.json"), + "report_path": str(tmp_path / "report.md"), + } + + monkeypatch.setattr(ship, "run_lifecycle", fake_run_lifecycle) + # Operator invariant: workers fly visibly in vc-frame tabs. Ship must route + # through the fleet-wide resolution instead of hardcoding headless. + monkeypatch.setattr( + "vibecrafted_core.cli._default_runtime", + lambda explicit, root="": explicit or "terminal", + ) + + assert ship.main(["codex", "--prompt", "ship it"]) == 0 + assert captured[0].runtime == "terminal" + + # An explicit --runtime always wins over the resolved default. + assert ship.main(["codex", "--prompt", "quiet", "--runtime", "headless"]) == 0 + assert captured[1].runtime == "headless" + + +def test_mission_stage_agents_parses_inline_and_nested() -> None: + from vibecrafted_core.lifecycle_runner import _mission_stage_agents + + inline = "---\nstage_agents: scaffold=claude, review=codex\n---\nmission" + assert _mission_stage_agents(inline) == {"scaffold": "claude", "review": "codex"} + + nested = ( + "---\n" + "doc_id: x\n" + "stage_agents:\n" + " marbles: codex\n" + " audit: claude\n" + "other: y\n" + "---\n" + "mission body\n" + ) + assert _mission_stage_agents(nested) == {"marbles": "codex", "audit": "claude"} + + assert _mission_stage_agents("plain mission, no frontmatter") == {} + + +def test_mission_stage_models_parses_and_manifest_filters() -> None: + from vibecrafted_core.lifecycle_runner import ( + _mission_stage_models, + _validated_stage_models, + ) + from vibecrafted_core.workflows.registry import workflow_manifest + + inline = "---\nstage_models: scaffold=opus, implement=gpt-5.5\n---\nmission" + assert _mission_stage_models(inline) == { + "scaffold": "opus", + "implement": "gpt-5.5", + } + + nested = ( + "---\n" + "doc_id: x\n" + "stage_models:\n" + " marbles: opus\n" + " audit: sonnet\n" + " nosuch: cheap\n" + "---\n" + "mission body\n" + ) + assert _mission_stage_models(nested) == { + "marbles": "opus", + "audit": "sonnet", + "nosuch": "cheap", + } + + manifest = workflow_manifest("vc-marbles") + assert manifest is not None + assert _validated_stage_models(_mission_stage_models(nested), manifest) == { + "marbles": "opus", + "audit": "sonnet", + } + assert _mission_stage_models("plain mission, no frontmatter") == {} + assert _validated_stage_models({}, manifest) == {} + + +def test_lifecycle_run_casts_stage_agents_from_mission_frontmatter( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setattr( + "vibecrafted_core.lifecycle_runner.load_context_atlas", + lambda *_args, **_kwargs: {"ok": True, "command": ["loct", "context"]}, + ) + casting: list[tuple[str, str]] = [] + + def fake_launcher(spec, _source_dir): + casting.append((spec.skill, spec.agent)) + report = tmp_path / f"{spec.skill}.md" + report.write_text(f"{spec.skill} ok\n", encoding="utf-8") + return { + "accepted": True, + "run_id": f"{spec.skill}-run", + "report": str(report), + "transcript": str(tmp_path / f"{spec.skill}.log"), + "meta": str(tmp_path / f"{spec.skill}.json"), + } + + runner = LifecycleRunner( + launcher=fake_launcher, + awaiter=lambda payload: { + "completed": True, + "artifact_ok": True, + "report": payload["report"], + }, + ) + mission = ( + "---\n" + "stage_agents:\n" + " marbles: codex\n" + " audit: claude\n" + "---\n" + "# Mission: cast the relay A-to-Z\n" + ) + state = asyncio.run( + runner.run( + LifecycleRunSpec( + workflow_id="vc-marbles", + agent="gemini", + prompt=mission, + root=str(tmp_path), + await_stages=True, + ) + ) + ) + + # The operator's A-to-Z casting drives every stage launch, overriding the + # run-level agent; the map is persisted for continuations and readers. + assert casting == [("marbles", "codex"), ("audit", "claude")] + assert state["spec"]["stage_agents"] == {"marbles": "codex", "audit": "claude"} + + +def test_lifecycle_run_casts_stage_models_from_mission_frontmatter( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + monkeypatch.setattr( + "vibecrafted_core.lifecycle_runner.load_context_atlas", + lambda *_args, **_kwargs: {"ok": True, "command": ["loct", "context"]}, + ) + casting: list[tuple[str, str, str]] = [] + + def fake_launcher(spec, _source_dir): + casting.append((spec.skill, spec.agent, spec.model)) + report = tmp_path / f"{spec.skill}.md" + report.write_text(f"{spec.skill} ok\n", encoding="utf-8") + return { + "accepted": True, + "run_id": f"{spec.skill}-run", + "report": str(report), + "transcript": str(tmp_path / f"{spec.skill}.log"), + "meta": str(tmp_path / f"{spec.skill}.json"), + "model_requested": spec.model, + } + + runner = LifecycleRunner( + launcher=fake_launcher, + awaiter=lambda payload: { + "completed": True, + "artifact_ok": True, + "report": payload["report"], + }, + ) + mission = ( + "---\n" + "stage_agents:\n" + " marbles: codex\n" + " audit: claude\n" + "stage_models:\n" + " marbles: opus\n" + " audit: sonnet\n" + " nosuch: ignored\n" + "---\n" + "# Mission: cast models A-to-Z\n" + ) + state = asyncio.run( + runner.run( + LifecycleRunSpec( + workflow_id="vc-marbles", + agent="gemini", + prompt=mission, + root=str(tmp_path), + await_stages=True, + ) + ) + ) + + assert casting == [("marbles", "codex", "opus"), ("audit", "claude", "sonnet")] + assert state["spec"]["stage_models"] == {"marbles": "opus", "audit": "sonnet"} + assert [stage["model_requested"] for stage in state["stages"]] == [ + "opus", + "sonnet", + ] + report = Path(state["report_path"]).read_text(encoding="utf-8") + assert "model_requested: opus" in report + assert "model_requested: sonnet" in report + + +def test_lifecycle_run_rejects_invalid_stage_casting( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + runner = LifecycleRunner( + launcher=lambda spec, _src: {"accepted": True}, + awaiter=lambda payload: {"completed": True}, + ) + + with pytest.raises(ValueError, match="unknown stage 'nosuch'"): + asyncio.run( + runner.run( + LifecycleRunSpec( + workflow_id="vc-marbles", + agent="codex", + prompt="---\nstage_agents: nosuch=claude\n---\nmission", + root=str(tmp_path), + ) + ) + ) + + with pytest.raises(ValueError, match="unsupported agent 'hal9000'"): + asyncio.run( + runner.run( + LifecycleRunSpec( + workflow_id="vc-marbles", + agent="codex", + prompt="---\nstage_agents: marbles=hal9000\n---\nmission", + root=str(tmp_path), + ) + ) + ) diff --git a/vibecrafted-core/tests/test_loop_ship.py b/vibecrafted-core/tests/test_loop_ship.py index 67297626..907250d3 100644 --- a/vibecrafted-core/tests/test_loop_ship.py +++ b/vibecrafted-core/tests/test_loop_ship.py @@ -135,7 +135,7 @@ def fake_await_run( "--root", str(root), "--verify", - f"{subprocess.list2cmdline(['python3', '-c', 'print("verified")'])}", + subprocess.list2cmdline(["python3", "-c", 'print("verified")']), "--tracker", str(tracker), "--cut-id", diff --git a/vibecrafted-core/tests/test_package_api_contract.py b/vibecrafted-core/tests/test_package_api_contract.py index d5d3d7d5..7319a49d 100644 --- a/vibecrafted-core/tests/test_package_api_contract.py +++ b/vibecrafted-core/tests/test_package_api_contract.py @@ -1,6 +1,9 @@ from __future__ import annotations import importlib +import importlib.metadata +import subprocess +import sys import tomllib from pathlib import Path @@ -10,6 +13,10 @@ from vibecrafted_core import workflows +REPO_ROOT = Path(__file__).resolve().parents[2] +CORE_ROOT = REPO_ROOT / "vibecrafted-core" + + def test_every_public_name_resolves() -> None: """`__all__` is the package contract; a broken hub import or stale lazy entry must surface here, not at a downstream caller's import site.""" @@ -42,9 +49,97 @@ def test_unknown_attribute_raises_attribute_error() -> None: def test_version_matches_distribution_metadata() -> None: - pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" + expected = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + pyproject = CORE_ROOT / "pyproject.toml" data = tomllib.loads(pyproject.read_text(encoding="utf-8")) - assert vibecrafted_core.__version__ == data["project"]["version"] + packaged = ( + (CORE_ROOT / "vibecrafted_core" / "VERSION").read_text(encoding="utf-8").strip() + ) + + assert packaged == expected + assert data["project"]["version"] == expected + try: + installed_version = importlib.metadata.version("vibecrafted") + except importlib.metadata.PackageNotFoundError: + installed_version = None + if installed_version is not None: + assert installed_version == expected + assert vibecrafted_core.__version__ == expected + + +def test_version_falls_back_to_installed_metadata(monkeypatch) -> None: + monkeypatch.setattr(vibecrafted_core, "read_version_file", lambda _root: "unknown") + monkeypatch.setattr(importlib.metadata, "version", lambda _name: "8.7.6") + + assert vibecrafted_core._resolve_installed_version() == "8.7.6" + + +def test_version_bump_updates_every_declared_projection(tmp_path: Path) -> None: + version_file = tmp_path / "VERSION" + pyproject = tmp_path / "vibecrafted-core" / "pyproject.toml" + packaged = tmp_path / "vibecrafted-core" / "vibecrafted_core" / "VERSION" + pyproject.parent.mkdir(parents=True) + packaged.parent.mkdir(parents=True) + version_file.write_text("1.4.1\n", encoding="utf-8") + pyproject.write_text( + '[project]\nname = "fixture"\nversion = "1.4.1"\n', encoding="utf-8" + ) + packaged.write_text("1.4.1\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "version_bump.py"), + "minor", + "--file", + str(version_file), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert version_file.read_text(encoding="utf-8") == "1.5.0\n" + assert ( + tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"] + == "1.5.0" + ) + assert packaged.read_text(encoding="utf-8") == "1.5.0\n" + + +def test_version_bump_rejects_drift_without_partial_writes(tmp_path: Path) -> None: + version_file = tmp_path / "VERSION" + pyproject = tmp_path / "vibecrafted-core" / "pyproject.toml" + packaged = tmp_path / "vibecrafted-core" / "vibecrafted_core" / "VERSION" + pyproject.parent.mkdir(parents=True) + packaged.parent.mkdir(parents=True) + version_file.write_text("1.4.1\n", encoding="utf-8") + pyproject.write_text( + '[project]\nname = "fixture"\nversion = "1.4.0"\n', encoding="utf-8" + ) + packaged.write_text("1.4.1\n", encoding="utf-8") + + before = { + path: path.read_text(encoding="utf-8") + for path in (version_file, pyproject, packaged) + } + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "version_bump.py"), + "patch", + "--file", + str(version_file), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 2 + assert "version drift" in result.stderr.lower() + assert {path: path.read_text(encoding="utf-8") for path in before} == before def test_workflows_package_reexports_resolve() -> None: diff --git a/vibecrafted-core/tests/test_run_state_parity.py b/vibecrafted-core/tests/test_run_state_parity.py index acd8128f..a991f3c6 100644 --- a/vibecrafted-core/tests/test_run_state_parity.py +++ b/vibecrafted-core/tests/test_run_state_parity.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime as dt import json import os import shlex @@ -109,8 +110,12 @@ def _write_async_worker(tmp_path: Path, *, sleep_seconds: float = 2.0) -> Path: def _append_lifecycle_event(home: Path, payload: dict[str, Any]) -> None: events = home / "control_plane" / "events.jsonl" events.parent.mkdir(parents=True, exist_ok=True) + # A fresh timestamp is load-bearing: with a hardcoded past ts, terminal-run + # cases become time bombs — once the event ages past the snapshot retention + # horizon, sync_state archives the projection in the same pass the test + # reads it (went red by itself on 2026-07-06 with ts=2026-06-29). event = { - "ts": "2026-06-29T00:00:00+00:00", + "ts": dt.datetime.now(dt.timezone.utc).isoformat(), "run_id": payload["run_id"], "kind": f"lifecycle:{payload['state']}", "message": f"test {payload['state']}", @@ -193,7 +198,7 @@ def _launch_shell_meta_path( meta = ( home / "artifacts" - / "VetCoders" + / "Vetcoders" / "vibecrafted" / "2026_0629" / "reports" diff --git a/vibecrafted-core/tests/test_supervisor.py b/vibecrafted-core/tests/test_supervisor.py index a9b0c65e..c8383729 100644 --- a/vibecrafted-core/tests/test_supervisor.py +++ b/vibecrafted-core/tests/test_supervisor.py @@ -173,6 +173,7 @@ def test_finalize_artifacts_python_owns_launcher_artifact_contract( "agent": "codex", "skill": "implement", "model": "unknown", + "model_requested": "gpt-5.5", "status": "completed", "root": str(tmp_path), "report": str(report), @@ -193,11 +194,13 @@ def test_finalize_artifacts_python_owns_launcher_artifact_contract( payload = json.loads(final_meta.read_text(encoding="utf-8")) assert payload["session_id"] == "codex-finalize-001" assert payload["model"] == "gpt-5.3-codex" + assert payload["model_requested"] == "gpt-5.5" assert payload["duration_s"] == 5.0 assert payload["tokens_input"] == 12 assert payload["tokens_cached_input"] == 3 assert payload["tokens_output"] == 7 - assert payload["tokens_total"] == 19 + assert payload["tokens_total"] == 22 + assert "tokens_cache_write" not in payload assert payload["cost_usd"] == 0.045 assert payload["artifact_contract"] == "vibecrafted.agent-artifact.v1" @@ -206,6 +209,8 @@ def test_finalize_artifacts_python_owns_launcher_artifact_contract( assert final_report.name.endswith("-report.md") assert final_report.is_file() assert final_transcript.is_file() + report_text = final_report.read_text(encoding="utf-8") + assert "model_requested: gpt-5.5" in report_text assert report.exists() assert transcript.exists() assert meta.exists() @@ -249,6 +254,67 @@ def test_finish_meta_python_owns_terminal_state(tmp_path: Path) -> None: assert payload["completed_at"] == payload["updated_at"] +def test_finalize_artifacts_maps_junie_json_stream_receipt(tmp_path: Path) -> None: + report = tmp_path / "junie.md" + transcript = tmp_path / "junie.transcript.log" + meta = tmp_path / "junie.meta.json" + + report.write_text("# Junie report\n\nDone.\n", encoding="utf-8") + transcript.write_text( + json.dumps({"type": "session", "session_id": "junie-session-123"}) + + "\n" + + json.dumps( + { + "type": "result", + "session_id": "junie-session-123", + "usage": { + "prompt_tokens": 1200, + "cached_prompt_tokens": 300, + "completion_tokens": 125, + "total_tokens": 1625, + }, + "cost_usd": 0.01925, + } + ) + + "\n", + encoding="utf-8", + ) + meta.write_text( + json.dumps( + { + "run_id": "junie-json-001", + "prompt_id": "prompt-junie-json", + "agent": "junie", + "skill": "implement", + "model": "junie-cli-default", + "status": "completed", + "root": str(tmp_path), + "report": str(report), + "transcript": str(transcript), + "meta": str(meta), + "created_at": "2026-07-12T03:50:39+00:00", + "completed_at": "2026-07-12T03:50:42+00:00", + } + ) + + "\n", + encoding="utf-8", + ) + + final_meta = finalize_artifacts(meta, report, transcript) + + assert final_meta == meta + payload = json.loads(meta.read_text(encoding="utf-8")) + assert payload["session_id"] == "junie-session-123" + assert payload["tokens_input"] == 1200 + assert payload["tokens_cached_input"] == 300 + assert payload["tokens_output"] == 125 + assert payload["tokens_total"] == 1625 + assert payload["cost_usd"] == 0.01925 + report_text = report.read_text(encoding="utf-8") + assert "session_id: junie-session-123" in report_text + assert "tokens_input: 1200" in report_text + + def test_subscribe_events_reads_appended_events( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -276,11 +342,21 @@ def test_extract_tokens_prefers_run_closure_footer_across_agents() -> None: "runner: vibecrafted\n" "model: gemini-3.1-pro-preview\n" "tokens_input: 648618\n" - "tokens_cached_input: 0\n" + "tokens_cached_input: 50\n" "tokens_output: 1680\n" - "tokens_total: 650298\n" + "tokens_total: 650348\n" + ) + assert _extract_tokens(gemini_transcript)["total"] == 650348 + + claude_footer = ( + "tokens_input: 100\n" + "tokens_cached_input: 400\n" + "tokens_cache_write: 25\n" + "tokens_output: 30\n" ) - assert _extract_tokens(gemini_transcript)["total"] == 650298 + claude_tokens = _extract_tokens(claude_footer) + assert claude_tokens["total"] == 530 + assert claude_tokens["cache_write"] == 25 # junie-style footer, no per-event render line either. junie_transcript = "tokens_input: 5000\ntokens_output: 200\ntokens_total: 5200\n" @@ -293,3 +369,32 @@ def test_extract_tokens_prefers_run_closure_footer_across_agents() -> None: # No footer: fall back to the per-event line (backward compatible). legacy = "[12:00] tokens: 12 in / 7 out\n" assert _extract_tokens(legacy)["total"] == 19 + + +def test_extract_tokens_does_not_let_zero_footer_mask_junie_json() -> None: + from vibecrafted_core.spawn import _extract_tokens + + transcript = ( + '{"modelUsage":[{"model":"gpt-5.5","inputTokens":807,' + '"cacheInputTokens":49792,"outputTokens":563,"cost":0.045821}]}\n' + "tokens_input: 0\ntokens_cached_input: 0\ntokens_output: 0\n" + ) + tokens = _extract_tokens(transcript) + assert tokens == { + "input": 807, + "cached_input": 49792, + "cache_write": None, + "output": 563, + "total": 51162, + } + + +def test_extract_cost_sums_junie_per_call_model_usage() -> None: + from vibecrafted_core.spawn import _extract_cost + + transcript = ( + '{"modelUsage":[{"model":"gpt-5.5","cost":0.045821}]}\n' + '{"modelUsage":[{"model":"gpt-4.1-mini","cost":0.0002904}]}\n' + "cost_usd: unknown\n" + ) + assert _extract_cost(transcript) == 0.046111 diff --git a/vibecrafted-core/tests/test_supervisor_async.py b/vibecrafted-core/tests/test_supervisor_async.py index 161fac0a..309b07ce 100644 --- a/vibecrafted-core/tests/test_supervisor_async.py +++ b/vibecrafted-core/tests/test_supervisor_async.py @@ -258,7 +258,11 @@ def test_async_supervisor_renders_claude_stream_json_for_visible_terminal( "'model': 'claude-opus-4-8'}))\n" "print(json.dumps({'type': 'assistant', 'message': {'content': [" "{'type': 'text', 'text': 'visible text from claude'}" - "]}}))\n", + "]}}))\n" + "print(json.dumps({'type': 'result', 'result': 'done', 'usage': {" + "'input_tokens': 10, 'cache_read_input_tokens': 3, " + "'cache_creation_input_tokens': 2, 'output_tokens': 5}, " + "'total_cost_usd': 0.02}))\n", encoding="utf-8", ) claude.chmod(0o755) @@ -286,6 +290,8 @@ def test_async_supervisor_renders_claude_stream_json_for_visible_terminal( assert "session: sess-123" in out assert "model: claude-opus-4-8" in out assert "visible text from claude" in out + assert "tokens_cache_write: 2" in out + assert "tokens_total: 18" in out assert '"type": "assistant"' not in out transcript_text = transcript.read_text(encoding="utf-8") assert '"type": "assistant"' in transcript_text @@ -295,6 +301,9 @@ def test_async_supervisor_renders_claude_stream_json_for_visible_terminal( meta_payload = json.loads(meta.read_text(encoding="utf-8")) assert meta_payload["agent_session_id"] == "sess-123" assert meta_payload["agent_model"] == "claude-opus-4-8" + assert meta_payload["tokens_cached_input"] == 3 + assert meta_payload["tokens_cache_write"] == 2 + assert meta_payload["tokens_total"] == 18 assert meta_payload["resume_command"].endswith("claude --resume sess-123") @@ -341,6 +350,55 @@ def test_async_supervisor_uses_env_model_for_codex_thread_banner( assert meta_payload["agent_model"] == "gpt-5.3-codex" +def test_async_supervisor_records_requested_model_next_to_reported_model( + tmp_path: Path, monkeypatch, capsys +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / "home")) + monkeypatch.setenv("VIBECRAFTED_AGENT", "gemini") + monkeypatch.setenv("VIBECRAFTED_MODEL_REQUESTED", "gemini-pro") + report = tmp_path / "dispatch-report.md" + transcript = tmp_path / "dispatch.log" + meta = tmp_path / "dispatch.meta.json" + worker = tmp_path / "gemini" + worker.write_text( + "#!/usr/bin/env python3\n" + "import os\n" + "from pathlib import Path\n" + "Path(os.environ['VIBECRAFTED_REPORT_PATH']).write_text(" + "'---\\nstatus: completed\\n---\\nbody\\n', encoding='utf-8'" + ")\n" + "print('model: gemini-real')\n", + encoding="utf-8", + ) + worker.chmod(0o755) + + handle = asyncio.run( + AsyncSupervisor().run( + run_id="asup-gemini-model-requested", + command=[str(worker)], + root=tmp_path, + meta_path=meta, + report_path=report, + transcript_path=transcript, + tee_output=True, + ) + ) + + assert handle.exit_code == 0 + assert handle.agent_model == "gemini-real" + assert handle.model_requested == "gemini-pro" + assert handle.model_override_skipped is True + out = capsys.readouterr().out + assert "model: gemini-real" in out + assert "model_requested: gemini-pro" in out + meta_payload = json.loads(meta.read_text(encoding="utf-8")) + assert meta_payload["agent_model"] == "gemini-real" + assert meta_payload["model_requested"] == "gemini-pro" + assert meta_payload["model_override_supported"] is False + assert meta_payload["model_override_skipped"] is True + assert meta_payload["model_override_skip_reason"] == "unsupported_agent_model_flag" + + def test_async_supervisor_salvages_grok_report_from_streaming_json( tmp_path: Path, monkeypatch, capsys ) -> None: @@ -478,3 +536,52 @@ def test_dispatcher_cli_fails_missing_report_contract( assert payload["artifact_ok"] is False assert payload["artifact_errors"] == ["report_missing"] assert payload["state"] == "report_missing" + + +def test_dispatcher_cli_records_lifecycle_worker_death( + tmp_path: Path, monkeypatch, capsys +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / "home")) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "status": "launching", + "stages": [{"id": "implement", "launch": {"run_id": "disp-death-1"}}], + } + ), + encoding="utf-8", + ) + script = tmp_path / "worker.py" + script.write_text("import sys\nsys.exit(3)\n", encoding="utf-8") + + rc = dispatcher.main( + [ + "run", + "--run-id", + "disp-death-1", + "--root", + str(tmp_path), + "--report", + str(tmp_path / "never-written.md"), + "--transcript", + str(tmp_path / "dispatch.log"), + "--lifecycle-state", + str(state_path), + "--json", + "--", + sys.executable, + str(script), + ] + ) + + assert rc == 3 + # Push-side report-on-death: the death landed in the lifecycle state + # itself, readable by purely passive consumers with no status verb. + reloaded = json.loads(state_path.read_text(encoding="utf-8")) + worker_exit = reloaded["stages"][0]["worker_exit"] + assert worker_exit["exit_code"] == 3 + assert worker_exit["artifact_ok"] is False + assert reloaded["stage_worker_exit"]["stage"] == "implement" + assert reloaded["stage_worker_exit"]["run_id"] == "disp-death-1" + capsys.readouterr() diff --git a/vibecrafted-core/tests/test_workflow.py b/vibecrafted-core/tests/test_workflow.py index 37aa4f35..fa83db6b 100644 --- a/vibecrafted-core/tests/test_workflow.py +++ b/vibecrafted-core/tests/test_workflow.py @@ -141,6 +141,73 @@ def test_launch_workflow_returns_pid_and_logs_spawn( assert "go" not in payload["worker_command"] log_lines = Path(payload["launch_log"]).read_text(encoding="utf-8").splitlines() assert any(json.loads(line).get("event") == "spawned" for line in log_lines) + assert payload["control_plane"] == { + "sync": "deferred", + "run_id": payload["run_id"], + } + + +def test_launch_workflow_never_runs_global_sync_after_spawn( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + source = _source_dir(tmp_path) + spec = workflow.normalize_launch_spec( + {"skill": "workflow", "agent": "claude", "prompt": "go"}, source + ) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [sys.executable, "-c", "pass"], + ) + monkeypatch.setattr( + workflow, + "sync_state", + lambda: pytest.fail("launch acknowledgement must not acquire board-sync lock"), + ) + + payload = workflow.launch_workflow(spec, source) + + assert payload["accepted"] is True + assert payload["control_plane"]["sync"] == "deferred" + + +def test_launch_workflow_records_skipped_model_override_for_unknown_flag( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("VIBECRAFTED_HOME", str(tmp_path / ".vibecrafted")) + source = _source_dir(tmp_path) + spec = workflow.WorkflowLaunchSpec( + agent="agy", + mode="implement", + skill="implement", + prompt="go", + file="", + runtime="headless", + root=str(source), + model="gemini-pro", + ) + monkeypatch.setattr( + workflow, + "_stdin_command", + lambda _agent: [ + sys.executable, + "-c", + "from pathlib import Path; import os; " + "Path(os.environ['VIBECRAFTED_REPORT_PATH']).write_text('ok\\n')", + ], + ) + + payload = workflow.launch_workflow(spec, source) + + assert payload["accepted"] is True + assert ( + payload["model_requested"] == "gemini-pro" + ) # Google family label preserved for agy telemetry + assert payload["model_override_supported"] is False + assert payload["model_override_skipped"] is True + assert payload["model_override_skip_reason"] == "unsupported_agent_model_flag" + assert "gemini-pro" not in payload["worker_command"] def test_launch_workflow_records_failure_event_when_spawn_errors( @@ -277,6 +344,76 @@ def test_build_launch_command_never_delegates_to_legacy_shell_runtime() -> None: assert "vibecrafted_core.workflow_runtime" in marbles +def test_build_launch_command_applies_stage_model_flags_by_runner( + tmp_path: Path, +) -> None: + claude = workflow.build_launch_command( + workflow.WorkflowLaunchSpec( + agent="claude", + mode="implement", + skill="implement", + prompt="x", + file="", + runtime="headless", + root=str(tmp_path), + model="opus", + ), + tmp_path, + prompt_file=tmp_path / "p.md", + ) + assert claude[:3] == ["claude", "--model", "opus"] + + codex = workflow.build_launch_command( + workflow.WorkflowLaunchSpec( + agent="codex", + mode="implement", + skill="implement", + prompt="x", + file="", + runtime="headless", + root=str(tmp_path), + model="gpt-5.5", + ), + tmp_path, + prompt_file=tmp_path / "p.md", + ) + assert codex[:4] == ["codex", "exec", "-m", "gpt-5.5"] + + agy = workflow.build_launch_command( + workflow.WorkflowLaunchSpec( + agent="agy", + mode="implement", + skill="implement", + prompt="x", + file="", + runtime="headless", + root=str(tmp_path), + model="gemini-pro", # model name may be Google family even on agy + ), + tmp_path, + prompt_file=tmp_path / "p.md", + ) + assert "gemini-pro" not in agy + assert "--model" not in agy + assert "-m" not in agy + + marbles = workflow.build_launch_command( + workflow.WorkflowLaunchSpec( + agent="codex", + mode="marbles", + skill="marbles", + prompt="x", + file="", + runtime="headless", + root=str(tmp_path), + model="gpt-5.5", + ), + tmp_path, + prompt_file=tmp_path / "p.md", + ) + assert marbles[marbles.index("--model") + 1] == "gpt-5.5" + + def test_terminal_runtime_launches_worker_in_vc_frame_tab( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -345,6 +482,8 @@ def fake_popen(command: list[str], **kwargs: Any) -> FakeProc: assert script.is_file() script_body = script.read_text(encoding="utf-8") assert "vibecrafted_core.dispatcher" in script_body + assert f"export PYTHONPATH={workflow._core_package_root()}" in script_body + assert "export PYTHONDONTWRITEBYTECODE=1" in script_body assert "--tee-output" in script_body assert "--quiet" in script_body assert "--json" not in script_body @@ -541,7 +680,7 @@ def fake_popen(command: list[str], **kwargs: Any) -> FakeProc: assert 'pane name="synthesis"' in layout_body assert 'pane name="claude"' in layout_body assert 'pane name="codex"' in layout_body - assert 'pane name="gemini"' in layout_body + assert 'pane name="agy"' in layout_body or 'pane name="codex"' in layout_body launch_dir = Path(payload["command_script"]).parent lane_bodies = "\n".join( path.read_text(encoding="utf-8") @@ -549,7 +688,10 @@ def fake_popen(command: list[str], **kwargs: Any) -> FakeProc: ) assert "research-lane --agent claude" in lane_bodies assert "research-lane --agent codex" in lane_bodies - assert "research-lane --agent gemini" in lane_bodies + assert ( + "research-lane --agent agy" in lane_bodies + or "research-lane --agent codex" in lane_bodies + ) assert "export VIBECRAFTED_RUN_ID=" in lane_bodies assert "export VIBECRAFTED_REPORT_PATH=" in lane_bodies assert "export VIBECRAFTED_TRANSCRIPT_PATH=" in lane_bodies @@ -589,7 +731,7 @@ def test_claude_terminal_command_streams_visible_json(tmp_path: Path) -> None: def test_stream_capable_agents_use_native_stream_commands(tmp_path: Path) -> None: expected = { "codex": ("--json",), - "gemini": ("-o", "stream-json"), + "agy": ("bash", "-c"), # agy uses bash -c shim containing agy "junie": ("--output-format", "json-stream"), "grok": ("--output-format", "streaming-json"), } @@ -685,7 +827,8 @@ def test_launch_workflow_artifact_paths_are_terminal_truth( ) assert truth["completed"] is True - assert truth["terminal"] is True + assert truth["terminal_evidence"] is True + assert truth["worker_alive"] is False assert truth["artifact_ok"] is True assert truth["paths_exist"] == { "report": True, @@ -1020,6 +1163,54 @@ def test_research_swarm_uses_core_codex_coordinator( assert "map the surface" not in command +def test_research_agentless_form_uses_runtime_config_swarm(tmp_path: Path) -> None: + spec = workflow.normalize_launch_spec( + {"skill": "research", "prompt": "map the surface", "root": str(tmp_path)}, + tmp_path, + ) + + assert spec.agent == "swarm" + assert spec.research_agents == () + assert spec.research_synthesizer == "" + + +def test_research_single_agent_form_keeps_synthesizer_pick(tmp_path: Path) -> None: + spec = workflow.normalize_launch_spec( + { + "skill": "research", + "agent": ["claude"], + "prompt": "map the surface", + "root": str(tmp_path), + }, + tmp_path, + ) + + command = workflow.build_launch_command(spec, tmp_path) + + assert spec.agent == "swarm" + assert spec.research_agents == () + assert spec.research_synthesizer == "claude" + assert command[command.index("--synthesizer") + 1] == "claude" + + +def test_research_multi_agent_form_overrides_lanes_and_first_synthesizes( + tmp_path: Path, +) -> None: + spec = workflow.normalize_launch_spec( + { + "skill": "research", + "agent": ["codex", "agy"], + "prompt": "map the surface", + "root": str(tmp_path), + }, + tmp_path, + ) + + assert spec.agent == "swarm" + assert spec.research_agents == ("codex", "agy") + assert spec.research_synthesizer == "codex" + + def test_marbles_uses_supervised_core_runtime(tmp_path: Path) -> None: spec = workflow.normalize_launch_spec( { @@ -1067,3 +1258,44 @@ def test_polarize_uses_supervised_marbles_runtime_with_polarize_prompt( assert "--prompt-file" in command assert command[command.index("--count") + 1] == "2" assert command[command.index("--depth") + 1] == "4" + + +def test_runtime_prompt_carries_worker_signal_discipline(tmp_path: Path) -> None: + spec = workflow.WorkflowLaunchSpec( + agent="codex", + mode="workflow", + skill="workflow", + prompt="ship it", + file="", + runtime="headless", + root=str(tmp_path), + ) + + prompt = workflow._runtime_prompt(spec) + + # Gate-nap prevention (docs/runtime/AGENT_OPS.md, Class 1): every dispatched + # worker is told WHY waiting is futile, not merely forbidden from doing it — + # the bare prohibition was broken in the wild. + assert "background-task completions will NEVER wake" in prompt + assert "Never end your turn waiting" in prompt + + +def test_dispatcher_command_carries_lifecycle_state_flag() -> None: + base = dict( + run_id="impl-1", + root="/repo", + meta_path=Path("/tmp/m.json"), + report_path=Path("/tmp/r.md"), + transcript_path=Path("/tmp/t.log"), + worker_command=["codex", "exec"], + ) + + with_state = workflow._dispatcher_command( + **base, lifecycle_state_path="/cp/lifecycle_runs/x/state.json" + ) + assert "--lifecycle-state" in with_state + flag_index = with_state.index("--lifecycle-state") + assert with_state[flag_index + 1] == "/cp/lifecycle_runs/x/state.json" + + without_state = workflow._dispatcher_command(**base) + assert "--lifecycle-state" not in without_state diff --git a/vibecrafted-core/tests/test_workflow_runtime.py b/vibecrafted-core/tests/test_workflow_runtime.py index 20046757..f8cddf33 100644 --- a/vibecrafted-core/tests/test_workflow_runtime.py +++ b/vibecrafted-core/tests/test_workflow_runtime.py @@ -16,6 +16,7 @@ def _fake_agent(bin_dir: Path, name: str) -> None: f"printf '[12:00:00] model: {name}-model\\n'\n" f"printf '[12:00:00] session: {name}-session\\n'\n" "printf '[12:00:01] tokens: 10 in (3 cached) / 5 out\\n'\n" + "printf 'cost_usd: $0.015\\n'\n" "printf 'fake worker ok\\n'\n" 'printf "%s\\n" "---" "status: completed" "---" "report for $0" > "$VIBECRAFTED_REPORT_PATH"\n', encoding="utf-8", @@ -27,7 +28,7 @@ def _runtime_env(monkeypatch, tmp_path: Path, run_id: str) -> Path: home = tmp_path / "home" bin_dir = tmp_path / "bin" bin_dir.mkdir() - for name in ("claude", "codex", "gemini", "agy", "junie", "grok"): + for name in ("claude", "codex", "agy", "junie", "grok"): _fake_agent(bin_dir, name) for name in ( "VIBECRAFTED_ARTIFACT_SLUG", @@ -59,10 +60,10 @@ def test_research_runtime_supervises_three_tracks(monkeypatch, tmp_path: Path) - report = (home / "parent.md").read_text(encoding="utf-8") assert "vc-research supervised run" in report assert "Research Lane Selection" in report - assert "agents: claude, codex, gemini" in report + assert "agents: claude, codex, agy" in report assert "research-claude" in report assert "research-codex" in report - assert "research-gemini" in report + assert "research-agy" in report assert "research-synthesis" in report assert "agent_session_id: claude-session" in report assert "agent_model: claude-model" in report @@ -70,7 +71,7 @@ def test_research_runtime_supervises_three_tracks(monkeypatch, tmp_path: Path) - assert "claude --resume claude-session" in report assert (home / "rsch-test-children" / "research-claude.md").is_file() assert (home / "rsch-test-children" / "research-codex.md").is_file() - assert (home / "rsch-test-children" / "research-gemini.md").is_file() + assert (home / "rsch-test-children" / "research-agy.md").is_file() assert (home / "rsch-test-children" / "research-synthesis.md").is_file() @@ -81,7 +82,7 @@ def test_research_runtime_uses_user_configured_agents( config_dir = tmp_path / "xdg" / "vibecrafted" config_dir.mkdir(parents=True) (config_dir / "config.toml").write_text( - '[runtime.picking.research]\ndefault_agents = ["grok", "codex", "gemini"]\n', + '[runtime.picking.research]\ndefault_agents = ["grok", "codex", "agy"]\n', encoding="utf-8", ) @@ -92,12 +93,130 @@ def test_research_runtime_uses_user_configured_agents( assert rc == 0 report = (home / "parent.md").read_text(encoding="utf-8") meta = (home / "parent.meta.json").read_text(encoding="utf-8") - assert "agents: grok, codex, gemini" in report + assert "agents: grok, codex, agy" in report assert "research-grok" in report assert "research-codex" in report - assert "research-gemini" in report + assert "research-agy" in report assert "research-claude" not in report - assert '"research_agents": [\n "grok",\n "codex",\n "gemini"\n ]' in meta + assert '"research_agents": [\n "grok",\n "codex",\n "agy"\n ]' in meta + + +def test_research_runtime_yaml_wins_over_legacy_toml_and_applies_lane_models( + monkeypatch, tmp_path: Path +) -> None: + home = _runtime_env(monkeypatch, tmp_path, "rsch-yaml") + legacy_dir = tmp_path / "xdg" / "vibecrafted" + legacy_dir.mkdir(parents=True) + (legacy_dir / "config.toml").write_text( + '[runtime.picking.research]\ndefault_agents = ["claude", "agy"]\n', + encoding="utf-8", + ) + config_dir = home / "config" + config_dir.mkdir(parents=True) + (config_dir / "research.yaml").write_text( + "\n".join( + [ + "lanes:", + " - agent: codex", + " model: gpt-yaml", + " enabled: true", + " - agent: agy", + " model: agy-yaml", + " enabled: true", + " - agent: claude", + " enabled: false", + "synthesizer:", + " agent: codex", + " model: gpt-synth", + "", + ] + ), + encoding="utf-8", + ) + + rc = workflow_runtime.main( + ["research", "--root", str(tmp_path), "--prompt", "map it"] + ) + + assert rc == 0 + report = (home / "parent.md").read_text(encoding="utf-8") + meta = json.loads((home / "parent.meta.json").read_text(encoding="utf-8")) + assert f"source: {config_dir / 'research.yaml'}" in report + assert "agents: codex, agy" in report + assert "synthesizer: codex" in report + assert "synthesizer_model: gpt-synth" in report + assert "research-claude" not in report + codex_transcript = ( + home / "rsch-yaml-children" / "research-codex.transcript.log" + ).read_text(encoding="utf-8") + agy_transcript = ( + home / "rsch-yaml-children" / "research-agy.transcript.log" + ).read_text(encoding="utf-8") + assert "exec\n-m\ngpt-yaml\n--json" in codex_transcript + assert "\nagy-yaml\n" not in agy_transcript + children = {child["agent"]: child for child in meta["children"]} + assert children["codex"]["model_requested"] == "gpt-yaml" + assert children["codex"]["model_override_supported"] is True + assert children["agy"]["model_requested"] == "agy-yaml" + assert children["agy"]["model_override_supported"] is False + assert children["agy"]["model_override_skip_reason"] == ( + "unsupported_agent_model_flag" + ) + assert meta["research_synthesizer"] == "codex" + assert meta["research_synthesizer_model"] == "gpt-synth" + + +def test_research_runtime_applies_model_request_per_child_runner( + monkeypatch, tmp_path: Path +) -> None: + home = _runtime_env(monkeypatch, tmp_path, "rsch-models") + config_dir = home / "config" + config_dir.mkdir(parents=True) + (config_dir / "research.yaml").write_text( + "lanes:\n - agent: codex\n model: yaml-codex\n", + encoding="utf-8", + ) + monkeypatch.setenv("VIBECRAFTED_RESEARCH_AGENTS", "claude,codex,agy") + + rc = workflow_runtime.main( + [ + "research", + "--root", + str(tmp_path), + "--prompt", + "map it", + "--model", + "frontier", + ] + ) + + assert rc == 0 + claude_transcript = ( + home / "rsch-models-children" / "research-claude.transcript.log" + ).read_text(encoding="utf-8") + codex_transcript = ( + home / "rsch-models-children" / "research-codex.transcript.log" + ).read_text(encoding="utf-8") + agy_transcript = ( + home / "rsch-models-children" / "research-agy.transcript.log" + ).read_text(encoding="utf-8") + assert "--model\nfrontier\n-p" in claude_transcript + assert "exec\n-m\nfrontier\n--json" in codex_transcript + assert "yaml-codex" not in codex_transcript + assert "\nfrontier\n" not in agy_transcript + + meta = json.loads((home / "parent.meta.json").read_text(encoding="utf-8")) + assert meta["model_requested"] == "frontier" + children = {child["agent"]: child for child in meta["children"]} + assert children["claude"]["model_requested"] == "frontier" + assert children["claude"]["model_override_supported"] is True + assert children["claude"]["model_override_skipped"] is False + assert children["codex"]["model_override_supported"] is True + assert children["agy"]["model_override_supported"] is False + assert children["agy"]["model_override_skipped"] is True + assert ( + children["agy"]["model_override_skip_reason"] == "unsupported_agent_model_flag" + ) def test_research_runtime_writes_canonical_named_lane_artifacts( @@ -136,7 +255,13 @@ def test_research_runtime_env_agents_override_user_config( config_dir = tmp_path / "xdg" / "vibecrafted" config_dir.mkdir(parents=True) (config_dir / "config.toml").write_text( - '[runtime.picking.research]\ndefault_agents = ["claude", "codex", "gemini"]\n', + '[runtime.picking.research]\ndefault_agents = ["claude", "codex", "agy"]\n', + encoding="utf-8", + ) + runtime_config_dir = home / "config" + runtime_config_dir.mkdir(parents=True) + (runtime_config_dir / "research.yaml").write_text( + "lanes:\n - agent: claude\n - agent: agy\n", encoding="utf-8", ) monkeypatch.setenv("VIBECRAFTED_RESEARCH_AGENTS", "grok,codex") @@ -151,7 +276,7 @@ def test_research_runtime_env_agents_override_user_config( assert "agents: grok, codex" in report assert "research-grok" in report assert "research-codex" in report - assert "research-gemini" not in report + assert "research-agy" not in report def test_research_synthesis_waits_for_lane_meta_and_resumes_last_finisher( @@ -302,7 +427,7 @@ def test_research_synthesis_degrades_to_partial_success_on_quorum( config_dir = tmp_path / "xdg" / "vibecrafted" config_dir.mkdir(parents=True) (config_dir / "config.toml").write_text( - '[runtime.picking.research]\ndefault_agents = ["grok", "codex", "gemini"]\n', + '[runtime.picking.research]\ndefault_agents = ["grok", "codex", "agy"]\n', encoding="utf-8", ) child_dir = home / "rsch-partial-children" @@ -334,18 +459,16 @@ def test_research_synthesis_degrades_to_partial_success_on_quorum( encoding="utf-8", ) - failed_report = child_dir / "research-gemini.md" + failed_report = child_dir / "research-agy.md" failed_report.write_text("---\nstatus: failed\n---\n", encoding="utf-8") - (child_dir / "research-gemini.transcript.log").write_text( - "boom\n", encoding="utf-8" - ) - (child_dir / "research-gemini.meta.json").write_text( + (child_dir / "research-agy.transcript.log").write_text("boom\n", encoding="utf-8") + (child_dir / "research-agy.meta.json").write_text( json.dumps( { - "run_id": "rsch-partial-research-gemini", - "agent": "gemini", + "run_id": "rsch-partial-research-agy", + "agent": "agy", "report": str(failed_report), - "transcript": str(child_dir / "research-gemini.transcript.log"), + "transcript": str(child_dir / "research-agy.transcript.log"), "exit_code": 1, "artifact_errors": ["worker_failed"], "completed_at": "2026-06-23T10:02:00+00:00", @@ -361,9 +484,10 @@ def test_research_synthesis_degrades_to_partial_success_on_quorum( assert rc == 0 report = (home / "parent.md").read_text(encoding="utf-8") assert "status: partial_success" in report - # synteza odpaliła z ostatniego ocalałego (codex), gemini odnotowany jako fail + assert "lanes_failed: agy" in report + # synteza odpaliła z ostatniego ocalałego (codex), agy odnotowany jako fail assert "research-synthesis (codex)" in report - assert "research-gemini" in report + assert "research-agy" in report assert "artifact_errors: worker_failed" in report assert (child_dir / "research-synthesis.md").is_file() @@ -382,7 +506,7 @@ def test_research_runtime_tees_child_output( out = capsys.readouterr().out assert "===== research:research-claude:claude =====" in out assert "===== research:research-codex:codex =====" in out - assert "===== research:research-gemini:gemini =====" in out + assert "===== research:research-agy:agy =====" in out assert "===== research:research-synthesis:" in out assert "fake worker ok" in out @@ -413,7 +537,21 @@ def test_marbles_runtime_supervises_loops(monkeypatch, tmp_path: Path) -> None: assert "marbles-L2" in report assert "agent_session_id: codex-session" in report assert "agent_model: codex-model" in report + assert "session_id: aggregated" in report + assert "tokens_total: 36" in report + assert "cost_usd: 0.03" in report + assert "cost_source: children_sum" in report assert "codex resume codex-session" in report + meta = json.loads((home / "parent.meta.json").read_text(encoding="utf-8")) + assert meta["session_id"] == "aggregated" + assert meta["tokens_input"] == 20 + assert meta["tokens_cached_input"] == 6 + assert meta["tokens_output"] == 10 + assert meta["tokens_total"] == 36 + assert meta["cost_usd"] == 0.03 + assert meta["cost_source"] == "children_sum" + assert meta["children"][0]["tokens_total"] == 18 + assert meta["children"][1]["cost_usd"] == 0.015 assert (home / "marb-test-children" / "marbles-L1.md").is_file() assert (home / "marb-test-children" / "marbles-L2.md").is_file() l2_transcript = ( @@ -466,3 +604,14 @@ def test_polarize_runtime_reuses_loop_with_polarize_identity( assert "intentionally blind to prior marbles runs" not in transcript assert "Previous loop report" not in transcript assert "marbles-L1.md" not in transcript + + +def test_child_prompt_carries_worker_signal_discipline() -> None: + prompt = workflow_runtime._child_prompt("marbles", "L1", "/repo", "find gaps") + + # Supervised children (marbles/polarize/research) are subagent-shaped too: + # the gate-nap preamble must ride in their contract, not only in the main + # dispatched-worker prompt. + assert "background-task completions will NEVER wake" in prompt + assert "Never end your turn waiting" in prompt + assert "intentionally blind to prior marbles runs" in prompt diff --git a/vibecrafted-core/tests/test_wrappers.py b/vibecrafted-core/tests/test_wrappers.py index f3c08df1..d394de34 100644 --- a/vibecrafted-core/tests/test_wrappers.py +++ b/vibecrafted-core/tests/test_wrappers.py @@ -86,6 +86,56 @@ def fake_call( ] +def test_print_completed_rejects_live_worker_completion_payload(capsys) -> None: + rc = wrappers._print_completed( + "impl-live", + { + "completed": True, + "reason": "report_delivered", + "worker_alive": True, + "run": { + "state": "running", + "exit_code": None, + "artifact_ok": True, + "latest_report": "/tmp/report.md", + }, + }, + ) + + assert rc == 3 + assert "non-terminal completion disagreement" in capsys.readouterr().err + + +@pytest.mark.parametrize("exit_code, expected", [(7, 7), (0, 3), (None, 3)]) +def test_print_completed_rejects_failed_delivered_artifact( + exit_code: int | None, expected: int +) -> None: + rc = wrappers._print_completed( + "impl-failed", + { + "completed": True, + "reason": "report_delivered", + "worker_alive": False, + "run": { + "state": "failed", + "exit_code": exit_code, + "artifact_ok": False, + "artifact_errors": ["report_missing"], + }, + }, + ) + + assert rc == expected + + +def test_print_completed_sends_missing_payload_error_to_stderr(capsys) -> None: + assert wrappers._print_completed("impl-missing", {}) == 3 + + captured = capsys.readouterr() + assert captured.out == "" + assert "completed without control-plane payload" in captured.err + + def test_resume_main_routes_captured_session_through_vc_frame_aware_resume_helper( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/vibecrafted-core/vibecrafted_core/VERSION b/vibecrafted-core/vibecrafted_core/VERSION new file mode 100644 index 00000000..1545d966 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/VERSION @@ -0,0 +1 @@ +3.5.0 diff --git a/vibecrafted-core/vibecrafted_core/__init__.py b/vibecrafted-core/vibecrafted_core/__init__.py index 38cf55f4..219f8eb4 100644 --- a/vibecrafted-core/vibecrafted_core/__init__.py +++ b/vibecrafted-core/vibecrafted_core/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import importlib.metadata +from pathlib import Path from typing import Any from .control_plane import ( @@ -48,7 +50,18 @@ ) from .supervisor_async import AsyncRunHandle, AsyncSupervisor -__version__ = "3.3.0" + +def _resolve_installed_version() -> str: + packaged_version = read_version_file(Path(__file__).resolve().parent) + if packaged_version != "unknown": + return packaged_version + try: + return importlib.metadata.version("vibecrafted") + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +__version__ = _resolve_installed_version() _LAZY_EXPORTS = { "WorkflowLaunchSpec": ".workflow", diff --git a/vibecrafted-core/vibecrafted_core/agent_dispatch.py b/vibecrafted-core/vibecrafted_core/agent_dispatch.py index 6dd91edd..c50cce25 100644 --- a/vibecrafted-core/vibecrafted_core/agent_dispatch.py +++ b/vibecrafted-core/vibecrafted_core/agent_dispatch.py @@ -25,6 +25,7 @@ import os import re import sys +from dataclasses import dataclass from typing import Optional __all__ = [ @@ -37,9 +38,19 @@ "sandbox_supported", "require_parity", "ParityError", + "DispatchGranularity", + "dispatch_granularity", ] +@dataclass(frozen=True) +class DispatchGranularity: + shape: str + max_files_per_cut: int + max_parallel_cuts: int + rationale: str + + class ParityError(RuntimeError): """Raised when a parity gate rejects a dispatch without an override.""" @@ -66,13 +77,12 @@ class ParityError(RuntimeError): re.IGNORECASE, ), ) - for agent in ("claude", "codex", "gemini", "agy", "junie", "grok") + for agent in ("claude", "codex", "agy", "junie", "grok") } _SANDBOX_SUPPORTED_AGENTS = { "claude", "codex", - "gemini", "agy", "junie", "grok", @@ -139,6 +149,10 @@ def normalize_model(raw: str) -> tuple[str, bool]: return "spark", True if "gpt-5.3" in lower or "gpt-5-3" in lower: return "gpt-5.3", True + if "gpt-5.6" in lower or "gpt-5-6" in lower: + return "gpt-5.6", True + if "gpt-5.5" in lower or "gpt-5-5" in lower: + return "gpt-5.5", True if "gpt-5" in lower: return "gpt-5", True if "gpt-4" in lower: @@ -157,6 +171,8 @@ def normalize_model(raw: str) -> tuple[str, bool]: return "gemini-3-flash", True if "gemini" in lower: return "gemini", True + if "grok-build" in lower or "grok-code-fast" in lower: + return "grok-build", True return lower, False @@ -168,6 +184,8 @@ def normalize_model(raw: str) -> tuple[str, bool]: "haiku": 100, # Codex "gpt-5.3": 530, + "gpt-5.6": 560, + "gpt-5.5": 550, "gpt-5": 500, "spark": 450, "gpt-4": 400, @@ -176,6 +194,7 @@ def normalize_model(raw: str) -> tuple[str, bool]: "gemini-3-auto": 720, "gemini-3-flash": 710, "gemini": 700, + "grok-build": 600, } _TIER_FAMILY: dict[str, str] = { @@ -190,9 +209,41 @@ def normalize_model(raw: str) -> tuple[str, bool]: "gemini-3-auto": "gemini", "gemini-3-flash": "gemini", "gemini": "gemini", + "grok-build": "xai", } +def dispatch_granularity(model: str) -> DispatchGranularity: + """Choose cut size from measured model capability/cost, conservatively. + + Frontier models get fewer, coherent cuts to avoid repeatedly paying for the + same large context. Economy and unknown models get smaller sequential cuts; + this never weakens the existing model-parity floor. + """ + tier, recognized = normalize_model(model) + if not recognized: + return DispatchGranularity( + "surgical", 1, 1, "unknown model: smallest sequential proof surface" + ) + if tier in {"opus", "gpt-5.6", "gpt-5.5", "gpt-5.3", "gemini-3-pro", "grok-build"}: + return DispatchGranularity( + "coherent", + 8, + 3, + "frontier model: amortize repeated context across coherent cuts", + ) + if tier in {"sonnet", "gpt-5", "gemini-3-auto", "gemini"}: + return DispatchGranularity( + "bounded", + 4, + 2, + "standard model: bounded cuts with explicit integration seams", + ) + return DispatchGranularity( + "surgical", 2, 1, "economy model: small sequential cuts with tight verification" + ) + + def tier_rank(token: str) -> int: """Numeric rank within a family — higher is stronger. Unknown -> 0.""" return _TIER_RANK.get(token, 0) diff --git a/vibecrafted-core/vibecrafted_core/agent_stream.py b/vibecrafted-core/vibecrafted_core/agent_stream.py index 7c87779c..fb2867c2 100644 --- a/vibecrafted-core/vibecrafted_core/agent_stream.py +++ b/vibecrafted-core/vibecrafted_core/agent_stream.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Any, Mapping, Sequence +from .telemetry import estimate_cost_usd + ANSI_PATTERN = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") SESSION_PATTERN = re.compile( r"(?:^|\[[0-9]{2}:[0-9]{2}:[0-9]{2}\]\s+)session:\s*([A-Za-z0-9][A-Za-z0-9._:-]*)", @@ -162,8 +164,10 @@ def __init__(self, agent: str, *, default_model: str = "") -> None: self.model_id = _clean_model(default_model) self.tokens_input = 0 self.tokens_cached_input = 0 + self.tokens_cache_write: int | None = None self.tokens_output = 0 self.cost_usd: float | None = None + self.cost_source: str | None = None def feed_line(self, chunk: bytes) -> str: text = chunk.decode("utf-8", errors="replace") @@ -217,6 +221,8 @@ def _scan_text(self, text: str) -> None: self.cost_usd = _as_float(matches[-1]) def _record_model(self, event: dict[str, Any]) -> None: + if self.model_id: + return for key in ("model", "model_id", "modelId", "model_name", "modelName"): value = event.get(key) if isinstance(value, str) and value.strip(): @@ -229,15 +235,51 @@ def _record_model(self, event: dict[str, Any]) -> None: if isinstance(value, str) and value.strip(): self.model_id = value.strip() return + model_usage = event.get("modelUsage") or event.get("model_usage") + if isinstance(model_usage, dict) and model_usage: + self.model_id = str(next(iter(model_usage))) + return + if isinstance(model_usage, list): + for item in model_usage: + if isinstance(item, dict): + value = item.get("model") or item.get("model_id") + if isinstance(value, str) and value.strip(): + self.model_id = value.strip() + return + for value in event.values(): + if isinstance(value, dict): + self._record_model(value) + if self.model_id: + return def _record_usage(self, usage: dict[str, Any]) -> None: - self.tokens_input += _as_int(usage.get("input_tokens")) - self.tokens_cached_input += _as_int( - usage.get("cached_input_tokens") - or usage.get("cache_read_input_tokens") - or usage.get("cache_creation_input_tokens") + self.tokens_input += _as_int( + usage.get("input_tokens") + or usage.get("inputTokens") + or usage.get("prompt_tokens") + ) + cached_input = usage.get("cached_input_tokens") + if cached_input is None: + cached_input = usage.get("cache_read_input_tokens") + if cached_input is None: + cached_input = usage.get("cacheReadInputTokens") + if cached_input is None: + cached_input = usage.get("cacheInputTokens") + if cached_input is None: + cached_input = usage.get("cached_prompt_tokens") + self.tokens_cached_input += _as_int(cached_input) + cache_write = usage.get("cache_creation_input_tokens") + if cache_write is None: + cache_write = usage.get("cacheCreateTokens") + if cache_write is not None: + self.tokens_cache_write = (self.tokens_cache_write or 0) + _as_int( + cache_write + ) + self.tokens_output += _as_int( + usage.get("output_tokens") + or usage.get("outputTokens") + or usage.get("completion_tokens") ) - self.tokens_output += _as_int(usage.get("output_tokens")) def _record_cost(self, event: dict[str, Any]) -> None: cost = _as_float( @@ -248,6 +290,36 @@ def _record_cost(self, event: dict[str, Any]) -> None: ) if cost is not None: self.cost_usd = cost + self.cost_source = "provider_reported" + + def _record_nested_telemetry(self, event: dict[str, Any]) -> None: + self._record_model(event) + pending: list[dict[str, Any]] = [event] + while pending: + current = pending.pop() + model_usage = current.get("modelUsage") or current.get("model_usage") + if isinstance(model_usage, list): + for usage in model_usage: + if isinstance(usage, dict): + self._record_usage(usage) + cost = _as_float(usage.get("cost") or usage.get("cost_usd")) + if cost is not None: + self.cost_usd = round((self.cost_usd or 0) + cost, 6) + self.cost_source = "provider_reported" + usage = current.get("usage") or current.get("stats") + if isinstance(usage, dict): + self._record_usage(usage) + self._record_cost(current) + for value in current.values(): + if isinstance(value, dict): + pending.append(value) + if self.cost_usd is None or (self.cost_source or "").startswith("estimated:"): + self.cost_usd, self.cost_source = estimate_cost_usd( + self.model_id, + tokens_input=self.tokens_input, + tokens_cached_input=self.tokens_cached_input, + tokens_output=self.tokens_output, + ) def _session_banner(self, session_id: str, suffix: str = "") -> str: if not session_id or session_id == "?": @@ -455,7 +527,7 @@ def _format_gemini_event(self, event: dict[str, Any]) -> str: return "" def _format_junie_event(self, event: dict[str, Any]) -> str: - self._record_model(event) + self._record_nested_telemetry(event) for key in ("session_id", "sessionId"): value = event.get(key) if isinstance(value, str) and value: @@ -480,16 +552,12 @@ def _format_junie_event(self, event: dict[str, Any]) -> str: return text + "\n" def _format_grok_event(self, event: dict[str, Any]) -> str: - self._record_model(event) + self._record_nested_telemetry(event) for key in ("session_id", "sessionId", "conversation_id"): value = event.get(key) if isinstance(value, str) and value: self.session_id = value break - usage = event.get("usage") or event.get("stats") - if isinstance(usage, dict): - self._record_usage(usage) - self._record_cost(event) event_type = str(event.get("type") or "") if event_type == "end": value = event.get("sessionId") or event.get("session_id") diff --git a/vibecrafted-core/vibecrafted_core/cli.py b/vibecrafted-core/vibecrafted_core/cli.py index 39c1d8eb..23afdfdc 100644 --- a/vibecrafted-core/vibecrafted_core/cli.py +++ b/vibecrafted-core/vibecrafted_core/cli.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -import datetime as dt import json import os import shutil @@ -13,11 +12,17 @@ from . import doctor as doctor_module from .agent_stream import ANSI_PATTERN, AgentStreamParser, resolve_default_model -from .control_plane import RunNotResolved, lookup_run, resolve_run, sync_state +from .control_plane import ( + RunNotResolved, + await_run, + lookup_run, + resolve_run, + sync_state, +) from .package_resources import deck_path, package_root from .workflow import await_launch_truth, launch_workflow, normalize_launch_spec -AGENTS = {"claude", "codex", "gemini", "agy", "junie", "grok", "swarm"} +AGENTS = {"claude", "codex", "agy", "junie", "grok", "swarm"} LAUNCHERS = ( "audit", "decorate", @@ -43,6 +48,20 @@ LAUNCH_ALIASES = { "justdo": "implement", } +# These installed names are symlinks to the ``vibecrafted`` Python entrypoint, +# but their behavior is still owned by the shell deck. Preserve the invoked +# name as an explicit deck verb instead of silently treating the first user +# argument as the command. +SHELL_WRAPPER_VERBS = { + "telemetry": "telemetry", + "vc-dashboard": "dashboard", + "vc-dispatch": "dispatch", + "vc-help": "help", + "vc-init": "init", + "vc-justdo": "justdo", + "vc-resume": "resume", + "vc-start": "start", +} SUCCESS_STATES = {"report_validated", "completed", "closed"} TERMINAL_STATES = { "blocked", @@ -61,7 +80,10 @@ def _add_launch_parser(sub: argparse._SubParsersAction, name: str) -> None: run = sub.add_parser(name, help=f"launch vc-{name} through core runtime") - run.add_argument("agent", nargs="?") + if name == "research": + run.add_argument("agent", nargs="*") + else: + run.add_argument("agent", nargs="?") if name == "paste": run.add_argument("--skill", default="workflow") run.add_argument("--root", default="") @@ -76,6 +98,10 @@ def _add_launch_parser(sub: argparse._SubParsersAction, name: str) -> None: run.add_argument("--mode", default="") run.add_argument("--count", type=int) run.add_argument("--depth", type=int) + run.add_argument("--model", default="") + if name == "research": + run.add_argument("--synthesizer", default="") + run.add_argument("--synthesizer-model", default="") run.add_argument("--source-dir", default="") run.add_argument("--json", action="store_true") @@ -203,26 +229,6 @@ def _tail_lines( return [_clip_line(line) for line in tail], "" -def _parse_iso(raw: object) -> dt.datetime | None: - text = str(raw or "").strip() - if not text: - return None - try: - parsed = dt.datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - return parsed.replace(tzinfo=dt.timezone.utc) - return parsed - - -def _run_age_seconds(run: dict[str, Any]) -> float | None: - timestamp = _parse_iso(run.get("heartbeat_at") or run.get("updated_at")) - if timestamp is None: - return None - return (dt.datetime.now(dt.timezone.utc) - timestamp).total_seconds() - - def _run_succeeded(run: dict[str, Any]) -> bool: state = str(run.get("state") or "") errors = [str(item) for item in (run.get("artifact_errors") or []) if str(item)] @@ -239,15 +245,6 @@ def _run_terminal(run: dict[str, Any]) -> bool: return run.get("exit_code") is not None -def _run_dead_or_stale(run: dict[str, Any], stale_after_seconds: float) -> bool: - liveness = str(run.get("liveness") or "") - state = str(run.get("state") or "") - if liveness not in {"pid_gone", "missing_signal_target"} and state != "stalled": - return False - age = _run_age_seconds(run) - return age is None or age >= stale_after_seconds - - def _print_launch_receipt(payload: dict[str, Any]) -> None: run_id = _field(payload, "run_id") agent = _field(payload, "agent") @@ -262,7 +259,9 @@ def _print_launch_receipt(payload: dict[str, Any]) -> None: print(f"report: {_field(payload, 'report')}") print(f"transcript: {_field(payload, 'transcript')}") print(f"observe: vibecrafted {agent} observe --run-id {run_id}") - print(f"await: vibecrafted {agent} await --run-id {run_id}") + print( + f"await (ARM NOW, supervisor-side): vibecrafted {agent} await --run-id {run_id}" + ) print("=====================================================================") @@ -411,13 +410,29 @@ def _observe_resolved(run_id: str, *, json_output: bool) -> int: def _agent_await(agent: str, argv: Sequence[str]) -> int: + # ONE await loop lives in control_plane.await_run — this verb must never + # grow a private wall-clock loop again. The old inline loop here treated + # --timeout as an absolute deadline and abandoned demonstrably-working + # runs at 300s, which taught supervising agents to distrust await and + # hedge with manual sleep/ps monitors. parser = argparse.ArgumentParser(prog=f"vibecrafted {agent} await") parser.add_argument("--run-id", default="") parser.add_argument("--last", action="store_true") - parser.add_argument("--timeout", type=float, default=300) + parser.add_argument( + "--timeout", + type=float, + default=300, + help="idle window in seconds — resets on movement or a live worker", + ) parser.add_argument("--interval", type=float, default=5) parser.add_argument("--status-interval", type=float, default=60) - parser.add_argument("--stale-after", type=float, default=600) + parser.add_argument( + "--stale-after", + type=float, + default=600, + help="deprecated: superseded by the liveness-aware idle window", + ) + parser.add_argument("--hard-cap", type=float, default=None) parser.add_argument("--json", action="store_true") args = parser.parse_args(list(argv)) run = _run_for_agent(agent, args.run_id, last=args.last) @@ -430,61 +445,78 @@ def _agent_await(agent: str, argv: Sequence[str]) -> int: run_id, timeout_seconds=args.timeout, interval_seconds=args.interval, + hard_cap_seconds=args.hard_cap, ) print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) - return 0 if result.get("completed") and result.get("artifact_ok") else 1 + return ( + 0 + if result.get("completed") + and result.get("artifact_ok") + and result.get("terminal_evidence") + else 1 + ) print("await: initial status") _print_run_status(run) - last_status_printed_at = time.monotonic() - timeout = max(float(args.timeout), 0.0) interval = max(float(args.interval), 0.1) status_interval = max(float(args.status_interval), interval) - stale_after = max(float(args.stale_after), 0.0) - deadline = time.monotonic() + timeout - next_status = time.monotonic() + status_interval - - while True: - run = lookup_run(run_id) - if run is None: - print(f"await: run disappeared: {run_id}", file=sys.stderr) - return 1 - if _run_succeeded(run): - print("await: completed") - if time.monotonic() - last_status_printed_at > 1: - _print_run_status(run) - return 0 - if _run_terminal(run): - print("await: terminal failure") - if time.monotonic() - last_status_printed_at > 1: - _print_run_status(run) - return 1 - if _run_dead_or_stale(run, stale_after): - print( - f"await: worker dead or stale for >= {int(stale_after)}s", - ) - if time.monotonic() - last_status_printed_at > 1: - _print_run_status(run) - return 1 + next_status = {"at": time.monotonic() + status_interval} + def _print_progress(current: dict[str, Any] | None) -> None: now = time.monotonic() - if now >= deadline: - print("await: timed out") - if time.monotonic() - last_status_printed_at > 1: - _print_run_status(run) - return 1 - if now >= next_status: + if current is not None and now >= next_status["at"]: print("await: still running") - _print_run_status(run) - last_status_printed_at = now - next_status = now + status_interval - time.sleep(min(interval, max(deadline - now, 0.0))) + _print_run_status(current) + next_status["at"] = now + status_interval + + result = await_run( + run_id, + timeout_seconds=args.timeout, + interval_seconds=interval, + hard_cap_seconds=args.hard_cap, + on_poll=_print_progress, + ) + final_run = dict(result.get("run") or {}) + reason = str(result.get("reason") or "") + if result.get("completed"): + worker_alive = bool(result.get("worker_alive")) + terminal_evidence = bool( + final_run and _run_terminal(final_run) and not worker_alive + ) + delivered_evidence = reason == "report_delivered" and not worker_alive + if terminal_evidence and final_run and not _run_succeeded(final_run): + print(f"await: terminal failure ({reason})") + _print_run_status(final_run) + return 1 + if not (terminal_evidence or delivered_evidence): + print( + f"await: non-terminal completion disagreement ({reason})", + file=sys.stderr, + ) + if final_run: + _print_run_status(final_run) + return 3 + print(f"await: completed ({reason})") + if final_run: + _print_run_status(final_run) + return 0 + if not result.get("found"): + print(f"await: run disappeared: {run_id}", file=sys.stderr) + return 1 + print(f"await: timed out ({reason})") + if final_run: + _print_run_status(final_run) + return 1 def main(argv: Sequence[str] | None = None) -> int: - raw_args = _normalize_raw_args(list(sys.argv[1:] if argv is None else argv)) + raw_args = list(sys.argv[1:] if argv is None else argv) invoked_as = Path(sys.argv[0]).name if argv is None else "vibecrafted" + shell_wrapper_verb = SHELL_WRAPPER_VERBS.get(invoked_as) if argv is None else None + if shell_wrapper_verb: + raw_args = [shell_wrapper_verb, *raw_args] + raw_args = _normalize_raw_args(raw_args) # `--version` / `-v` / `version` report the INSTALLED runtime version — the # one `vibecrafted start` / `vc-start` actually runs — read straight from the @@ -500,8 +532,8 @@ def main(argv: Sequence[str] | None = None) -> int: python_commands = {"dispatch", "doctor", "paste", "stop"} | set(LAUNCHERS) agent_python_verbs = {"observe", "await", "stop"} - is_lifecycle = False - if raw_args: + is_lifecycle = shell_wrapper_verb is not None + if raw_args and shell_wrapper_verb is None: first = raw_args[0] second = raw_args[1] if len(raw_args) > 1 else "" if first in AGENTS and second in agent_python_verbs: @@ -523,6 +555,12 @@ def main(argv: Sequence[str] | None = None) -> int: if deck.is_file(): res = subprocess.run([str(deck), *raw_args]) return res.returncode + if shell_wrapper_verb is not None: + print( + f"error: {invoked_as} cannot find the runtime deck at {deck}", + file=sys.stderr, + ) + return 1 if raw_args and raw_args[0] == "dispatch": from .dispatch.cli import main as dispatch_main @@ -567,6 +605,10 @@ def main(argv: Sequence[str] | None = None) -> int: return run_namespace(args, source_dir=package_root()) source_dir = args.source_dir or package_root() + agent_arg = args.agent + research_agents = () + if args.command == "research" and isinstance(agent_arg, list): + research_agents = tuple(agent_arg) if len(agent_arg) > 1 else () payload = { "skill": LAUNCH_ALIASES.get(args.command, args.command), "agent": args.agent, @@ -577,6 +619,10 @@ def main(argv: Sequence[str] | None = None) -> int: "mode": args.mode or args.command, "count": args.count, "depth": args.depth, + "model": args.model, + "research_agents": research_agents, + "synthesizer": getattr(args, "synthesizer", ""), + "synthesizer_model": getattr(args, "synthesizer_model", ""), } try: spec = normalize_launch_spec(payload, source_dir) diff --git a/vibecrafted-core/vibecrafted_core/control_plane.py b/vibecrafted-core/vibecrafted_core/control_plane.py index 6608d308..97fb9641 100644 --- a/vibecrafted-core/vibecrafted_core/control_plane.py +++ b/vibecrafted-core/vibecrafted_core/control_plane.py @@ -11,7 +11,7 @@ import uuid from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Iterator +from typing import Any, Callable, Iterator from .runtime_paths import vibecrafted_home @@ -142,12 +142,70 @@ def _sync_lock_path() -> Path: return control_plane_home() / ".sync.lock" +class ControlPlaneLockBusy(RuntimeError): + """The shared control-plane lock could not be acquired inside the budget. + + Raised instead of blocking forever. A single global ``flock(LOCK_EX)`` with + no timeout was the root cause of the "empty dispatcher, 2-minute wait, false + stalled/pid_gone" migraine: under many concurrent runs every mutation queued + on one mutex and a lock-starved-but-live worker could not even record that it + was alive. Failing loud here turns an invisible infinite hang into a + diagnosable error the operator can act on. + """ + + +_SYNC_LOCK_POLL_SECONDS = 0.05 +_DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 15.0 + + +def _sync_lock_timeout_seconds() -> float: + raw = os.environ.get("VIBECRAFTED_SYNC_LOCK_TIMEOUT_S") + if raw: + try: + return max(float(raw), 0.0) + except ValueError: + pass + return _DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS + + @contextlib.contextmanager -def _sync_lock() -> Iterator[None]: +def _sync_lock( + *, timeout: float | None = None, purpose: str = "sync" +) -> Iterator[None]: + """Bounded, non-blocking exclusive lock over the shared control-plane files. + + Never blocks indefinitely: acquires with ``LOCK_NB`` in a short poll loop up + to ``timeout`` seconds, then raises :class:`ControlPlaneLockBusy`. + + DOCTRINE (enforced by ``tests/test_control_plane_lock_doctrine.py`` — do not + weaken): this lock guards ONLY the full (unscoped) board rebuild in + ``sync_state``. It must NEVER wrap a per-run path or the append/emit path. + Per-run snapshots are single-writer atomic (``_write_json`` = tmp + + ``os.replace``) and event appends are atomic ``O_APPEND`` writes; neither + needs this mutex. Re-serializing them "for safety" is the exact regression + that caused the 2026-07-12 flock migraine (empty dispatchers, false + stalled/pid_gone). If a change wants the lock on a hot path, the change is + wrong. + """ control_plane_home().mkdir(parents=True, exist_ok=True) lock_path = _sync_lock_path() + budget = _sync_lock_timeout_seconds() if timeout is None else max(timeout, 0.0) + deadline = time.monotonic() + budget with lock_path.open("a+", encoding="utf-8") as handle: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + while True: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError as exc: + if exc.errno not in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK): + raise + if time.monotonic() >= deadline: + raise ControlPlaneLockBusy( + f"control-plane {purpose} lock busy for > {budget:.0f}s " + f"({lock_path}). Another process is holding it — look for " + f"a stuck run instead of waiting on a silent hang." + ) from exc + time.sleep(_SYNC_LOCK_POLL_SECONDS) try: yield finally: @@ -411,24 +469,25 @@ def _heartbeat_age_seconds(run: dict[str, Any], now: dt.datetime) -> float | Non def _reconcile_dead_launcher(run: dict[str, Any]) -> dict[str, Any]: result = dict(run) state = str(result.get("state") or "") - if state in FINAL_STATES: - return result - if _has_success_evidence(result): - return _reconcile_successful_terminal(result) - if _run_is_terminal(result): - return result - if state not in ACTIVE_STATES - {"paused"}: - return result launcher_pid = _coerce_int(result.get("launcher_pid")) liveness = str(result.get("liveness") or "") + if state in FINAL_STATES: + return result # P0: a dead/absent launcher pid is NOT proof the run died. The launcher is an # ephemeral spawn-shell that exits right after forking the detached dispatcher; # for headless/detached dispatch it is gone within seconds while the dispatcher # and worker keep running and DELIVER. If the worker (or its process group) is - # still alive, the run is live regardless of the launcher pid — reconciling it - # to stalled/recovery here false-blocks live, delivering runs. + # still alive, the run is live regardless of the launcher pid, exit_code, or + # stale terminal-looking metadata — reconciling it to success/stalled/recovery + # here makes await capable of rc=0 on a live worker. if _worker_is_alive(result): return result + if _has_success_evidence(result): + return _reconcile_successful_terminal(result) + if _run_is_terminal(result): + return result + if state not in ACTIVE_STATES - {"paused"}: + return result if liveness == "pid_alive": if launcher_pid is None or _pid_is_alive(launcher_pid): return result @@ -621,10 +680,24 @@ def _skill_from_code(skill_code: str) -> str: def _append_event(event: dict[str, Any]) -> None: + """Append one event line to the stream — atomically, WITHOUT the sync lock. + + Appending to events.jsonl is the hottest control-plane path (every spawn / + emit / stop of every run). It must never serialize on the global lock: a + single ``O_APPEND`` ``os.write`` of one already-encoded line is atomic on a + local filesystem (the kernel appends at the current end without a + read-modify-write), so concurrent writers interleave cleanly by whole lines. + This is the last piece of the flock migraine — the emit path used to take the + global lock and, under a herd, raise ControlPlaneLockBusy after the timeout. + """ stream_path = event_stream_path() stream_path.parent.mkdir(parents=True, exist_ok=True) - with stream_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(event, ensure_ascii=False) + "\n") + line = (json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8") + fd = os.open(stream_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) + try: + os.write(fd, line) + finally: + os.close(fd) def _iter_meta_files() -> Iterator[Path]: @@ -778,11 +851,16 @@ def _normalize_marbles_state(path: Path) -> RunStatus | None: ) -def _merge_event_stream(merged: dict[str, RunStatus]) -> dict[str, RunStatus]: +def _merge_event_stream( + merged: dict[str, RunStatus], *, only_run_id: str | None = None +) -> dict[str, RunStatus]: stream = event_stream_path() if not stream.exists(): return merged + scope = str(only_run_id or "").strip() + child_prefix = f"{scope}-" if scope else "" + for line in _read_lines(stream): try: event = json.loads(line) @@ -791,6 +869,12 @@ def _merge_event_stream(merged: dict[str, RunStatus]) -> dict[str, RunStatus]: run_id = str(event.get("run_id") or "").strip() if not run_id: continue + # Scoped projection: process only the target run and its child rounds + # (marbles/polarize L2/L3 live in "-…-L" records). Skipping + # every other run's events is what keeps a per-run await/lookup off the + # O(all-runs) board rebuild that caused the lock herd. + if scope and run_id != scope and not run_id.startswith(child_prefix): + continue payload = dict(event.get("payload") or {}) kind = str(event.get("kind") or "") @@ -1217,8 +1301,9 @@ def record_stop_transition( ), "payload": payload, } - with _sync_lock(): - _append_event(event) + # Lockless atomic append (see _append_event) — the stop path never blocks + # on the shared mutex. + _append_event(event) return event @@ -1300,50 +1385,91 @@ def subscribe_events( time.sleep(1.0) -def sync_state() -> dict[str, Any]: - with _sync_lock(): +def _project_run_payload( + run_id: str, status: RunStatus, previous: dict[str, Any] | None +) -> dict[str, Any]: + """Project one run's status to its snapshot payload (single-run, lockless).""" + payload = _artifact_projection(_status_to_payload(status), previous) + payload = _reconcile_dead_launcher(payload) + payload["failure_card"] = _failure_card(payload) + payload["health"] = _state_health( + str(payload.get("state") or ""), str(payload.get("updated_at") or "") + ) + if not payload.get("liveness"): + payload["liveness"] = "terminal" if _run_is_terminal(payload) else "heartbeat" + payload["lifecycle"] = _lifecycle_controls(payload) + return payload + + +def sync_state(only_run_id: str | None = None) -> dict[str, Any]: + """Project on-disk run artifacts into control-plane snapshots. + + ``only_run_id`` scopes the whole pass to one run and its child rounds. The + scoped path is the hot path (``lookup_run`` / ``await_run`` poll it every few + seconds): it takes NO global lock, skips the ``artifacts/`` meta rglob, and + writes only the target run's snapshot atomically. That removes the O(runs²) + lock herd where every per-run poll used to rebuild the whole board under one + exclusive ``flock``. The full board rebuild (``only_run_id is None``) stays + behind the bounded ``_sync_lock`` and is used by dashboards/status-all. + """ + scope = str(only_run_id or "").strip() + scoped = bool(scope) + child_prefix = f"{scope}-" if scope else "" + + def _in_scope(run_id: str) -> bool: + return not scoped or run_id == scope or run_id.startswith(child_prefix) + + lock_ctx = contextlib.nullcontext() if scoped else _sync_lock(purpose="board-sync") + with lock_ctx: previous_snapshots = _load_existing_snapshots() merged: dict[str, RunStatus] = {} + # The migraine was the exclusive GLOBAL LOCK, not the file walk: every + # per-run poll serialised on one flock while rebuilding the whole board. + # Scoped mode keeps the same on-disk reads (so a run seeded only via its + # meta/lock is still resolved) but folds ONLY the target + child rounds, + # holds no lock, and writes only their snapshots — so concurrent per-run + # polls run in parallel instead of queueing. for path in _iter_meta_files(): status = _normalize_agent_meta(path) - if status is None: + if status is None or not _in_scope(status.run_id): continue merged[status.run_id] = _merge_status(merged.get(status.run_id), status) for path in _iter_lock_files(): status = _normalize_lock(path) - if status is None: + if status is None or not _in_scope(status.run_id): continue merged[status.run_id] = _merge_status(merged.get(status.run_id), status) for path in _iter_marbles_state_files(): status = _normalize_marbles_state(path) - if status is None: + if status is None or not _in_scope(status.run_id): continue merged[status.run_id] = _merge_status(merged.get(status.run_id), status) - merged = _merge_event_stream(merged) + merged = _merge_event_stream(merged, only_run_id=scope if scoped else None) payload_runs = [] run_snapshot_dir().mkdir(parents=True, exist_ok=True) for run_id, status in merged.items(): + if not _in_scope(run_id): + continue previous = previous_snapshots.get(run_id) - payload = _artifact_projection(_status_to_payload(status), previous) - payload = _reconcile_dead_launcher(payload) - payload["failure_card"] = _failure_card(payload) - payload["health"] = _state_health( - str(payload.get("state") or ""), str(payload.get("updated_at") or "") - ) - if not payload.get("liveness"): - payload["liveness"] = ( - "terminal" if _run_is_terminal(payload) else "heartbeat" - ) - payload["lifecycle"] = _lifecycle_controls(payload) + payload = _project_run_payload(run_id, status, previous) _record_transition(previous, payload) _write_json(_snapshot_path(run_id), payload) payload_runs.append(payload) - _archive_expired_snapshots() + if not scoped: + _archive_expired_snapshots() + + # A scoped pass only re-projects runs that had fresh events; quiet target/ + # child runs keep their last snapshot, which _select_run reads directly. + if scoped: + seen = {str(run.get("run_id") or "") for run in payload_runs} + for run_id, prev in previous_snapshots.items(): + if _in_scope(run_id) and run_id not in seen: + payload_runs.append(prev) payload_runs.sort( key=lambda item: ( @@ -1369,8 +1495,14 @@ def sync_state() -> dict[str, Any]: def lookup_run(run_id: str) -> dict[str, Any] | None: - """Return the current control-plane projection for one run id.""" - snapshot = sync_state() + """Return the current control-plane projection for one run id. + + Lockless single-run path: never triggers the global board rebuild, so a + per-run lookup no longer serialises behind every other run on the shared + control-plane lock. + """ + target = str(run_id or "").strip() + snapshot = sync_state(only_run_id=target or None) return _select_run(snapshot, run_id) @@ -1495,6 +1627,33 @@ def _await_progress_fingerprint(run: dict[str, Any] | None) -> tuple[Any, ...]: ) +def _await_child_runs(snapshot: dict[str, Any], target: str) -> list[dict[str, Any]]: + """Child runs of a loop parent (marbles/polarize ``--L``). + + Loop parents write almost nothing themselves — the children carry the real + transcripts and pids, yet they are separate run records linked only by the + id prefix. An await that fingerprints the parent alone sees a frozen record + (and possibly a gone pid between rounds) while a child is demonstrably + working, then fires a false ``idle_stall`` mid-loop. + """ + prefix = f"{target}-" + children: list[dict[str, Any]] = [] + for key in ("active_runs", "recent_runs"): + for run in snapshot.get(key) or []: + if not isinstance(run, dict): + continue + if str(run.get("run_id") or "").startswith(prefix): + children.append(run) + return children + + +def _report_file_written(path: str) -> bool: + try: + return Path(str(path)).stat().st_size > 0 + except OSError: + return False + + def _resolve_await_hard_cap(hard_cap_seconds: float | None) -> float | None: """Optional absolute ceiling for :func:`await_run`. @@ -1522,6 +1681,8 @@ def await_run( timeout_seconds: float = 300, interval_seconds: float = 5, hard_cap_seconds: float | None = None, + report_path: str | None = None, + on_poll: Callable[[dict[str, Any] | None], None] | None = None, ) -> dict[str, Any]: """Liveness-aware bounded wait for a run using control-plane state only. @@ -1533,6 +1694,22 @@ def await_run( optional ``hard_cap_seconds`` absolute ceiling. This stops the orchestrator from killing a marbles loop that is still doing real work (~13 min single marble) just because a fixed wall-clock budget expired. + + Two more truths keep this the ONE await nobody has to second-guess: + + - A non-empty report file (``report_path`` argument, else the run's own + ``latest_report``) from a worker that is GONE is the handoff itself — + return ``completed`` with ``reason: report_delivered`` immediately + instead of idling out a full window on the corpse. While the worker is + alive the report alone never completes the wait: it may be mid-write, + or a stale leftover from a previous attempt on the same announced path. + - Movement and liveness aggregate the run's CHILD runs (marbles/polarize + loop rounds live in separate ``-…-L`` records), so a working + child keeps the parent's await open even while the parent record is + frozen between rounds. + + ``on_poll`` (when given) is called once per poll with the latest run + projection — for callers that print progress while blocking. """ target = str(run_id or "").strip() if not target: @@ -1551,23 +1728,63 @@ def await_run( while True: attempts += 1 - snapshot = sync_state() + # Scoped to the awaited run (+ its child rounds): a 5s await poll must + # not rebuild the whole board under the shared lock every tick. + snapshot = sync_state(only_run_id=target) last_run = _select_run(snapshot, target) - if last_run is not None and _run_is_terminal(last_run): + if on_poll is not None: + on_poll(last_run) + children = _await_child_runs(snapshot, target) + worker_alive = bool( + (last_run is not None and _worker_is_alive(last_run)) + or any(_worker_is_alive(child) for child in children) + ) + if last_run is not None and _run_is_terminal(last_run) and not worker_alive: return { "run_id": target, "found": True, "completed": True, "timed_out": False, "reason": "terminal", - "worker_alive": _worker_is_alive(last_run), + "worker_alive": False, + "attempts": attempts, + "run": last_run, + } + + delivered_report = str( + report_path or (last_run.get("latest_report") if last_run else "") or "" + ).strip() + # Worker death is the handoff seal: a LIVE worker may still be + # mid-write, and the announced path can hold a stale report from a + # previous attempt (stage retries reuse artifact paths) — returning + # `completed` on it reported exit 0 for a still-working run. + if ( + delivered_report + and _report_file_written(delivered_report) + and not worker_alive + ): + return { + "run_id": target, + "found": last_run is not None, + "completed": True, + "timed_out": False, + "reason": "report_delivered", + "worker_alive": False, "attempts": attempts, "run": last_run, } now = time.monotonic() - worker_alive = _worker_is_alive(last_run) if last_run else False - fingerprint = _await_progress_fingerprint(last_run) + fingerprint = ( + _await_progress_fingerprint(last_run), + tuple( + sorted( + (str(child.get("run_id") or ""),) + + _await_progress_fingerprint(child) + for child in children + ) + ), + ) moved = fingerprint != previous_fingerprint previous_fingerprint = fingerprint # Real activity or a live worker keeps the idle window open: never @@ -1606,6 +1823,36 @@ def await_run( time.sleep(max(sleep_for, 0.0)) +def run_liveness(run_id: str) -> dict[str, Any]: + """Bounded, reconciled liveness projection for a single run. + + Closes the report-on-death gap (docs/runtime/AGENT_OPS.md, Class 2): in + no-await mode a worker that dies at startup emits no report and no + terminal state, so passive readers wait on a corpse. This runs the same + sync/reconcile pass the dispatch verbs use and answers the one question a + supervisor needs before trusting silence: is the worker demonstrably + alive, and what state did reconciliation settle on. + """ + target = str(run_id or "").strip() + if not target: + return {"run_id": "", "found": False} + try: + snapshot = sync_state() + except OSError: + return {"run_id": target, "found": False} + run = _select_run(snapshot, target) + if run is None: + return {"run_id": target, "found": False} + return { + "run_id": target, + "found": True, + "state": str(run.get("state") or ""), + "liveness": str(run.get("liveness") or ""), + "worker_alive": _worker_is_alive(run), + "recovery_required": bool(run.get("recovery_required")), + } + + def cli(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Sync and inspect Vibecrafted control-plane state." diff --git a/vibecrafted-core/vibecrafted_core/deck/vibecrafted b/vibecrafted-core/vibecrafted_core/deck/vibecrafted index 941e1424..759d4748 100755 --- a/vibecrafted-core/vibecrafted_core/deck/vibecrafted +++ b/vibecrafted-core/vibecrafted_core/deck/vibecrafted @@ -2316,12 +2316,22 @@ _post_update_banner() { cmd_update() { local makefile="" repo_source="" installed_ver="" channel_ref="main" local force=0 - for _u_arg in "$@"; do + local _u_arg="" + while (( $# )); do + _u_arg="$1" case "$_u_arg" in --force|-f) force=1 ;; - --ref) : ;; # consumed below + --ref) + shift + if [[ $# -eq 0 || "${1:-}" == --* ]]; then + printf '%b✗%b --ref requires a channel value.\n' "$_red" "$_reset" >&2 + return 2 + fi + channel_ref="$1" + ;; --ref=*) channel_ref="${_u_arg#--ref=}" ;; esac + shift done repo_source="$(_repo_source_root 2>/dev/null || true)" @@ -2353,7 +2363,7 @@ cmd_update() { done if [[ -n "$makefile" ]]; then - make -C "$(dirname "$makefile")" update + make -C "$(dirname "$makefile")" update BRANCH="$channel_ref" update_status=$? _post_update_banner "$installed_ver" return "$update_status" diff --git a/vibecrafted-core/vibecrafted_core/dispatch/model.py b/vibecrafted-core/vibecrafted_core/dispatch/model.py index f2e1d194..f20640aa 100644 --- a/vibecrafted-core/vibecrafted_core/dispatch/model.py +++ b/vibecrafted-core/vibecrafted_core/dispatch/model.py @@ -114,6 +114,7 @@ class Cut: resolved_workflow: str critical: bool = False mode: str = "write" + model: str = "" prompt: str = "" brief: str = "" extra: str = "" diff --git a/vibecrafted-core/vibecrafted_core/dispatch/schema.py b/vibecrafted-core/vibecrafted_core/dispatch/schema.py index d7911c7b..1356ddd1 100644 --- a/vibecrafted-core/vibecrafted_core/dispatch/schema.py +++ b/vibecrafted-core/vibecrafted_core/dispatch/schema.py @@ -352,6 +352,7 @@ def _parse_cuts( resolved_workflow=resolved_workflow, critical=bool(item.get("critical")), mode=mode, + model=_string(item.get("model")), prompt=prompt, brief=_resolve_brief(brief, base_dir), extra=_string(item.get("extra")), diff --git a/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py b/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py index 1b73aad6..b9ffd9f8 100644 --- a/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py +++ b/vibecrafted-core/vibecrafted_core/dispatch/supervisor.py @@ -114,6 +114,7 @@ def launch(cut: Cut, prompt: str, kind: str) -> CellRun: file="", runtime="headless", root=dispatch.meta.repo, + model=cut.model, ) result = launch_workflow(spec, base_dir) return CellRun( diff --git a/vibecrafted-core/vibecrafted_core/dispatcher.py b/vibecrafted-core/vibecrafted_core/dispatcher.py index cfffb1f2..06add62b 100644 --- a/vibecrafted-core/vibecrafted_core/dispatcher.py +++ b/vibecrafted-core/vibecrafted_core/dispatcher.py @@ -5,7 +5,7 @@ import json import os import sys -from typing import Sequence +from typing import Any, Sequence from .supervisor_async import AsyncSupervisor @@ -26,6 +26,15 @@ def _build_parser() -> argparse.ArgumentParser: "--prompt-file", help="deliver this prompt file to the worker on stdin and VIBECRAFTED_PROMPT_PATH", ) + run.add_argument( + "--lifecycle-state", + default="", + help=( + "lifecycle state.json this run belongs to; on worker failure the " + "dispatcher records the terminal truth there (push-side " + "report-on-death)" + ), + ) run.add_argument("--timeout", type=float) run.add_argument( "--no-require-report", @@ -65,6 +74,36 @@ def _normalize_worker(argv: Sequence[str]) -> list[str]: return worker +def _maybe_record_lifecycle_worker_exit( + state_path: str, summary: dict[str, Any] +) -> None: + """Push-side report-on-death (docs/runtime/AGENT_OPS.md, Class 2). + + Only failures are written back: a healthy no-await handoff already leaves + its truth in the report file, and keeping the write rare keeps the window + for racing an operator verb on state.json effectively closed. + """ + exit_code = summary.get("exit_code") + failed = (isinstance(exit_code, int) and exit_code != 0) or not summary.get( + "artifact_ok" + ) + if not failed: + return + from .lifecycle_runner import record_stage_worker_exit + + record_stage_worker_exit( + state_path, + str(summary.get("run_id") or ""), + { + "state": summary.get("state"), + "exit_code": exit_code, + "artifact_ok": bool(summary.get("artifact_ok")), + "artifact_errors": list(summary.get("artifact_errors") or []), + "transcript": str(summary.get("transcript") or ""), + }, + ) + + async def _run(args: argparse.Namespace) -> int: worker = _normalize_worker(args.worker) handle = await AsyncSupervisor().run( @@ -94,6 +133,10 @@ async def _run(args: argparse.Namespace) -> int: "artifact_errors": artifact_errors, } + lifecycle_state = str(getattr(args, "lifecycle_state", "") or "") + if lifecycle_state: + _maybe_record_lifecycle_worker_exit(lifecycle_state, summary) + if args.quiet: pass elif args.json: diff --git a/vibecrafted-core/vibecrafted_core/events.py b/vibecrafted-core/vibecrafted_core/events.py index 632a5d93..0204df3d 100644 --- a/vibecrafted-core/vibecrafted_core/events.py +++ b/vibecrafted-core/vibecrafted_core/events.py @@ -9,7 +9,7 @@ def append_event( message: str, payload: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Append one control-plane event under the shared sync lock.""" + """Append one control-plane event via an atomic, lockless O_APPEND write.""" from . import control_plane event = { @@ -19,6 +19,7 @@ def append_event( "message": str(message or ""), "payload": payload or {}, } - with control_plane._sync_lock(): - control_plane._append_event(event) + # No global lock: _append_event is a single atomic O_APPEND write, so the + # hot emit path never serializes on the shared control-plane mutex. + control_plane._append_event(event) return event diff --git a/vibecrafted-core/vibecrafted_core/lifecycle_control.py b/vibecrafted-core/vibecrafted_core/lifecycle_control.py index a4d4adc6..c7c6a5c3 100644 --- a/vibecrafted-core/vibecrafted_core/lifecycle_control.py +++ b/vibecrafted-core/vibecrafted_core/lifecycle_control.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, Callable, Sequence +from .control_plane import await_run as control_plane_await_run from .control_plane import control_plane_home from .lifecycle_runner import ( LifecycleRunSpec, @@ -26,6 +27,7 @@ { "runs", "status", + "await", "approve", "interrupt", "force-audit", @@ -183,15 +185,86 @@ def _continuation_spec( depth=spec_data.get("depth"), parent_run_id=str(state.get("run_id") or ""), previous_reports=_baton_previous_reports(state), + stage_agents=dict(spec_data.get("stage_agents") or {}), + stage_models=dict(spec_data.get("stage_models") or {}), ) -def _baton_agent(state: dict[str, Any]) -> str: +def _baton_agent(state: dict[str, Any], stage: str = "") -> str: baton = dict(state.get("baton") or {}) spec_data = dict(state.get("spec") or {}) + # Operator-declared casting (mission frontmatter stage_agents) wins for a + # stage it names; worker-requested next_agent steers only the un-cast rest. + stage_agents = dict(spec_data.get("stage_agents") or {}) + cast = str(stage_agents.get(str(stage or "").strip()) or "").strip() + if cast: + return cast return str(baton.get("next_agent") or spec_data.get("agent") or "codex") +def await_stage( + state: dict[str, Any], + *, + idle_seconds: float = 300, + interval_seconds: float = 5, + hard_cap_seconds: float | None = None, +) -> dict[str, Any]: + """The lifecycle-level observability contract: block on the CURRENT stage. + + Wraps ``control_plane.await_run`` (liveness-aware idle window that resets + on real movement, optional hard cap) so supervisors — human or agent — + wait through the runtime contract instead of ad-hoc sleep/poll loops, and + get back the one truth that decides the next verb: the stage delivered + its report, died without one, or genuinely stalled. + """ + stages = list(state.get("stages") or []) + if not stages: + raise ValueError("nothing to await: no stage launched yet") + last_stage = stages[-1] + launch = dict(last_stage.get("launch") or {}) + stage_run_id = str(launch.get("run_id") or "").strip() + if not stage_run_id: + raise ValueError("nothing to await: current stage has no worker run") + report_path = str(launch.get("report") or "").strip() + + # The stage report IS the no-await handoff: with report_path forwarded, + # await_run returns `report_delivered` on its first poll instead of idling + # out a full window on a worker that already delivered and exited. + result = control_plane_await_run( + stage_run_id, + timeout_seconds=idle_seconds, + interval_seconds=interval_seconds, + hard_cap_seconds=hard_cap_seconds, + report_path=report_path or None, + ) + + report_written = False + if report_path: + try: + report_written = Path(report_path).stat().st_size > 0 + except OSError: + report_written = False + settled = bool(result.get("completed")) or bool(result.get("timed_out")) + worker_alive = bool(result.get("worker_alive")) + baton = dict(state.get("baton") or {}) + return { + "run_id": str(state.get("run_id") or ""), + "stage": str(last_stage.get("id") or ""), + "stage_run_id": stage_run_id, + "report": report_path, + "report_written": report_written, + "completed": bool(result.get("completed")), + "timed_out": bool(result.get("timed_out")), + "reason": str(result.get("reason") or ""), + "worker_alive": worker_alive, + "worker_dead_without_report": settled + and not worker_alive + and not report_written, + "next_stage": str(baton.get("next_stage") or ""), + "next_agent": str(baton.get("next_agent") or ""), + } + + def approve_transition( state_path: Path, state: dict[str, Any], @@ -221,7 +294,7 @@ def approve_transition( state, workflow_id=str(state.get("workflow") or ""), start_stage=next_stage, - agent=_baton_agent(state), + agent=_baton_agent(state, next_stage), ) child = launcher(spec) details: dict[str, Any] = { @@ -316,7 +389,7 @@ def force_audit( state, workflow_id="vc-audit", start_stage="", - agent=_baton_agent(state), + agent=_baton_agent(state, "audit"), ) child = launcher(spec) entry = record_operator_action( @@ -406,6 +479,22 @@ def _run_parser(verb: str, help_text: str) -> argparse.ArgumentParser: return verb_parser _run_parser("status", "show one lifecycle run's status") + await_parser = _run_parser( + "await", "await_stage: block on the current stage via the runtime contract" + ) + await_parser.add_argument( + "--idle", + type=float, + default=300, + help="idle window in seconds; resets on real movement (default 300)", + ) + await_parser.add_argument("--interval", type=float, default=5) + await_parser.add_argument( + "--hard-cap", + type=float, + default=None, + help="absolute ceiling in seconds (default: none, liveness governs)", + ) approve_parser = _run_parser( "approve", "approve_transition: launch the baton's next_stage" ) @@ -441,6 +530,13 @@ def _run_parser(verb: str, help_text: str) -> argparse.ArgumentParser: payload = LifecycleSupervisor().status(state) payload["parent_run_id"] = str(state.get("parent_run_id") or "") payload["operator_actions"] = len(state.get("operator_actions") or []) + elif args.verb == "await": + payload = await_stage( + state, + idle_seconds=args.idle, + interval_seconds=args.interval, + hard_cap_seconds=args.hard_cap, + ) elif args.verb == "approve": payload = approve_transition( state_path, state, force=bool(getattr(args, "force", False)) diff --git a/vibecrafted-core/vibecrafted_core/lifecycle_runner.py b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py index 263982ad..cd147dfb 100644 --- a/vibecrafted-core/vibecrafted_core/lifecycle_runner.py +++ b/vibecrafted-core/vibecrafted_core/lifecycle_runner.py @@ -8,11 +8,11 @@ import subprocess import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Sequence -from .control_plane import control_plane_home, normalize_run_root +from .control_plane import control_plane_home, normalize_run_root, run_liveness from .workflow import ( SUPPORTED_AGENTS, WorkflowLaunchSpec, @@ -20,11 +20,16 @@ launch_workflow, report_dou_index, ) -from .workflows.registry import workflow_manifest, workflow_manifest_payload +from .workflows.registry import ( + workflow_definition, + workflow_manifest, + workflow_manifest_payload, +) from .workflows.model import WorkflowManifest, WorkflowStage LaunchWorkflow = Callable[[WorkflowLaunchSpec, str | Path], dict[str, Any]] AwaitWorkflow = Callable[[dict[str, Any]], dict[str, Any]] +LIFECYCLE_SCHEMA_ID = "vibecrafted.lifecycle.v1" @dataclass(frozen=True) @@ -44,6 +49,14 @@ class LifecycleRunSpec: # Continuation runs (approve / force-audit) seed these so the next stage # prompt keeps consuming what the previous Read/Write stages produced. previous_reports: tuple[str, ...] = () + # Operator-declared per-stage casting: {stage_id: agent}. Usually parsed + # from the mission file's frontmatter (stage_agents:) so one plan file + # carries the whole relay's crew. An explicit entry for a stage wins over + # worker-requested next_agent steering. + stage_agents: dict[str, str] = field(default_factory=dict) + # Operator-declared per-stage model requests: {stage_id: model}. Explicit + # mission frontmatter wins over manifest defaults; empty = runner default. + stage_models: dict[str, str] = field(default_factory=dict) def _lifecycle_run_id(workflow_id: str) -> str: @@ -274,6 +287,160 @@ def _state_dou_value(state: dict[str, Any]) -> int | None: return value +def _stage_worker_liveness( + state: dict[str, Any], + stage_launch: dict[str, Any], + liveness_reader: Callable[[str], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Reconciled liveness of the current stage's worker run. + + Closes the report-on-death gap at the lifecycle surface: in no-await mode + a worker that dies at startup leaves the lifecycle run in ``launching`` + forever, and only OS-level liveness tells a corpse apart from slow work. + ``worker_dead_without_report`` is the actionable signal — the stage will + never deliver on its own; recover with interrupt/fallback/approve. + """ + stage_run_id = str(stage_launch.get("run_id") or "").strip() + if not stage_run_id or state.get("status") != "launching": + return {} + reader = liveness_reader or run_liveness + liveness = reader(stage_run_id) + report_path = str(stage_launch.get("report") or "").strip() + report_written = bool(report_path) and Path(report_path).is_file() + liveness["report_written"] = report_written + liveness["worker_dead_without_report"] = ( + bool(liveness.get("found")) + and not liveness.get("worker_alive") + and not report_written + ) + return liveness + + +def _mission_stage_agents(mission_text: str) -> dict[str, str]: + """Per-stage casting declared in the mission file's YAML frontmatter. + + Two accepted shapes, so an operator plan file can carry the whole relay's + crew A-to-Z: + + --- + stage_agents: scaffold=claude, review=codex + --- + + --- + stage_agents: + scaffold: claude + review: codex + --- + """ + lines = str(mission_text or "").splitlines() + if not lines or lines[0].strip() != "---": + return {} + agents: dict[str, str] = {} + in_block = False + for line in lines[1:]: + stripped = line.strip() + if stripped == "---": + break + if not in_block: + if not stripped.startswith("stage_agents:"): + continue + inline = stripped.split(":", 1)[1].strip() + if inline: + for pair in inline.split(","): + separator = "=" if "=" in pair else ":" + if separator not in pair: + continue + key, value = pair.split(separator, 1) + agents[key.strip()] = value.strip().strip('"') + break + in_block = True + continue + if not line.startswith((" ", "\t")) or ":" not in stripped: + break + key, value = stripped.split(":", 1) + agents[key.strip()] = value.strip().strip('"') + return agents + + +def _mission_stage_models(mission_text: str) -> dict[str, str]: + """Per-stage model pins declared in mission YAML frontmatter. + + Accepted shapes mirror stage_agents. Model names are passed through + verbatim; the runtime only knows whether a runner has a model flag. + """ + + lines = str(mission_text or "").splitlines() + if not lines or lines[0].strip() != "---": + return {} + models: dict[str, str] = {} + in_block = False + for line in lines[1:]: + stripped = line.strip() + if stripped == "---": + break + if not in_block: + if not stripped.startswith("stage_models:"): + continue + inline = stripped.split(":", 1)[1].strip() + if inline: + for pair in inline.split(","): + separator = "=" if "=" in pair else ":" + if separator not in pair: + continue + key, value = pair.split(separator, 1) + models[key.strip()] = value.strip().strip('"') + break + in_block = True + continue + if not line.startswith((" ", "\t")) or ":" not in stripped: + break + key, value = stripped.split(":", 1) + models[key.strip()] = value.strip().strip('"') + return models + + +def _validated_stage_agents( + raw: dict[str, str], manifest: WorkflowManifest +) -> dict[str, str]: + """Fail fast at launch: an A-to-Z casting with a typo must not fly.""" + stage_ids = {stage.id for stage in manifest.stages} + resolved: dict[str, str] = {} + for stage_id, agent in dict(raw or {}).items(): + stage_key = str(stage_id).strip() + agent_name = str(agent).strip() + if not stage_key or not agent_name: + continue + if stage_key not in stage_ids: + raise ValueError( + f"stage_agents names unknown stage '{stage_key}' " + f"for workflow {manifest.id}" + ) + if agent_name not in SUPPORTED_AGENTS: + raise ValueError( + f"stage_agents names unsupported agent '{agent_name}' " + f"for stage '{stage_key}' (supported: " + + ", ".join(sorted(SUPPORTED_AGENTS)) + + ")" + ) + resolved[stage_key] = agent_name + return resolved + + +def _validated_stage_models( + raw: dict[str, str], manifest: WorkflowManifest +) -> dict[str, str]: + """Manifest-gate optional model pins without whitelisting model names.""" + stage_ids = {stage.id for stage in manifest.stages} + resolved: dict[str, str] = {} + for stage_id, model in dict(raw or {}).items(): + stage_key = str(stage_id).strip() + model_name = str(model).strip() + if not stage_key or not model_name or stage_key not in stage_ids: + continue + resolved[stage_key] = model_name + return resolved + + def _lifecycle_max_stage_launches(manifest: WorkflowManifest) -> int: """Ceiling on stage launches per lifecycle run. @@ -318,6 +485,14 @@ async def run(self, spec: LifecycleRunSpec) -> dict[str, Any]: root = Path(normalize_run_root(spec.root, Path.cwd())) source_prompt = spec.prompt or _read_file(spec.file) + stage_agents = _validated_stage_agents( + dict(spec.stage_agents or {}) or _mission_stage_agents(source_prompt), + manifest, + ) + stage_models = _validated_stage_models( + dict(spec.stage_models or {}) or _mission_stage_models(source_prompt), + manifest, + ) run_id = _lifecycle_run_id(manifest.id) run_dir = control_plane_home() / "lifecycle_runs" / run_id run_dir.mkdir(parents=True, exist_ok=True) @@ -334,6 +509,7 @@ async def run(self, spec: LifecycleRunSpec) -> dict[str, Any]: str(path).strip() for path in spec.previous_reports if str(path).strip() ] state: dict[str, Any] = { + "schema": LIFECYCLE_SCHEMA_ID, "run_id": run_id, "workflow": manifest.id, "agent": spec.agent, @@ -354,6 +530,8 @@ async def run(self, spec: LifecycleRunSpec) -> dict[str, Any]: "count": spec.count, "depth": spec.depth, "previous_reports": list(previous_reports), + "stage_agents": dict(stage_agents), + "stage_models": dict(stage_models), }, "supervisor": "vibecrafted_core.lifecycle_runner.LifecycleSupervisor", "human_controls": list(manifest.human_controls), @@ -377,6 +555,7 @@ async def run(self, spec: LifecycleRunSpec) -> dict[str, Any]: "dou_index": None, }, "stages": [], + "accepted_dou_findings": [], } self._write_state(state_path, state) @@ -400,11 +579,15 @@ async def run(self, spec: LifecycleRunSpec) -> dict[str, Any]: manifest=manifest, stage=stage, spec=spec, - agent=stage.agent or current_agent, + # Operator-declared casting wins for a stage it names; then + # manifest defaults; then the baton's current agent. + agent=stage_agents.get(current) or stage.agent or current_agent, + model=stage_models.get(current) or stage.model, source_prompt=source_prompt, root=root, previous_reports=previous_reports, context=context, + state_path=state_path, ) state["stages"].append(record) self._write_state(state_path, state) @@ -517,10 +700,12 @@ async def _start_stage( stage: WorkflowStage, spec: LifecycleRunSpec, agent: str, + model: str, source_prompt: str, root: Path, previous_reports: list[str], context: dict[str, Any], + state_path: Path | None = None, ) -> dict[str, Any]: prompt = self._stage_prompt( manifest=manifest, @@ -529,6 +714,7 @@ async def _start_stage( previous_reports=previous_reports, context=context, ) + stage_definition = workflow_definition(stage.workflow) launch_spec = WorkflowLaunchSpec( agent=agent, mode=stage.workflow, @@ -537,8 +723,18 @@ async def _start_stage( file="", runtime=spec.runtime, root=str(root), - count=spec.count if stage.workflow == "marbles" else None, - depth=spec.depth if stage.workflow == "marbles" else None, + count=( + spec.count + if stage_definition is not None and stage_definition.supports_count + else None + ), + depth=( + spec.depth + if stage_definition is not None and stage_definition.supports_depth + else None + ), + model=model, + lifecycle_state_path=str(state_path or ""), ) commit_before = _git_head(root) git_before = _git_status(root) @@ -549,6 +745,7 @@ async def _start_stage( "name": stage.name, "workflow": stage.workflow, "agent": agent, + "model_requested": model, "phase": stage.phase, "can_modify_code": stage.can_modify_code, "tooling": list(stage.tooling), @@ -687,6 +884,66 @@ def write_lifecycle_state(path: Path, state: dict[str, Any]) -> None: ) +def record_stage_worker_exit( + state_path: str | Path, + stage_run_id: str, + exit_payload: dict[str, Any], +) -> bool: + """Push-side report-on-death: write the worker's terminal truth into the + lifecycle state itself (docs/runtime/AGENT_OPS.md, Class 2). + + Called by the dispatcher when a lifecycle stage worker exits in failure, + so purely passive readers of ``state.json`` (the Rust server, dashboards) + see the death without anyone running a status verb. Additive within + lifecycle.schema.v1: annotates the matching stage with ``worker_exit`` and + mirrors it top-level as ``stage_worker_exit`` while the run still waits in + ``launching``. Never raises — a corrupt or missing state file returns + ``False``; poisoning the dispatch exit path would trade one blind spot for + another. + """ + target = str(stage_run_id or "").strip() + path = Path(state_path).expanduser() + if not target: + return False + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(state, dict): + return False + stages = state.get("stages") + if not isinstance(stages, list) or not stages: + return False + matched_index: int | None = None + for index in range(len(stages) - 1, -1, -1): + stage = stages[index] + if not isinstance(stage, dict): + continue + launch = stage.get("launch") or {} + if str(launch.get("run_id") or "") == target: + matched_index = index + break + if matched_index is None: + return False + payload = dict(exit_payload) + payload.setdefault("recorded_at", time.strftime("%Y-%m-%dT%H:%M:%S%z")) + stages[matched_index]["worker_exit"] = payload + # Top-level mirror only for the CURRENT stage of a still-waiting run: a + # stage that was already superseded (fallback/approve relaunch) is + # history, not an alarm. + if matched_index == len(stages) - 1 and state.get("status") == "launching": + state["stage_worker_exit"] = { + **payload, + "stage": str(stages[matched_index].get("id") or ""), + "run_id": target, + } + try: + write_lifecycle_state(path, state) + except OSError: + return False + return True + + def write_lifecycle_report(path: Path, state: dict[str, Any]) -> None: stages = state.get("stages") or [] lines = [ @@ -715,6 +972,12 @@ def write_lifecycle_report(path: Path, state: dict[str, Any]) -> None: "", f"- {stage.get('id')} ({stage.get('phase')}): {stage.get('status')}", f" - agent: {stage.get('agent', '')}", + " - model_requested: " + + str( + stage.get("model_requested") + or stage.get("launch", {}).get("model_requested") + or "" + ), f" - run_id: {stage.get('launch', {}).get('run_id', '')}", f" - report: {stage.get('launch', {}).get('report', '')}", f" - commit_before: {stage.get('commit_before', '')}", @@ -772,14 +1035,14 @@ def read_state(self, state_path: str | Path) -> dict[str, Any]: def status(self, state: dict[str, Any]) -> dict[str, Any]: stages = list(state.get("stages") or []) last_stage = stages[-1] if stages else {} + stage_launch = dict(last_stage.get("launch") or {}) dou_value = _state_dou_value(state) if dou_value is None: # Primary no-await mode: the runner exits before the worker writes # its report, so the DoU index only exists in the live frontmatter. - dou_value = report_dou_index( - str((last_stage.get("launch") or {}).get("report") or "") - ) + dou_value = report_dou_index(str(stage_launch.get("report") or "")) return { + "schema": state.get("schema"), "run_id": state.get("run_id"), "workflow": state.get("workflow"), "status": state.get("status"), @@ -791,6 +1054,7 @@ def status(self, state: dict[str, Any]) -> dict[str, Any]: "accepted_dou": len(state.get("accepted_dou_findings") or []), "state_path": state.get("state_path"), "report_path": state.get("report_path"), + "stage_worker": _stage_worker_liveness(state, stage_launch), } @@ -825,8 +1089,8 @@ def lifecycle_main(workflow_id: str, argv: Sequence[str] | None = None) -> int: from .lifecycle_control import lifecycle_control_main return lifecycle_control_main(args_list, workflow_id=manifest.id) - supports_loop_options = any( - stage.workflow == "marbles" for stage in manifest.stages + stage_definitions = tuple( + workflow_definition(stage.workflow) for stage in manifest.stages ) parser = argparse.ArgumentParser(prog=workflow_id) @@ -840,8 +1104,15 @@ def lifecycle_main(workflow_id: str, argv: Sequence[str] | None = None) -> int: parser.add_argument("--start-stage", default="") parser.add_argument("--checkpoint", dest="start_stage", default="") parser.add_argument("--await-stages", action="store_true") - if supports_loop_options: + if any( + definition is not None and definition.supports_count + for definition in stage_definitions + ): parser.add_argument("--count", type=int) + if any( + definition is not None and definition.supports_depth + for definition in stage_definitions + ): parser.add_argument("--depth", type=int) parser.add_argument("--json", action="store_true") args = parser.parse_args(args_list) diff --git a/vibecrafted-core/vibecrafted_core/model_overrides.py b/vibecrafted-core/vibecrafted_core/model_overrides.py new file mode 100644 index 00000000..0492862c --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/model_overrides.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Sequence + +MODEL_OVERRIDE_FLAGS = { + "claude": "--model", + "codex": "-m", +} + + +def _model_override_receipt( + agent: str, model_requested: str | None +) -> dict[str, object]: + """Receipt fields for an operator-requested model pin. + + The model value is intentionally not validated: agent model catalogs change + faster than this runtime. We only record whether this runner knows how to + carry the request as a CLI flag. + """ + + requested = str(model_requested or "").strip() + if not requested: + return {} + supported = agent in MODEL_OVERRIDE_FLAGS + receipt: dict[str, object] = { + "model_requested": requested, + "model_override_supported": supported, + "model_override_skipped": not supported, + } + if not supported: + receipt["model_override_skip_reason"] = "unsupported_agent_model_flag" + return receipt + + +def _with_model_override( + agent: str, command: Sequence[str], model_requested: str | None +) -> list[str]: + requested = str(model_requested or "").strip() + command_list = list(command) + flag = MODEL_OVERRIDE_FLAGS.get(agent) + if not requested or not flag or not command_list: + return command_list + if agent == "codex" and len(command_list) > 1 and command_list[1] == "exec": + return [command_list[0], command_list[1], flag, requested, *command_list[2:]] + return [command_list[0], flag, requested, *command_list[1:]] diff --git a/vibecrafted-core/vibecrafted_core/research_config.py b/vibecrafted-core/vibecrafted_core/research_config.py new file mode 100644 index 00000000..dd425866 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/research_config.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import os +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + +from .runtime_paths import vibecrafted_home + +SUPPORTED_RESEARCH_AGENTS = ("claude", "codex", "agy", "junie", "grok") +DEFAULT_RESEARCH_AGENTS = ("claude", "codex", "agy") + + +@dataclass(frozen=True) +class ResearchAgentSelection: + agents: tuple[str, ...] + source: str + ignored: tuple[str, ...] = () + lane_models: Mapping[str, str] | None = None + synthesizer: str = "" + synthesizer_model: str = "" + synthesizer_source: str = "" + + def lane_model(self, agent: str, global_model: str = "") -> str: + if global_model: + return global_model + return str((self.lane_models or {}).get(agent, "")).strip() + + def synthesis_model(self, global_model: str = "") -> str: + return global_model or self.synthesizer_model + + +def research_yaml_path() -> Path: + raw = os.environ.get("VIBECRAFTED_RESEARCH_CONFIG", "").strip() + if raw: + return Path(raw).expanduser() + return vibecrafted_home() / "config" / "research.yaml" + + +def _strip_comment(line: str) -> str: + in_quote = "" + for index, char in enumerate(line): + if char in {"'", '"'}: + in_quote = "" if in_quote == char else char if not in_quote else in_quote + if char == "#" and not in_quote: + return line[:index] + return line + + +def _scalar(value: str) -> Any: + value = value.strip() + if not value: + return "" + if value[0:1] in {"'", '"'} and value[-1:] == value[0]: + return value[1:-1] + lowered = value.lower() + if lowered in {"true", "yes", "on"}: + return True + if lowered in {"false", "no", "off"}: + return False + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + if not inner: + return [] + return [_scalar(part.strip()) for part in inner.split(",")] + try: + return int(value) + except ValueError: + return value + + +def _read_runtime_yaml(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {} + result: dict[str, Any] = {"lanes": [], "models": {}, "synthesizer": {}} + section = "" + current_lane: dict[str, Any] | None = None + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + print(f"Failed to read research runtime config: {path}: {exc}", file=sys.stderr) + return {} + for raw_line in lines: + line = _strip_comment(raw_line).rstrip() + if not line.strip(): + continue + stripped = line.strip() + if not raw_line.startswith((" ", "\t")): + current_lane = None + if stripped.endswith(":"): + section = stripped[:-1].strip() + continue + if ":" in stripped: + key, value = stripped.split(":", 1) + key = key.strip() + parsed = _scalar(value) + if key == "synthesizer" and isinstance(parsed, str): + result["synthesizer"] = {"agent": parsed} + else: + result[key] = parsed + section = "" + continue + if section == "lanes" and stripped.startswith("-"): + current_lane = {} + result["lanes"].append(current_lane) + body = stripped[1:].strip() + if body and ":" in body: + key, value = body.split(":", 1) + current_lane[key.strip()] = _scalar(value) + continue + if section == "lanes" and current_lane is not None and ":" in stripped: + key, value = stripped.split(":", 1) + current_lane[key.strip()] = _scalar(value) + continue + if section in {"models", "synthesizer"} and ":" in stripped: + key, value = stripped.split(":", 1) + bucket = result.get(section) + if isinstance(bucket, dict): + bucket[key.strip()] = _scalar(value) + return result + + +def _split_agent_tokens(raw: object) -> list[str]: + if isinstance(raw, str): + return [ + token.strip() for token in raw.replace(",", " ").split() if token.strip() + ] + if isinstance(raw, Iterable): + return [str(token).strip() for token in raw if str(token).strip()] + return [] + + +def _select_supported_agents( + tokens: Iterable[str], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + agents: list[str] = [] + ignored: list[str] = [] + seen: set[str] = set() + for token in tokens: + agent = token.strip().lower() + if not agent: + continue + if agent not in SUPPORTED_RESEARCH_AGENTS: + ignored.append(agent) + continue + if agent in seen: + continue + seen.add(agent) + agents.append(agent) + return tuple(agents), tuple(ignored) + + +def _read_legacy_toml_agents(path: Path) -> tuple[str, ...]: + if not path.is_file(): + return () + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError) as exc: + print(f"Failed to read research agent config: {path}: {exc}", file=sys.stderr) + return () + raw = ( + data.get("runtime", {}) + .get("picking", {}) + .get("research", {}) + .get("default_agents", []) + ) + return tuple(_split_agent_tokens(raw)) + + +def _legacy_toml_paths() -> tuple[Path, ...]: + config_home = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + paths: list[Path] = [config_home / "vibecrafted" / "config.toml"] + for candidate in ( + os.environ.get("VIBECRAFTED_ROOT", ""), + str(Path.cwd()), + str(Path(os.environ.get("VIBECRAFTED_TOOLS_HOME", "")) / "vibecrafted-current") + if os.environ.get("VIBECRAFTED_TOOLS_HOME") + else "", + ): + if candidate: + paths.append(Path(candidate).expanduser() / "install.toml") + return tuple(paths) + + +def _yaml_lanes( + data: Mapping[str, Any], +) -> tuple[tuple[str, ...], dict[str, str], tuple[str, ...]]: + models: dict[str, str] = {} + ignored: list[str] = [] + agents: list[str] = [] + lane_rows = data.get("lanes") + if isinstance(lane_rows, list): + for row in lane_rows: + if not isinstance(row, Mapping): + continue + if row.get("enabled") is False: + continue + agent = str(row.get("agent") or "").strip().lower() + selected, bad = _select_supported_agents([agent]) + ignored.extend(bad) + if not selected: + continue + agent = selected[0] + if agent not in agents: + agents.append(agent) + model = str(row.get("model") or "").strip() + if model: + models[agent] = model + else: + selected, bad = _select_supported_agents( + _split_agent_tokens(data.get("agents", [])) + ) + agents = list(selected) + ignored.extend(bad) + raw_models = data.get("models") + if isinstance(raw_models, Mapping): + for agent, model in raw_models.items(): + key = str(agent).strip().lower() + if key in SUPPORTED_RESEARCH_AGENTS and str(model).strip(): + models[key] = str(model).strip() + lane_count = data.get("lane_count") + if isinstance(lane_count, int) and lane_count > 0: + agents = agents[:lane_count] + return tuple(agents), models, tuple(ignored) + + +def _yaml_synthesizer(data: Mapping[str, Any]) -> tuple[str, str]: + raw = data.get("synthesizer") + if isinstance(raw, str): + return raw.strip().lower(), "" + if isinstance(raw, Mapping): + return ( + str(raw.get("agent") or "").strip().lower(), + str(raw.get("model") or "").strip(), + ) + return ( + str(data.get("synthesizer_agent") or "").strip().lower(), + str(data.get("synthesizer_model") or "").strip(), + ) + + +def resolve_research_runtime_config( + *, + override_agents: Iterable[str] = (), + synthesizer: str = "", + synthesizer_model: str = "", +) -> ResearchAgentSelection: + agents: tuple[str, ...] = DEFAULT_RESEARCH_AGENTS + models: dict[str, str] = {} + ignored: tuple[str, ...] = () + source = "builtin-default" + synth_agent = "" + synth_model = "" + synth_source = "" + + for path in _legacy_toml_paths(): + legacy_agents = _read_legacy_toml_agents(path) + if legacy_agents: + agents, ignored = _select_supported_agents(legacy_agents) + source = str(path) + break + + yaml_path = research_yaml_path() + yaml_data = _read_runtime_yaml(yaml_path) + if yaml_data: + yaml_agents, yaml_models, yaml_ignored = _yaml_lanes(yaml_data) + if yaml_agents: + agents = yaml_agents + source = str(yaml_path) + models.update(yaml_models) + ignored = (*ignored, *yaml_ignored) + synth_agent, synth_model = _yaml_synthesizer(yaml_data) + if synth_agent or synth_model: + synth_source = str(yaml_path) + + env_agents = os.environ.get("VIBECRAFTED_RESEARCH_AGENTS", "").strip() + if env_agents: + agents, env_ignored = _select_supported_agents(_split_agent_tokens(env_agents)) + ignored = (*ignored, *env_ignored) + source = "env:VIBECRAFTED_RESEARCH_AGENTS" + + override_tokens = tuple( + str(agent).strip() for agent in override_agents if str(agent).strip() + ) + if override_tokens: + agents, override_ignored = _select_supported_agents(override_tokens) + ignored = (*ignored, *override_ignored) + source = "positional-override" + if not synthesizer: + synthesizer = agents[0] if agents else "" + + env_synth = os.environ.get("VIBECRAFTED_RESEARCH_SYNTHESIZER", "").strip() + if env_synth: + synthesizer = env_synth + synth_source = "env:VIBECRAFTED_RESEARCH_SYNTHESIZER" + if synthesizer: + selected, bad = _select_supported_agents([synthesizer]) + ignored = (*ignored, *bad) + synth_agent = selected[0] if selected else "" + synth_source = synth_source or "explicit" + if synthesizer_model: + synth_model = synthesizer_model + env_synth_model = os.environ.get( + "VIBECRAFTED_RESEARCH_SYNTHESIZER_MODEL", "" + ).strip() + if env_synth_model: + synth_model = env_synth_model + + return ResearchAgentSelection( + agents=agents, + source=source, + ignored=tuple(dict.fromkeys(ignored)), + lane_models=models, + synthesizer=synth_agent, + synthesizer_model=synth_model, + synthesizer_source=synth_source, + ) diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/agy_spawn.sh b/vibecrafted-core/vibecrafted_core/runtime/scripts/agy_spawn.sh index fd1a02b3..d6f5a870 100755 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/agy_spawn.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/scripts/agy_spawn.sh @@ -114,7 +114,7 @@ TXT last_message_extract="if [[ -s $qtranscript ]]; then cp $qtranscript $qlast_message 2>/dev/null || rm -f $qlast_message; [[ -s $qlast_message ]] || rm -f $qlast_message; fi;" salvage_success_report="if [[ \$pipeline_status -eq 0 && ! -s $qreport && -s $qlast_message ]]; then { printf '%s\n' '---'; printf 'run_id: %s\n' \"\${SPAWN_RUN_ID:-unknown}\"; printf 'prompt_id: %s\n' \"\${SPAWN_PROMPT_ID:-unknown}\"; printf 'agent: %s\n' \"\${SPAWN_AGENT:-agy}\"; printf 'skill: %s\n' \"\${SPAWN_SKILL_CODE:-unknown}\"; printf 'model: %s\n' \"\${SPAWN_MODEL:-unknown}\"; printf 'status: completed\n'; printf 'session_id: %s\n' \"\${SPAWN_SESSION_ID:-pending}\"; printf 'repo_path: %s\n' \"\${SPAWN_ROOT:-unknown}\"; printf 'tokens_input: 0\n'; printf 'tokens_output: 0\n'; printf 'tokens_total: 0\n'; printf 'cost_usd: unknown\n'; printf '%s\n\n' '---'; cat $qlast_message; } > $qreport || pipeline_status=\$?; fi;" salvage_failure_report="if [[ \$pipeline_status -ne 0 && ! -s $qreport ]]; then { printf '%s\n' '---'; printf 'run_id: %s\n' \"\${SPAWN_RUN_ID:-unknown}\"; printf 'prompt_id: %s\n' \"\${SPAWN_PROMPT_ID:-unknown}\"; printf 'agent: %s\n' \"\${SPAWN_AGENT:-agy}\"; printf 'skill: %s\n' \"\${SPAWN_SKILL_CODE:-unknown}\"; printf 'model: %s\n' \"\${SPAWN_MODEL:-unknown}\"; printf 'status: failed\n'; printf 'session_id: %s\n' \"\${SPAWN_SESSION_ID:-pending}\"; printf 'repo_path: %s\n' \"\${SPAWN_ROOT:-unknown}\"; printf 'tokens_input: 0\n'; printf 'tokens_output: 0\n'; printf 'tokens_total: 0\n'; printf 'cost_usd: unknown\n'; printf '%s\n\n' '---'; if [[ -s $qlast_message ]]; then cat $qlast_message; else printf '%s\n' 'Agy failed before writing a standalone report file, and no final message was captured.'; printf '%s\n' 'See transcript for the full event stream:'; printf '%s\n' $qtranscript; printf '%s\n' 'Last message path checked:'; printf '%s\n' $qlast_message; fi; } > $qreport; fi;" -launch_cmd="set -o pipefail && cd $qroot && { rm -f $qlast_message; agy --print --dangerously-skip-permissions --add-dir $qroot --print-timeout 30m '' < $qruntime 2>&1 | tee -a $qtranscript; pipeline_status=\$?; $last_message_extract $salvage_success_report $salvage_failure_report echo; { grep -oE '\\[[0-9]{2}:[0-9]{2}:[0-9]{2}\\] session: [[:alnum:]-]+' $qtranscript 2>/dev/null | tail -1 | awk '{print \$3}' | xargs -I{} printf '\\n\\033[33m━━━ session: {} ━━━\\033[0m\\n'; } || true; exit \$pipeline_status; }" +launch_cmd="set -o pipefail && cd $qroot && { rm -f $qlast_message; agy --dangerously-skip-permissions --add-dir $qroot --print-timeout 30m --print \"\$(cat $qruntime)\" 2>&1 | tee -a $qtranscript; pipeline_status=\$?; $last_message_extract $salvage_success_report $salvage_failure_report echo; { grep -oE '\\[[0-9]{2}:[0-9]{2}:[0-9]{2}\\] session: [[:alnum:]-]+' $qtranscript 2>/dev/null | tail -1 | awk '{print \$3}' | xargs -I{} printf '\\n\\033[33m━━━ session: {} ━━━\\033[0m\\n'; } || true; exit \$pipeline_status; }" combined_success="${agy_success_hook}${success_hook_extra:+ $success_hook_extra}" diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_spawn.sh b/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_spawn.sh deleted file mode 100755 index 102a9129..00000000 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_spawn.sh +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=common.sh -source "$SCRIPT_DIR/common.sh" - -usage() { - cat <] [--runtime ] [--model ] [--root ] [--dry-run] - -Portable Gemini spawn wrapper. -EOF_USAGE -} - -mode="implement" -runtime="terminal" -model="${GEMINI_MODEL:-}" -root="" -plan_file="" -dry_run=0 -success_hook_extra="" -failure_hook_extra="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --mode) - shift - [[ $# -gt 0 ]] || spawn_die "Missing value for --mode" - mode="$1" - ;; - --runtime) - shift - [[ $# -gt 0 ]] || spawn_die "Missing value for --runtime" - runtime="$1" - ;; - --model) - shift - [[ $# -gt 0 ]] || spawn_die "Missing value for --model" - model="$1" - ;; - --root) - shift - [[ $# -gt 0 ]] || spawn_die "Missing value for --root" - root="$1" - ;; - --dry-run) - dry_run=1 - ;; - --success-hook) - shift - [[ $# -gt 0 ]] || spawn_die "Missing value for --success-hook" - success_hook_extra="$1" - ;; - --failure-hook) - shift - [[ $# -gt 0 ]] || spawn_die "Missing value for --failure-hook" - failure_hook_extra="$1" - ;; - -h|--help) - usage - exit 0 - ;; - *) - [[ -z "$plan_file" ]] || spawn_die "Unexpected argument: $1" - plan_file="$1" - ;; - esac - shift -done - -[[ -n "$plan_file" ]] || { - usage - exit 1 -} -spawn_require_file "$plan_file" -spawn_validate_runtime "$runtime" -spawn_prepare_paths gemini "$plan_file" "$root" "$mode" -spawn_scan_active "${SPAWN_LOG_DIR:-$SPAWN_REPORT_DIR}" -runtime_input="$SPAWN_TMP_DIR/${SPAWN_TS}_${SPAWN_RUN_ID}_${SPAWN_SLUG}_gemini_prompt.md" -spawn_build_runtime_prompt "$SPAWN_PLAN" "$runtime_input" "$SPAWN_REPORT" gemini -spawn_write_meta "$SPAWN_META" "launching" "gemini" "$mode" "$SPAWN_ROOT" "$SPAWN_PLAN" "$SPAWN_REPORT" "$SPAWN_TRANSCRIPT" "$SPAWN_LAUNCHER" "$model" - -if (( !dry_run )); then - spawn_require_command gemini -fi - -qroot="$(spawn_shell_quote "$SPAWN_ROOT")" -qruntime="$(spawn_shell_quote "$runtime_input")" -qreport="$(spawn_shell_quote "$SPAWN_REPORT")" -qtranscript="$(spawn_shell_quote "$SPAWN_TRANSCRIPT")" -qlast_message="$(spawn_shell_quote "${SPAWN_TRANSCRIPT%.log}.last-message.md")" -qmodel="$(spawn_shell_quote "$model")" - -# shellcheck disable=SC2016 -gemini_success_hook=' - if [[ ! -s "$report" ]]; then - spawn_write_frontmatter "$report" "$SPAWN_AGENT" "${SPAWN_MODEL:-unknown}" "completed" - cat >> "$report" < $qreport || pipeline_status=\$?; fi;" -salvage_failure_report="if [[ \$pipeline_status -ne 0 && ! -s $qreport ]]; then { printf '%s\n' '---'; printf 'run_id: %s\n' \"\${SPAWN_RUN_ID:-unknown}\"; printf 'prompt_id: %s\n' \"\${SPAWN_PROMPT_ID:-unknown}\"; printf 'agent: %s\n' \"\${SPAWN_AGENT:-gemini}\"; printf 'skill: %s\n' \"\${SPAWN_SKILL_CODE:-unknown}\"; printf 'model: %s\n' \"\${SPAWN_MODEL:-unknown}\"; printf 'status: failed\n'; printf 'session_id: %s\n' \"\${SPAWN_SESSION_ID:-pending}\"; printf 'repo_path: %s\n' \"\${SPAWN_ROOT:-unknown}\"; printf 'tokens_input: 0\n'; printf 'tokens_output: 0\n'; printf 'tokens_total: 0\n'; printf 'cost_usd: unknown\n'; printf '%s\n\n' '---'; if [[ -s $qlast_message ]]; then cat $qlast_message; else printf '%s\n' 'Gemini failed before writing a standalone report file, and no final message was captured.'; printf '%s\n' 'See transcript for the full event stream:'; printf '%s\n' $qtranscript; printf '%s\n' 'Last message path checked:'; printf '%s\n' $qlast_message; fi; } > $qreport; fi;" -launch_cmd="set -o pipefail && cd $qroot && { rm -f $qlast_message; GEMINI_FORCE_FILE_STORAGE=true gemini -p '' --approval-mode yolo $model_flag --include-directories $qvhome -o stream-json < $qruntime 2>&1 | grep --line-buffered '^{' | jq --unbuffered -rj -f $qfilter | tee -a $qtranscript; pipeline_status=\$?; $last_message_extract $salvage_success_report $salvage_failure_report exit \$pipeline_status; }" - -# Combine built-in hooks with caller-provided hooks (marbles chain, etc.) -combined_success="${gemini_success_hook}${success_hook_extra:+ -$success_hook_extra}" -combined_failure="${gemini_failure_hook}${failure_hook_extra:+ -$failure_hook_extra}" - -spawn_generate_launcher "$SPAWN_LAUNCHER" \ - "$SPAWN_META" \ - "$SPAWN_REPORT" \ - "$SPAWN_TRANSCRIPT" \ - "$SCRIPT_DIR/common.sh" \ - "$launch_cmd" \ - "" \ - "$combined_success" \ - "$combined_failure" - -chmod +x "$SPAWN_LAUNCHER" -spawn_print_launch gemini "$mode" "$runtime" "$dry_run" -[[ -n "$model" ]] && printf ' model: %s\n' "$model" || printf ' model: (CLI default)\n' -spawn_launch "$SPAWN_LAUNCHER" "$runtime" "$dry_run" "gemini-${VIBECRAFTED_SKILL_NAME:-$mode}" -if [[ "${VIBECRAFTED_SUPPRESS_REPORT_HINT:-0}" != "1" ]]; then - if (( dry_run )); then - printf 'Dry run: agent not launched.\n' - else - printf 'Agent launched.\n' - bash "$SCRIPT_DIR/await.sh" gemini --describe "$SPAWN_LAUNCHER" 2>/dev/null || true - printf '\nAwait:\n\n' - printf 'vibecrafted gemini await --run-id %s\n' "$SPAWN_RUN_ID" - fi -fi diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_stream_filter.jq b/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_stream_filter.jq deleted file mode 100644 index e42fb5a6..00000000 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_stream_filter.jq +++ /dev/null @@ -1,68 +0,0 @@ -# Gemini CLI stream-json filter -# Handles both older format (type=message, tool_use, tool_result) -# and Gemini 3.1 format (type=gemini with thoughts[] + toolCalls[]) -# Verified against real output 2026-03-27 (older) + 2026-04-12 (3.1) - -def stamp: (now | strftime("%H:%M:%S")); -def tool_tag($name): "\u001b[36m[" + stamp + " " + $name + "]\u001b[0m "; - -# Helper: emit thoughts array as dimmed reasoning lines -def emit_thoughts: - ((.thoughts // [])[] | - "\u001b[2m[" + stamp + " thinking] " + (.subject // "?") + ": " + (.description // "") + "\u001b[0m\n" - ); - -if .type == "init" then - "\u001b[33m[" + stamp + "] session: " + (.session_id // "?") + "\u001b[0m" - + (if .model then " (" + .model + ")" else "" end) + "\n" - -# Gemini 3.1 format: type=gemini with thoughts, content, toolCalls -elif .type == "gemini" then - # Emit thoughts as reasoning - emit_thoughts, - # Emit content if non-empty - (if (.content // "") != "" then .content else empty end), - # Emit tool calls - ((.toolCalls // [])[] | - "\n" + tool_tag(.name // "?") - ) - -# Older format: type=message with role=assistant -elif .type == "message" then - if .role == "assistant" then - # Support thoughts on older message events too (hybrid format) - emit_thoughts, - (if (.content // "") != "" then .content else empty end) - else empty end - -elif .type == "tool_use" then - "\n" + tool_tag(.tool_name // .name // "?") - -elif .type == "tool_result" then - (.output // "") as $out | - if ($out | length) > 0 then - ($out | split("\n")) as $lines | - if ($lines | length) > 12 then - "\u001b[2m" + ($lines[0:5] | join("\n")) + "\n ... (" + ($lines | length | tostring) + " lines)\u001b[0m\n" - elif ($out | length) > 500 then - "\u001b[2m" + $out[0:400] + " ...\u001b[0m\n" - else - "\u001b[2m" + $out + "\u001b[0m\n" - end - else empty end - -elif .type == "error" then - "\u001b[31m[" + stamp + " error] " + (.message // .error // "unknown") + "\u001b[0m\n" - -elif .type == "result" then - "\n\u001b[32m[" + stamp + "] " + (.status // "done") + "\u001b[0m" - + (if .stats then - " \u001b[2m" + (.stats.input_tokens | tostring) + " in / " - + (.stats.output_tokens | tostring) + " out" - + " / " + (.stats.duration_ms | tostring) + "ms" - + (if .stats.tool_calls then " / " + (.stats.tool_calls | tostring) + " tools" else "" end) - + "\u001b[0m" - else "" end) - + "\n" - -else empty end diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_stream_filter.sh b/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_stream_filter.sh deleted file mode 100755 index 09994914..00000000 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/gemini_stream_filter.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Gemini CLI output cleaner -# Removes: 429 retry stacktraces (massive), MCP bootstrap chatter -# Keeps: agent reasoning, errors (real ones), tool calls, findings - -awk ' - # 429 retry block detection — starts with "Attempt N failed" and ends ~115 lines later - # These contain full GaxiosError with headers, response body, config — pure noise - /^Attempt [0-9]+ failed with status 429/ { - printf "\033[2m [429 retry — model capacity exhausted]\033[0m\n" - # Skip until next non-indented non-error line or next Attempt or real content - skip = 1 - next - } - - # Inside a 429 block — skip indented lines and JSON error structure - skip && /^[[:space:]]/ { next } - skip && /^[{}]/ { next } - skip && /^ "/ { next } - skip && /^ at / { next } - skip && /Symbol\(/ { next } - skip && /^\]/ { next } - skip { skip = 0 } - - # MCP bootstrap noise — collapse to one line - /^Registering notification handlers/ { next } - /Server .* has tools but did not declare/ { next } - /Server .* supports tool updates/ { next } - /Scheduling MCP context refresh/ { next } - /Executing MCP context refresh/ { next } - /MCP context refresh complete/ { - printf "\033[2m [MCP servers connected]\033[0m\n" - next - } - - # Keychain noise - /^Keychain initialization encountered/ { next } - /^Require stack:/ { next } - /keytar\.js$/ { next } - /^Using FileKeychain fallback/ { next } - - # Duplicate YOLO line - /^YOLO mode is enabled/ { - if (!yolo_seen) { print; yolo_seen = 1 } - next - } - - # Duplicate "Loaded cached credentials" - /^Loaded cached credentials/ { - if (!creds_seen) { print; creds_seen = 1 } - next - } - - # Everything else passes through - { print } -' diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/grok_spawn.sh b/vibecrafted-core/vibecrafted_core/runtime/scripts/grok_spawn.sh index 4a01272c..18acff87 100755 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/grok_spawn.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/scripts/grok_spawn.sh @@ -123,7 +123,7 @@ model_flag="" last_message_extract="if [[ -s $qtranscript ]]; then cp $qtranscript $qlast_message 2>/dev/null || rm -f $qlast_message; [[ -s $qlast_message ]] || rm -f $qlast_message; fi;" salvage_success_report="if [[ \$pipeline_status -eq 0 && ! -s $qreport && -s $qlast_message ]]; then { printf '%s\n' '---'; printf 'run_id: %s\n' \"\${SPAWN_RUN_ID:-unknown}\"; printf 'prompt_id: %s\n' \"\${SPAWN_PROMPT_ID:-unknown}\"; printf 'agent: %s\n' \"\${SPAWN_AGENT:-grok}\"; printf 'skill: %s\n' \"\${SPAWN_SKILL_CODE:-unknown}\"; printf 'model: %s\n' \"\${SPAWN_MODEL:-unknown}\"; printf 'status: completed\n'; printf 'session_id: %s\n' \"\${SPAWN_SESSION_ID:-pending}\"; printf 'repo_path: %s\n' \"\${SPAWN_ROOT:-unknown}\"; printf 'tokens_input: 0\n'; printf 'tokens_output: 0\n'; printf 'tokens_total: 0\n'; printf 'cost_usd: unknown\n'; printf '%s\n\n' '---'; cat $qlast_message; } > $qreport || pipeline_status=\$?; fi;" salvage_failure_report="if [[ \$pipeline_status -ne 0 && ! -s $qreport ]]; then { printf '%s\n' '---'; printf 'run_id: %s\n' \"\${SPAWN_RUN_ID:-unknown}\"; printf 'prompt_id: %s\n' \"\${SPAWN_PROMPT_ID:-unknown}\"; printf 'agent: %s\n' \"\${SPAWN_AGENT:-grok}\"; printf 'skill: %s\n' \"\${SPAWN_SKILL_CODE:-unknown}\"; printf 'model: %s\n' \"\${SPAWN_MODEL:-unknown}\"; printf 'status: failed\n'; printf 'session_id: %s\n' \"\${SPAWN_SESSION_ID:-pending}\"; printf 'repo_path: %s\n' \"\${SPAWN_ROOT:-unknown}\"; printf 'tokens_input: 0\n'; printf 'tokens_output: 0\n'; printf 'tokens_total: 0\n'; printf 'cost_usd: unknown\n'; printf '%s\n\n' '---'; if [[ -s $qlast_message ]]; then cat $qlast_message; else printf '%s\n' 'Grok failed before writing a standalone report file, and no final message was captured.'; printf '%s\n' 'See transcript for the full event stream:'; printf '%s\n' $qtranscript; printf '%s\n' 'Last message path checked:'; printf '%s\n' $qlast_message; fi; } > $qreport; fi;" -launch_cmd="set -o pipefail && cd $qroot && { rm -f $qlast_message; grok --cwd $qroot --permission-mode bypassPermissions --no-alt-screen --output-format plain --prompt-file $qruntime $model_flag 2>&1 | tee -a $qtranscript; pipeline_status=\$?; $last_message_extract $salvage_success_report $salvage_failure_report exit \$pipeline_status; }" +launch_cmd="set -o pipefail && cd $qroot && { rm -f $qlast_message; grok --cwd $qroot --permission-mode bypassPermissions --no-alt-screen --output-format streaming-json --prompt-file $qruntime $model_flag 2>&1 | tee -a $qtranscript; pipeline_status=\$?; $last_message_extract $salvage_success_report $salvage_failure_report exit \$pipeline_status; }" combined_success="${grok_success_hook}${success_hook_extra:+ $success_hook_extra}" diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/junie_spawn.sh b/vibecrafted-core/vibecrafted_core/runtime/scripts/junie_spawn.sh index 07851701..b63ccbd7 100755 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/junie_spawn.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/scripts/junie_spawn.sh @@ -127,7 +127,7 @@ timeout_flag="" last_message_extract="if [[ -s $qtranscript ]]; then cp $qtranscript $qlast_message 2>/dev/null || rm -f $qlast_message; [[ -s $qlast_message ]] || rm -f $qlast_message; fi;" salvage_success_report="if [[ \$pipeline_status -eq 0 && ! -s $qreport && -s $qlast_message ]]; then { printf '%s\n' '---'; printf 'run_id: %s\n' \"\${SPAWN_RUN_ID:-unknown}\"; printf 'prompt_id: %s\n' \"\${SPAWN_PROMPT_ID:-unknown}\"; printf 'agent: %s\n' \"\${SPAWN_AGENT:-junie}\"; printf 'skill: %s\n' \"\${SPAWN_SKILL_CODE:-unknown}\"; printf 'model: %s\n' \"\${SPAWN_MODEL:-unknown}\"; printf 'status: completed\n'; printf 'session_id: %s\n' \"\${SPAWN_SESSION_ID:-pending}\"; printf 'repo_path: %s\n' \"\${SPAWN_ROOT:-unknown}\"; printf 'tokens_input: 0\n'; printf 'tokens_output: 0\n'; printf 'tokens_total: 0\n'; printf 'cost_usd: unknown\n'; printf '%s\n\n' '---'; cat $qlast_message; } > $qreport || pipeline_status=\$?; fi;" salvage_failure_report="if [[ \$pipeline_status -ne 0 && ! -s $qreport ]]; then { printf '%s\n' '---'; printf 'run_id: %s\n' \"\${SPAWN_RUN_ID:-unknown}\"; printf 'prompt_id: %s\n' \"\${SPAWN_PROMPT_ID:-unknown}\"; printf 'agent: %s\n' \"\${SPAWN_AGENT:-junie}\"; printf 'skill: %s\n' \"\${SPAWN_SKILL_CODE:-unknown}\"; printf 'model: %s\n' \"\${SPAWN_MODEL:-unknown}\"; printf 'status: failed\n'; printf 'session_id: %s\n' \"\${SPAWN_SESSION_ID:-pending}\"; printf 'repo_path: %s\n' \"\${SPAWN_ROOT:-unknown}\"; printf 'tokens_input: 0\n'; printf 'tokens_output: 0\n'; printf 'tokens_total: 0\n'; printf 'cost_usd: unknown\n'; printf '%s\n\n' '---'; if [[ -s $qlast_message ]]; then cat $qlast_message; else printf '%s\n' 'Junie failed before writing a standalone report file, and no final message was captured.'; printf '%s\n' 'See transcript for the full event stream:'; printf '%s\n' $qtranscript; printf '%s\n' 'Last message path checked:'; printf '%s\n' $qlast_message; fi; } > $qreport; fi;" -launch_cmd="set -o pipefail && cd $qroot && { rm -f $qlast_message; junie --project=$qroot --task=\"\$(cat $qruntime)\" --skip-update-check --use-local-cache --output-format=text $model_flag $review_flag $timeout_flag 2>&1 | tee -a $qtranscript; pipeline_status=\$?; $last_message_extract $salvage_success_report $salvage_failure_report exit \$pipeline_status; }" +launch_cmd="set -o pipefail && cd $qroot && { rm -f $qlast_message; junie --project=$qroot --task=\"\$(cat $qruntime)\" --skip-update-check --use-local-cache --input-format=text --output-format=json-stream $model_flag $review_flag $timeout_flag 2>&1 | tee -a $qtranscript; pipeline_status=\$?; $last_message_extract $salvage_success_report $salvage_failure_report exit \$pipeline_status; }" combined_success="${junie_success_hook}${success_hook_extra:+ $success_hook_extra}" diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/util.sh b/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/util.sh index 3849de7a..5aeeac60 100755 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/util.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/scripts/lib/util.sh @@ -176,7 +176,7 @@ spawn_validate_runtime() { # Normalize a model frontmatter value: empty string for placeholders # (`pending`, `unknown`, `null`), pass-through otherwise. Used by every # marbles dispatch site so a single point of truth gates which model values -# reach `claude_spawn.sh --model` / `gemini_spawn.sh --model`. Adding a new +# reach `claude_spawn.sh --model` / `codex_spawn.sh --model`. Adding a new # placeholder token here propagates to every consumer automatically. spawn_clean_model() { local raw="${1:-}" diff --git a/vibecrafted-core/vibecrafted_core/runtime/scripts/skills_sync.sh b/vibecrafted-core/vibecrafted_core/runtime/scripts/skills_sync.sh index 0dd4311b..bc30fbd5 100755 --- a/vibecrafted-core/vibecrafted_core/runtime/scripts/skills_sync.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/scripts/skills_sync.sh @@ -150,9 +150,6 @@ rsync_args=(-az --exclude '.DS_Store' --exclude '.backup' --exclude '.loctree' - if (( mirror )); then rsync_args+=(--delete) fi -if (( dry_run )); then - rsync_args+=(--dry-run --itemize-changes) -fi printf 'Syncing skills from %s to %s\n' "$repo_root" "$host" # shellcheck disable=SC2088,SC2016 @@ -168,6 +165,32 @@ else ssh -n "$host" "mkdir -p $remote_tools_target/skills && ln -sfn $remote_tools_target $remote_current_link" \ || die "Could not prepare staged tools store on $host" fi + +rule_files=() +localized_rule_dirs=(pl) +for rule_name in VERIFICATION_RULE.md LIVING_TREE_RULE.md; do + [[ -f "$skills_root/$rule_name" ]] && rule_files+=("$rule_name") +done +for localized_dir in "${localized_rule_dirs[@]}"; do + for rule_name in VERIFICATION_RULE.md LIVING_TREE_RULE.md; do + rule_path="$localized_dir/$rule_name" + [[ -f "$skills_root/$rule_path" ]] && rule_files+=("$rule_path") + done +done +for rule_path in "${rule_files[@]}"; do + remote_rule_dir="$(dirname "$remote_tools_target/skills/$rule_path")" + if (( dry_run )); then + printf ' ssh %s mkdir -p %s\n' "$host" "$remote_rule_dir" + else + ssh -n "$host" "mkdir -p $remote_rule_dir" \ + || die "Could not create $remote_rule_dir on $host" + fi + if (( dry_run )); then + printf ' rsync %s %s %s:%s\n' "${rsync_args[*]}" "$skills_root/$rule_path" "$host" "$remote_tools_target/skills/$rule_path" + else + rsync "${rsync_args[@]}" "$skills_root/$rule_path" "$host:$remote_tools_target/skills/$rule_path" + fi +done for skill in "${skills[@]}"; do name="$(basename "$skill")" if (( ! dry_run )); then @@ -175,7 +198,11 @@ for skill in "${skills[@]}"; do else printf ' ssh %s mkdir -p %s/skills/%s\n' "$host" "$remote_tools_target" "$name" fi - rsync "${rsync_args[@]}" "$skill/" "$host:$remote_tools_target/skills/$name/" + if (( dry_run )); then + printf ' rsync %s %s %s:%s\n' "${rsync_args[*]}" "$skill/" "$host" "$remote_tools_target/skills/$name/" + else + rsync "${rsync_args[@]}" "$skill/" "$host:$remote_tools_target/skills/$name/" + fi done printf '\n' @@ -187,6 +214,20 @@ for tool in "${tools[@]}"; do else ssh -n "$host" "mkdir -p $remote_target" || die "Could not prepare $remote_target on $host" fi + for rule_path in "${rule_files[@]}"; do + remote_rule_dir="$(dirname "$remote_target/$rule_path")" + if (( dry_run )); then + printf ' ssh %s mkdir -p %s\n' "$host" "$remote_rule_dir" + else + ssh -n "$host" "mkdir -p $remote_rule_dir" \ + || die "Could not create $remote_rule_dir on $host" + fi + if (( dry_run )); then + printf ' rsync %s %s %s:%s\n' "${rsync_args[*]}" "$skills_root/$rule_path" "$host" "$remote_target/$rule_path" + else + rsync "${rsync_args[@]}" "$skills_root/$rule_path" "$host:$remote_target/$rule_path" + fi + done for skill in "${skills[@]}"; do name="$(basename "$skill")" if (( dry_run )); then @@ -213,8 +254,16 @@ if (( with_shell )); then remote_legacy_target="$remote_legacy_dir/vc-skills.zsh" printf 'Syncing optional shell helper layer to %s\n' "$host" - ssh -n "$host" "mkdir -p $remote_helper_dir $remote_legacy_dir" || die "Could not prepare helper dirs on $host" - rsync "${rsync_args[@]}" "$shell_source" "$host:$remote_shell_target" + if (( dry_run )); then + printf ' ssh %s mkdir -p %s %s\n' "$host" "$remote_helper_dir" "$remote_legacy_dir" + else + ssh -n "$host" "mkdir -p $remote_helper_dir $remote_legacy_dir" || die "Could not prepare helper dirs on $host" + fi + if (( dry_run )); then + printf ' rsync %s %s %s:%s\n' "${rsync_args[*]}" "$shell_source" "$host" "$remote_shell_target" + else + rsync "${rsync_args[@]}" "$shell_source" "$host:$remote_shell_target" + fi if (( dry_run )); then printf ' ssh %s ln -sfn %s %s\n' "$host" "$remote_shell_target" "$remote_legacy_target" else @@ -227,8 +276,12 @@ if (( with_shell )); then printf 'Skipping remote $HOME/.zshrc update (--no-zshrc).\n' else remote_zshrc="\$HOME/.zshrc" - ssh -n "$host" "touch ${remote_zshrc} && if ! grep -Fqx '$source_line' ${remote_zshrc}; then printf '\n# Vetcoders shell helpers\n%s\n' '$source_line' >> ${remote_zshrc}; fi" \ - || die "Could not update $HOME/.zshrc on $host" + if (( dry_run )); then + printf ' ssh %s touch %s && if ! grep -Fqx '"'"'%s'"'"' %s; then printf ... >> %s; fi\n' "$host" "$remote_zshrc" "$source_line" "$remote_zshrc" "$remote_zshrc" + else + ssh -n "$host" "touch ${remote_zshrc} && if ! grep -Fqx '$source_line' ${remote_zshrc}; then printf '\n# Vetcoders shell helpers\n%s\n' '$source_line' >> ${remote_zshrc}; fi" \ + || die "Could not update $HOME/.zshrc on $host" + fi fi if (( shell_no_bashrc )); then @@ -236,8 +289,12 @@ if (( with_shell )); then printf 'Skipping remote $HOME/.bashrc update (--no-bashrc).\n' else remote_bashrc="\$HOME/.bashrc" - ssh -n "$host" "touch ${remote_bashrc} && if ! grep -Fqx '$source_line' ${remote_bashrc}; then printf '\n# Vetcoders shell helpers\n%s\n' '$source_line' >> ${remote_bashrc}; fi" \ - || die "Could not update $HOME/.bashrc on $host" + if (( dry_run )); then + printf ' ssh %s touch %s && if ! grep -Fqx '"'"'%s'"'"' %s; then printf ... >> %s; fi\n' "$host" "$remote_bashrc" "$source_line" "$remote_bashrc" "$remote_bashrc" + else + ssh -n "$host" "touch ${remote_bashrc} && if ! grep -Fqx '$source_line' ${remote_bashrc}; then printf '\n# Vetcoders shell helpers\n%s\n' '$source_line' >> ${remote_bashrc}; fi" \ + || die "Could not update $HOME/.bashrc on $host" + fi fi printf '\n' diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/marbles.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/marbles.sh index 3c755f23..d1cfbd7f 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/marbles.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/marbles.sh @@ -144,8 +144,22 @@ _vetcoders_resume_agent() { local tool="$1" shift _vetcoders_parse_contract "$@" || return 1 + # Positional form: `vc-resume [prompt words...]`. + # Without --session the shared parser routes positionals into tail/prompt, + # but a resume cannot run without a session — promote the first tail token. + if [[ -z "$_vetcoders_contract_session" && -n "$_vetcoders_contract_tail" ]]; then + local -a _resume_positional=() + read -r -a _resume_positional <<<"$_vetcoders_contract_tail" + _vetcoders_contract_session="${_resume_positional[0]}" + local _resume_rest="${_vetcoders_contract_tail#"${_resume_positional[0]}"}" + _resume_rest="${_resume_rest# }" + if [[ "$_vetcoders_contract_prompt" == "$_vetcoders_contract_tail" ]]; then + _vetcoders_contract_prompt="$_resume_rest" + fi + _vetcoders_contract_tail="$_resume_rest" + fi [[ -n "$_vetcoders_contract_session" ]] || { - echo "Usage: vc-resume [] --session [--prompt ] [--file ]" >&2 + echo "Usage: vc-resume [] [prompt ...] | --session [--prompt ] [--file ]" >&2 return 1 } [[ -z "$_vetcoders_contract_count" ]] || { @@ -249,13 +263,14 @@ _vetcoders_resume_command() { fi ;; agy) - # agy resumes by --conversation ; headless needs --print - # (--prompt-interactive is, as the name says, interactive). + # agy resumes by --conversation ; headless needs --print . + # Since agy 1.1 --print takes the prompt as its VALUE (Go flags) and + # print mode reads no stdin — flags first, prompt as the flag value. if [[ "$mode" == headless ]]; then if [[ -n "$resume_prompt" ]]; then - printf 'agy --print --dangerously-skip-permissions --conversation %s %s\n' "$quoted_session" "$quoted_prompt" + printf 'agy --dangerously-skip-permissions --conversation %s --print %s\n' "$quoted_session" "$quoted_prompt" else - printf 'agy --print --dangerously-skip-permissions --conversation %s\n' "$quoted_session" + printf 'agy --dangerously-skip-permissions --conversation %s --print "Continue."\n' "$quoted_session" fi elif [[ -n "$resume_prompt" ]]; then printf 'agy --conversation %s --prompt-interactive %s\n' "$quoted_session" "$quoted_prompt" @@ -275,10 +290,12 @@ _vetcoders_resume_command() { grok) # grok --single is one-shot non-interactive. NEVER pass --restore-code: it # checks out the original session's commit and would clobber the working tree. + # Use streaming-json (matching verified python lane + grok 0.2.97 headless) + # so session/usage parse via JSON_TOKEN_PATTERNS works from transcript. if [[ -n "$resume_prompt" ]]; then - printf 'grok --resume %s --cwd . --permission-mode bypassPermissions --no-alt-screen --output-format plain --single %s\n' "$quoted_session" "$quoted_prompt" + printf 'grok --resume %s --cwd . --permission-mode bypassPermissions --no-alt-screen --output-format streaming-json --single %s\n' "$quoted_session" "$quoted_prompt" else - printf 'grok --resume %s --cwd . --permission-mode bypassPermissions --no-alt-screen --output-format plain\n' "$quoted_session" + printf 'grok --resume %s --cwd . --permission-mode bypassPermissions --no-alt-screen --output-format streaming-json\n' "$quoted_session" fi ;; *) @@ -326,7 +343,7 @@ PY vc-resume() { local tool="${1:-}" [[ -n "$tool" ]] || { - echo "Usage: vc-resume [] --session [--prompt ] [--file ]" >&2 + echo "Usage: vc-resume [] [prompt ...] | --session [--prompt ] [--file ]" >&2 return 1 } if [[ "$tool" == "--session" ]]; then diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/observe.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/observe.sh index d507d77e..a70051e4 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/observe.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/observe.sh @@ -58,7 +58,7 @@ _vetcoders_print_launch_receipt() { [[ -z "$transcript" ]] || printf 'transcript: %s\n' "$transcript" [[ -z "$launcher" ]] || printf 'launcher: %s\n' "$launcher" printf 'observe: vibecrafted %s observe --run-id %s\n' "$tool" "$run_id" - printf 'await: vibecrafted %s await --run-id %s\n' "$tool" "$run_id" + printf 'await (ARM NOW, supervisor-side): vibecrafted %s await --run-id %s\n' "$tool" "$run_id" printf '=====================================================================\n' } @@ -94,4 +94,3 @@ _vetcoders_loop() { } bash "$script" "$@" } - diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh index 6e45dafc..f58462ab 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/operator.sh @@ -46,7 +46,7 @@ _vetcoders_init_command_text() { printf 'gemini -y -i %s' "$quoted_prompt" ;; agy) - printf 'agy --prompt-interactive --dangerously-skip-permissions --add-dir . %s' "$quoted_prompt" + printf 'agy --dangerously-skip-permissions --add-dir . --prompt-interactive %s' "$quoted_prompt" ;; junie) printf 'junie --task=%s --project=. --skip-update-check --use-local-cache' "$quoted_prompt" @@ -112,7 +112,7 @@ _vetcoders_operator_command_text() { printf 'gemini -y -i %s' "$quoted_prompt" ;; agy) - printf 'agy --prompt-interactive --dangerously-skip-permissions --add-dir . %s' "$quoted_prompt" + printf 'agy --dangerously-skip-permissions --add-dir . --prompt-interactive %s' "$quoted_prompt" ;; junie) printf 'junie --task=%s --project=. --skip-update-check --use-local-cache' "$quoted_prompt" diff --git a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/vc_frame.sh b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/vc_frame.sh index 3f21422a..b16c4aae 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/shell/lib/vc_frame.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/shell/lib/vc_frame.sh @@ -5,6 +5,7 @@ _vetcoders_vc_frame_missing_message() { local xdg_data_home="${XDG_DATA_HOME:-$HOME/.local/share}" local runtime_bin="${VIBECRAFTED_RUNTIME_BIN:-${VIBECRAFTED_RUNTIME_HOME:-$xdg_data_home/vibecrafted}/bin}" echo "vc-frame is required for the Vibecrafted operator runtime." >&2 + echo "Run 'vc-start' first to create or attach the operator vc-frame session, then retry." >&2 echo "Expected vc-frame on PATH or bundled at: $runtime_bin/vc-frame" >&2 } @@ -424,6 +425,8 @@ _vetcoders_spawn_into_operator_session() { local layout_file state local cmd_script local vc_frame_bin="" + local run_id="${VIBECRAFTED_RUN_ID:-interactive}" + local status=0 _vetcoders_require_vc_frame || return 1 vc_frame_bin="$(_vetcoders_vc_frame_bin)" || return 1 @@ -444,8 +447,18 @@ _vetcoders_spawn_into_operator_session() { # readable trail for debugging. cmd_script="$(_vetcoders_tmp_script_path "vc-spawn-cmd" "$root_dir")" _vetcoders_write_command_script "$cmd_script" "$command_text" || return 1 - "$vc_frame_bin" --session "$session_name" action new-tab \ + if "$vc_frame_bin" --session "$session_name" action new-tab \ --name "$tab_name" \ --cwd "$root_dir" \ - -- "$cmd_script" >/dev/null + -- "$cmd_script" >/dev/null; then + printf 'launch accepted: run_id=%s target=%s/%s watch=vc-frame attach %s\n' \ + "$run_id" "$session_name" "$tab_name" "$session_name" + return 0 + else + status=$? + fi + + printf 'launch failed: run_id=%s target=%s/%s status=%s\n' \ + "$run_id" "$session_name" "$tab_name" "$status" >&2 + return "$status" } diff --git a/vibecrafted-core/vibecrafted_core/runtime/vc-research/research.yaml.example b/vibecrafted-core/vibecrafted_core/runtime/vc-research/research.yaml.example new file mode 100644 index 00000000..9b14018d --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/runtime/vc-research/research.yaml.example @@ -0,0 +1,24 @@ +# Runtime-owned vc-research swarm config. +# Copy to: ${VIBECRAFTED_HOME:-$HOME/.vibecrafted}/config/research.yaml +# This file is read at launch time; it is not install-owned. + +lanes: + - agent: claude + # model: claude-opus-4-6 + enabled: true + - agent: codex + # model: gpt-5.5 + enabled: true + - agent: agy + # Agy currently has no supported model flag in Vibecrafted. + # A configured model is reported as unsupported, not silently applied. + # model: unsupported-example + enabled: true + +# Optional cap after disabled lanes are removed. +# lane_count: 3 + +synthesizer: + # If omitted, synthesis resumes the last surviving lane. + agent: claude + # model: claude-sonnet-4-6 diff --git a/vibecrafted-core/vibecrafted_core/runtime/vc-research/shell/research.sh b/vibecrafted-core/vibecrafted_core/runtime/vc-research/shell/research.sh index adea6135..44d7a886 100644 --- a/vibecrafted-core/vibecrafted_core/runtime/vc-research/shell/research.sh +++ b/vibecrafted-core/vibecrafted_core/runtime/vc-research/shell/research.sh @@ -62,6 +62,9 @@ _vetcoders_user_config_path() { _vetcoders_research_config_paths() { local config_path manifest + [[ -n "${VIBECRAFTED_RESEARCH_CONFIG:-}" && -f "${VIBECRAFTED_RESEARCH_CONFIG}" ]] && printf '%s\n' "${VIBECRAFTED_RESEARCH_CONFIG}" + [[ -f "${VIBECRAFTED_HOME:-$HOME/.vibecrafted}/config/research.yaml" ]] && printf '%s\n' "${VIBECRAFTED_HOME:-$HOME/.vibecrafted}/config/research.yaml" + config_path="$(_vetcoders_user_config_path 2>/dev/null || true)" [[ -n "$config_path" ]] && printf '%s\n' "$config_path" @@ -102,23 +105,61 @@ import sys import tomllib path = sys.argv[1] -with open(path, "rb") as handle: - data = tomllib.load(handle) - -agents = ( - data.get("runtime", {}) - .get("picking", {}) - .get("research", {}) - .get("default_agents", []) -) - -printed = False -for agent in agents: - if isinstance(agent, str) and agent.strip(): - print(agent.strip()) - printed = True - -sys.exit(0 if printed else 3) +if path.endswith((".yaml", ".yml")): + agents = [] + in_lanes = False + current = None + with open(path, "r", encoding="utf-8") as handle: + for raw in handle: + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + stripped = line.strip() + if not raw.startswith((" ", "\t")): + in_lanes = stripped == "lanes:" + current = None + if stripped.startswith("agents:") and "[" in stripped and "]" in stripped: + inner = stripped.split("[", 1)[1].rsplit("]", 1)[0] + agents.extend(part.strip().strip("\"'\''") for part in inner.split(",")) + continue + if in_lanes and stripped.startswith("-"): + current = {"enabled": "true"} + body = stripped[1:].strip() + if body.startswith("agent:"): + current["agent"] = body.split(":", 1)[1].strip().strip("\"'\''") + agents.append(current) + continue + if in_lanes and current is not None and ":" in stripped: + key, value = stripped.split(":", 1) + current[key.strip()] = value.strip().strip("\"'\''") + for item in agents: + if isinstance(item, dict): + if item.get("enabled", "true").lower() in {"false", "no", "off"}: + continue + agent = item.get("agent", "") + else: + agent = item + if agent: + print(agent) + sys.exit(0 if agents else 3) +else: + with open(path, "rb") as handle: + data = tomllib.load(handle) + + agents = ( + data.get("runtime", {}) + .get("picking", {}) + .get("research", {}) + .get("default_agents", []) + ) + + printed = False + for agent in agents: + if isinstance(agent, str) and agent.strip(): + print(agent.strip()) + printed = True + + sys.exit(0 if printed else 3) ' "$config_path")" parse_status=$? if (( parse_status == 0 )); then @@ -227,26 +268,30 @@ _vetcoders_research_help() { ───────────────────────────────────────── Configurable triple-agent research swarm launcher. -Usage: - vc-research --prompt "Question to research" - vc-research --file /path/to/plan.md - vc-research uno --prompt "Question to research" - vc-research uno --file /path/to/plan.md + Usage: + vc-research --prompt "Question to research" + vc-research --file /path/to/plan.md + vc-research [agent2 agent3] --prompt "Question to research" + vc-research [agent2 agent3] --file /path/to/plan.md + vc-research uno --prompt "Question to research" + vc-research uno --file /path/to/plan.md Common flags: -p, --prompt Inline prompt -f, --file Input file as prompt context - --runtime Runtime backend (terminal|headless|visible) - --root Root workspace for this research run + --runtime Runtime backend (terminal|headless|visible) + --root Root workspace for this research run + --synthesizer Override synthesis agent (default: first positional agent, or YAML/default behavior) Examples: - vc-research --prompt "Compare API alternatives for oauth libraries" - vc-research uno codex --prompt "Probe just one research lane" - vc-research --file /path/to/research-plan.md - vibecrafted research --prompt "State of the art for MCP streaming" - -Do not pass an agent directly to vc-research. -Use `vc-research uno ...` if you intentionally need single-agent mode. + vc-research --prompt "Compare API alternatives for oauth libraries" + vc-research codex --prompt "Probe just one research lane" + vc-research claude codex gemini --synthesizer claude --file /path/to/research-plan.md + vc-research --file /path/to/research-plan.md + vibecrafted research --prompt "State of the art for MCP streaming" + +One invocation is one full swarm. Positional agents override the YAML lane set for this run. +`uno ` is kept as an alias for the one-lane override form. HELP } @@ -254,8 +299,8 @@ _vetcoders_research() { local first_arg="${1:-}" local inherited_run_id inherited_run_lock local prompt root run_id run_lock runtime run_dir prompt_file layout_file summary_file - local session_name agent launcher cmd_file research_mode requested_research_agent lock_actor launch_label - local -a research_agents launchers launcher_entries command_entries + local session_name agent launcher cmd_file research_mode requested_research_agent lock_actor launch_label research_synthesizer + local -a research_agents launchers launcher_entries command_entries positional_research_agents contract_args for _arg in "$@"; do case "$_arg" in @@ -268,6 +313,7 @@ _vetcoders_research() { research_mode="swarm" requested_research_agent="" + positional_research_agents=() if [[ "$first_arg" == "uno" ]]; then shift || true requested_research_agent="${1:-}" @@ -277,20 +323,52 @@ _vetcoders_research() { return 1 } shift || true - research_mode="uno" + positional_research_agents=("$requested_research_agent") + research_mode="override" first_arg="${1:-}" fi - case "$first_arg" in - claude|codex|gemini|agy|junie|grok) - printf 'vc-research is a triple-agent swarm launcher. Do not pass %s.\n' "$first_arg" >&2 - printf 'Use vc-research --prompt "..." or vc-research --file /path/to/plan.md.\n' >&2 - printf 'If you intentionally want one researcher, use vc-research uno %s --prompt "...".\n' "$first_arg" >&2 - return 1 - ;; - esac + while [[ $# -gt 0 ]]; do + case "${1:-}" in + claude|codex|gemini|agy|junie|grok) + positional_research_agents+=("$1") + research_mode="override" + shift || true + ;; + *) + break + ;; + esac + if (( ${#positional_research_agents[@]} >= 3 )); then + break + fi + done - _vetcoders_parse_contract "$@" || return 1 + research_synthesizer="" + contract_args=() + while [[ $# -gt 0 ]]; do + case "$1" in + --synthesizer) + shift + [[ $# -gt 0 ]] || { echo "Missing value for --synthesizer" >&2; return 1; } + _vetcoders_has_agent "$1" || { + printf 'vc-research --synthesizer expects .\n' >&2 + return 1 + } + research_synthesizer="$1" + ;; + -p|--prompt) + contract_args+=("$@") + break + ;; + *) + contract_args+=("$1") + ;; + esac + shift || true + done + + _vetcoders_parse_contract "${contract_args[@]}" || return 1 [[ -z "$_vetcoders_contract_count" ]] || { echo "--count is only supported by vibecrafted marbles." >&2 return 1 @@ -325,7 +403,7 @@ _vetcoders_research() { run_lock="$inherited_run_lock" if [[ -z "$run_lock" || ! -f "$run_lock" ]]; then lock_actor="swarm" - [[ "$research_mode" == "uno" ]] && lock_actor="$requested_research_agent" + [[ "$research_mode" == "override" && ${#positional_research_agents[@]} -gt 0 ]] && lock_actor="${positional_research_agents[0]}" run_lock="$(_vetcoders_create_run_lock "$run_id" "$lock_actor" "research" "$root")" || return 1 fi @@ -334,8 +412,9 @@ _vetcoders_research() { prompt_file="$(_vetcoders_research_prompt_file "$run_dir" "$prompt")" || return 1 research_agents=() - if [[ "$research_mode" == "uno" ]]; then - research_agents=("$requested_research_agent") + if [[ "$research_mode" == "override" ]]; then + research_agents=("${positional_research_agents[@]}") + [[ -n "$research_synthesizer" ]] || research_synthesizer="${research_agents[0]:-}" else local agents_output agents_output="$(_vetcoders_research_agents)" || return 1 @@ -351,6 +430,9 @@ _vetcoders_research() { return 1 fi fi + if [[ -n "$research_synthesizer" ]]; then + export VIBECRAFTED_RESEARCH_SYNTHESIZER="$research_synthesizer" + fi launchers=() launcher_entries=() @@ -404,7 +486,7 @@ _vetcoders_research() { export VIBECRAFTED_RESEARCH_RUN_DIR="$run_dir" "$vc_frame_bin" --session "$session_name" action new-tab --layout "$layout_file" >/dev/null launch_label="Research swarm" - [[ "$research_mode" == "uno" ]] && launch_label="Research uno ($requested_research_agent)" + [[ "$research_mode" == "override" ]] && launch_label="Research override (${research_agents[*]})" printf '%s launched in shared tab (run_id=%s).\n' "$launch_label" "$run_id" printf ' run dir: %s\n' "$run_dir" printf ' reports: %s\n' "$run_dir/reports" @@ -416,7 +498,7 @@ _vetcoders_research() { fi launch_label="Research swarm" - [[ "$research_mode" == "uno" ]] && launch_label="Research uno ($requested_research_agent)" + [[ "$research_mode" == "override" ]] && launch_label="Research override (${research_agents[*]})" printf '%s prepared (run_id=%s), but runtime %s does not use the shared vc_frame layout.\n' "$launch_label" "$run_id" "$runtime" printf 'Run directory: %s\n' "$run_dir" printf 'Reports: %s\n' "$run_dir/reports" diff --git a/vibecrafted-core/vibecrafted_core/runtime_paths.py b/vibecrafted-core/vibecrafted_core/runtime_paths.py index d1cda855..ef676f0d 100644 --- a/vibecrafted-core/vibecrafted_core/runtime_paths.py +++ b/vibecrafted-core/vibecrafted_core/runtime_paths.py @@ -32,6 +32,10 @@ def vibecrafted_home() -> Path: return Path.home() / ".vibecrafted" +def vibecrafted_backups_home() -> Path: + return vibecrafted_home() / "backups" / "installer" + + def vibecrafted_runtime_home() -> Path: return resolve_env_path("VIBECRAFTED_RUNTIME_HOME", xdg_data_home() / "vibecrafted") diff --git a/vibecrafted-core/vibecrafted_core/schemas/lifecycle.schema.v1.json b/vibecrafted-core/vibecrafted_core/schemas/lifecycle.schema.v1.json new file mode 100644 index 00000000..0240aa50 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/schemas/lifecycle.schema.v1.json @@ -0,0 +1,414 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "vibecrafted.lifecycle.v1", + "title": "Vibecrafted Lifecycle State v1", + "description": "Machine-readable contract for lifecycle state.json and worker report steering frontmatter.", + "type": "object", + "additionalProperties": true, + "required": [ + "schema", + "run_id", + "workflow", + "agent", + "root", + "status", + "await_stages", + "parent_run_id", + "operator_actions", + "spec", + "supervisor", + "human_controls", + "state_path", + "report_path", + "transcript_path", + "context_atlas", + "manifest", + "baton", + "stages", + "accepted_dou_findings" + ], + "properties": { + "schema": { + "const": "vibecrafted.lifecycle.v1", + "description": "Versioned lifecycle contract identifier." + }, + "run_id": { + "type": "string" + }, + "workflow": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "root": { + "type": "string" + }, + "status": { + "type": "string" + }, + "await_stages": { + "type": "boolean" + }, + "parent_run_id": { + "type": [ + "string", + "null" + ] + }, + "operator_actions": { + "type": "array", + "items": { + "$ref": "#/$defs/operator_action" + } + }, + "spec": { + "$ref": "#/$defs/spec" + }, + "supervisor": { + "type": "string" + }, + "human_controls": { + "type": "array", + "items": { + "type": "string" + } + }, + "state_path": { + "type": "string" + }, + "report_path": { + "type": "string" + }, + "transcript_path": { + "type": "string" + }, + "context_atlas": { + "type": "object", + "additionalProperties": true + }, + "manifest": { + "type": "object", + "additionalProperties": true + }, + "baton": { + "$ref": "#/$defs/baton" + }, + "stages": { + "type": "array", + "items": { + "$ref": "#/$defs/stage" + } + }, + "dou_index": { + "oneOf": [ + { + "$ref": "#/$defs/dou_index" + }, + { + "type": "null" + } + ] + }, + "accepted_dou": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "accepted_dou_findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "next_stage": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "$defs": { + "spec": { + "type": "object", + "additionalProperties": true, + "required": [ + "workflow_id", + "agent" + ], + "properties": { + "workflow_id": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "file": { + "type": "string" + }, + "root": { + "type": "string" + }, + "runtime": { + "type": "string" + }, + "await_stages": { + "type": "boolean" + }, + "start_stage": { + "type": "string" + }, + "count": { + "type": [ + "integer", + "null" + ] + }, + "depth": { + "type": [ + "integer", + "null" + ] + }, + "previous_reports": { + "type": "array", + "items": { + "type": "string" + } + }, + "stage_agents": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "stage_models": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "operator_action": { + "type": "object", + "additionalProperties": true, + "properties": { + "action": { + "type": "string" + }, + "at": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": true + } + } + }, + "baton": { + "type": "object", + "additionalProperties": true, + "required": [ + "from_stage", + "next_stage", + "next_agent", + "reason", + "previous_reports", + "dou_index" + ], + "properties": { + "from_stage": { + "type": "string" + }, + "from_phase": { + "type": "string" + }, + "next_stage": { + "type": "string" + }, + "next_agent": { + "type": "string" + }, + "fallback_stage": { + "type": "string" + }, + "audit_after": { + "type": "string" + }, + "dou_index": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "reason": { + "type": "string" + }, + "previous_reports": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "stage": { + "type": "object", + "additionalProperties": true, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workflow": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "read", + "write", + "" + ] + }, + "agent": { + "type": "string" + }, + "model_requested": { + "type": "string" + }, + "status": { + "type": "string" + }, + "launch": { + "type": "object", + "additionalProperties": true + }, + "await": { + "type": "object", + "additionalProperties": true + }, + "commit_before": { + "type": "string" + }, + "commit_after": { + "type": "string" + }, + "changed_files": { + "type": "array", + "items": { + "type": "string" + } + }, + "new_commits": { + "type": "array", + "items": { + "type": "string" + } + }, + "transition": { + "$ref": "#/$defs/transition" + }, + "dou_index": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + }, + "transition": { + "type": "object", + "additionalProperties": true, + "properties": { + "next_stage": { + "type": "string" + }, + "requested_next_stage": { + "type": "string" + }, + "next_agent": { + "type": "string" + }, + "requested_next_agent": { + "type": "string" + }, + "conditions": { + "type": "array", + "items": { + "type": "string" + } + }, + "fallback_stage": { + "type": "string" + }, + "audit_after": { + "type": "string" + } + } + }, + "dou_index": { + "type": "object", + "additionalProperties": true, + "required": [ + "value", + "stage", + "report" + ], + "properties": { + "value": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "stage": { + "type": "string" + }, + "report": { + "type": "string" + } + } + }, + "worker_report_frontmatter": { + "type": "object", + "additionalProperties": true, + "properties": { + "next_stage": { + "type": "string", + "description": "Manifest stage id requested by the worker." + }, + "next_agent": { + "type": "string", + "description": "Supported agent id requested by the worker." + }, + "dou_index": { + "type": [ + "integer", + "string" + ], + "description": "Open Definition-of-Undone finding count; 0 means launch-ready." + }, + "status": { + "type": "string", + "description": "Worker-reported status metadata. Current steering consumes next_stage, next_agent, and dou_index." + } + } + } + } +} diff --git a/vibecrafted-core/vibecrafted_core/ship.py b/vibecrafted-core/vibecrafted_core/ship.py index 43f4c108..4f9217d5 100644 --- a/vibecrafted-core/vibecrafted_core/ship.py +++ b/vibecrafted-core/vibecrafted_core/ship.py @@ -82,6 +82,14 @@ def main(argv: Sequence[str] | None = None) -> int: ] ) + # Operator invariant: stage workers fly VISIBLY, in vc-frame tabs, whenever + # a live operator session can host them. Route through the same resolution + # the rest of the fleet uses (cli._default_runtime → "terminal" on live + # session/TTY, "headless" only as the degrade-not-die fallback) instead of + # hardcoding headless. Continuations inherit spec.runtime via the baton. + from .cli import _default_runtime + + root = args.root or str(Path.cwd()) state = run_lifecycle( LifecycleRunSpec( workflow_id="vc-ship", @@ -91,8 +99,8 @@ def main(argv: Sequence[str] | None = None) -> int: # empty prompt to actually reach the stage workers. prompt=args.prompt or ("" if args.file else DEFAULT_SHIP_PROMPT), file=args.file, - root=args.root or str(Path.cwd()), - runtime=args.runtime or "headless", + root=root, + runtime=_default_runtime(args.runtime, root), await_stages=args.await_stages, start_stage=args.start_stage or args.checkpoint or "scaffold", ) diff --git "a/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate-PL-przyk\305\202ad.md" "b/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate-PL-przyk\305\202ad.md" index aad865cd..65798eb6 100644 --- "a/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate-PL-przyk\305\202ad.md" +++ "b/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate-PL-przyk\305\202ad.md" @@ -48,7 +48,7 @@ repo". W przeciwnym razie brak dowodów z `vc-init`/Loctree to błąd procesu. Standardowy launcher (`vibecrafted start` / `vc-start`, następnie `vc- [--prompt|--file ...]`). ```bash -vibecrafted decorate gemini --prompt 'Polish the landing page' +vibecrafted decorate agy --prompt 'Polish the landing page' vc-decorate claude --prompt 'Coherence audit on the CLI output surface' vibecrafted decorate codex --file /path/to/decorate-plan.md ``` @@ -212,4 +212,4 @@ Zachowuj tożsamość. Redukuj dryf. _Faza 3 — Ship (dou → decorate → hydrate → release)_ -_𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by VetCoders (c)2024-2026 LibraxisAI_ +_𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI_ diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate/SKILL.md index 68da50bd..6de1eeaa 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-decorate/SKILL.md @@ -50,7 +50,7 @@ repo". W przeciwnym razie brak dowodów z `vc-init`/Loctree to błąd procesu. Standardowy launcher (`vibecrafted start` / `vc-start`, następnie `vc- [--prompt|--file ...]`). ```bash -vibecrafted decorate gemini --prompt 'Polish the landing page' +vibecrafted decorate agy --prompt 'Polish the landing page' vc-decorate claude --prompt 'Coherence audit on the CLI output surface' vibecrafted decorate codex --file /path/to/decorate-plan.md ``` diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-delegate/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-delegate/SKILL.md index 8872becf..f3c10a61 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-delegate/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-delegate/SKILL.md @@ -62,7 +62,7 @@ cięcie jest wciąż bounded, i kiedy operator powinien eskalować do `vc-agents ```bash vibecrafted partner codex --prompt 'Split this into one small native cut' vibecrafted implement claude --file /path/to/plan.md -vibecrafted workflow gemini --prompt 'Keep this local unless it clearly wants the external fleet' +vibecrafted workflow agy --prompt 'Keep this local unless it clearly wants the external fleet' # gemini deprecated ``` ## Doktryna pracy z repozytorium diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/SKILL.md index 39c4d752..52b9c2f1 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/SKILL.md @@ -80,6 +80,15 @@ pre-flight → DISPATCH → SPANKO → SPRAWDZENIE → FLIP → BATON → next c └── refire ←┘ (partial delivery / convergence pressure) ``` +**Pin modelu per cut (pre-flight):** każdy cut deklaruje pin `model` zgodny ze +swoją klasą — cut mechaniczny, w pełni rozpisany, jedzie na tańszym, +szybszym tierze; cut chirurgiczny albo niosący decyzje — na mocniejszym +tierze. Pin jedzie z planem do launchera (`Cut.model` → +`WorkflowLaunchSpec.model` → flaga modelu agenta: `--model` dla claude, `-m` +dla codex). Cut bez pinu leci na defaulcie +konta — a to NIE-decyzja, nie bezpieczny default: pinuj świadomie, a brak +pinu traktuj jako smell do rozwiązania przed startem. + 1. **Pre-flight (raz na linię)**: przetestuj komendy weryfikujące z briefów, zanim linia ruszy — bramka, która matchuje 0 testów, jest trywialnie zielona; żądaj ≥1 nowego nietrywialnego testu w EXTRA. `grep -c` wychodzi z kodem 1 przy 0 trafień @@ -91,14 +100,25 @@ pre-flight → DISPATCH → SPANKO → SPRAWDZENIE → FLIP → BATON → next c (shelle mogą nieść miękki `ulimit -f` → SIGXFSZ/exit 153). Zapisz receipt (run_id, report, transcript, meta) w trackerze. 3. **Spanko**: czekaj przez artefakty, nigdy przez gapienie się w pane. Użyj - dedykowanej komendy jako standardowej pętli dyspozytora: + dedykowanej komendy jako standardowej pętli dyspozytora. Kanoniczny kontrakt + supervisora (zobacz `docs/runtime/AGENT_OPS.md`): po dispatchu uzbrój + `vibecrafted await --run-id ` natychmiast, po stronie supervisora. + JSON control-plane, pliki raportów, transkrypty, karty terminala i + zaplanowane wybudzenia są wyłącznie diagnostyczne, nie są sygnałem + wybudzenia. Hedge'owanie await ad-hoc pollerami/watcherami to naruszenie + Class 3; napraw `control_plane.await_run`, nie normalizuj hedge'u. + Liveness jest zawsze 3-sygnałowy: przed "done" pogódź await verdict, + terminalny stan w run meta i martwy worker pid; gdy raport jest obiecany, + sprawdź obecność raportu. Dwa zgodne sygnały wystarczą do działania, trzy do + deklaracji done; rozjazd = traktuj jako live i uzbrój await ponownie. Znany + skew: rc=0-on-live oraz meta `active`/`stalled` po realnym zakończeniu. `vibecrafted loop spanko --run-id --agent --verify '' --tracker --cut-id --then ''` (heartbeat cron frameworka → control-plane await → sprawdzenie → flip → baton), niższopoziomowego `vibecrafted loop await-run --run-id --agent --then-cmd ''`, - probe await-watch - (`vibecrafted-await-watch.sh --meta ` — tail-await-die), albo - zwykły zbackgroundowany `vibecrafted await --run-id`. Żywy worker - dostaje ZERO ingerencji; przerywanie mu w fazie bramki to czysta strata. + albo probe await-watch + (`vibecrafted-await-watch.sh --meta ` — tail-await-die) jako + warstwy widoczności podporządkowanej kanonicznemu await. Żywy worker dostaje + ZERO ingerencji; przerywanie mu w fazie bramki to czysta strata. 4. **Sprawdzenie** (przy wyjściu workera): SHA commita istnieje → esencja diffa zgadza się z briefem → odczytane wyniki bramek i acceptance z raportu workera. **NIE uruchamiaj ponownie lintów/testów workera** — workerzy uruchamiają własne bramki, a commit diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/references/prompt-checklist.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/references/prompt-checklist.md index 4b476494..cc074cbf 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/references/prompt-checklist.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-dispatch/references/prompt-checklist.md @@ -79,6 +79,11 @@ grep -c '{repo}\|{id}\|{reports_dir}\|{[a-z_]*}' prompt.md # MUST be 0 wc -l prompt.md # sanity: full brief present ``` +- **Pin modelu obecny i zgodny z klasą cuta**: cut niesie pin `model` (tańszy, + szybszy tier dla mechanicznego, w pełni rozpisanego cuta; mocniejszy tier + dla chirurgicznego lub niosącego decyzje). Brak pinu = default konta, czyli + NIE-decyzja — rozwiąż przed startem. + Launch tylko przez plik: ```bash diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-init/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-init/SKILL.md index 4071a62c..079062c8 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-init/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-init/SKILL.md @@ -48,7 +48,7 @@ gdy niepotrzebne. Uruchamia się w natywnym trybie interaktywnym, nie w headless ```bash vibecrafted init claude vc-init codex -vibecrafted init gemini --prompt 'Bootstrap context for the payments module' +vibecrafted init agy --prompt 'Bootstrap context for the payments module' ``` Zależności fundamentowe (ładowane wraz z frameworkiem): `vc-loctree`, `vc-aicx`. diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/AWAIT.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/AWAIT.md index f00c9929..abf5375c 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/AWAIT.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/AWAIT.md @@ -9,18 +9,31 @@ Czytaj razem z [`SKILL.md`](SKILL.md), [`GUIDE.md`](GUIDE.md), [`DISPATCH.md`](D ## Doktryna -Runnery zadań w tle powiadamiają o ukończeniu automatycznie przez payloady -``. Nie polluj. +Po dispatchu uzbrój `vibecrafted await --run-id ` natychmiast, +po stronie supervisora. JSON control-plane, pliki raportów, transkrypty, karty +terminala i zaplanowane wybudzenia są wyłącznie diagnostyczne, nie są sygnałem +wybudzenia. Hedge'owanie await ad-hoc pollerami/watcherami to naruszenie +Class 3; napraw `control_plane.await_run`, nie normalizuj hedge'u. + +Liveness jest zawsze 3-sygnałowy przed deklaracją done: await verdict, +terminalny stan w run meta i martwy worker pid; jeśli raport jest obiecany, +potwierdź jego obecność. Dwa zgodne sygnały wystarczą do działania, trzy do +deklaracji done; rozjazd = traktuj jako live i uzbrój await ponownie. Znany +skew: rc=0-on-live oraz meta `active`/`stalled` po realnym zakończeniu. + +Zobacz `docs/runtime/AGENT_OPS.md` dla kanonu awarii Class 1/2/3. CLI await jest +podstawowym kanałem wybudzenia supervisora; notify taska i heartbeat są +diagnostycznym fallbackiem pętli czatu operatora, nie zamiennikiem uzbrojenia +await. Dla trybu operatora przekłada się to na: -- **Sygnał podstawowy**: payload ``, który budzi cię, gdy - działająca w tle pętla await `vc-implement` / `vc-agents` / dispatch - zakończy się. To jest kontrakt. -- **Sygnał zapasowy**: zaplanowany heartbeat (długointerwałowy `ScheduleWakeup` - albo ponowne wejście w `/loop`), który odpala się tylko wtedy, gdy - podstawowe powiadomienie nigdy nie przyjdzie. Heartbeat to siatka - bezpieczeństwa, a nie puls stanu ustalonego. +- **Sygnał podstawowy**: foreground/supervisor-side + `vibecrafted await --run-id `, albo komenda framework loop, która + deleguje do tego await. +- **Sygnał diagnostyczny**: payload ``, ścieżka raportu, + JSON control-plane, transcript, widoczna karta albo zaplanowany heartbeat. + One wyjaśniają stan; nie zastępują kanonicznego await. - **Antywzorzec**: krótkointerwałowy polling. Ponowne sprawdzanie statusu zadania co 60 sekund spala prompt cache i sygnalizuje operatorowi, że nie ufasz własnej infrastrukturze. @@ -52,19 +65,22 @@ autoryzuje ten krok. 2. Potwierdź start (~30s po odpaleniu): → sprawdź, że tracker zadania żyje → potwierdź, że operator widzi obserwowaną kartę - → napisz do operatora „Fala B-1 odpalona, czekam na notify" + → arm: vibecrafted claude await --run-id impl-181153-86836 + → napisz do operatora „Fala B-1 odpalona, canonical await armed" 3. Zaplanuj zapasowy heartbeat: → ScheduleWakeup delaySeconds=1800 (30 min) - → reason: „Wave B-1 await fallback if notify lost" + → reason: „Wave B-1 diagnostic heartbeat for impl-181153-86836" 4. Bezczynność: → odpowiadaj na czat operatora, jeśli pinguje → trzymaj treść promptu dla Fali B-2 w gotowości, na wypadek gdybyśmy musieli szybko odpalić → nie polluj, nie tailuj logów, nie czytaj /tmp/.../tasks/*.output -5. Przychodzi notify: - → budzi cię +5. Await wraca: + → vibecrafted await wychodzi z prawdą terminal/report + → potwierdź martwy worker pid i terminalny run meta; jeśli raport był + obiecany, potwierdź obecność raportu → przeczytaj plik raportu workera (NIE plik /tmp output — zobacz „Co czyta agent operatora") → zweryfikuj, że commit wylądował na oczekiwanej gałęzi @@ -79,9 +95,11 @@ autoryzuje ten krok. --- -## Konfiguracja heartbeat +## Konfiguracja diagnostycznego heartbeat -Zapasowy heartbeat ustawia się per długodziałający dispatch. +Zapasowy heartbeat jest tylko diagnostyczny. Ustawia się go per długo działający +dispatch, żeby supervisor mógł sprawdzić, dlaczego kanoniczny await jeszcze nie +wrócił. | Kontekst fali | Opóźnienie heartbeat | Uzasadnienie | | --------------------------------- | -------------------- | ----------------------------------------------------------- | @@ -90,11 +108,12 @@ Zapasowy heartbeat ustawia się per długodziałający dispatch. | Fala C równolegle (~15–25 min) | 1800s (30 min) | Trzy równoległe; jeden heartbeat pokrywa wszystkie. | | Fala D finalna (~20–30 min każda) | 2400s (40 min) | Najcięższe dispatche; daj margines. | -Pole reason w heartbeat powinno zawsze zawierać run_id: +Pole reason heartbeat powinno zawsze zawierać run_id i nie może sugerować, że +jest sygnałem wybudzenia: ```text ScheduleWakeup delaySeconds=1800 - reason: "Wave B-1 await fallback for impl-181153-86836 — verify completion if notify lost" + reason: "Wave B-1 diagnostic heartbeat for impl-181153-86836 — inspect if canonical await has not returned" ``` Heartbeat jest zmarnowany (no-op), jeśli notify przyszedł pierwszy. To diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/WHY_MATRIX_TABLE.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/WHY_MATRIX_TABLE.md index c50cd564..e971cbd8 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/WHY_MATRIX_TABLE.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-operator/WHY_MATRIX_TABLE.md @@ -44,6 +44,23 @@ kontekstu (fokus operatora, worker poprzedniej fali, bieżące obciążenie). AG to nie kwota rotacji — to uczciwe przypisanie autorstwa i równa godność. Nie dispatchuj round-robinem; dispatchuj wg dopasowania. +## Granulacja dispatchy zależna od modelu + +Model parity jest podłogą, a granulacja określa ilość spójnej pracy należącej +do jednego dispatchu. Używaj `agent_dispatch.dispatch_granularity(model)` +zamiast traktować każdy model jak anonimowego workera o identycznym rozmiarze. + +| klasa modelu | kształt cuta | pliki na cut | równoległe cuty | powód | +| ------------------------------------------------------ | ------------ | -----------: | ---------------------------------: | -------------------------------------------------------------------------- | +| frontier (`opus`, GPT-5.5/5.6, Gemini Pro, Grok Build) | coherent | do 8 | do 3 przy rozłącznych scope plików | Amortyzuj powtarzany kontekst i zachowaj rozumowanie nad całym kontraktem. | +| standard (`sonnet`, GPT-5, Gemini auto/default) | bounded | do 4 | do 2 | Utrzymuj jawne szwy integracyjne bez nadmiernej fragmentacji. | +| economy (`haiku`, Spark, Flash) | surgical | do 2 | 1 | Małe sekwencyjne powierzchnie dowodu ograniczają drift i koszt retry. | +| nieznany model | surgical | 1 | 1 | Brak telemetrii nie jest zgodą na szeroki dispatch. | + +Koszt zgłoszony przez providera wygrywa. Gdy go nie ma, akceptuj tylko jawny +estimate z `cost_source: estimated:`; nigdy po cichu nie zamieniaj +nieznanego kosztu na zero. Koszt wpływa na cut, ale nie pozwala obniżyć tieru. + ## Noty ze stopki - AGENT PEER PARITY: Claude, Codex i Gemini to peerowi frontierowi workerzy. Routuj diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-polarize/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-polarize/SKILL.md index ca94ea84..34c95016 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-polarize/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-polarize/SKILL.md @@ -64,7 +64,7 @@ Standardowy launcher: vibecrafted polarize codex --task 'marbles versus polarize skills: polarize them' vc-polarize codex --task 'installer public contract' vc-polarize claude --file /path/to/prism-pack.md -vibecrafted polarize gemini --prompt 'Choose one launch thesis after marbles' +vibecrafted polarize agy --prompt 'Choose one launch thesis after marbles' ``` Gdy obecne jest `--task`, runner wykonuje świeży prism preflight i diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-review/PRVIEW.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-review/PRVIEW.md index fa4dc616..f958b7d2 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-review/PRVIEW.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-review/PRVIEW.md @@ -14,7 +14,7 @@ Czytaj wraz z [`SKILL.md`](SKILL.md) i [`FINDINGS.md`](FINDINGS.md). ## Tabela dispatchu | Tryb | Komenda | Kiedy używać | -| -------------------------------- | ----------------------------------------------- | ---------------------------------------------------- | --------------------- | +| -------------------------------- | ----------------------------------------------- | ---------------------------------------------------- | | Local branch vs develop/main | `prview --pr ` | Domyślnie dla aktywnych PR-ów na lokalnym checkoucie | | Remote branch (no checkout) | `prview -R --remote-only ` | Review gałęzi kontrybutora na origin | | GitHub PR by number | `prview --pr --with-tests --with-lint` | Domyślnie dla gruntownego review PR-a | @@ -22,7 +22,7 @@ Czytaj wraz z [`SKILL.md`](SKILL.md) i [`FINDINGS.md`](FINDINGS.md). | Fast triage | `prview --quick` | Tylko jawny szybki triage — NIE domyślnie | | Refresh after amend / force-push | `prview --update` | Unikanie zduplikowanych zestawów artefaktów | | Ambiguous origin | dodaj `--gh-repo owner/repo` | Gdy working tree ma wiele remote'ów | -| JSON-only mode | `prview --json --quiet | jq ...` | Integracja pipeline'u | +| JSON-only mode | `prview --json --quiet | jq ...` | Integracja pipeline'u | Domyślnie dla vc-review: **nie używaj `--quick`**. Używaj `--with-tests --with-lint` jako bazy. Zachowaj `--deep` na merge gate / wysokie ryzyko. diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-scaffold/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-scaffold/SKILL.md index d6d0da6a..e433b862 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-scaffold/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-scaffold/SKILL.md @@ -44,7 +44,7 @@ Wejdź w sesję frameworka, a potem odpalaj przez command deck (nie surowe `skil ```bash vibecrafted start # or: vc-start vibecrafted scaffold claude --prompt 'Design the payment system' -vc-scaffold gemini --prompt 'Plan migration from NextAuth to custom auth' +vc-scaffold agy --prompt 'Plan migration from NextAuth to custom auth' vibecrafted scaffold codex --file /path/to/idea-brief.md ``` diff --git a/vibecrafted-core/vibecrafted_core/skills/pl/vc-workflow/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/pl/vc-workflow/SKILL.md index 907347ae..0a0ba6b3 100644 --- a/vibecrafted-core/vibecrafted_core/skills/pl/vc-workflow/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/pl/vc-workflow/SKILL.md @@ -38,7 +38,7 @@ Standardowy launcher (`vibecrafted start` / `vc-start`, następnie `vc- Skrypty domyślnie używają widocznego trybu Terminala na macOS i przełączają się na headless, diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-agents/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-agents/SKILL.md index 4b80fdba..0c0c3441 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-agents/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-agents/SKILL.md @@ -253,17 +253,28 @@ That card should expose at least: - report path - transcript path - metadata path +- exact await command If the operator cannot see those paths, observability is incomplete even if the agent is technically running. ## Observation -Observe progress through durable artifacts in `$VIBECRAFTED_HOME/artifacts////reports/`. - -The default check is metadata-first, not pane-first. -Use the dedicated runtime helper to wait on metadata completion and print the -final summary: +Canonical supervisor contract (see `docs/runtime/AGENT_OPS.md`): After +dispatch, arm `vibecrafted await --run-id ` immediately, +supervisor-side. Control-plane JSON, report files, transcripts, panes, and +scheduled wakeups are diagnostic only, not wake signals. Hedging await with +ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, +do not normalize the hedge. + +3-signal liveness: await verdict, terminal run meta, worker pid dead, plus +promised report presence. Two agreeing signals are enough to act, three to +declare done; any disagreement means treat as live and re-arm await. Known skew: +rc=0-on-live and meta stuck `active`/`stalled` after real completion. + +Observe progress through durable artifacts in +`$VIBECRAFTED_HOME/artifacts////reports/`, but let the +dedicated runtime helper own waiting and final summary: ```bash vibecrafted codex await --run-id diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-decorate/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-decorate/SKILL.md index eccda49a..990cab77 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-decorate/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-decorate/SKILL.md @@ -48,7 +48,7 @@ If the task is explicitly non-repo or no-code, state the no-repo exception in th Standard launcher (`vibecrafted start` / `vc-start`, then `vc- [--prompt|--file ...]`). ```bash -vibecrafted decorate gemini --prompt 'Polish the landing page' +vibecrafted decorate agy --prompt 'Polish the landing page' vc-decorate claude --prompt 'Coherence audit on the CLI output surface' vibecrafted decorate codex --file /path/to/decorate-plan.md ``` diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-delegate/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-delegate/SKILL.md index c9867b3b..709667c4 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-delegate/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-delegate/SKILL.md @@ -62,7 +62,7 @@ cut is still bounded, and when the operator should escalate into `vc-agents`. ```bash vibecrafted partner codex --prompt 'Split this into one small native cut' vibecrafted implement claude --file /path/to/plan.md -vibecrafted workflow gemini --prompt 'Keep this local unless it clearly wants the external fleet' +vibecrafted workflow agy --prompt 'Keep this local unless it clearly wants the external fleet' # gemini deprecated ``` ## Repository Work Doctrine diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/SKILL.md index eb53efc2..4941941d 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/SKILL.md @@ -80,6 +80,14 @@ pre-flight → DISPATCH → SPANKO → SPRAWDZENIE → FLIP → BATON → next c └── refire ←┘ (partial delivery / convergence pressure) ``` +**Model pin per cut (pre-flight):** every cut declares a `model` pin that +matches its class — a mechanical, fully-briefed cut runs on a cheaper, faster +tier, a surgical or decision-bearing cut on a stronger tier. The pin +rides the plan into the launcher (`Cut.model` → `WorkflowLaunchSpec.model` → +the agent's model flag: `--model` for claude, `-m` for codex). An unpinned +cut runs on the account default — which is a non-decision, not a safe default: +pin deliberately, and treat a missing pin as a smell to resolve before launch. + 1. **Pre-flight (once per line)**: test the verify commands from the briefs before the line moves — a gate that matches 0 tests is trivially green; demand ≥1 new non-trivial test in EXTRA. `grep -c` exits 1 on 0 hits @@ -98,14 +106,25 @@ pre-flight → DISPATCH → SPANKO → SPRAWDZENIE → FLIP → BATON → next c explicitly, or use the CLI. Headless is the unattended/cron lane only. Record the receipt (run_id, report, transcript, meta) in the tracker. 3. **Spanko**: await through artifacts, never by staring at a pane. Use the - dedicated command as the standard dispatcher loop: + dedicated command as the standard dispatcher loop. Canonical supervisor + contract (see `docs/runtime/AGENT_OPS.md`): After dispatch, arm + `vibecrafted await --run-id ` immediately, supervisor-side. + Control-plane JSON, report files, transcripts, panes, and scheduled wakeups + are diagnostic only, not wake signals. Hedging await with ad-hoc + pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, do + not normalize the hedge. + 3-signal liveness: before declaring done, reconcile await verdict, terminal + run meta, and worker pid dead; if a report is promised, confirm it exists. + Two agreeing signals are enough to act, three to declare done; disagreement + means treat as live and re-arm await. Known skew: rc=0-on-live and meta stuck + `active`/`stalled` after real completion. `vibecrafted loop spanko --run-id --agent --verify '' --tracker --cut-id --then ''` (framework cron heartbeat → control-plane await → verify → flip → baton), the lower-level `vibecrafted loop await-run --run-id --agent --then-cmd ''`, - the await-watch probe - (`vibecrafted-await-watch.sh --meta ` — tail-await-die), or a - plain backgrounded `vibecrafted await --run-id`. A living worker - gets ZERO interference; interrupting during its gate phase is pure loss. + or the await-watch probe + (`vibecrafted-await-watch.sh --meta ` — tail-await-die) as a + visibility aid subordinate to the canonical await. A living worker gets ZERO + interference; interrupting during its gate phase is pure loss. 4. **Sprawdzenie** (on worker exit): commit SHA exists → diff essence matches the brief → worker report's gate results and acceptance read. **Do NOT re-run the worker's lints/tests** — workers run their own gates and commit diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/prompt-checklist.md b/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/prompt-checklist.md index cc4bf4c5..274b48de 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/prompt-checklist.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/prompt-checklist.md @@ -80,6 +80,11 @@ grep -c '{repo}\|{id}\|{reports_dir}\|{[a-z_]*}' prompt.md # MUST be 0 wc -l prompt.md # sanity: full brief present ``` +- **Model pin present and consistent with the cut's class**: the cut carries a + `model` pin (a cheaper, faster tier for a mechanical, fully-briefed cut; a + stronger tier for a surgical or decision-bearing one). A missing pin means + the account default — a non-decision — so resolve it before launch. + Launch only via file: ```bash diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/pulse-and-stall.md b/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/pulse-and-stall.md index 9e93f2f5..236f1cc4 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/pulse-and-stall.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-dispatch/references/pulse-and-stall.md @@ -19,6 +19,18 @@ vibecrafted ships the heartbeat — reach for it before hand-rolling timers: | resume after idle window | `vibecrafted cron tick --after-idle-minutes 10 --then-cmd ` | | per-dispatch auto-await pane | `vibecrafted-await-watch.sh --meta ` — tails transcript, watches meta status + size delta + process liveness, self-terminates (tunables: `VIBECRAFTED_AWAIT_IDLE_TIMEOUT`, `VIBECRAFTED_AWAIT_POLL`) | +Canonical supervisor contract (see `docs/runtime/AGENT_OPS.md`): After +dispatch, arm `vibecrafted await --run-id ` immediately, +supervisor-side. Control-plane JSON, report files, transcripts, panes, and +scheduled wakeups are diagnostic only, not wake signals. Hedging await with +ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, +do not normalize the hedge. + +3-signal liveness: await verdict, terminal run meta, worker pid dead, plus +promised report presence. Two agreeing signals are enough to act, three to +declare done; any disagreement means treat as live and re-arm await. Known skew: +rc=0-on-live and meta stuck `active`/`stalled` after real completion. + Drive the await with the dedicated command (OUR vc-loop / cron) as the STANDARD even from an interactive session — a dispatched run HAS the CLI. A harness-level loop (Claude `/loop 15m `) is a true last-resort, diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-marbles/FLOW.md b/vibecrafted-core/vibecrafted_core/skills/vc-marbles/FLOW.md index 2e0c6d2c..b20fe542 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-marbles/FLOW.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-marbles/FLOW.md @@ -24,6 +24,17 @@ flowchart TD | `vc-marbles ` | same | same | `0` on launch | | `vibecrafted marbles pause\|stop\|resume\|session\|inspect\|delete` | control args | marbles runtime control actions | `0` on control success | +After dispatch, arm `vibecrafted await --run-id ` immediately, +supervisor-side. Control-plane JSON, report files, transcripts, panes, and +scheduled wakeups are diagnostic only, not wake signals. Hedging await with +ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, +do not normalize the hedge. See `docs/runtime/AGENT_OPS.md`. + +3-signal liveness: await verdict, terminal run meta, worker pid dead, plus +promised report presence. Two agreeing signals are enough to act, three to +declare done; any disagreement means treat as live and re-arm await. Known skew: +rc=0-on-live and meta stuck `active`/`stalled` after real completion. + ### Escalation edges - Same blocker persists after repeated loops -> `vibecrafted partner ` diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-marbles/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-marbles/SKILL.md index 2587c65c..e30d46d0 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-marbles/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-marbles/SKILL.md @@ -84,6 +84,17 @@ vibecrafted marbles codex --count 10 --depth n vc-marbles claude --depth 12 --prompt 'Focus on "vc-followup assumptions from the last 12 plans' ``` +After dispatch, arm `vibecrafted await --run-id ` immediately, +supervisor-side. Control-plane JSON, report files, transcripts, panes, and +scheduled wakeups are diagnostic only, not wake signals. Hedging await with +ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, +do not normalize the hedge. See `docs/runtime/AGENT_OPS.md`. + +3-signal liveness: await verdict, terminal run meta, worker pid dead, plus +promised report presence. Two agreeing signals are enough to act, three to +declare done; any disagreement means treat as live and re-arm await. Known skew: +rc=0-on-live and meta stuck `active`/`stalled` after real completion. + **Not the same as `vibecrafted codex implement `.** `implement` is how code appears. `marbles` is what happens after code exists but still needs to be made truthful and shippable. Each round wraps a diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-operator/AWAIT.md b/vibecrafted-core/vibecrafted_core/skills/vc-operator/AWAIT.md index afbecb7f..10d7dfc0 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-operator/AWAIT.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-operator/AWAIT.md @@ -9,17 +9,31 @@ Read alongside [`SKILL.md`](SKILL.md), [`GUIDE.md`](GUIDE.md), [`DISPATCH.md`](D ## The doctrine -Background-task runners notify completion automatically via -`` payloads. Do not poll. +After dispatch, arm `vibecrafted await --run-id ` immediately, +supervisor-side. Control-plane JSON, report files, transcripts, panes, and +scheduled wakeups are diagnostic only, not wake signals. Hedging await with +ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, +do not normalize the hedge. + +Liveness is always 3-signal before declaring done: await verdict, terminal run +meta, and worker pid dead; if a report is promised, confirm it exists. Two +agreeing signals are enough to act, three to declare done; disagreement means +treat as live and re-arm await. Known skew: rc=0-on-live and meta stuck +`active`/`stalled` after real completion. + +See `docs/runtime/AGENT_OPS.md` for the Class 1/2/3 failure canon. The CLI +await path is the supervisor's primary wake channel; background-task notify and +scheduled heartbeat are diagnostic fallbacks for the operator chat loop, not +substitutes for arming await. For operator mode that translates into: -- **Primary signal**: the `` payload that wakes you when - the background `vc-implement` / `vc-agents` / dispatch await loop - exits. This is the contract. -- **Fallback signal**: a scheduled heartbeat (long-interval `ScheduleWakeup` - or `/loop` re-entry) that triggers only if the primary notify never - arrives. The heartbeat is a safety net, not the steady-state pulse. +- **Primary signal**: the foreground/supervisor-side + `vibecrafted await --run-id ` command, or the framework loop + command that delegates to it. +- **Diagnostic signal**: the `` payload, report path, + control-plane JSON, transcript, visible pane, or scheduled heartbeat. + These can explain state; they do not replace the canonical await. - **Anti-pattern**: short-interval polling. Re-checking task status every 60 seconds burns the prompt cache and signals to the operator that you don't trust your own infrastructure. @@ -51,19 +65,22 @@ authorizes that step. 2. Confirm start (~30s after fire): → check task tracker is alive → confirm operator sees the watched tab - → write "Wave B-1 fired, awaiting notify" to operator + → arm: vibecrafted claude await --run-id impl-181153-86836 + → write "Wave B-1 fired, canonical await armed" to operator 3. Schedule fallback heartbeat: → ScheduleWakeup delaySeconds=1800 (30 min) - → reason: "Wave B-1 await fallback if notify lost" + → reason: "Wave B-1 diagnostic heartbeat for impl-181153-86836" 4. Idle: → answer operator chat if they ping → keep prompt body for Wave B-2 ready in case we need to fire fast → do not poll, do not tail logs, do not read /tmp/.../tasks/*.output -5. Notify arrives: - → wakes you +5. Await returns: + → vibecrafted await exits with terminal/report truth + → confirm worker pid dead and terminal run meta; if report promised, + confirm the report exists → read the worker's report file (NOT the /tmp output file — see "What the operator-agent reads") → verify commit landed on expected branch @@ -78,9 +95,10 @@ authorizes that step. --- -## Heartbeat configuration +## Diagnostic Heartbeat Configuration -The fallback heartbeat is set per long-running dispatch. +The fallback heartbeat is diagnostic only. It is set per long-running dispatch +so the supervisor can inspect why the canonical await has not returned yet. | Wave context | Heartbeat delay | Rationale | | ------------------------------- | --------------- | ------------------------------------------------------ | @@ -89,11 +107,12 @@ The fallback heartbeat is set per long-running dispatch. | Wave C parallel (~15–25 min) | 1800s (30 min) | Three parallels; one heartbeat covers all. | | Wave D final (~20–30 min each) | 2400s (40 min) | Heaviest dispatches; allow margin. | -Heartbeat reason field should always include the run_id: +Heartbeat reason field should always include the run_id and must not imply it +is the wake signal: ```text ScheduleWakeup delaySeconds=1800 - reason: "Wave B-1 await fallback for impl-181153-86836 — verify completion if notify lost" + reason: "Wave B-1 diagnostic heartbeat for impl-181153-86836 — inspect if canonical await has not returned" ``` A heartbeat is wasted (no-op) if the notify arrived first. That's the @@ -187,7 +206,7 @@ operator can't intervene. That violates the autonomy contract. When in doubt, prefer: 1. `vibecrafted implement --file ` in a foreground watched tab. -2. Background background-task await for completion signals via notify. +2. Supervisor-side `vibecrafted await --run-id ` armed immediately. 3. Operator can pull focus to the tab at any time and see the worker's live output. diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-operator/WHY_MATRIX_TABLE.md b/vibecrafted-core/vibecrafted_core/skills/vc-operator/WHY_MATRIX_TABLE.md index 6b24d234..f67d419c 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-operator/WHY_MATRIX_TABLE.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-operator/WHY_MATRIX_TABLE.md @@ -44,6 +44,23 @@ context (operator focus, prior wave's worker, current load). AGENT FAIRNESS is not a rotation quota — it means honest attribution and equal dignity. Don't dispatch by round-robin; dispatch by fit. +## Model-aware dispatch granularity + +Model parity is the floor; granularity controls how much coherent work one +dispatch owns. Use `agent_dispatch.dispatch_granularity(model)` instead of +treating every model as an equally sized anonymous worker. + +| model class | cut shape | files per cut | parallel cuts | reason | +| ------------------------------------------------------ | --------- | ------------: | -------------------------------: | ---------------------------------------------------------------- | +| frontier (`opus`, GPT-5.5/5.6, Gemini Pro, Grok Build) | coherent | up to 8 | up to 3 for disjoint file scopes | Amortize repeated context and preserve whole-contract reasoning. | +| standard (`sonnet`, GPT-5, Gemini auto/default) | bounded | up to 4 | up to 2 | Keep integration seams explicit without over-fragmenting. | +| economy (`haiku`, Spark, Flash) | surgical | up to 2 | 1 | Small sequential proof surfaces reduce drift and retry cost. | +| unknown model | surgical | 1 | 1 | Unknown telemetry is not permission for broad dispatch. | + +Provider-reported cost wins. Otherwise accept only a named +`cost_source: estimated:` estimate; never silently convert unknown +cost to zero. Cost informs cut shape but never authorizes a model downgrade. + ## Footer Notes - AGENT PEER PARITY: Claude, Codex, and Gemini are peer frontier workers. Route diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-polarize/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-polarize/SKILL.md index dbd8cd0b..d7f96510 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-polarize/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-polarize/SKILL.md @@ -63,7 +63,7 @@ Standard launcher: vibecrafted polarize codex --task 'marbles versus polarize skills: polarize them' vc-polarize codex --task 'installer public contract' vc-polarize claude --file /path/to/prism-pack.md -vibecrafted polarize gemini --prompt 'Choose one launch thesis after marbles' +vibecrafted polarize agy --prompt 'Choose one launch thesis after marbles' ``` When `--task` is present, the runner executes a fresh prism preflight diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-research/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-research/SKILL.md index cfc86e02..ba4f60a5 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-research/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-research/SKILL.md @@ -3,7 +3,7 @@ name: vc-research version: 1.3.0 description: > Standalone triple-agent research skill. Co-define the problem with the user, - write a research plan, then spawn claude + codex + gemini simultaneously on the + write a research plan, then spawn claude + codex + agy simultaneously on the same questions. Three independent reports come back. Synthesize into one gap-free research document ready for implementation. Use whenever the team needs ground truth before coding: unknown APIs, architecture decisions, library @@ -50,10 +50,15 @@ Enter the framework session via `vibecrafted start` (or `vc-start`). Then launch vibecrafted research --prompt 'Compare auth libraries for Tauri desktop' vc-research --prompt 'State of the art for MCP streaming transports' vibecrafted research --file /path/to/research-plan.md +vc-research codex agy --prompt 'Override lanes for this run' ``` If invoked outside vc-frame, the framework attaches/creates the operator session and runs in a new tab. Prefer `--file` for an existing plan, `--prompt` for inline intent. +**Critical swarm semantics:** one invocation launches the research swarm. Do not call the command once per agent. `vibecrafted research ` is the backward-compatible synthesizer-pick form; it does not mean "research only with this agent." `vc-research [agent2 agent3] --prompt/--file ...` is the explicit lane override form for one to three lanes; the first listed agent synthesizes unless `--synthesizer ` is provided. + +Runtime lane defaults are read at launch time from `${VIBECRAFTED_HOME:-$HOME/.vibecrafted}/config/research.yaml`. Missing file means built-in defaults: `claude`, `codex`, `agy`, with no model pins and last-survivor synthesis. The packaged commented example lives at `runtime/vc-research/research.yaml.example`; copy it into the runtime config dir to edit operator policy without touching the repo or reinstalling. +
Foundation Dependencies @@ -72,7 +77,7 @@ or misses a surface, append feedback to `~/.vibecrafted/loctree/loctree-fail.md` ## Purpose -Research a problem from three independent angles before writing code. The orchestrating agent co-defines the problem with the user, writes a plan, spawns claude + codex + gemini on the same questions, then synthesizes findings into one gap-free document. This is the Research phase from `vc-workflow`, extracted as a standalone skill and upgraded with triple-agent triangulation. +Research a problem from three independent angles before writing code. The orchestrating agent co-defines the problem with the user, writes a plan, spawns claude + codex + agy on the same questions, then synthesizes findings into one gap-free document. This is the Research phase from `vc-workflow`, extracted as a standalone skill and upgraded with triple-agent triangulation. ## When To Use @@ -119,7 +124,7 @@ Create one plan file. Every agent receives this plan: ```markdown --- run_id: -agent: +agent: skill: vc-research project: status: in-progress @@ -162,7 +167,7 @@ Conclude with **Synthesis**: recommended approach, alternatives, open questions, `vc-research` records the effective plan under `$VIBECRAFTED_HOME/artifacts////research//plans/__research-plan.md`. Plans can be split for separable domains, but each agent gets ALL plans — they are independent researchers, not specialists. -### Step 3 — Spawn triple research swarm +### Step 3 — Spawn the research swarm ```bash PLAN="$VIBECRAFTED_HOME/artifacts////plans/__research-plan.md" @@ -171,7 +176,46 @@ vc-research --file "$PLAN" Repo-owned spawn scripts remain the internal engine. Do not document raw `bash skills/...spawn.sh` paths as the operator entrypoint. -The launcher opens one shared vc-frame research tab using `research.kdl`, keeps a common `run_id`, and starts claude + codex + gemini against the same plan. Divergence between reports reveals blind spots. +The launcher opens one shared vc-frame research tab using `research.kdl`, keeps a common `run_id`, and starts the configured lanes against the same plan. Defaults are claude + codex + agy. Divergence between reports reveals blind spots. + +Supported invocation forms: + +```bash +# Canonical agentless form: full configured swarm. +vibecrafted research --prompt "State of the art for MCP streaming" +vibecrafted research --file "$PLAN" + +# Backward-compatible core form: full configured swarm, claude synthesizes. +vibecrafted research claude --file "$PLAN" + +# Shell override form: exactly these lanes for this run; codex synthesizes. +vc-research codex agy --prompt "Compare two toolchains" + +# Explicit synthesizer override. +vc-research claude codex agy --synthesizer claude --file "$PLAN" +``` + +Runtime YAML schema: + +```yaml +lanes: + - agent: claude + model: claude-opus-4-6 + enabled: true + - agent: codex + model: gpt-5.5 + enabled: true + - agent: agy + # Unsupported model flags are reported honestly in receipts. + model: gemini-pro + enabled: true +lane_count: 3 +synthesizer: + agent: claude + model: claude-sonnet-4-6 +``` + +Precedence is: built-in defaults < legacy TOML fallback < runtime YAML < env/positional override < per-run `--model` flags. Unsupported model flags are not silently dropped; the receipt records `model_override_supported: false`, `model_override_skipped: true`, and `model_override_skip_reason: unsupported_agent_model_flag`. Immediately after spawn, the operator gets a launch card with shared `run_id`, run directory, reports directory, summary path, and the exact await command. **The launch card is the default surface.** `observe --last` is a drilldown tool, not the primary source of truth. @@ -180,7 +224,7 @@ Immediately after spawn, the operator gets a launch card with shared `run_id`, r Reports land in: ``` -$VIBECRAFTED_HOME/artifacts////research//reports/{claude,codex,gemini}.md +$VIBECRAFTED_HOME/artifacts////research//reports/{claude,codex,agy}.md ``` Launch card lives at `research//summary.md`. Metadata, transcripts, raw streams, prompts, launchers, vc-frame layout stay inside `research//logs/` and `research//tmp/`. @@ -197,7 +241,7 @@ For transcript-level inspection while the swarm is running: ```bash vibecrafted claude observe --last vibecrafted codex observe --last -vibecrafted gemini observe --last +vibecrafted agy observe --last ``` Do not treat manual `observe --last` calls as sufficient observability. Workflow state goes through launch metadata, the await helper, and durable report paths by default. @@ -249,13 +293,14 @@ vc-research can be used: research │ │ plan ──├─── codex ──→ report ───├──→ synthesis.md │ │ - └─── gemini ──→ report ───┘ + └────── agy ──→ report ───┘ ``` ## Anti-Patterns -- Passing `claude|codex|gemini` to `vc-research` (defeats the purpose — the launcher is the swarm) +- Passing `claude|codex|agy` to `vc-research` (defeats the purpose — the launcher is the swarm) - Giving each agent different questions (they must answer the SAME questions for triangulation) +- Running `vc-research` three times for claude/codex/agy; one invocation already launches the swarm - Skipping synthesis and concatenating reports (the value is in the delta) - Researching things you can verify by reading one file (use loctree slice) - Writing the research plan without the user (Step 1 is collaborative) diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-review/PRVIEW.md b/vibecrafted-core/vibecrafted_core/skills/vc-review/PRVIEW.md index 4af5f1d5..80afab4e 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-review/PRVIEW.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-review/PRVIEW.md @@ -14,7 +14,7 @@ Read alongside [`SKILL.md`](SKILL.md) and [`FINDINGS.md`](FINDINGS.md). ## Dispatch table | Mode | Command | When to use | -| -------------------------------- | ----------------------------------------------- | ------------------------------------------ | -------------------- | +| -------------------------------- | ----------------------------------------------- | ------------------------------------------ | | Local branch vs develop/main | `prview --pr ` | Default for active PRs on local checkout | | Remote branch (no checkout) | `prview -R --remote-only ` | Reviewing a contributor branch on origin | | GitHub PR by number | `prview --pr --with-tests --with-lint` | Default for thorough PR review | @@ -22,7 +22,7 @@ Read alongside [`SKILL.md`](SKILL.md) and [`FINDINGS.md`](FINDINGS.md). | Fast triage | `prview --quick` | Explicit fast triage only — NOT default | | Refresh after amend / force-push | `prview --update` | Avoid duplicate artifact sets | | Ambiguous origin | add `--gh-repo owner/repo` | When the working tree has multiple remotes | -| JSON-only mode | `prview --json --quiet | jq ...` | Pipeline integration | +| JSON-only mode | `prview --json --quiet | jq ...` | Pipeline integration | Default for vc-review: **do not use `--quick`**. Use `--with-tests --with-lint` as the baseline. Save `--deep` for merge gate / high-risk. diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-scaffold/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-scaffold/SKILL.md index ca523a37..cbb2d6dc 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-scaffold/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-scaffold/SKILL.md @@ -44,7 +44,7 @@ Enter the framework session, then launch through the command deck (not raw `skil ```bash vibecrafted start # or: vc-start vibecrafted scaffold claude --prompt 'Design the payment system' -vc-scaffold gemini --prompt 'Plan migration from NextAuth to custom auth' +vc-scaffold agy --prompt 'Plan migration from NextAuth to custom auth' vibecrafted scaffold codex --file /path/to/idea-brief.md ``` diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-ship/FLOW.md b/vibecrafted-core/vibecrafted_core/skills/vc-ship/FLOW.md new file mode 100644 index 00000000..e9d655fc --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/skills/vc-ship/FLOW.md @@ -0,0 +1,49 @@ +# `vc-ship` Flow + +## Flow + +```mermaid +flowchart TD + A[Operator: vibecrafted ship codex --file mission.md] --> B[Supervisor vc-init: atlas + intents + risk -> mission file] + B --> C[Launch lifecycle run life-ship-*; stage 1 scaffold READ] + C --> D{Stage report lands?} + D -->|report written| E[Verify: commits/gates on WRITE, no violation on READ] + D -->|worker dead, no report| F[interrupt -> fallback --stage -> approve --force] + F --> C + E --> G{More stages in the baton?} + G -->|yes| H[ship approve: baton + report cargo -> next stage] + H --> C + G -->|no, release done| I[Final flight report to operator] +``` + +## Routes + +| Entry | Args | Produces | Exit | +| -------------------------- | ----------------------------------------- | ----------------------------------------------------------- | --------------- | +| `vibecrafted ship ` | `--prompt` or `--file`, `[--start-stage]` | lifecycle run dir (`state.json`, transcript), stage reports | `0` on dispatch | +| `vc-ship ` | same | same | `0` on dispatch | + +### Escalation edges + +- Mission has no grounded plan yet → `vibecrafted scaffold ` first, then + feed the plan file to `vc-ship` as the mission. +- Only one delivery cut is needed → `vibecrafted implement ` / + `vc-justdo`; the umbrella is overkill for a single known-shape change. +- Steering decisions exceed the standing mandates (publish, merge, spend) → + stop and surface to the human operator; `vibecrafted partner ` for + shared steering. + +### Session artifacts + +- Artifact root: `$VIBECRAFTED_HOME/artifacts////` +- Lifecycle run: `$VIBECRAFTED_HOME/control_plane/lifecycle_runs//state.json` +- Outputs: `reports//…_report.md` per stage, with matching transcripts + and meta under the runtime run dirs + +### Anti-patterns + +- Watching stage gates from inside a subagent (gate-nap, AGENT_OPS.md Class 1) + — watchers live with the supervisor. +- Waiting out a stage budget on a dead worker instead of checking + `status --json` → `stage_worker.worker_dead_without_report` (Class 2). +- Approving on silence: no report read, no commits verified, no button. diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-ship/README.md b/vibecrafted-core/vibecrafted_core/skills/vc-ship/README.md new file mode 100644 index 00000000..df1cc5c3 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/skills/vc-ship/README.md @@ -0,0 +1,28 @@ +# vc-ship + +The lifecycle umbrella: one mission flown through all eleven Read-Write +cadence stages (scaffold → … → release) as a single supervised run. The +invoking agent becomes the run's supervising operator — verifying every stage +report, steering with the human-controls verbs (approve / interrupt / +fallback / force-audit / accept-dou), and carrying the baton with its report +cargo to release. Reach for it when a product cut deserves the full cadence; +you get back a traced lifecycle run, per-stage reports, and an honest final +flight report with the DoU trail. + +## Quick reference + +| Field | Value | +| ---------------- | -------------------------- | +| Name | `vc-ship` | +| Version | `1.0.0` | +| Operator command | `vibecrafted ship ` | +| Shell shortcut | `vc-ship ` | +| Canonical doc | [`SKILL.md`](SKILL.md) | + +## Related canon + +- [`docs/runtime/LIFECYCLE.md`](../../docs/runtime/LIFECYCLE.md) — the + Read-Write cadence, component architecture, and supervision model. +- [`docs/runtime/AGENT_OPS.md`](../../docs/runtime/AGENT_OPS.md) — failure + classes every supervisor must know (gate-nap, report-on-death) and the + battle-tested watcher patterns. diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-ship/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-ship/SKILL.md new file mode 100644 index 00000000..dc7d2d88 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/skills/vc-ship/SKILL.md @@ -0,0 +1,253 @@ +--- +name: "vc-ship" +version: 1.0.0 +description: > + Meta-skill: the full Vibecrafted lifecycle umbrella. Launches the 11-stage + Read-Write cadence (scaffold → implement → review → workflow → followup → + marbles → audit → polarize → dou → hydrate → release) as ONE supervised + lifecycle run, then turns the invoking agent into the supervising operator + driving the baton relay with the human-controls verbs. Usually invoked in + the vc-operator formula. Trigger phrases: "vc-ship", "/vc-ship", + "ship it through the lifecycle", "parasol", "umbrella flight", "pełny lot", + "lifecycle run", "od scaffoldu po release". +default: vc-ship +compatibility: + tools: + - Skill + - Bash + - Read + - Write + - TaskCreate + - TaskUpdate +requires: + - vc-init +loctree_value: "primary repo map for structural/literal repository work" +aicx_value: "intent, session, and decision-context retrieval" +dogfooding: "required for repo-impacting work" +--- + + + +> **Operator CLI / slash-command layer:** invoking `/vc-` or +> `vibecrafted ` means dispatching through the Vibecrafted +> launcher. +> +> **Skill-loading / chat layer:** loading this `SKILL.md` inside Codex, Claude, +> Gemini, or another local agent does not mean self-dispatch. Read and apply the +> skill in the current thread unless the operator explicitly asks for runtime +> launch, dispatch, or native delegation. +> +> Native in-process subagents are allowed only through the bounded +> `vc-delegate` doctrine. + + + +# vc-ship — the lifecycle umbrella: one mission, eleven stages, one baton + +--- + +## Operator Entry + +### Living Tree / Worktree Rule + +This workflow runs in the operator's current checkout and current branch. Do not +create, switch to, or move execution into a git worktree unless the operator +explicitly asks for one in this prompt. Re-read files before editing, adapt to +concurrent changes, and report substrate failure if the tree is too poisoned to +continue safely. + +See [Living Tree Rule](../LIVING_TREE_RULE.md). + +## Canonical Orientation Gate + +Before this lifecycle umbrella launches its first stage — and before every +WRITE stage it later supervises touches source — it MUST run or consume the +`vc-init` procedure for the mission's repo. The mission file itself is only +valid when grounded in a real `vc-init` pass; if fresh `vc-init` evidence is +absent, perform the init pass first and treat the whole flight as blocked until +repo truth exists. A prompt is a hypothesis, never the ground truth. + +`Loctree:loctree` is the default structural perception skill for that pass. Use +Loctree before grep or docs-driven claims to produce or refresh the +Code-Derived Application Map: repo-view, focus, slice, impact, find, and follow +as relevant. Search for existing symbols and contracts before creating new +ones; run impact before delete or major refactor; run slice before editing. As +supervisor you carry this map as baton cargo, so each stage worker inherits the +same structural truth instead of re-discovering the repo from zero. + +The point is to find the hooks before the eleven stages fly: load-bearing hubs, +twins, dead code, drift, runtime entrypoints, and blast-radius traps. If the +mission is explicitly non-repo or no-code, state the no-repo exception in the +report. Otherwise, missing `vc-init`/Loctree evidence is a process failure and +the flight has not honestly begun. + +## Repository Work Doctrine + +For repository work, start with Loctree as the map: use `loct context`, +`loct occurrences`, `loct body`, and `loct find --literal` before broad manual +search. Use AICX for intent and session context. Use rg/grep as fallback or +local magnifier, not as a replacement for structural mapping. If Loctree fails +or misses a surface, append feedback to `~/.vibecrafted/loctree/loctree-fail.md`. + +Standard launcher: + +```bash +vibecrafted ship --file /path/to/mission.md # canonical: mission file +vibecrafted ship codex --prompt 'one-cut mission text' # short missions +vc-ship claude --file mission.md # shell shortcut +vibecrafted ship --start-stage review --file m.md # resume mid-pipeline +``` + +Operator invariant: stage workers fly **visibly, as vc-frame tabs**, whenever +a live operator session can host them (headless is only the degrade-not-die +fallback; force quiet with `--runtime headless`). + +--- + +## Purpose + +Run one product mission through the complete Read-Write cadence as a single +supervised lifecycle run, and make the invoking agent the **supervisor** of +that run: verifying every stage report, steering with the human-controls +verbs, and carrying the baton (with its report cargo) from scaffold all the +way to release. The goal of every flight is the dictated one: **big win and +ZERO DoU index** — or an honest, traced `accept-dou` for what remains undone. + +## The Pipeline (Read-Write Cadence) + +| # | Stage | Phase | Discovery / delivery tooling | +| --- | --------- | ----- | ---------------------------------------------------------------- | +| 1 | scaffold | READ | vc-init, vc-loctree, vc-research | +| 2 | implement | WRITE | vc-init, vc-operator, vc-agents | +| 3 | review | READ | vc-init, vc-loctree, vc-review, vc-prview (test-heavy by design) | +| 4 | workflow | WRITE | vc-init, vc-research, vc-justdo | +| 5 | followup | READ | vc-init, vc-intents, vc-loctree, TDD | +| 6 | marbles | WRITE | vc-marbles runtime — entropy UP, flood every crack | +| 7 | audit | READ | vc-init, vc-loctree, vc-aicx, vc-research | +| 8 | polarize | WRITE | marbles runtime — entropy DOWN, one truth, no mercy | +| 9 | dou | READ | Definition of Undone: find the gaps before release | +| 10 | hydrate | WRITE | vc-init, vc-operator, vc-decorate | +| 11 | release | — | deployment/publishing/signing — the operator-plane stage | + +READ stages must not write source (a violation is traced as +`read_phase_violation`); WRITE stages must show commits and green gates. + +## How To Fly (supervisor protocol) + +1. **Mission first.** Compose the mission as a durable `.md` file under + `~/.vibecrafted/artifacts////plans/` — grounded in a real + vc-init pass (Loctree atlas + AICX intents + git/risk truth), with explicit + deliverables, hard constraints, and gates. `--file` delivers it verbatim to + every stage worker. +2. **Launch** with the standard launcher above. Verify the launch receipt: + run_id (`life-ship-…`), context atlas loaded, stage 1 accepted. +3. **Arm await immediately, supervisor-side** (never inside a subagent — see + `docs/runtime/AGENT_OPS.md`): After dispatch, arm + `vibecrafted await --run-id ` immediately, supervisor-side. + Control-plane JSON, report files, transcripts, panes, and scheduled wakeups + are diagnostic only, not wake signals. Hedging await with ad-hoc + pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, do + not normalize the hedge. Stage report checks and `ship status --json` are + diagnostics subordinate to that canonical await; `ship status --json` + exposes `stage_worker` with `worker_dead_without_report` — the actionable + death signal; the dispatcher also writes `worker_exit` / + `stage_worker_exit` into `state.json` push-side when a worker dies. + 3-signal liveness: before declaring done, reconcile await verdict, terminal + run meta, and worker pid dead; if a report is promised, confirm it exists. + Two agreeing signals are enough to act, three to declare done; disagreement + means treat as live and re-arm await. Known skew: rc=0-on-live and meta stuck + `active`/`stalled` after real completion. +4. **Verify before every button.** Read the report; for WRITE stages confirm + the commits and gates actually exist; for READ stages confirm no + `read_phase_violation`. Honor worker steering from report frontmatter + (`next_stage`, `next_agent`, `dou_index`) unless it is nonsense — then + override with a verb. +5. **Drive with verbs, never with manual state surgery:** + + ```bash + vibecrafted ship runs # list lifecycle runs + vibecrafted ship status --json # truth before any button + vibecrafted ship approve # baton → next stage (cargo-gated) + vibecrafted ship approve --force # traced override of the cargo gate + vibecrafted ship interrupt # stop a blind/dead continuation + vibecrafted ship fallback --stage # rewind the baton WITH cargo + vibecrafted ship force-audit # suspicious WRITE output + vibecrafted ship accept-dou --finding "…" # conscious, traced gap + ``` + + Dead worker recovery is always: `interrupt` → `fallback --stage ` → + `approve [--force]`. No baton cargo is lost. + +6. **Report at the end, not along the way** (unless the operator asks + otherwise): stages flown, corrections made, commits, gate colours, + dou_index, and what release honestly did NOT verify. + +## Boundaries (what the human keeps) + +- The baton is an **agent↔agent relay**; the supervising agent is the + operator of the run. The human stays the human: mandates, pushes to the + world, and merges are theirs unless explicitly delegated. +- Never merge your own PR without an explicit one-time mandate. +- "Production ready" is a forbidden verdict. Report evidence, `file:line`, + gate colours; the release stage and the human own the verdict — honest + outcomes like `repo_contract_green_external_release_blocked` beat a + confident lie. + +## When To Use + +- A mission needs the full cadence: discovery, delivery, adversarial review, + entropy-up/entropy-down stabilization, DoU, and a release gate — as one + supervised, auditable run. +- The operator says "ship it", "pełny lot", "parasol" for a scoped product + cut in ANY repo (the runtime is repo-agnostic; the mission file names the + root). + +**When NOT to use:** + +- A single cut with known shape → `vc-implement` or `vc-justdo`. +- Only stabilization → `vc-marbles` (then `vc-polarize`). +- Only discovery → `vc-init` / `vc-research` / `vc-scaffold` directly. + +## Pipeline Position + +- Upstream: `vc-operator` (usual invoker), a scaffold-grade mission plan. +- Downstream: nothing — vc-ship IS the pipeline; its release stage emits the + handoff (release report + DoU trail) the human acts on. + +## Acceptance Criteria + +The skill run is **done** when: + +- [ ] Lifecycle run reached `release` (or an operator-decided stop), with + every transition traced in `operator_actions`. +- [ ] Every WRITE stage has verifiable commits + green gates; every READ + stage is violation-free. +- [ ] `dou_index` is 0 — or every remaining gap is an explicit, traced + `accept-dou` with its follow-up named. +- [ ] Final report delivered: stages, corrections, commits, gate colours, + and what was NOT verified. + +If any acceptance bullet cannot be ticked with evidence, the flight has not +completed — say so explicitly in the final report. + +## Anti-Patterns + +- Launching without a mission file grounded in vc-init truth (the prompt is a + hypothesis, not the ground truth). +- Watching gates or workers from inside a subagent — gate-nap class failure + (`docs/runtime/AGENT_OPS.md`, Class 1); watchers live with the supervisor. +- Trusting silence: a missing report is indistinguishable from a dead worker + until you check liveness (Class 2) — use `status`/`stage_worker`, don't + wait out budgets on a corpse. +- Manual edits to `state.json` instead of verbs — the trace IS the product. +- Approving a stage without reading its report, or pronouncing "ready" + yourself instead of handing the verdict to the gate. + +## Examples + +See [`examples/example-prompt.md`](examples/example-prompt.md) for a minimal +trigger phrase + expected behavior pair. + +--- + +_𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI_ diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-ship/examples/example-prompt.md b/vibecrafted-core/vibecrafted_core/skills/vc-ship/examples/example-prompt.md new file mode 100644 index 00000000..f314772e --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/skills/vc-ship/examples/example-prompt.md @@ -0,0 +1,38 @@ +# vc-ship — example trigger + +## Trigger phrase + +> "lecisz z taskiem vc-ship dla vibecrafted-server adaptation — dopilnuj tego +> aż do release z właściwymi korektami po drodze" + +## Expected agent behavior + +1. vc-init pass in the target repo: Loctree context atlas read to the end, + AICX intents recovered, git/risk truth graded. +2. Compose the mission as a durable file under + `~/.vibecrafted/artifacts////plans/…_prompt.md` — + deliverables, hard constraints, named gates — and launch: + `vibecrafted ship codex --file `. +3. Supervise the baton relay stage by stage: watcher on the stage report + + `ship status --json` liveness; verify commits/gates before every + `approve`; recover dead workers with `interrupt → fallback → approve`; + trace conscious gaps with `accept-dou`. +4. Deliver the final flight report: stages flown, corrections, commits, gate + colours, `dou_index`, and what release honestly did NOT verify. + +## Acceptance evidence + +What the operator should see in the final agent report: + +- Lifecycle run id (`life-ship-…`) with 11/11 stages traced in + `operator_actions` (or an operator-decided stop, stated as such). +- Per-stage report paths + WRITE-stage commit hashes + gate results + (e.g. core suite green, `make server-test` green). +- `dou_index: 0` — or each remaining gap as an explicit `accept-dou` entry + with its follow-up named. + +## Notes + +- Real precedent: flights `life-ship-260702-123238-24000` (v3.3.0) and + `life-ship-260702-202338-58000` (lifecycle.schema.v1) — both supervised + end-to-end with every verb used in anger. diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-workflow/SKILL.md b/vibecrafted-core/vibecrafted_core/skills/vc-workflow/SKILL.md index 284eb98f..29166d0d 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-workflow/SKILL.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-workflow/SKILL.md @@ -55,7 +55,7 @@ Standard launcher (`vibecrafted start` / `vc-start`, then `vc- ```bash vibecrafted workflow claude --prompt 'Examine auth surface and implement fixes' vc-workflow codex --prompt 'Research SSO options then implement the best fit' -vibecrafted workflow gemini --file /path/to/research-plan.md +vibecrafted workflow agy --file /path/to/research-plan.md # gemini deprecated; agy is Google replacement ``` Foundation deps (loaded with framework): `vc-loctree`, `vc-aicx`. @@ -124,7 +124,7 @@ Write to `$VIBECRAFTED_HOME/artifacts////plans/_ ```markdown --- run_id: -agent: +agent: skill: vc-workflow project: status: completed @@ -179,7 +179,7 @@ WebFetch directly: query `" usage example "`, fetch standard docs. ```markdown --- run_id: -agent: +agent: skill: vc-workflow project: status: completed @@ -244,6 +244,17 @@ default `plans/`, reports → default `reports/` under `$VIBECRAFTED_HOME/artifacts////`. Repo-local `.vibecrafted/plans` and `.vibecrafted/reports` are convenience symlinks only. +After dispatch, arm `vibecrafted await --run-id ` immediately, +supervisor-side. Control-plane JSON, report files, transcripts, panes, and +scheduled wakeups are diagnostic only, not wake signals. Hedging await with +ad-hoc pollers/watchers is a Class 3 violation; fix `control_plane.await_run`, +do not normalize the hedge. See `docs/runtime/AGENT_OPS.md`. + +3-signal liveness: await verdict, terminal run meta, worker pid dead, plus +promised report presence. Two agreeing signals are enough to act, three to +declare done; any disagreement means treat as live and re-arm await. Known skew: +rc=0-on-live and meta stuck `active`/`stalled` after real completion. + ### Phase 4 — CONVERGE (Marbles & Polarize) After implementation agents complete, the code exists but may not be true or shippable. diff --git a/vibecrafted-core/vibecrafted_core/skills/vc-workflow/references/phase-implement.md b/vibecrafted-core/vibecrafted_core/skills/vc-workflow/references/phase-implement.md index e4d815b9..394fabec 100644 --- a/vibecrafted-core/vibecrafted_core/skills/vc-workflow/references/phase-implement.md +++ b/vibecrafted-core/vibecrafted_core/skills/vc-workflow/references/phase-implement.md @@ -153,7 +153,7 @@ bash agents//claude_spawn.sh "$PLAN" --mode review --runtime terminal ### Gemini ```bash -bash agents//gemini_spawn.sh "$PLAN" --mode implement --runtime terminal +bash agents//agy_spawn.sh "$PLAN" --mode implement --runtime terminal # gemini deprecated; agy is the Google-family runtime ``` > The scripts default to visible Terminal mode on macOS and fall back to headless diff --git a/vibecrafted-core/vibecrafted_core/spawn.py b/vibecrafted-core/vibecrafted_core/spawn.py index b1158349..821ae1c9 100644 --- a/vibecrafted-core/vibecrafted_core/spawn.py +++ b/vibecrafted-core/vibecrafted_core/spawn.py @@ -15,6 +15,7 @@ from .agent_dispatch import extract_session_id, sandbox_supported from .control_plane import ensure_session_id, normalize_run_root from .events import append_event +from .telemetry import estimate_cost_usd EventCallback = Callable[[dict[str, Any]], None] @@ -42,12 +43,30 @@ "cached_input": re.compile( r"^\s*tokens_cached_input:\s*([0-9]+)", re.IGNORECASE | re.MULTILINE ), + "cache_write": re.compile( + r"^\s*tokens_cache_write:\s*([0-9]+)", re.IGNORECASE | re.MULTILINE + ), "output": re.compile( r"^\s*tokens_output:\s*([0-9]+)", re.IGNORECASE | re.MULTILINE ), } +JSON_TOKEN_PATTERNS = { + "input": re.compile(r'"(?:input_tokens|inputTokens|prompt_tokens)"\s*:\s*([0-9]+)'), + "cached_input": re.compile( + r'"(?:cached_input_tokens|cached_prompt_tokens|cache_read_input_tokens|cacheReadInputTokens|cacheInputTokens)"\s*:\s*([0-9]+)' + ), + "cache_write": re.compile( + r'"(?:cache_creation_input_tokens|cacheCreateTokens)"\s*:\s*([0-9]+)' + ), + "output": re.compile( + r'"(?:output_tokens|outputTokens|completion_tokens)"\s*:\s*([0-9]+)' + ), +} COST_PATTERNS = ( - re.compile(r"cost(?:_usd)?\s*[:=]\s*\$?([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE), + re.compile( + r"cost(?:_usd)?['\"]?\s*[:=]\s*\$?([0-9]+(?:\.[0-9]+)?)", + re.IGNORECASE, + ), re.compile(r"\$([0-9]+\.[0-9]+)\s*(?:usd)?", re.IGNORECASE), ) MODEL_ENV_VARS = ( @@ -109,6 +128,12 @@ def _set_child_pgid() -> None: def _default_command(agent: str, prompt: str) -> list[str]: + if agent == "gemini": + raise ValueError( + "gemini CLI is deprecated. Google Antigravity CLI (agy) is the replacement. " + "Use 'vibecrafted workflow agy --prompt ...' (or agy in other launchers). " + "No execution path may launch the gemini binary." + ) if agent == "claude": return [ "claude", @@ -119,14 +144,17 @@ def _default_command(agent: str, prompt: str) -> list[str]: ] if agent == "codex": return ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", prompt] - if agent == "gemini": - return ["gemini", "--yolo", "--prompt", prompt] if agent == "agy": + # agy >= 1.1: --print takes the prompt as its value (Go flags) and + # print mode does not read stdin; flags must precede it. return [ - "bash", - "-lc", - "agy --print --dangerously-skip-permissions --add-dir . --print-timeout 30m '' <<< \"$1\"", "agy", + "--dangerously-skip-permissions", + "--add-dir", + ".", + "--print-timeout", + "30m", + "--print", prompt, ] if agent == "junie": @@ -170,17 +198,20 @@ def _stdin_command(agent: str) -> list[str]: "-", ] if agent == "gemini": - return ["gemini", "-p", "", "--approval-mode", "yolo", "-o", "stream-json"] + raise ValueError( + "gemini CLI is deprecated. Google Antigravity CLI (agy) is the replacement. " + "Use 'vibecrafted workflow agy --prompt ...' (or agy in other launchers). " + "No execution path may launch the gemini binary." + ) if agent == "agy": + # agy >= 1.1 print mode reads no stdin and --print requires a value; + # a shell shim folds stdin into the flag. The prompt lands on the + # inner argv (ARG_MAX-bound) because agy has no file/stdin lane. return [ - "agy", - "--print", - "--dangerously-skip-permissions", - "--add-dir", - ".", - "--print-timeout", - "30m", - "", + "bash", + "-c", + "agy --dangerously-skip-permissions --add-dir . " + '--print-timeout 30m --print "$(cat)"', ] if agent == "junie": return [ @@ -265,9 +296,19 @@ def _extract_session(text: str) -> str: return "" -def _extract_tokens(text: str) -> dict[str, int]: +def _tokens_total( + input_tokens: int, cached_input_tokens: int, output_tokens: int +) -> int: + return input_tokens + cached_input_tokens + output_tokens + + +def _extract_tokens(text: str) -> dict[str, int | None]: clean = _clean_text(text) found = TOKEN_PATTERN.findall(clean) + json_tokens = { + key: sum(int(match) for match in pattern.findall(clean)) + for key, pattern in JSON_TOKEN_PATTERNS.items() + } # Prefer the authoritative run-closure footer totals when present: they are # written for every agent and carry the final per-run usage, so they work # uniformly across providers and never sum partial streaming deltas. @@ -275,19 +316,42 @@ def _extract_tokens(text: str) -> dict[str, int]: footer_out = FOOTER_TOKEN_PATTERNS["output"].findall(clean) if footer_in or footer_out: footer_cached = FOOTER_TOKEN_PATTERNS["cached_input"].findall(clean) + footer_cache_write = FOOTER_TOKEN_PATTERNS["cache_write"].findall(clean) input_tokens = int(footer_in[-1]) if footer_in else 0 cached_tokens = int(footer_cached[-1]) if footer_cached else 0 + cache_write_tokens = int(footer_cache_write[-1]) if footer_cache_write else None output_tokens = int(footer_out[-1]) if footer_out else 0 - total_tokens = input_tokens + output_tokens - if total_tokens or not found: + total_tokens = _tokens_total(input_tokens, cached_tokens, output_tokens) + if total_tokens or (not found and not any(json_tokens.values())): return { "input": input_tokens, "cached_input": cached_tokens, + "cache_write": cache_write_tokens, "output": output_tokens, "total": total_tokens, } + if any(json_tokens.values()): + return { + "input": json_tokens["input"], + "cached_input": json_tokens["cached_input"], + "cache_write": json_tokens["cache_write"] + if json_tokens["cache_write"] + else None, + "output": json_tokens["output"], + "total": _tokens_total( + json_tokens["input"], + json_tokens["cached_input"], + json_tokens["output"], + ), + } if not found: - return {"input": 0, "cached_input": 0, "output": 0, "total": 0} + return { + "input": 0, + "cached_input": 0, + "cache_write": None, + "output": 0, + "total": 0, + } input_tokens = cached_tokens = output_tokens = 0 for raw_in, raw_cached, raw_out in found: input_tokens += int(raw_in) @@ -296,13 +360,31 @@ def _extract_tokens(text: str) -> dict[str, int]: return { "input": input_tokens, "cached_input": cached_tokens, + "cache_write": None, "output": output_tokens, - "total": input_tokens + output_tokens, + "total": _tokens_total(input_tokens, cached_tokens, output_tokens), } def _extract_cost(text: str) -> float | None: clean = _clean_text(text) + footer = re.findall( + r"^\s*cost_usd:\s*\$?([0-9]+(?:\.[0-9]+)?)\s*$", + clean, + re.IGNORECASE | re.MULTILINE, + ) + if footer: + return round(float(footer[-1]), 6) + totals = re.findall( + r'"(?:total_cost_usd|totalCostUsd|total_cost)"\s*:\s*([0-9]+(?:\.[0-9]+)?)', + clean, + re.IGNORECASE, + ) + if totals: + return round(float(totals[-1]), 6) + item_costs = re.findall(r'"cost"\s*:\s*([0-9]+(?:\.[0-9]+)?)', clean, re.IGNORECASE) + if item_costs: + return round(sum(float(value) for value in item_costs), 6) for pattern in COST_PATTERNS: matches = pattern.findall(clean) if not matches: @@ -332,6 +414,17 @@ def _extract_model_from_text(text: str) -> str: model = _clean_model(match) if model: return model + json_models = re.findall( + r'"(?:model|model_id|modelId|model_name|modelName)"\s*:\s*"([^"]+)"', + clean, + ) + for match in json_models: + model = _clean_model(match) + if model: + return model + model_usage_maps = re.findall(r'"modelUsage"\s*:\s*\{\s*"([^"]+)"', clean) + if model_usage_maps: + return _clean_model(model_usage_maps[-1]) return "" @@ -398,6 +491,7 @@ def write_meta( transcript: str, launcher: str, model: str = "", + model_requested: str = "", prompt_id: str = "", run_id: str = "", loop_nr: str | int = 0, @@ -436,6 +530,8 @@ def write_meta( "liveness": "pid_pending", "model": model, } + if str(model_requested or "").strip(): + payload["model_requested"] = str(model_requested).strip() _write_meta(meta, payload) if run_id: @@ -453,6 +549,11 @@ def write_meta( "transcript": transcript, "launcher": launcher, "model": model, + **( + {"model_requested": str(model_requested).strip()} + if str(model_requested or "").strip() + else {} + ), "prompt_id": prompt_id, "started_at": now_iso, "liveness": "active", @@ -534,6 +635,7 @@ def _render_frontmatter(data: dict[str, object]) -> str: "agent", "skill", "model", + "model_requested", "status", "date", "session_id", @@ -541,9 +643,12 @@ def _render_frontmatter(data: dict[str, object]) -> str: "artifact_kind", "repo_path", "tokens_input", + "tokens_cached_input", + "tokens_cache_write", "tokens_output", "tokens_total", "cost_usd", + "cost_source", ] lines = ["---"] emitted = set() @@ -646,18 +751,31 @@ def _leave_compat_link(announced: Path, final: Path) -> None: def _footer(marker: str, payload: dict[str, object]) -> str: - return "\n".join( + lines = [ + "", + f"", + "---", + "run_closure:", + f" run_id: {payload.get('run_id', 'unknown')}", + f" session_id: {payload.get('session_id') or 'unknown'}", + f" tokens_input: {payload.get('tokens_input', 0)}", + f" tokens_cached_input: {payload.get('tokens_cached_input', 0)}", + ] + if payload.get("tokens_cache_write") is not None: + lines.append(f" tokens_cache_write: {payload.get('tokens_cache_write')}") + lines.extend( [ - "", - f"", - "---", - "run_closure:", - f" run_id: {payload.get('run_id', 'unknown')}", - f" session_id: {payload.get('session_id') or 'unknown'}", - f" tokens_input: {payload.get('tokens_input', 0)}", f" tokens_output: {payload.get('tokens_output', 0)}", f" tokens_total: {payload.get('tokens_total', 0)}", f" cost_usd: {payload.get('cost_usd') if payload.get('cost_usd') is not None else 'unknown'}", + ] + ) + if payload.get("cost_source"): + lines.append(f" cost_source: {payload.get('cost_source')}") + if payload.get("model_requested"): + lines.append(f" model_requested: {payload.get('model_requested')}") + lines.extend( + [ f" status: {payload.get('status', 'unknown')}", f" completed_at: {payload.get('completed_at', 'unknown')}", f' resume_hint: "{payload.get("resume_hint", "")}"', @@ -665,6 +783,7 @@ def _footer(marker: str, payload: dict[str, object]) -> str: "", ] ) + return "\n".join(lines) def _normalize_markdown_artifact( @@ -677,27 +796,39 @@ def _normalize_markdown_artifact( return fm, body = _parse_frontmatter(text) frontmatter: dict[str, object] = dict(fm) - frontmatter.update( - { - "run_id": payload.get("run_id", "unknown"), - "prompt_id": payload.get("prompt_id", "unknown"), - "agent": payload.get("agent", "unknown"), - "skill": payload.get("skill_code") or payload.get("skill") or "unknown", - "model": payload.get("model", "unknown"), - "status": payload.get("status", "unknown"), - "date": payload.get("date", "unknown"), - "session_id": payload.get("session_id") or "unknown", - "artifact_stem": payload.get("artifact_stem", "unknown"), - "artifact_kind": payload.get("artifact_kind", "unknown"), - "repo_path": payload.get("root", "unknown"), - "tokens_input": payload.get("tokens_input", 0), - "tokens_output": payload.get("tokens_output", 0), - "tokens_total": payload.get("tokens_total", 0), - "cost_usd": payload.get("cost_usd") - if payload.get("cost_usd") is not None - else "unknown", - } - ) + frontmatter_update = { + "run_id": payload.get("run_id", "unknown"), + "prompt_id": payload.get("prompt_id", "unknown"), + "agent": payload.get("agent", "unknown"), + "skill": payload.get("skill_code") or payload.get("skill") or "unknown", + "model": payload.get("model", "unknown"), + "status": payload.get("status", "unknown"), + "date": payload.get("date", "unknown"), + "session_id": payload.get("session_id") or "unknown", + "artifact_stem": payload.get("artifact_stem", "unknown"), + "artifact_kind": payload.get("artifact_kind", "unknown"), + "repo_path": payload.get("root", "unknown"), + "tokens_input": payload.get("tokens_input", 0), + "tokens_cached_input": payload.get("tokens_cached_input", 0), + "tokens_output": payload.get("tokens_output", 0), + "tokens_total": payload.get("tokens_total", 0), + "cost_usd": payload.get("cost_usd") + if payload.get("cost_usd") is not None + else "unknown", + } + if payload.get("model_requested"): + frontmatter_update["model_requested"] = payload.get("model_requested") + else: + frontmatter.pop("model_requested", None) + if payload.get("tokens_cache_write") is not None: + frontmatter_update["tokens_cache_write"] = payload.get("tokens_cache_write") + else: + frontmatter.pop("tokens_cache_write", None) + if payload.get("cost_source"): + frontmatter_update["cost_source"] = payload.get("cost_source") + else: + frontmatter.pop("cost_source", None) + frontmatter.update(frontmatter_update) marker = str(payload.get("run_id") or "unknown") new_text = _render_frontmatter(frontmatter) + body.rstrip() + "\n" if f"vibecrafted-artifact-footer:{marker}" not in new_text: @@ -730,6 +861,11 @@ def finalize_artifacts( session_id = payload.get("session_id") or _extract_session(combined_text) tokens = _extract_tokens(combined_text) + tokens_input = int(tokens["input"] or 0) + tokens_cached_input = int(tokens["cached_input"] or 0) + tokens_cache_write = tokens["cache_write"] + tokens_output = int(tokens["output"] or 0) + tokens_total = int(tokens["total"] or 0) cost = _extract_cost(combined_text) completed_at = ( payload.get("completed_at") or dt.datetime.now(dt.timezone.utc).isoformat() @@ -744,13 +880,35 @@ def finalize_artifacts( payload["session_id"] = session_id or payload.get("session_id") or "" payload["model"] = _resolve_model(payload, combined_text) + cost_source = "provider_reported" if cost is not None else None + if cost is None: + cost, cost_source = estimate_cost_usd( + payload["model"], + tokens_input=tokens_input, + tokens_cached_input=tokens_cached_input, + tokens_output=tokens_output, + ) payload["duration_s"] = _resolve_duration(payload, str(completed_at)) - payload["tokens_input"] = tokens["input"] - payload["tokens_cached_input"] = tokens["cached_input"] - payload["tokens_output"] = tokens["output"] - payload["tokens_total"] = tokens["total"] - payload["token_usage"] = tokens + payload["tokens_input"] = tokens_input + payload["tokens_cached_input"] = tokens_cached_input + if tokens_cache_write is not None: + payload["tokens_cache_write"] = tokens_cache_write + else: + payload.pop("tokens_cache_write", None) + payload["tokens_output"] = tokens_output + payload["tokens_total"] = tokens_total + token_usage: dict[str, int] = { + "input": tokens_input, + "cached_input": tokens_cached_input, + "output": tokens_output, + "total": tokens_total, + } + if tokens_cache_write is not None: + token_usage["cache_write"] = int(tokens_cache_write) + payload["token_usage"] = token_usage payload["cost_usd"] = cost + if cost_source: + payload["cost_source"] = cost_source payload["resume_hint"] = resume_hint payload["artifact_contract"] = "vibecrafted.agent-artifact.v1" payload["date"] = payload.get("date") or artifact_time @@ -807,10 +965,19 @@ def finalize_artifacts( payload["artifact_footer"] = { "run_id": payload.get("run_id", "unknown"), "session_id": payload.get("session_id") or "", - "tokens_total": tokens["total"], + "tokens_input": tokens_input, + "tokens_cached_input": tokens_cached_input, + "tokens_output": tokens_output, + "tokens_total": tokens_total, "cost_usd": cost, "resume_hint": resume_hint, } + if payload.get("model_requested"): + payload["artifact_footer"]["model_requested"] = payload.get("model_requested") + if tokens_cache_write is not None: + payload["artifact_footer"]["tokens_cache_write"] = tokens_cache_write + if payload.get("cost_source"): + payload["artifact_footer"]["cost_source"] = payload.get("cost_source") payload.setdefault("completed_at", completed_at) payload["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat() @@ -825,11 +992,16 @@ def finalize_artifacts( footer_payload = { **payload, - "tokens_input": tokens["input"], - "tokens_output": tokens["output"], - "tokens_total": tokens["total"], + "tokens_input": tokens_input, + "tokens_cached_input": tokens_cached_input, + "tokens_output": tokens_output, + "tokens_total": tokens_total, "cost_usd": cost, } + if tokens_cache_write is not None: + footer_payload["tokens_cache_write"] = tokens_cache_write + else: + footer_payload.pop("tokens_cache_write", None) if str(transcript): _normalize_markdown_artifact(transcript, footer_payload) @@ -1179,6 +1351,7 @@ def _build_parser() -> argparse.ArgumentParser: write.add_argument("transcript") write.add_argument("launcher") write.add_argument("--model", default="") + write.add_argument("--model-requested", default="") write.add_argument("--prompt-id", default="") write.add_argument("--run-id", default="") write.add_argument("--loop-nr", default="0") @@ -1216,6 +1389,7 @@ def main(argv: Sequence[str] | None = None) -> int: args.transcript, args.launcher, model=args.model, + model_requested=args.model_requested, prompt_id=args.prompt_id, run_id=args.run_id, loop_nr=args.loop_nr, diff --git a/vibecrafted-core/vibecrafted_core/supervisor_async.py b/vibecrafted-core/vibecrafted_core/supervisor_async.py index db53bd1d..38ee1bd5 100644 --- a/vibecrafted-core/vibecrafted_core/supervisor_async.py +++ b/vibecrafted-core/vibecrafted_core/supervisor_async.py @@ -19,6 +19,7 @@ from .control_plane import ensure_session_id, normalize_run_root from .events import append_event from .lifecycle import EventKind, RunState +from .model_overrides import _model_override_receipt STDIO_LIMIT_BYTES = 16 * 1024 * 1024 @@ -31,7 +32,7 @@ def _infer_agent(command: Sequence[str]) -> str: if not command: return "agent" name = Path(str(command[0])).name - if name in {"claude", "codex", "gemini", "agy", "junie", "grok"}: + if name in {"claude", "codex", "agy", "junie", "grok"}: return name if name in {"python", "python3"}: return "python" @@ -78,6 +79,22 @@ def _fallback_report_body(transcript_text: str) -> str: return "\n".join(plain_lines).strip() + ("\n" if plain_lines else "") +def _tokens_total( + input_tokens: int, cached_input_tokens: int, output_tokens: int +) -> int: + return input_tokens + cached_input_tokens + output_tokens + + +def _handle_tokens_total(handle: AsyncRunHandle) -> int: + return _tokens_total( + handle.tokens_input, handle.tokens_cached_input, handle.tokens_output + ) + + +def _cache_write_line(prefix: str, value: int | None) -> str: + return f"{prefix}tokens_cache_write: {value}\n" if value is not None else "" + + def _render_fallback_report(handle: AsyncRunHandle, transcript_text: str) -> str: body = _fallback_report_body(transcript_text) if not body: @@ -98,8 +115,9 @@ def _render_fallback_report(handle: AsyncRunHandle, transcript_text: str) -> str f"session_id: {handle.agent_session_id or 'unknown'}\n" f"tokens_input: {handle.tokens_input}\n" f"tokens_cached_input: {handle.tokens_cached_input}\n" + f"{_cache_write_line('', handle.tokens_cache_write)}" f"tokens_output: {handle.tokens_output}\n" - f"tokens_total: {handle.tokens_input + handle.tokens_output}\n" + f"tokens_total: {_handle_tokens_total(handle)}\n" f"cost_usd: {handle.cost_usd if handle.cost_usd is not None else 'unknown'}\n" f"completed_at: {now}\n" "fallback_report: true\n" @@ -113,11 +131,15 @@ def _render_fallback_report(handle: AsyncRunHandle, transcript_text: str) -> str def _terminal_frontmatter(handle: AsyncRunHandle) -> str: + model_requested = ( + f"model_requested: {handle.model_requested}\n" if handle.model_requested else "" + ) return ( "---\n" "runner: vibecrafted\n" f"run_id: {handle.run_id}\n" f"agent: {handle.agent}\n" + f"{model_requested}" f"root: {handle.root}\n" f"report: {handle.report_path or ''}\n" f"transcript: {handle.transcript_path or ''}\n" @@ -127,7 +149,15 @@ def _terminal_frontmatter(handle: AsyncRunHandle) -> str: def _terminal_footer(handle: AsyncRunHandle) -> str: - tokens_total = handle.tokens_input + handle.tokens_output + model_requested = ( + f"model_requested: {handle.model_requested}\n" if handle.model_requested else "" + ) + override_skipped = ( + f"model_override_skipped: {str(handle.model_override_skipped).lower()}\n" + if handle.model_requested + else "" + ) + cost_source = f"cost_source: {handle.cost_source}\n" if handle.cost_source else "" return ( "\n---\n" "runner: vibecrafted\n" @@ -136,11 +166,15 @@ def _terminal_footer(handle: AsyncRunHandle) -> str: f"exit_code: {handle.exit_code if handle.exit_code is not None else 'unknown'}\n" f"session_id: {handle.agent_session_id or 'unknown'}\n" f"model: {handle.agent_model or 'unknown'}\n" + f"{model_requested}" + f"{override_skipped}" f"tokens_input: {handle.tokens_input}\n" f"tokens_cached_input: {handle.tokens_cached_input}\n" + f"{_cache_write_line('', handle.tokens_cache_write)}" f"tokens_output: {handle.tokens_output}\n" - f"tokens_total: {tokens_total}\n" + f"tokens_total: {_handle_tokens_total(handle)}\n" f"cost_usd: {handle.cost_usd if handle.cost_usd is not None else 'unknown'}\n" + f"{cost_source}" f"resume: {handle.resume_command}\n" f"report: {handle.report_path or ''}\n" f"transcript: {handle.transcript_path or ''}\n" @@ -173,10 +207,16 @@ class AsyncRunHandle: agent: str = "" agent_session_id: str = "" agent_model: str = "" + model_requested: str = "" + model_override_supported: bool = False + model_override_skipped: bool = False + model_override_skip_reason: str = "" tokens_input: int = 0 tokens_cached_input: int = 0 + tokens_cache_write: int | None = None tokens_output: int = 0 cost_usd: float | None = None + cost_source: str | None = None resume_command: str = "" @property @@ -226,6 +266,9 @@ async def spawn( merged_env["VIBECRAFTED_PROMPT_PATH"] = str(prompt_file) agent = str(merged_env.get("VIBECRAFTED_AGENT") or _infer_agent(command)) agent_model = resolve_default_model(agent, command=command, env=merged_env) + model_receipt = _model_override_receipt( + agent, str(merged_env.get("VIBECRAFTED_MODEL_REQUESTED") or "") + ) started_at = _utc_now() await self._emit( @@ -244,6 +287,7 @@ async def spawn( "identity_required": True, "agent": agent, "agent_model": agent_model, + **model_receipt, }, ) @@ -276,6 +320,14 @@ async def spawn( session_id=session_id, agent=agent, agent_model=agent_model, + model_requested=str(model_receipt.get("model_requested") or ""), + model_override_supported=bool( + model_receipt.get("model_override_supported") + ), + model_override_skipped=bool(model_receipt.get("model_override_skipped")), + model_override_skip_reason=str( + model_receipt.get("model_override_skip_reason") or "" + ), ) try: handle.pgid = os.getpgid(process.pid) @@ -382,26 +434,29 @@ async def run( require_report=require_report, require_transcript_output=require_transcript_output, ) + artifact_payload = { + "event_kind": EventKind.ARTIFACT.value, + "meta": str(handle.meta_path or ""), + "report": str(handle.report_path or ""), + "transcript": str(handle.transcript_path or ""), + "agent": handle.agent, + "agent_session_id": handle.agent_session_id, + "agent_model": handle.agent_model, + "tokens_input": handle.tokens_input, + "tokens_cached_input": handle.tokens_cached_input, + "tokens_output": handle.tokens_output, + "tokens_total": _handle_tokens_total(handle), + "cost_usd": handle.cost_usd, + "resume_command": handle.resume_command, + **handle.artifact_validation.as_payload(), + } + if handle.tokens_cache_write is not None: + artifact_payload["tokens_cache_write"] = handle.tokens_cache_write await self._emit( handle.run_id, RunState.ARTIFACT_SEEN, "artifacts inspected", - payload={ - "event_kind": EventKind.ARTIFACT.value, - "meta": str(handle.meta_path or ""), - "report": str(handle.report_path or ""), - "transcript": str(handle.transcript_path or ""), - "agent": handle.agent, - "agent_session_id": handle.agent_session_id, - "agent_model": handle.agent_model, - "tokens_input": handle.tokens_input, - "tokens_cached_input": handle.tokens_cached_input, - "tokens_output": handle.tokens_output, - "tokens_total": handle.tokens_input + handle.tokens_output, - "cost_usd": handle.cost_usd, - "resume_command": handle.resume_command, - **handle.artifact_validation.as_payload(), - }, + payload=artifact_payload, ) if handle.artifact_validation.ok: await self._transition( @@ -493,8 +548,10 @@ def _sync_stream_summary( handle.agent_model = parser.model_id handle.tokens_input = parser.tokens_input handle.tokens_cached_input = parser.tokens_cached_input + handle.tokens_cache_write = parser.tokens_cache_write handle.tokens_output = parser.tokens_output handle.cost_usd = parser.cost_usd + handle.cost_source = parser.cost_source handle.resume_command = parser.resume_command(handle.root) def _write_meta_summary(self, handle: AsyncRunHandle) -> None: @@ -508,34 +565,44 @@ def _write_meta_summary(self, handle: AsyncRunHandle) -> None: loaded = {} if isinstance(loaded, dict): payload.update(loaded) - payload.update( - { - "run_id": handle.run_id, - "agent": handle.agent, - "session_id": handle.agent_session_id, - "agent_session_id": handle.agent_session_id, - "agent_model": handle.agent_model, - "runtime_session_id": handle.session_id, - "root": str(handle.root), - "report": str(handle.report_path or ""), - "transcript": str(handle.transcript_path or ""), - "tokens_input": handle.tokens_input, - "tokens_cached_input": handle.tokens_cached_input, - "tokens_output": handle.tokens_output, - "tokens_total": handle.tokens_input + handle.tokens_output, - "cost_usd": handle.cost_usd - if handle.cost_usd is not None - else "unknown", - "resume_command": handle.resume_command, - "exit_code": handle.exit_code, - "completed_at": handle.completed_at.isoformat() - if handle.completed_at - else "", - "status": "completed" - if handle.exit_code == 0 - else ("failed" if handle.exit_code is not None else "running"), - } - ) + summary = { + "run_id": handle.run_id, + "agent": handle.agent, + "session_id": handle.agent_session_id, + "agent_session_id": handle.agent_session_id, + "agent_model": handle.agent_model, + "runtime_session_id": handle.session_id, + "root": str(handle.root), + "report": str(handle.report_path or ""), + "transcript": str(handle.transcript_path or ""), + "tokens_input": handle.tokens_input, + "tokens_cached_input": handle.tokens_cached_input, + "tokens_output": handle.tokens_output, + "tokens_total": _handle_tokens_total(handle), + "cost_usd": handle.cost_usd if handle.cost_usd is not None else "unknown", + "cost_source": handle.cost_source or "unknown", + "resume_command": handle.resume_command, + "exit_code": handle.exit_code, + "completed_at": handle.completed_at.isoformat() + if handle.completed_at + else "", + "status": "completed" + if handle.exit_code == 0 + else ("failed" if handle.exit_code is not None else "running"), + } + if handle.model_requested: + summary["model_requested"] = handle.model_requested + summary["model_override_supported"] = handle.model_override_supported + summary["model_override_skipped"] = handle.model_override_skipped + if handle.model_override_skip_reason: + summary["model_override_skip_reason"] = ( + handle.model_override_skip_reason + ) + if handle.tokens_cache_write is not None: + summary["tokens_cache_write"] = handle.tokens_cache_write + else: + payload.pop("tokens_cache_write", None) + payload.update(summary) handle.meta_path.parent.mkdir(parents=True, exist_ok=True) handle.meta_path.write_text( json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + "\n", diff --git a/vibecrafted-core/vibecrafted_core/telemetry.py b/vibecrafted-core/vibecrafted_core/telemetry.py new file mode 100644 index 00000000..b124a376 --- /dev/null +++ b/vibecrafted-core/vibecrafted_core/telemetry.py @@ -0,0 +1,52 @@ +"""Provider-neutral model usage and cost estimation contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ModelPrice: + input_per_million: float + cached_input_per_million: float + output_per_million: float + source: str + + +# API-equivalent rates. Provider-reported CLI cost always wins; these rates are +# only a transparent fallback when a stream exposes tokens but no monetary cost. +_PRICES: tuple[tuple[tuple[str, ...], ModelPrice], ...] = ( + (("grok-build", "grok-code-fast"), ModelPrice(1.0, 0.2, 2.0, "xai-api-2026-07")), + (("gpt-5.6-sol",), ModelPrice(5.0, 0.5, 30.0, "openai-api-2026-07")), + (("gpt-5.6-terra",), ModelPrice(2.5, 0.25, 15.0, "openai-api-2026-07")), + (("gpt-5.6-luna",), ModelPrice(1.0, 0.1, 6.0, "openai-api-2026-07")), + (("gpt-5.5",), ModelPrice(5.0, 0.5, 30.0, "openai-api-2026-07")), + (("gpt-5.4",), ModelPrice(2.5, 0.25, 15.0, "openai-api-2026-07")), + (("gpt-5",), ModelPrice(1.25, 0.125, 10.0, "openai-api-2026-07")), +) + + +def model_price(model: str) -> ModelPrice | None: + normalized = (model or "").strip().lower() + for aliases, price in _PRICES: + if any(alias in normalized for alias in aliases): + return price + return None + + +def estimate_cost_usd( + model: str, + *, + tokens_input: int, + tokens_cached_input: int, + tokens_output: int, +) -> tuple[float | None, str | None]: + price = model_price(model) + if price is None or not (tokens_input or tokens_cached_input or tokens_output): + return None, None + cost = ( + tokens_input * price.input_per_million + + tokens_cached_input * price.cached_input_per_million + + tokens_output * price.output_per_million + ) / 1_000_000 + return round(cost, 6), f"estimated:{price.source}" diff --git a/vibecrafted-core/vibecrafted_core/workflow.py b/vibecrafted-core/vibecrafted_core/workflow.py index 77360713..7e43a377 100644 --- a/vibecrafted-core/vibecrafted_core/workflow.py +++ b/vibecrafted-core/vibecrafted_core/workflow.py @@ -28,13 +28,15 @@ ) from .package_resources import deck_path as package_deck_path from .events import append_event +from .model_overrides import _model_override_receipt, _with_model_override +from .research_config import ResearchAgentSelection, resolve_research_runtime_config from .spawn import _stdin_command -from .workflow_runtime import research_agent_selection +from .workflow_runtime import WORKER_SIGNAL_DISCIPLINE from .workflows import registry as workflow_registry SUPPORTED_WORKFLOWS = workflow_registry.SUPPORTED_WORKFLOWS WORKFLOW_ALIASES = workflow_registry.WORKFLOW_ALIASES -SUPPORTED_AGENTS = {"claude", "codex", "gemini", "agy", "junie", "grok", "swarm"} +SUPPORTED_AGENTS = {"claude", "codex", "agy", "junie", "grok", "swarm"} SUPPORTED_RUNTIMES = {"headless", "terminal", "visible"} TERMINAL_STATES = { "completed", @@ -61,6 +63,14 @@ class WorkflowLaunchSpec: root: str count: int | None = None depth: int | None = None + model: str = "" + research_agents: tuple[str, ...] = () + research_synthesizer: str = "" + research_synthesizer_model: str = "" + # Push-side report-on-death (docs/runtime/AGENT_OPS.md, Class 2): when the + # launch belongs to a lifecycle run, this carries the lifecycle state.json + # path so the dispatcher can write the worker's terminal truth into it. + lifecycle_state_path: str = "" def to_payload(self) -> dict[str, Any]: return asdict(self) @@ -260,6 +270,7 @@ def _dispatcher_command( tee_output: bool = False, emit_json: bool = True, quiet: bool = False, + lifecycle_state_path: str = "", ) -> list[str]: command = [ sys.executable, @@ -284,6 +295,8 @@ def _dispatcher_command( str(prompt_path), ] ) + if lifecycle_state_path: + command.extend(["--lifecycle-state", lifecycle_state_path]) if tee_output: command.append("--tee-output") if quiet: @@ -328,6 +341,16 @@ def _runtime_script_exports( artifact_ts: str = "", artifact_suffix: str = "", ) -> dict[str, str]: + pythonpath = os.pathsep.join( + dict.fromkeys( + [str(_core_package_root())] + + [ + item + for item in os.environ.get("PYTHONPATH", "").split(os.pathsep) + if item + ] + ) + ) exports = { "VIBECRAFTED_RUN_ID": run_id, "VIBECRAFTED_REPORT_PATH": str(report_path), @@ -337,6 +360,11 @@ def _runtime_script_exports( "VIBECRAFTED_AGENT": agent, "VIBECRAFTED_SKILL": skill, "VIBECRAFTED_RUNTIME": runtime, + # vc-frame starts this script from its long-lived server environment, + # not from launch_workflow's Popen(env=...). Keep the generated + # dispatcher self-contained and keep installed payloads bytecode-clean. + "PYTHONPATH": pythonpath, + "PYTHONDONTWRITEBYTECODE": "1", } if canonical_report_dir is not None: exports["VIBECRAFTED_CANONICAL_REPORT_DIR"] = str(canonical_report_dir) @@ -368,9 +396,11 @@ def _write_research_lane_scripts( artifact_slug: str, artifact_ts: str, artifact_suffix: str, + research_selection: ResearchAgentSelection, + model_requested: str = "", ) -> dict[str, Path]: scripts: dict[str, Path] = {} - for agent in research_agent_selection().agents: + for agent in research_selection.agents: path = launch_dir / f"{run_id}-research-{agent}.sh" command = [ sys.executable, @@ -384,6 +414,9 @@ def _write_research_lane_scripts( "--prompt-file", str(prompt_path), ] + lane_model = research_selection.lane_model(agent, model_requested) + if lane_model: + command.extend(["--model", lane_model]) exports = _runtime_script_exports( run_id=run_id, prompt_path=prompt_path, @@ -504,6 +537,7 @@ def _launch_transport_command( artifact_slug: str, artifact_ts: str, artifact_suffix: str, + research_selection: ResearchAgentSelection | None = None, ) -> tuple[list[str], str, Path | None]: if spec.runtime not in {"terminal", "visible"}: return dispatch_command, "headless", None @@ -537,6 +571,11 @@ def _launch_transport_command( ) definition = workflow_registry.workflow_definition(spec.skill) if spec.skill == "research": + selection = research_selection or resolve_research_runtime_config( + override_agents=spec.research_agents, + synthesizer=spec.research_synthesizer, + synthesizer_model=spec.research_synthesizer_model, + ) lane_scripts = _write_research_lane_scripts( launch_dir=launch_dir, run_id=run_id, @@ -550,6 +589,8 @@ def _launch_transport_command( artifact_slug=artifact_slug, artifact_ts=artifact_ts, artifact_suffix=artifact_suffix, + research_selection=selection, + model_requested=spec.model, ) layout_file = _write_research_layout( path=launch_dir / f"{run_id}-research.kdl", @@ -813,12 +854,21 @@ def await_launch_truth( hard_cap_seconds=hard_cap_seconds, ) run = dict(awaited.get("run") or {}) + await_reason = str(awaited.get("reason") or "") + worker_alive = bool(awaited.get("worker_alive")) report_path = str(launch_payload.get("report") or run.get("latest_report") or "") transcript_path = str( launch_payload.get("transcript") or run.get("latest_transcript") or "" ) meta_path = str(launch_payload.get("meta") or run.get("meta") or "") - terminal = bool(awaited.get("completed")) and _run_is_terminal(run) + terminal = ( + bool(awaited.get("completed")) and _run_is_terminal(run) and not worker_alive + ) + terminal_evidence = terminal or ( + bool(awaited.get("completed")) + and await_reason == "report_delivered" + and not worker_alive + ) meta_payload: dict[str, Any] = {} if terminal: @@ -852,6 +902,9 @@ def await_launch_truth( "completed": bool(awaited.get("completed")), "timed_out": bool(awaited.get("timed_out")), "terminal": terminal, + "terminal_evidence": terminal_evidence, + "await_reason": await_reason, + "worker_alive": worker_alive, "attempts": awaited.get("attempts"), "run": run, "report": report_path, @@ -938,8 +991,26 @@ def normalize_launch_spec( if definition is None: raise ValueError(f"Unsupported workflow: {skill}") - agent = str(payload.get("agent") or definition.default_agent).strip() + raw_agent = payload.get("agent") + raw_research_agents = payload.get("research_agents") or () + if isinstance(raw_agent, (list, tuple)): + positional_agents = tuple( + str(item).strip() for item in raw_agent if str(item).strip() + ) + agent = positional_agents[0] if positional_agents else definition.default_agent + else: + positional_agents = () + agent = str(raw_agent or definition.default_agent).strip() if definition.runtime_kind == "supervised_research": + if not positional_agents and raw_research_agents: + positional_agents = tuple( + str(item).strip() for item in raw_research_agents if str(item).strip() + ) + unsupported = [ + item for item in positional_agents if item not in SUPPORTED_AGENTS + ] + if unsupported: + raise ValueError(f"Unsupported research agent: {unsupported[0]}") agent = "swarm" if agent not in SUPPORTED_AGENTS: raise ValueError(f"Unsupported agent: {agent}") @@ -957,6 +1028,29 @@ def normalize_launch_spec( depth = _coerce_positive_int( payload.get("depth"), 3 if definition.supports_depth else None ) + model = str(payload.get("model") or payload.get("model_requested") or "").strip() + research_agents: tuple[str, ...] = () + research_synthesizer = "" + research_synthesizer_model = str( + payload.get("synthesizer_model") + or payload.get("research_synthesizer_model") + or "" + ).strip() + if definition.runtime_kind == "supervised_research": + explicit_synthesizer = str( + payload.get("synthesizer") or payload.get("research_synthesizer") or "" + ).strip() + if len(positional_agents) > 1: + research_agents = positional_agents + research_synthesizer = explicit_synthesizer or positional_agents[0] + elif positional_agents: + research_synthesizer = explicit_synthesizer or positional_agents[0] + elif explicit_synthesizer: + research_synthesizer = explicit_synthesizer + if research_synthesizer and research_synthesizer not in SUPPORTED_AGENTS: + raise ValueError( + f"Unsupported research synthesizer: {research_synthesizer}" + ) if definition.requires_input and not prompt and not file_path: raise ValueError("Launch requires either --prompt text or --file path.") @@ -971,6 +1065,10 @@ def normalize_launch_spec( root=root, count=count, depth=depth, + model=model, + research_agents=research_agents, + research_synthesizer=research_synthesizer, + research_synthesizer_model=research_synthesizer_model, ) @@ -1006,6 +1104,7 @@ def _runtime_prompt(spec: WorkflowLaunchSpec) -> str: - Write your final report to the path in VIBECRAFTED_REPORT_PATH ({report_hint}). - Let stdout/stderr form the transcript captured at VIBECRAFTED_TRANSCRIPT_PATH ({transcript_hint}). - Do not create, overwrite, or summarize run metadata yourself. The runtime owns VIBECRAFTED_META_PATH. +{WORKER_SIGNAL_DISCIPLINE.rstrip()} Step 0 — orient before you touch (the vc-init pass). The operator prompt below is one framing — a hypothesis, not the ground truth. Reading a @@ -1045,7 +1144,7 @@ def build_launch_command( if spec.runtime in {"terminal", "visible"} else "research" ) - return [ + launch_command = [ sys.executable, "-m", "vibecrafted_core.workflow_runtime", @@ -1055,8 +1154,17 @@ def build_launch_command( "--prompt-file", prompt_path, ] + if spec.model: + launch_command.extend(["--model", spec.model]) + if spec.research_synthesizer: + launch_command.extend(["--synthesizer", spec.research_synthesizer]) + if spec.research_synthesizer_model: + launch_command.extend( + ["--synthesizer-model", spec.research_synthesizer_model] + ) + return launch_command if runtime_kind == "supervised_marbles": - return [ + launch_command = [ sys.executable, "-m", "vibecrafted_core.workflow_runtime", @@ -1074,9 +1182,12 @@ def build_launch_command( "--depth", str(spec.depth or 3), ] + if spec.model: + launch_command.extend(["--model", spec.model]) + return launch_command worker_agent = spec.agent - return _stdin_command(worker_agent) + return _with_model_override(worker_agent, _stdin_command(worker_agent), spec.model) def launch_workflow( @@ -1089,6 +1200,15 @@ def launch_workflow( run_id = _run_id(spec.skill) artifacts = _run_artifact_paths(run_id) runtime_kind = workflow_registry.workflow_runtime_kind(spec.skill) + research_selection = ( + resolve_research_runtime_config( + override_agents=spec.research_agents, + synthesizer=spec.research_synthesizer, + synthesizer_model=spec.research_synthesizer_model, + ) + if runtime_kind == "supervised_research" + else None + ) source_prompt = _source_prompt(spec) prompt_body = ( source_prompt @@ -1113,6 +1233,9 @@ def launch_workflow( prompt_path = _write_prompt_file(artifacts["prompt"], prompt_body) safe_spec = {**spec.to_payload(), "prompt": "", "file": str(prompt_path)} worker_command = build_launch_command(spec, source_dir, prompt_file=prompt_path) + model_receipt = _model_override_receipt(spec.agent, spec.model) + if spec.model and runtime_kind == "supervised_research": + model_receipt = {"model_requested": spec.model} dispatch_command = _dispatcher_command( run_id=run_id, root=spec.root, @@ -1124,6 +1247,7 @@ def launch_workflow( tee_output=spec.runtime in {"terminal", "visible"}, emit_json=spec.runtime not in {"terminal", "visible"}, quiet=spec.runtime in {"terminal", "visible"}, + lifecycle_state_path=spec.lifecycle_state_path, ) launch_dir = control_plane_home() / "launches" launch_dir.mkdir(parents=True, exist_ok=True) @@ -1143,6 +1267,33 @@ def launch_workflow( merged_env["VIBECRAFTED_AGENT"] = spec.agent merged_env["VIBECRAFTED_SKILL"] = spec.skill merged_env["VIBECRAFTED_RUNTIME"] = spec.runtime + if spec.model: + merged_env["VIBECRAFTED_MODEL_REQUESTED"] = spec.model + if research_selection is not None: + if spec.research_agents: + merged_env["VIBECRAFTED_RESEARCH_AGENTS"] = ",".join( + research_selection.agents + ) + if research_selection.synthesizer: + merged_env["VIBECRAFTED_RESEARCH_SYNTHESIZER"] = ( + research_selection.synthesizer + ) + if research_selection.synthesizer_model: + merged_env["VIBECRAFTED_RESEARCH_SYNTHESIZER_MODEL"] = ( + research_selection.synthesizer_model + ) + if "model_override_supported" in model_receipt: + merged_env["VIBECRAFTED_MODEL_OVERRIDE_SUPPORTED"] = str( + bool(model_receipt["model_override_supported"]) + ).lower() + if "model_override_skipped" in model_receipt: + merged_env["VIBECRAFTED_MODEL_OVERRIDE_SKIPPED"] = str( + bool(model_receipt["model_override_skipped"]) + ).lower() + if model_receipt.get("model_override_skip_reason"): + merged_env["VIBECRAFTED_MODEL_OVERRIDE_SKIP_REASON"] = str( + model_receipt["model_override_skip_reason"] + ) merged_env["VIBECRAFTED_CANONICAL_REPORT_DIR"] = str(canonical_report_dir) merged_env["VIBECRAFTED_ARTIFACT_SLUG"] = artifact_slug merged_env["VIBECRAFTED_ARTIFACT_TS"] = artifact_ts @@ -1167,6 +1318,7 @@ def launch_workflow( artifact_slug=artifact_slug, artifact_ts=artifact_ts, artifact_suffix=artifact_suffix, + research_selection=research_selection, ) merged_env["VIBECRAFTED_OPERATOR_SESSION"] = operator_session @@ -1192,6 +1344,19 @@ def launch_workflow( "transcript": str(artifacts["transcript"]), "meta": str(artifacts["meta"]), "workflow": _workflow_metadata(spec.skill), + **( + { + "research_agents": list(research_selection.agents), + "research_agent_source": research_selection.source, + "research_synthesizer": research_selection.synthesizer, + "research_synthesizer_model": research_selection.synthesizer_model, + "research_synthesizer_source": research_selection.synthesizer_source, + "research_ignored_agents": list(research_selection.ignored), + } + if research_selection is not None + else {} + ), + **model_receipt, "worker_command": worker_command, "dispatch_command": dispatch_command, "command": command, @@ -1208,6 +1373,7 @@ def launch_workflow( "run_id": run_id, "spec": safe_spec, "workflow": _workflow_metadata(spec.skill), + **model_receipt, "worker_command": worker_command, "dispatch_command": dispatch_command, "command": command, @@ -1216,6 +1382,7 @@ def launch_workflow( "retry_of": retry_of, "session_id": session_id, "operator_session": operator_session, + **model_receipt, } ) + "\n" @@ -1260,6 +1427,7 @@ def launch_workflow( "session_id": session_id, "error": f"{type(exc).__name__}: {exc}", "retry_of": retry_of, + **model_receipt, }, ) return { @@ -1275,6 +1443,7 @@ def launch_workflow( "error": f"{type(exc).__name__}: {exc}", "run_id": run_id, "retry_of": retry_of, + **model_receipt, "control_plane": sync_state(), } append_event( @@ -1300,6 +1469,7 @@ def launch_workflow( "transcript": str(artifacts["transcript"]), "meta": str(artifacts["meta"]), "workflow": _workflow_metadata(spec.skill), + **model_receipt, "worker_command": worker_command, "dispatch_command": dispatch_command, "command": command, @@ -1312,7 +1482,6 @@ def launch_workflow( json.dumps({"ts": stamp, "event": "spawned", "pid": proc.pid}) + "\n" ) - snapshot = sync_state() return { "accepted": True, "message": f"Launched {spec.skill} via Vibecrafted core runtime.", @@ -1341,10 +1510,16 @@ def launch_workflow( "operator_session": operator_session, }, "workflow": _workflow_metadata(spec.skill), + **model_receipt, "retry_of": retry_of, "launch_log": str(launch_log), "spec": safe_spec, - "control_plane": snapshot, + # Launch acceptance is already durable in the event stream, run meta, + # and dispatcher process. A global board reconciliation here can block + # on an unrelated run and turn a successful launch into a traceback. + # Reconciliation belongs to observe/await/board readers, never the + # launch acknowledgement path. + "control_plane": {"sync": "deferred", "run_id": run_id}, } @@ -1505,6 +1680,9 @@ def retry_run( mode = str(run.get("mode") or "").strip() if mode: payload["mode"] = mode + model_requested = str(run.get("model_requested") or "").strip() + if model_requested: + payload["model_requested"] = model_requested try: spec = normalize_launch_spec(payload, source_dir) diff --git a/vibecrafted-core/vibecrafted_core/workflow_runtime.py b/vibecrafted-core/vibecrafted_core/workflow_runtime.py index 774a9d24..7f84a654 100644 --- a/vibecrafted-core/vibecrafted_core/workflow_runtime.py +++ b/vibecrafted-core/vibecrafted_core/workflow_runtime.py @@ -5,20 +5,23 @@ import json import os import re +import shlex import sys -import tomllib from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Sequence +from .model_overrides import _with_model_override from .package_resources import package_root +from .research_config import ( + SUPPORTED_RESEARCH_AGENTS, + ResearchAgentSelection, + resolve_research_runtime_config, +) from .spawn import _stdin_command from .supervisor_async import AsyncRunHandle, AsyncSupervisor -SUPPORTED_RESEARCH_AGENTS = ("claude", "codex", "gemini", "agy", "junie", "grok") -DEFAULT_RESEARCH_AGENTS = ("claude", "codex", "gemini") - @dataclass(frozen=True) class ChildResult: @@ -27,6 +30,10 @@ class ChildResult: run_id: str agent_session_id: str agent_model: str + model_requested: str + model_override_supported: bool + model_override_skipped: bool + model_override_skip_reason: str report: Path transcript: Path exit_code: int | None @@ -34,19 +41,13 @@ class ChildResult: artifact_errors: tuple[str, ...] tokens_input: int = 0 tokens_cached_input: int = 0 + tokens_cache_write: int | None = None tokens_output: int = 0 cost_usd: float | None = None resume_command: str = "" completed_at: str = "" -@dataclass(frozen=True) -class ResearchAgentSelection: - agents: tuple[str, ...] - source: str - ignored: tuple[str, ...] = () - - def _parent_run_id() -> str: return os.environ.get("VIBECRAFTED_RUN_ID", "workflow-runtime") @@ -155,13 +156,19 @@ def _child_artifact_paths( def _child_env( - agent: str, report: Path, transcript: Path, meta: Path + agent: str, + report: Path, + transcript: Path, + meta: Path, + model_requested: str = "", ) -> dict[str, str]: env = os.environ.copy() env["VIBECRAFTED_AGENT"] = agent env["VIBECRAFTED_REPORT_PATH"] = str(report) env["VIBECRAFTED_TRANSCRIPT_PATH"] = str(transcript) env["VIBECRAFTED_META_PATH"] = str(meta) + if model_requested: + env["VIBECRAFTED_MODEL_REQUESTED"] = model_requested return env @@ -176,6 +183,154 @@ def _write_json(path: Path, payload: dict[str, object]) -> None: ) +def _optional_int(value: object) -> int | None: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _optional_float(value: object) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return None + return None + + +def _tokens_total( + input_tokens: int, cached_input_tokens: int, output_tokens: int +) -> int: + return input_tokens + cached_input_tokens + output_tokens + + +def _child_tokens_total(result: ChildResult) -> int: + return _tokens_total( + result.tokens_input, + result.tokens_cached_input, + result.tokens_output, + ) + + +def _sum_cache_write(results: Sequence[ChildResult]) -> int | None: + values = [ + item.tokens_cache_write + for item in results + if item.tokens_cache_write is not None + ] + return sum(values) if values else None + + +def _sum_cost_usd(results: Sequence[ChildResult]) -> float | None: + values = [item.cost_usd for item in results if item.cost_usd is not None] + return round(sum(values), 6) if values else None + + +def _runtime_model_requested() -> str: + return str(os.environ.get("VIBECRAFTED_MODEL_REQUESTED") or "").strip() + + +def _remember_runtime_model_request(model_requested: str) -> str: + requested = str(model_requested or "").strip() + if requested: + os.environ["VIBECRAFTED_MODEL_REQUESTED"] = requested + return requested + + +def _result_meta(result: ChildResult) -> dict[str, object]: + payload: dict[str, object] = { + "label": result.label, + "agent": result.agent, + "run_id": result.run_id, + "agent_session_id": result.agent_session_id, + "agent_model": result.agent_model, + "model_requested": result.model_requested, + "model_override_supported": result.model_override_supported, + "model_override_skipped": result.model_override_skipped, + "report": str(result.report), + "transcript": str(result.transcript), + "exit_code": result.exit_code, + "artifact_ok": result.artifact_ok, + "artifact_errors": list(result.artifact_errors), + "tokens_input": result.tokens_input, + "tokens_cached_input": result.tokens_cached_input, + "tokens_output": result.tokens_output, + "tokens_total": _child_tokens_total(result), + "cost_usd": result.cost_usd if result.cost_usd is not None else "unknown", + "resume_command": result.resume_command, + "completed_at": result.completed_at, + } + if result.tokens_cache_write is not None: + payload["tokens_cache_write"] = result.tokens_cache_write + if result.model_override_skip_reason: + payload["model_override_skip_reason"] = result.model_override_skip_reason + return payload + + +def _parent_receipt(results: Sequence[ChildResult]) -> dict[str, object]: + tokens_input = sum(result.tokens_input for result in results) + tokens_cached_input = sum(result.tokens_cached_input for result in results) + tokens_output = sum(result.tokens_output for result in results) + receipt: dict[str, object] = { + "session_id": "aggregated" if results else "", + "tokens_input": tokens_input, + "tokens_cached_input": tokens_cached_input, + "tokens_output": tokens_output, + "tokens_total": _tokens_total(tokens_input, tokens_cached_input, tokens_output), + "cost_usd": _sum_cost_usd(results), + "cost_source": "children_sum" if results else "none", + } + cache_write = _sum_cache_write(results) + if cache_write is not None: + receipt["tokens_cache_write"] = cache_write + model_requested = _runtime_model_requested() + if model_requested: + receipt["model_requested"] = model_requested + return receipt + + +def _parent_footer(run_id: str, status: str, receipt: dict[str, object]) -> list[str]: + cost = receipt.get("cost_usd") + lines = [ + "", + f"", + "---", + "run_closure:", + f" run_id: {run_id}", + f" session_id: {receipt.get('session_id') or 'aggregated'}", + f" tokens_input: {receipt.get('tokens_input', 0)}", + f" tokens_cached_input: {receipt.get('tokens_cached_input', 0)}", + ] + if receipt.get("tokens_cache_write") is not None: + lines.append(f" tokens_cache_write: {receipt.get('tokens_cache_write')}") + if receipt.get("model_requested"): + lines.append(f" model_requested: {receipt.get('model_requested')}") + lines.extend( + [ + f" tokens_output: {receipt.get('tokens_output', 0)}", + f" tokens_total: {receipt.get('tokens_total', 0)}", + f" cost_usd: {cost if cost is not None else 'unknown'}", + f" cost_source: {receipt.get('cost_source', 'none')}", + f" status: {status}", + f" completed_at: {datetime.now(timezone.utc).isoformat()}", + ' resume_hint: ""', + "---", + "", + ] + ) + return lines + + def _read_prompt_file(path: str) -> str: if not path: return "" @@ -217,74 +372,22 @@ def _manifest_config_paths() -> tuple[Path, ...]: return tuple(paths) -def _split_agent_tokens(raw: object) -> list[str]: - if isinstance(raw, str): - return [item.strip() for item in raw.replace(",", " ").split() if item.strip()] - if isinstance(raw, (list, tuple)): - return [str(item).strip() for item in raw if str(item).strip()] - return [] - - -def _select_supported_agents( - tokens: Sequence[str], -) -> tuple[tuple[str, ...], tuple[str, ...]]: - agents: list[str] = [] - ignored: list[str] = [] - seen: set[str] = set() - for token in tokens: - agent = token.strip() - if not agent: - continue - if agent not in SUPPORTED_RESEARCH_AGENTS: - ignored.append(agent) - continue - if agent in seen: - continue - seen.add(agent) - agents.append(agent) - return tuple(agents), tuple(ignored) - - -def _read_research_agents_from_toml(path: Path) -> tuple[str, ...]: - if not path.is_file(): - return () - try: - with path.open("rb") as handle: - data = tomllib.load(handle) - except (OSError, tomllib.TOMLDecodeError) as exc: - print(f"Failed to read research agent config: {path}: {exc}", file=sys.stderr) - return () - raw = ( - data.get("runtime", {}) - .get("picking", {}) - .get("research", {}) - .get("default_agents", []) - ) - return tuple(_split_agent_tokens(raw)) - - def research_agent_selection() -> ResearchAgentSelection: - env_agents = os.environ.get("VIBECRAFTED_RESEARCH_AGENTS", "").strip() - if env_agents: - agents, ignored = _select_supported_agents(_split_agent_tokens(env_agents)) - return ResearchAgentSelection( - agents, "env:VIBECRAFTED_RESEARCH_AGENTS", ignored - ) - - user_config = _user_config_path() - tokens = _read_research_agents_from_toml(user_config) - if tokens: - agents, ignored = _select_supported_agents(tokens) - return ResearchAgentSelection(agents, str(user_config), ignored) + return resolve_research_runtime_config() - for manifest in _manifest_config_paths(): - tokens = _read_research_agents_from_toml(manifest) - if not tokens: - continue - agents, ignored = _select_supported_agents(tokens) - return ResearchAgentSelection(agents, str(manifest), ignored) - return ResearchAgentSelection(DEFAULT_RESEARCH_AGENTS, "builtin-default") +# Gate-nap prevention (docs/runtime/AGENT_OPS.md, Class 1): dispatched workers +# are never re-invoked when their own background tasks complete, so a worker +# that ends its turn "waiting for the gate signal" hangs forever while its run +# reports completed. Explaining the mechanics beats a bare prohibition — the +# ban alone was broken in the wild with the affordance still visible. +WORKER_SIGNAL_DISCIPLINE = ( + "- You are a dispatched worker: background-task completions will NEVER wake\n" + " you or re-invoke you. Never end your turn waiting for a signal, monitor,\n" + " or background gate. Run quality gates (tests, builds) synchronously in\n" + " the foreground and finish everything — work, report, commits — within\n" + " this turn.\n" +) def _child_prompt(kind: str, label: str, root: str, prompt: str) -> str: @@ -304,7 +407,7 @@ def _child_prompt(kind: str, label: str, root: str, prompt: str) -> str: - Do not launch external agent fleets. - Write your durable report to VIBECRAFTED_REPORT_PATH. - Let stdout/stderr form VIBECRAFTED_TRANSCRIPT_PATH. -{marbles_blindness} +{WORKER_SIGNAL_DISCIPLINE}{marbles_blindness} Operator prompt: {prompt} """ @@ -332,7 +435,7 @@ def _research_synthesis_prompt( reports = "\n".join( f"- {result.agent}: {result.report}" for result in results if result.report ) - return f"""You are resuming the last completed vc-research lane to produce the objective synthesis. + return f"""You are producing the objective vc-research synthesis from completed lanes. Contract: - Work in repository root: {root} @@ -372,29 +475,20 @@ def _resume_stdin_command(agent: str, session_id: str) -> list[str]: "-", ] if agent == "gemini": - return [ - "gemini", - "--resume", - session_id, - "-p", - "", - "--approval-mode", - "yolo", - "-o", - "stream-json", - ] + raise ValueError( + "gemini CLI is deprecated. Google Antigravity CLI (agy) is the replacement. " + "Use 'vibecrafted workflow agy --prompt ...' (or agy in other launchers). " + "No execution path may launch the gemini binary." + ) if agent == "agy": + # agy >= 1.1: --print takes a value and reads no stdin; a shell shim + # folds the stdin prompt into the flag (see spawn._stdin_command). return [ - "agy", - "--conversation", - session_id, - "--print", - "--dangerously-skip-permissions", - "--add-dir", - ".", - "--print-timeout", - "30m", - "", + "bash", + "-c", + "agy --dangerously-skip-permissions --conversation " + f"{shlex.quote(session_id)} --add-dir . " + '--print-timeout 30m --print "$(cat)"', ] if agent == "junie": return [ @@ -435,6 +529,7 @@ async def _run_child( agent: str, root: str, prompt: str, + model_requested: str = "", command: Sequence[str] | None = None, prompt_body: str | None = None, ) -> ChildResult: @@ -450,14 +545,18 @@ async def _run_child( prompt_file.write_text( prompt_body or _child_prompt(kind, label, root, prompt), encoding="utf-8" ) - child_command = list(command) if command is not None else _stdin_command(agent) + child_command = ( + list(command) + if command is not None + else _with_model_override(agent, _stdin_command(agent), model_requested) + ) if _tee_enabled(): print(f"\n===== {kind}:{label}:{agent} =====", flush=True) handle: AsyncRunHandle = await AsyncSupervisor().run( run_id=run_id, command=child_command, root=root, - env=_child_env(agent, report, transcript, meta), + env=_child_env(agent, report, transcript, meta, model_requested), meta_path=meta, report_path=report, transcript_path=transcript, @@ -473,6 +572,10 @@ async def _run_child( run_id=run_id, agent_session_id=handle.agent_session_id, agent_model=handle.agent_model, + model_requested=handle.model_requested, + model_override_supported=handle.model_override_supported, + model_override_skipped=handle.model_override_skipped, + model_override_skip_reason=handle.model_override_skip_reason, report=report, transcript=transcript, exit_code=handle.exit_code, @@ -480,6 +583,7 @@ async def _run_child( artifact_errors=tuple(validation.errors if validation is not None else ()), tokens_input=handle.tokens_input, tokens_cached_input=handle.tokens_cached_input, + tokens_cache_write=handle.tokens_cache_write, tokens_output=handle.tokens_output, cost_usd=handle.cost_usd, resume_command=handle.resume_command, @@ -520,6 +624,10 @@ def _child_result_from_meta(label: str, meta_path: Path) -> ChildResult | None: payload.get("agent_session_id") or payload.get("session_id") or "" ), agent_model=str(payload.get("agent_model") or payload.get("model") or ""), + model_requested=str(payload.get("model_requested") or ""), + model_override_supported=bool(payload.get("model_override_supported")), + model_override_skipped=bool(payload.get("model_override_skipped")), + model_override_skip_reason=str(payload.get("model_override_skip_reason") or ""), report=report, transcript=transcript, exit_code=exit_code, @@ -527,10 +635,9 @@ def _child_result_from_meta(label: str, meta_path: Path) -> ChildResult | None: artifact_errors=artifact_errors, tokens_input=int(payload.get("tokens_input") or 0), tokens_cached_input=int(payload.get("tokens_cached_input") or 0), + tokens_cache_write=_optional_int(payload.get("tokens_cache_write")), tokens_output=int(payload.get("tokens_output") or 0), - cost_usd=payload.get("cost_usd") - if isinstance(payload.get("cost_usd"), float) - else None, + cost_usd=_optional_float(payload.get("cost_usd")), resume_command=str(payload.get("resume_command") or ""), completed_at=str( payload.get("completed_at") or payload.get("updated_at") or "" @@ -669,6 +776,10 @@ def _failed_synthesis_result(last: ChildResult, reason: str) -> ChildResult: run_id=f"{_parent_run_id()}-research-synthesis", agent_session_id=last.agent_session_id, agent_model=last.agent_model, + model_requested=last.model_requested, + model_override_supported=last.model_override_supported, + model_override_skipped=last.model_override_skipped, + model_override_skip_reason=last.model_override_skip_reason, report=report, transcript=transcript, exit_code=1, @@ -680,11 +791,25 @@ def _failed_synthesis_result(last: ChildResult, reason: str) -> ChildResult: async def _run_research_synthesis( - root: str, prompt: str, results: Sequence[ChildResult] + root: str, + prompt: str, + results: Sequence[ChildResult], + selection: ResearchAgentSelection | None = None, + model_requested: str = "", ) -> ChildResult | None: survivors = _research_survivors(results) if not survivors or len(survivors) < _research_quorum(len(results)): return None + if selection is not None and selection.synthesizer: + return await _run_child( + kind="research", + label="research-synthesis", + agent=selection.synthesizer, + root=root, + prompt=prompt, + model_requested=selection.synthesis_model(model_requested), + prompt_body=_research_synthesis_prompt(root, prompt, survivors), + ) last = max(survivors, key=lambda item: item.completed_at or "") if not last.agent_session_id: return _failed_synthesis_result(last, "missing_agent_session_id_for_resume") @@ -709,6 +834,16 @@ def _write_parent_report( research_selection: ResearchAgentSelection | None = None, ) -> None: status = _research_run_status(results, synthesis, kind=kind) + lanes_failed = [ + result.agent + for result in results + if result.exit_code != 0 or not result.artifact_ok + ] + accounting_results = tuple(results) + ( + (synthesis,) if synthesis is not None else () + ) + receipt = _parent_receipt(accounting_results) + cost = receipt.get("cost_usd") report = _parent_report_path() report.parent.mkdir(parents=True, exist_ok=True) lines = [ @@ -716,20 +851,41 @@ def _write_parent_report( f"status: {status}", f"skill: vc-{kind}", f"run_id: {_parent_run_id()}", + f"session_id: {receipt.get('session_id') or 'aggregated'}", f"root: {root}", - "---", - "", - f"# vc-{kind} supervised run", - "", - "## Operator Prompt", - "", - prompt or "(empty)", - "", - "## Reception Ledger", - "", - "Child reports are supervised artifacts for the parent runtime. Research synthesis resumes the last-finishing lane so the reducer can use native agent context/cache.", - "", + f"tokens_input: {receipt['tokens_input']}", + f"tokens_cached_input: {receipt['tokens_cached_input']}", ] + if receipt.get("tokens_cache_write") is not None: + lines.append(f"tokens_cache_write: {receipt['tokens_cache_write']}") + if receipt.get("model_requested"): + lines.append(f"model_requested: {receipt['model_requested']}") + lines.extend( + [ + f"tokens_output: {receipt['tokens_output']}", + f"tokens_total: {receipt['tokens_total']}", + f"cost_usd: {cost if cost is not None else 'unknown'}", + f"cost_source: {receipt['cost_source']}", + ] + ) + lines.extend( + [ + "---", + "", + f"# vc-{kind} supervised run", + "", + "## Operator Prompt", + "", + prompt or "(empty)", + "", + "## Reception Ledger", + "", + "Child reports are supervised artifacts for the parent runtime. " + "Research synthesis resumes the last-finishing lane so the reducer " + "can use native agent context/cache.", + "", + ] + ) if research_selection is not None: lines.extend( [ @@ -738,47 +894,82 @@ def _write_parent_report( f"- source: {research_selection.source}", f"- agents: {', '.join(research_selection.agents) or 'none'}", f"- ignored: {', '.join(research_selection.ignored) or 'none'}", + f"- synthesizer: {research_selection.synthesizer or 'last-survivor'}", + f"- synthesizer_source: {research_selection.synthesizer_source or 'default'}", + f"- synthesizer_model: {research_selection.synthesizer_model or 'none'}", + f"- lanes_failed: {', '.join(lanes_failed) or 'none'}", "", ] ) if synthesis is not None: - lines.extend( + synthesis_lines = [ + "## Synthesis", + "", + f"- {synthesis.label} ({synthesis.agent})", + f" - run_id: {synthesis.run_id}", + f" - agent_session_id: {synthesis.agent_session_id or 'unknown'}", + f" - agent_model: {synthesis.agent_model or 'unknown'}", + f" - model_requested: {synthesis.model_requested or 'none'}", + " - model_override_supported: " + f"{str(synthesis.model_override_supported).lower()}", + " - model_override_skipped: " + f"{str(synthesis.model_override_skipped).lower()}", + f" - exit_code: {synthesis.exit_code}", + f" - artifact_ok: {str(synthesis.artifact_ok).lower()}", + f" - resume: {synthesis.resume_command}", + " - tokens: " + f"{synthesis.tokens_input} in " + f"({synthesis.tokens_cached_input} cached) / " + f"{synthesis.tokens_output} out", + ] + if synthesis.tokens_cache_write is not None: + synthesis_lines.append( + f" - tokens_cache_write: {synthesis.tokens_cache_write}" + ) + synthesis_lines.extend( [ - "## Synthesis", - "", - f"- {synthesis.label} ({synthesis.agent})", - f" - run_id: {synthesis.run_id}", - f" - agent_session_id: {synthesis.agent_session_id or 'unknown'}", - f" - agent_model: {synthesis.agent_model or 'unknown'}", - f" - exit_code: {synthesis.exit_code}", - f" - artifact_ok: {str(synthesis.artifact_ok).lower()}", - f" - resume: {synthesis.resume_command}", + " - cost_usd: " + f"{synthesis.cost_usd if synthesis.cost_usd is not None else 'unknown'}", f" - report: {synthesis.report}", f" - transcript: {synthesis.transcript}", "", ] ) + lines.extend(synthesis_lines) elif kind == "research": lines.extend(["## Synthesis", "", "- skipped: child run failure", ""]) lines.extend(["## Child Runs", ""]) for result in results: errors = ", ".join(result.artifact_errors) if result.artifact_errors else "none" - lines.extend( + child_lines = [ + f"- {result.label} ({result.agent})", + f" - run_id: {result.run_id}", + f" - agent_session_id: {result.agent_session_id or 'unknown'}", + f" - agent_model: {result.agent_model or 'unknown'}", + f" - model_requested: {result.model_requested or 'none'}", + " - model_override_supported: " + f"{str(result.model_override_supported).lower()}", + f" - model_override_skipped: {str(result.model_override_skipped).lower()}", + f" - exit_code: {result.exit_code}", + f" - artifact_ok: {str(result.artifact_ok).lower()}", + f" - artifact_errors: {errors}", + " - tokens: " + f"{result.tokens_input} in ({result.tokens_cached_input} cached) / " + f"{result.tokens_output} out", + ] + if result.tokens_cache_write is not None: + child_lines.append(f" - tokens_cache_write: {result.tokens_cache_write}") + child_lines.extend( [ - f"- {result.label} ({result.agent})", - f" - run_id: {result.run_id}", - f" - agent_session_id: {result.agent_session_id or 'unknown'}", - f" - agent_model: {result.agent_model or 'unknown'}", - f" - exit_code: {result.exit_code}", - f" - artifact_ok: {str(result.artifact_ok).lower()}", - f" - artifact_errors: {errors}", - f" - tokens: {result.tokens_input} in ({result.tokens_cached_input} cached) / {result.tokens_output} out", - f" - cost_usd: {result.cost_usd if result.cost_usd is not None else 'unknown'}", + " - cost_usd: " + f"{result.cost_usd if result.cost_usd is not None else 'unknown'}", f" - resume: {result.resume_command}", f" - report: {result.report}", f" - transcript: {result.transcript}", ] ) + lines.extend(child_lines) + lines.extend(_parent_footer(_parent_run_id(), status, receipt)) report.write_text("\n".join(lines) + "\n", encoding="utf-8") _write_json( _parent_meta_path(), @@ -787,6 +978,23 @@ def _write_parent_report( "skill": kind, "status": status, "report": str(report), + "session_id": receipt.get("session_id") or "aggregated", + "tokens_input": receipt["tokens_input"], + "tokens_cached_input": receipt["tokens_cached_input"], + **( + {"tokens_cache_write": receipt["tokens_cache_write"]} + if receipt.get("tokens_cache_write") is not None + else {} + ), + "tokens_output": receipt["tokens_output"], + "tokens_total": receipt["tokens_total"], + "cost_usd": cost if cost is not None else "unknown", + "cost_source": receipt["cost_source"], + **( + {"model_requested": receipt["model_requested"]} + if receipt.get("model_requested") + else {} + ), "research_agent_source": research_selection.source if research_selection is not None else "", @@ -796,50 +1004,24 @@ def _write_parent_report( "research_ignored_agents": list(research_selection.ignored) if research_selection is not None else [], - "synthesis": { - "label": synthesis.label, - "agent": synthesis.agent, - "run_id": synthesis.run_id, - "agent_session_id": synthesis.agent_session_id, - "agent_model": synthesis.agent_model, - "report": str(synthesis.report), - "transcript": str(synthesis.transcript), - "exit_code": synthesis.exit_code, - "artifact_ok": synthesis.artifact_ok, - "artifact_errors": list(synthesis.artifact_errors), - "resume_command": synthesis.resume_command, - } - if synthesis is not None - else {}, - "children": [ - { - "label": result.label, - "agent": result.agent, - "run_id": result.run_id, - "agent_session_id": result.agent_session_id, - "agent_model": result.agent_model, - "report": str(result.report), - "transcript": str(result.transcript), - "exit_code": result.exit_code, - "artifact_ok": result.artifact_ok, - "artifact_errors": list(result.artifact_errors), - "tokens_input": result.tokens_input, - "tokens_cached_input": result.tokens_cached_input, - "tokens_output": result.tokens_output, - "tokens_total": result.tokens_input + result.tokens_output, - "cost_usd": result.cost_usd - if result.cost_usd is not None - else "unknown", - "resume_command": result.resume_command, - "completed_at": result.completed_at, - } - for result in results - ], + "research_synthesizer": research_selection.synthesizer + if research_selection is not None + else "", + "research_synthesizer_model": research_selection.synthesizer_model + if research_selection is not None + else "", + "research_synthesizer_source": research_selection.synthesizer_source + if research_selection is not None + else "", + "lanes_failed": lanes_failed, + "synthesis": _result_meta(synthesis) if synthesis is not None else {}, + "children": [_result_meta(result) for result in results], }, ) -async def run_research(root: str, prompt: str) -> int: +async def run_research(root: str, prompt: str, model_requested: str = "") -> int: + model_requested = _remember_runtime_model_request(model_requested) selection = research_agent_selection() for agent in selection.ignored: print( @@ -856,11 +1038,14 @@ async def run_research(root: str, prompt: str) -> int: agent=agent, root=root, prompt=prompt, + model_requested=selection.lane_model(agent, model_requested), ) for agent in selection.agents ] results = await asyncio.gather(*tasks) - synthesis = await _run_research_synthesis(root, prompt, results) + synthesis = await _run_research_synthesis( + root, prompt, results, selection, model_requested + ) _write_parent_report( "research", root, @@ -877,21 +1062,29 @@ async def run_research(root: str, prompt: str) -> int: ) -async def run_research_lane(root: str, prompt: str, agent: str) -> int: +async def run_research_lane( + root: str, prompt: str, agent: str, model_requested: str = "" +) -> int: + model_requested = _remember_runtime_model_request(model_requested) if agent not in SUPPORTED_RESEARCH_AGENTS: print(f"vc-research: unsupported research agent: {agent}", file=sys.stderr) return 1 + selection = research_agent_selection() result = await _run_child( kind="research", label=f"research-{agent}", agent=agent, root=root, prompt=prompt, + model_requested=selection.lane_model(agent, model_requested), ) return 0 if result.exit_code == 0 and result.artifact_ok else 1 -async def run_research_synthesis(root: str, prompt: str) -> int: +async def run_research_synthesis( + root: str, prompt: str, model_requested: str = "" +) -> int: + model_requested = _remember_runtime_model_request(model_requested) selection = research_agent_selection() for agent in selection.ignored: print( @@ -916,7 +1109,9 @@ async def run_research_synthesis(root: str, prompt: str) -> int: research_selection=selection, ) return 1 - synthesis = await _run_research_synthesis(root, prompt, results) + synthesis = await _run_research_synthesis( + root, prompt, results, selection, model_requested + ) _write_parent_report( "research", root, @@ -940,7 +1135,9 @@ async def run_marbles( count: int, depth: int, workflow: str = "marbles", + model_requested: str = "", ) -> int: + model_requested = _remember_runtime_model_request(model_requested) kind = _safe_label(workflow) or "marbles" results: list[ChildResult] = [] for index in range(1, max(count, 1) + 1): @@ -951,6 +1148,7 @@ async def run_marbles( agent=agent, root=root, prompt=loop_prompt, + model_requested=model_requested, ) results.append(result) if result.exit_code != 0 or not result.artifact_ok: @@ -973,15 +1171,22 @@ def _parser() -> argparse.ArgumentParser: research.add_argument("--root", required=True) research.add_argument("--prompt", default="") research.add_argument("--prompt-file", default="") + research.add_argument("--model", default="") + research.add_argument("--synthesizer", default="") + research.add_argument("--synthesizer-model", default="") research_lane = sub.add_parser("research-lane") research_lane.add_argument("--agent", required=True) research_lane.add_argument("--root", required=True) research_lane.add_argument("--prompt", default="") research_lane.add_argument("--prompt-file", default="") + research_lane.add_argument("--model", default="") research_synthesis = sub.add_parser("research-synthesis") research_synthesis.add_argument("--root", required=True) research_synthesis.add_argument("--prompt", default="") research_synthesis.add_argument("--prompt-file", default="") + research_synthesis.add_argument("--model", default="") + research_synthesis.add_argument("--synthesizer", default="") + research_synthesis.add_argument("--synthesizer-model", default="") marbles = sub.add_parser("marbles") marbles.add_argument("--workflow", default="marbles") marbles.add_argument("--agent", default="codex") @@ -990,24 +1195,46 @@ def _parser() -> argparse.ArgumentParser: marbles.add_argument("--prompt-file", default="") marbles.add_argument("--count", type=int, default=3) marbles.add_argument("--depth", type=int, default=3) + marbles.add_argument("--model", default="") return parser def main(argv: Sequence[str] | None = None) -> int: ns = _parser().parse_args(argv) + model_requested = str( + getattr(ns, "model", "") or os.environ.get("VIBECRAFTED_MODEL_REQUESTED") or "" + ).strip() + if model_requested: + os.environ["VIBECRAFTED_MODEL_REQUESTED"] = model_requested + synthesizer = str(getattr(ns, "synthesizer", "") or "").strip() + if synthesizer: + os.environ["VIBECRAFTED_RESEARCH_SYNTHESIZER"] = synthesizer + synthesizer_model = str(getattr(ns, "synthesizer_model", "") or "").strip() + if synthesizer_model: + os.environ["VIBECRAFTED_RESEARCH_SYNTHESIZER_MODEL"] = synthesizer_model if ns.command == "research": prompt = ns.prompt or _read_prompt_file(ns.prompt_file) - return asyncio.run(run_research(ns.root, prompt)) + return asyncio.run(run_research(ns.root, prompt, model_requested)) if ns.command == "research-lane": prompt = ns.prompt or _read_prompt_file(ns.prompt_file) - return asyncio.run(run_research_lane(ns.root, prompt, ns.agent)) + return asyncio.run( + run_research_lane(ns.root, prompt, ns.agent, model_requested) + ) if ns.command == "research-synthesis": prompt = ns.prompt or _read_prompt_file(ns.prompt_file) - return asyncio.run(run_research_synthesis(ns.root, prompt)) + return asyncio.run(run_research_synthesis(ns.root, prompt, model_requested)) if ns.command == "marbles": prompt = ns.prompt or _read_prompt_file(ns.prompt_file) return asyncio.run( - run_marbles(ns.root, ns.agent, prompt, ns.count, ns.depth, ns.workflow) + run_marbles( + ns.root, + ns.agent, + prompt, + ns.count, + ns.depth, + ns.workflow, + model_requested, + ) ) return 2 diff --git a/vibecrafted-core/vibecrafted_core/workflows/model.py b/vibecrafted-core/vibecrafted_core/workflows/model.py index 2fcfdf7a..a733e209 100644 --- a/vibecrafted-core/vibecrafted_core/workflows/model.py +++ b/vibecrafted-core/vibecrafted_core/workflows/model.py @@ -50,6 +50,8 @@ class WorkflowStage: tooling: tuple[str, ...] = () # Optional per-stage agent pin; empty = the current baton holder runs it. agent: str = "" + # Optional per-stage model pin; empty = runner default. + model: str = "" next_stage: str = "" fallback_stage: str = "" audit_after: str = "" diff --git a/vibecrafted-core/vibecrafted_core/workflows/registry.py b/vibecrafted-core/vibecrafted_core/workflows/registry.py index 277de46f..06dab899 100644 --- a/vibecrafted-core/vibecrafted_core/workflows/registry.py +++ b/vibecrafted-core/vibecrafted_core/workflows/registry.py @@ -163,6 +163,7 @@ def _stage( id: str = "", name: str = "", agent: str = "", + model: str = "", next_stage: str = "", fallback_stage: str = "", audit_after: str = "", @@ -185,6 +186,7 @@ def _stage( name=name or f"VC {workflow.title()}", tooling=definition.tooling, agent=agent, + model=model, next_stage=next_stage, fallback_stage=fallback_stage, audit_after=audit_after, @@ -403,6 +405,7 @@ def workflow_manifest_payload(manifest_id: str) -> dict[str, object]: "name": stage.name, "tooling": list(stage.tooling), "agent": stage.agent, + "model": stage.model, "can_modify_code": stage.can_modify_code, "next_stage": stage.next_stage, "fallback_stage": stage.fallback_stage, diff --git a/vibecrafted-core/vibecrafted_core/wrappers.py b/vibecrafted-core/vibecrafted_core/wrappers.py index 6c10218c..94cb9194 100644 --- a/vibecrafted-core/vibecrafted_core/wrappers.py +++ b/vibecrafted-core/vibecrafted_core/wrappers.py @@ -16,7 +16,8 @@ from .package_resources import package_root, runtime_path from .spawn import Supervisor -AGENTS = {"claude", "codex", "gemini", "agy", "junie", "grok"} +AGENTS = {"claude", "codex", "agy", "junie", "grok"} +SUCCESS_STATES = {"report_validated", "completed", "closed"} SKILL_PREFIX = { "agents": "agnt", "followup": "fwup", @@ -150,9 +151,35 @@ def _print_completed(run_id: str, payload: dict[str, Any]) -> int: print(f"transcript={run['latest_transcript']}") if run.get("session_id"): print(f"session_id={run['session_id']}") - return int(run.get("exit_code") or 0) - print(f"run_id={run_id} completed without control-plane payload") - return 0 + state = str(run.get("state") or "") + errors = [str(item) for item in (run.get("artifact_errors") or []) if str(item)] + worker_alive = bool(payload.get("worker_alive")) + delivered = str(payload.get("reason") or "") == "report_delivered" + terminal = control_plane._run_is_terminal(run) and not worker_alive + succeeded = ( + state in SUCCESS_STATES + and run.get("artifact_ok") is not False + and not errors + ) + if terminal and succeeded: + return int(run.get("exit_code") or 0) + if delivered and not worker_alive: + exit_code = int(run.get("exit_code") or 0) + if run.get("artifact_ok") is False or errors: + return exit_code or 3 + return exit_code + print( + "run_id=" + f"{run_id} non-terminal completion disagreement " + f"reason={payload.get('reason')}", + file=sys.stderr, + ) + return 3 + print( + f"run_id={run_id} completed without control-plane payload", + file=sys.stderr, + ) + return 3 def _await_run_forever(run_id: str, interval: float = 5.0) -> dict[str, Any]: @@ -194,7 +221,7 @@ def supervised_skill_main(skill: str, argv: Sequence[str] | None = None) -> int: return handle.wait() if not args or args[0] not in AGENTS: print( - f"Usage: vc-{skill} [--prompt |--file ]", + f"Usage: vc-{skill} [--prompt |--file ]", file=sys.stderr, ) return 2 diff --git a/vibecrafted-mcp/tests/__init__.py b/vibecrafted-mcp/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/vibecrafted-mcp/tests/test_server.py b/vibecrafted-mcp/tests/test_server.py index 43b55568..96ac8085 100644 --- a/vibecrafted-mcp/tests/test_server.py +++ b/vibecrafted-mcp/tests/test_server.py @@ -234,6 +234,7 @@ async def _inspect() -> tuple[set[str], set[str]]: "vc_lifecycle_fallback", } <= tool_names assert any("vibecrafted://board/runs" in uri for uri in resource_uris) + assert any("vibecrafted://lifecycle/schema" in uri for uri in resource_uris) assert any("vibecrafted://control-plane/events" in uri for uri in resource_uris) assert any("vibecrafted://runs/{run_id}/transcript" in uri for uri in resource_uris) assert any("vibecrafted://runs/{run_id}/events" in uri for uri in resource_uris) @@ -851,6 +852,7 @@ def _seed_lifecycle_run(home: Path, run_id: str, report: Path) -> Path: run_dir.mkdir(parents=True) state_path = run_dir / "state.json" state = { + "schema": "vibecrafted.lifecycle.v1", "run_id": run_id, "workflow": "vc-ship", "agent": "codex", @@ -933,6 +935,7 @@ async def _call(tool: str, args: dict[str, Any]) -> Any: status = _run(_call("vc_lifecycle_status", {"home": str(home)})).data assert status["ok"] is True + assert status["result"]["schema"] == "vibecrafted.lifecycle.v1" assert status["result"]["next_stage"] == "implement" assert status["result"]["human_controls"] == [ "approve_transition", @@ -954,3 +957,19 @@ async def _call(tool: str, args: dict[str, Any]) -> Any: ).data assert fallback["ok"] is False assert "choose_fallback_stage" in fallback["error"] + + +def test_lifecycle_schema_resource_returns_packaged_contract() -> None: + from fastmcp import Client + + mcp = server.build_server() + + async def _call() -> Any: + async with Client(mcp) as client: + return await client.read_resource("vibecrafted://lifecycle/schema") + + result = _run(_call()) + payload = json.loads(result[0].text) + assert payload["$id"] == "vibecrafted.lifecycle.v1" + assert payload["properties"]["schema"]["const"] == "vibecrafted.lifecycle.v1" + assert "worker_report_frontmatter" in payload["$defs"] diff --git a/vibecrafted-mcp/vibecrafted_mcp/server.py b/vibecrafted-mcp/vibecrafted_mcp/server.py index 29a0daef..ea87ef13 100644 --- a/vibecrafted-mcp/vibecrafted_mcp/server.py +++ b/vibecrafted-mcp/vibecrafted_mcp/server.py @@ -33,8 +33,10 @@ workflow as _workflow, ) from vibecrafted_core.lifecycle_runner import ( + LIFECYCLE_SCHEMA_ID as _LIFECYCLE_SCHEMA_ID, LifecycleSupervisor as _LifecycleSupervisor, ) +from vibecrafted_core.package_resources import resource_path as _core_resource_path from . import synthesis as _synthesis @@ -451,6 +453,13 @@ def _lifecycle_state_summary(state: dict[str, Any]) -> dict[str, Any]: return payload +def _lifecycle_schema_resource_payload() -> dict[str, Any]: + path = _core_resource_path("schemas", "lifecycle.schema.v1.json") + payload = json.loads(path.read_text(encoding="utf-8")) + payload.setdefault("$id", _LIFECYCLE_SCHEMA_ID) + return payload + + def _lifecycle_verb( run_id: str, workflow_id: str, @@ -973,6 +982,11 @@ def board_runs() -> dict[str, Any]: "warnings": snapshot.get("warnings") or [], } + @mcp.resource("vibecrafted://lifecycle/schema") + def lifecycle_schema_resource() -> dict[str, Any]: + """Packaged JSON Schema for the lifecycle state/report contract.""" + return _lifecycle_schema_resource_payload() + @mcp.resource("vibecrafted://control-plane/events/{run_id}") def event_stream(run_id: str) -> list[dict[str, Any]]: """Last 50 events for a specific run from the operator stream.""" diff --git a/vibecrafted-server/control-core/src/model.rs b/vibecrafted-server/control-core/src/model.rs index d8fedad9..73b4a7ca 100644 --- a/vibecrafted-server/control-core/src/model.rs +++ b/vibecrafted-server/control-core/src/model.rs @@ -311,6 +311,8 @@ impl RunStatus { /// nested, while `RunStatus` is the legacy flat dashboard/API projection. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct LifecycleRun { + #[serde(default)] + pub schema: Option, #[serde(default)] pub run_id: String, #[serde(default)] @@ -386,6 +388,7 @@ impl LifecycleRun { .to_string(); LifecycleRunSummary { + schema: self.schema.clone(), run_id: self.run_id.clone(), workflow: self.workflow.clone(), status: nonempty_or(&self.status, "unknown"), @@ -563,6 +566,7 @@ pub struct LifecycleDouIndex { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct LifecycleRunSummary { + pub schema: Option, pub run_id: String, pub workflow: String, pub status: String, diff --git a/vibecrafted-server/control-core/tests/schema_fidelity.rs b/vibecrafted-server/control-core/tests/schema_fidelity.rs index 8bdf3fa0..6e7463cd 100644 --- a/vibecrafted-server/control-core/tests/schema_fidelity.rs +++ b/vibecrafted-server/control-core/tests/schema_fidelity.rs @@ -26,10 +26,10 @@ const GOLDEN_RUN_FINAL: &str = r#"{ "agent": "codex", "skill": "marbles", "mode": "implement", - "root": "/Users/tester/hosted/vetcoders/example-app", + "root": "/Users/you/hosted/vetcoders/example-app", "operator_session": "example-app-marb-000", - "latest_report": "/Users/tester/.vibecrafted/artifacts/vetcoders/example-app/2026_0329/reports/report.md", - "latest_transcript": "/Users/tester/.vibecrafted/artifacts/vetcoders/example-app/2026_0329/reports/report.transcript.log", + "latest_report": "/Users/you/.vibecrafted/artifacts/vetcoders/example-app/2026_0329/reports/report.md", + "latest_transcript": "/Users/you/.vibecrafted/artifacts/vetcoders/example-app/2026_0329/reports/report.transcript.log", "last_error": "", "updated_at": "2026-03-29T09:21:15.681613+00:00", "started_at": "2026-03-29T09:21:15.681613+00:00", @@ -101,6 +101,7 @@ const GOLDEN_EVENT_LINE: &str = r#"{"ts": "2026-04-18T14:52:42.135162+00:00", "r /// on 2026-07-02. Lifecycle state is intentionally nested and must not be folded /// into the flat `RunStatus` golden schema above. const GOLDEN_LIFECYCLE_STATE: &str = r#"{ + "schema": "vibecrafted.lifecycle.v1", "run_id": "life-ship-260702-123238-24000", "workflow": "vc-ship", "agent": "codex", @@ -294,6 +295,11 @@ fn lifecycle_state_parses_and_projects_without_runstatus_schema_drift() { let summary = lifecycle.summary("2026-07-02T19:32:38Z".to_string(), None); assert_eq!(lifecycle.run_id, "life-ship-260702-123238-24000"); + assert_eq!( + lifecycle.schema.as_deref(), + Some("vibecrafted.lifecycle.v1") + ); + assert_eq!(summary.schema.as_deref(), Some("vibecrafted.lifecycle.v1")); assert_eq!(summary.workflow, "vc-ship"); assert_eq!(summary.current_stage, "scaffold"); assert_eq!(summary.next_stage, "implement"); diff --git a/vibecrafted-vm/README.md b/vibecrafted-vm/README.md index 5ba80a58..9ec65d2f 100644 --- a/vibecrafted-vm/README.md +++ b/vibecrafted-vm/README.md @@ -86,17 +86,17 @@ ssh root@vc-workspace- The container expects these host paths (mounted automatically by `compose.yaml`): -| Host path | Container path | Purpose | -| ------------------------ | -------------------------- | ------------------------------------------- | -| `~/.vibecrafted/vc-runtime/` | `/workspace/` | Operator repos — the multiroot (read-write) | -| `~/.aicx/` | `/root/.aicx/` | Canonical corpus (persistent) | -| `~/.keys/` | `/root/.keys/` (ro) | GPG passphrase, notary creds — read-only | -| `~/.claude/` | `/root/.claude/` | Claude sessions (persistent) | -| `~/.codex/` | `/root/.codex/` | Codex sessions (persistent) | -| `~/.gemini/` | `/root/.gemini/` | Gemini sessions (persistent) | -| `~/.vibecrafted/` | `/root/.vibecrafted/` | vibecrafted artifacts (plans, reports) | -| `~/.config/vetcoders/` | `/root/.config/vetcoders/` | Frontier config (starship, atuin, vc_frame) | -| `~/.gnupg/` | `/root/.gnupg/` (ro) | GPG keyring for release-tag signing | +| Host path | Container path | Purpose | +| ---------------------------- | -------------------------- | ------------------------------------------- | +| `~/.vibecrafted/vc-runtime/` | `/workspace/` | Operator repos — the multiroot (read-write) | +| `~/.aicx/` | `/root/.aicx/` | Canonical corpus (persistent) | +| `~/.keys/` | `/root/.keys/` (ro) | GPG passphrase, notary creds — read-only | +| `~/.claude/` | `/root/.claude/` | Claude sessions (persistent) | +| `~/.codex/` | `/root/.codex/` | Codex sessions (persistent) | +| `~/.gemini/` | `/root/.gemini/` | Gemini sessions (persistent) | +| `~/.vibecrafted/` | `/root/.vibecrafted/` | vibecrafted artifacts (plans, reports) | +| `~/.config/vetcoders/` | `/root/.config/vetcoders/` | Frontier config (starship, atuin, vc_frame) | +| `~/.gnupg/` | `/root/.gnupg/` (ro) | GPG keyring for release-tag signing | ## Tailnet integration diff --git a/vibecrafted-vm/compose.yaml b/vibecrafted-vm/compose.yaml index b61e1544..890d892e 100644 --- a/vibecrafted-vm/compose.yaml +++ b/vibecrafted-vm/compose.yaml @@ -40,9 +40,7 @@ services: - TERM=${TERM:-xterm-256color} - COLORTERM=${COLORTERM:-truecolor} volumes: - # The multiroot repo tree → /workspace (override path via VC_RUNTIME_DIR). - # Upgrading from the older ~/Libraxis/vc-runtime layout? This static mount - # cannot auto-detect the legacy path — set VC_RUNTIME_DIR to it in .env. + # The multiroot repo tree → /workspace (override path via VC_RUNTIME_DIR) - ${VC_RUNTIME_DIR:-${HOME}/.vibecrafted/vc-runtime}:/workspace:rw # Persistent Vetcoders state - ${HOME}/.aicx:/root/.aicx:rw diff --git a/vibecrafted-vm/wizard/vc-onboard.py b/vibecrafted-vm/wizard/vc-onboard.py index 541eb6f2..58a1c39c 100755 --- a/vibecrafted-vm/wizard/vc-onboard.py +++ b/vibecrafted-vm/wizard/vc-onboard.py @@ -415,7 +415,11 @@ def render_env_file(state: WizardState, target: Path) -> None: # key → (host_path, container_path, mode) # vc-runtime = the multiroot repo tree (NOT this container folder). # Override host path with VC_RUNTIME_DIR (e.g. /Volumes/shared-vol/vc-runtime). - "workspace": ("${VC_RUNTIME_DIR:-${HOME}/.vibecrafted/vc-runtime}", "/workspace", "rw"), + "workspace": ( + "${VC_RUNTIME_DIR:-${HOME}/.vibecrafted/vc-runtime}", + "/workspace", + "rw", + ), "aicx_store": ("${HOME}/.aicx", "/root/.aicx", "rw"), "keys": ("${HOME}/.keys", "/root/.keys", "ro"), "gnupg": ("${HOME}/.gnupg", "/root/.gnupg", "ro"),