Skip to content

feat(cli): add cap-evolve quickstart — free/local presets to a runnable project (#133) - #248

Open
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-133-quickstart
Open

feat(cli): add cap-evolve quickstart — free/local presets to a runnable project (#133)#248
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-133-quickstart

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #133.

What

cap-evolve quickstart — the zero-question fast path from a fresh install to a real, sealed run. Before this, reaching a run meant hand-editing capevolve.yaml, and reaching a real one meant paid credentials; the only zero-friction option was examples/toy_calc/run.sh, which runs inside the repo and scaffolds nothing you own.

quickstart writes a project into a directory of your own that is already cap-evolve check-green, so the next command is cap-evolve run.

Presets

A plain dict in quickstart.PRESETS. No plugin framework — a preset is one row.

preset cost target runner needs
mock (default) $0 offline deterministic stand-in nothing at all
local $0 local OpenAI-compatible server (Ollama / llama.cpp / vLLM) a server on 127.0.0.1
free $0 Gemini free tier, via its OpenAI-compatible endpoint GEMINI_API_KEY exported

How this differs from intake

No code is shared, and neither invokes the other.

  • intake is the guided interview: it mines your working dir, asks what capability to optimize, and leaves an adapter stub you must implement before implement-and-check opens the gate.
  • quickstart asks nothing and writes a working adapter, so a curious installer sees a sealed test number before hitting a credential or cost wall.

A quickstart project is an ordinary project that intake could have produced. Use quickstart to see the pipeline work; use intake when you have real work to optimize.

Non-interactive contract

--yes, --preset, or a non-TTY stdin/stderr all mean "use defaults, never read stdin". A piped or CI invocation cannot hang — proven with a subprocess timeout, since "hangs forever" is not a value an assertion can inspect. The TTY decision reuses #215's eventstream.capability ladder (pipe/none are the non-TTY rungs) rather than sniffing isatty locally; plain/dumb are real terminals that merely refuse colour, so they still get the prompt. There is a documented fallback for while #215 is unmerged.

No-secret guarantee

  • Credential presence only — never a value, never a prefix, never a length. Resolution goes through feat(config): provider-scoped credential resolution + auto provider probing (closes #134) #190's model_config, so provider-scoping is not re-implemented here.
  • The scaffolded adapter stores the credential's env var NAME and reads os.environ at run time. Nothing written to disk contains a credential.
  • URL userinfo (https://user:token@host/) is stripped at the single point of resolution, before anything is stored, printed, or reported.
  • A non-default base URL renders as <custom> — a real internal gateway URL already leaked into a public PR in this epic.
  • Everything printed passes dashboard.redact. One deliberate exception, re-stamped after redaction: credential_env and credential_present. redact masks any value under a key that merely looks secret, so the provider block came out as credential_env: «redacted», hiding the one thing the user needs (which var to export) for zero security gain. credential_env is a NAME and credential_present a bool, secret-free by construction in model_config. Add cap-evolve doctor install/health diagnostic #121's doctor solves this identically (render names post-redaction).

Verified with multi-shape canaries under innocent-looking env names (BUILD_NUMBER, DEPLOY_TAG, REGION_HINT, TELEMETRY_ID) — a key-name heuristic missed exactly that case earlier in this epic. Shapes: bare high-entropy, UUID, ghp_, sk-, opaque watsonx-style base64. Prefixes (16- and 12-char) are checked too, since a truncated fragment still identifies the account and no longer matches a shape rule.

Reuse rather than reimplementation

Dependency How it is used
#193 / #121 doctor run_doctor() + branch on rep.ok. No health checks reimplemented; format_report goes to stderr on failure.
#190 provider creds model_config.resolve(require_credential=False) + strip_url_userinfo. Provider-scoping and precedence not duplicated.
#215 TTY ladder eventstream.capability for the interactive decision.
#214 CLI ergonomics The subcommand appears in the generated listing with zero edits — see Verification.
#197 protected paths The spec is produced by patching the shipped templates/project/capevolve.yaml, which deliberately OMITS protected_paths. Asserted, so a future template change cannot reintroduce an empty list (a hard error).
#195 val floor The seed set is 16 tasks, giving val=4. 8 tasks at the default ratios lands on MIN_VAL_TASKS exactly — one rounding change from a hard-failing scaffold.
#217 one JSON object Stdout is exactly one object; the human summary goes to stderr.

Each optional dependency is reached through a single _optional() import lookup with a documented fallback, so this merges in any order.

#124

No — the branch does not exist. git branch -r | grep 124 returns nothing on origin, so there was no entry point to build on. Rather than invent a parallel preset, PRESETS is a dict whose rows carry provider / runner / base_url / model / optimizer / needs: #124's cheap-real-run preset is one more row, no structural change. Happy to add it in this PR if the branch lands first.

Exact cli.py lines touched

12 insertions, 1 deletion, three places — nothing else:

Location Change
after _cmd_run (new lines 469-474) the 4-line _cmd_quickstart handler + blank lines
COMMANDS (new line 652) one row: "quickstart": _cmd_quickstart,
main() (line 655 → 662-666) replaced the literal usage: cap-evolve {version|splits|...} string with one joined from COMMANDS

That third change is not a new literal — it removes one. #214 deletes that whole block and replaces it with _usage(); until it lands, joining COMMANDS means a newly registered subcommand appears with zero edits either way. On merge with #214, that hunk is the single conflict and the resolution is "take #214's side wholesale".

Expected merge order

Order-independent. Recommended: #214#193/#121#190 → this. Verified against all three merged locally (see Verification). #214 is the only conflict, and it is one hunk with an obvious resolution.

Note found while verifying: #214 and #190 conflict with each other on #217's contract — test_run_stdout_is_a_single_json_object fails with both merged, because #190 prints a {"step": "provider"} line to run's stdout. Pre-existing, reproduced without any of my changes applied, and not mine to fix; flagging it for whoever merges second.

Files touched

  • core/cap_evolve/quickstart.py — new, 430 lines
  • core/tests/test_quickstart.py — new, 17 tests
  • core/cap_evolve/cli.py — +12 / -1
  • docs/GETTING_STARTED.md — new section 4 (preset table, intake contrast, contracts)

Verification

Full suite: 196 passed, 0 failed (baseline 179 + 17 new). compileall clean. On the #214 merge: 207 passed, 0 failed, including its documented-CLI checker over the new docs.

The money evidence — quickstart → check → run → sealed test number

$ cd /tmp/ev && cap-evolve quickstart --yes
exit=0
--- stderr (human) ---
quickstart: preset mock scaffolded in /private/tmp/ev
  16 tasks (4 val) · optimizer mock · fully offline, $0, no credential — a deterministic stand-in agent
next:
  export CAPEVOLVE_MOCK_SCRIPT=/private/tmp/ev/.capevolve/mock_script.json
  cd /private/tmp/ev
  cap-evolve check .capevolve/project
  cap-evolve run
--- stdout (machine) ---
{
  "preset": "mock",
  "dir": "/private/tmp/ev",
  "created": [
    ".capevolve/mock_script.json",
    ".capevolve/project/adapters/adapter.py",
    ".capevolve/project/adapters/tasks.jsonl",
    ".capevolve/project/capevolve.yaml",
    "seed_capability/prompt.txt"
  ],
  "provider": {
    "provider": "mock",
    "credential_env": "",
    "credential_present": false,
    "base_url": "",
    "reason": "offline preset — no credential, no endpoint"
  },
  "model": "",
  "base_url": "",
  "val_tasks": 4,
  "tasks": 16,
  "ok": true
}

$ cap-evolve check .capevolve/project
{
  "ok": true,
  "stubs": [],
  "problems": [],
  "notes": [
    "tasks('val') -> 16 task(s)",
    "scorer deterministic (probe reward=0.0000)",
    "materialize() callable (dry-run into temp copy; host untouched)"
  ]
}
exit=0

$ export CAPEVOLVE_MOCK_SCRIPT=/tmp/ev/.capevolve/mock_script.json
$ cap-evolve run --run-ts demo --dashboard off
exit=0
{
  "run_dir": ".capevolve/run_demo",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": { "1": 1.0, "2": 0.0 },
  "iterations": 3,
  "dashboard": ".capevolve/run_demo/dashboard.html"
}

baseline_val 0.0 → sealed test_reward 1.0, gate-accepted, $0, zero model calls.

Stdout is exactly one JSON object

$ python -c "json.load(open(stdout))"
parsed OK — exactly one object; keys: ['base_url', 'created', 'dir', 'model', 'next', 'ok', 'preset', 'provider', 'tasks', 'val_tasks']

Human output is on stderr (see the transcript above — the two streams were captured separately).

Appears in #214's generated listing with ZERO edits

Merged origin/feat/issue-137-cli-ergonomics locally. _usage() untouched:

$ cap-evolve --help
usage: cap-evolve {version|splits|check|quickstart|run|estimate|dashboard} [args]

commands:
  version     Print the installed cap-evolve version as JSON.
  splits      Compute the seeded train/val/test split for a set of task ids.
  check       Verify a project's adapter is fully implemented and deterministic.
  quickstart  Scaffold a ready-to-run project from a free/local preset (zero questions).
  run         Sequence the whole optimization run: baseline → algorithm → finalize → report.
  estimate    Pre-run cost estimate without spending anything.
  dashboard   Launch (or focus) the live dashboard server over a base dir of runs.

run `cap-evolve <command> --help` for a command's flags and examples.

The description is pulled from the handler docstring. All 11 of #214's tests pass, including test_every_subcommand_renders_help for quickstart.

Piped / non-TTY stdin does not hang

$ echo "hello" | cap-evolve quickstart          # no --yes, no --preset
  "preset": "mock",      ← the piped "hello" is IGNORED, not consulted
  "ok": true
0.06s user 0.02s system 88% cpu 0.086 total

$ cap-evolve quickstart < /dev/null
  "preset": "mock",
  "ok": true
0.06s user 0.02s system 95% cpu 0.078 total

0.08s, defaults used. Four stdin shapes are covered by a parametrized test with timeout=60.

No canary leaks — including innocent key names

$ export GEMINI_API_KEY='AIzaSyCANARY9bare1high2entropy3value4here5xyzQ'
$ export BUILD_NUMBER='7f3c9a21-4b8e-4d1f-9c2a-6e5b7d8f0a13'                 # UUID, innocent name
$ export DEPLOY_TAG='ghp_CANARYghp0123456789abcdefghijklmnopqrs'             # ghp_, innocent name
$ export REGION_HINT='cGFzc3dvcmQ6Y2FuYXJ5d2F0c29ueDEyMzQ1Njc4OTBhYmNkZWY='  # watsonx-style opaque
$ export TELEMETRY_ID='sk-CANARYsk0123456789abcdefghijklmnopqrstuvwxyz'      # sk-, innocent name
$ export OPENAI_BASE_URL='https://gw-user:s3cr3tCANARYtoken@internal.gw.example.corp/v1'
$ cap-evolve quickstart --yes --preset free --dir /tmp/qs-canary
exit=0

provider block on stdout:
{
  "provider": "gemini",
  "credential_env": "GEMINI_API_KEY",     ← the NAME, so the user knows what to export
  "credential_present": true,             ← presence, nothing more
  "base_url": "<custom>",
  "base_url_source": "<custom>",
  "reason": "provider 'gemini' from CLI flag"
}

$ grep -rniE 'AIzaSyCANARY|7f3c9a21|ghp_CANARY|cGFzc3dvcmQ6|sk-CANARY|s3cr3tCANARY|internal\.gw\.example|gw-user' \
    /tmp/qs-canary /tmp/canary-stdout.json /tmp/canary-stderr.txt
NO LEAK anywhere

No value, no prefix, no length, in output or in any written file.

#197 protected_paths and #195 val floor

$ pytest core/tests/test_quickstart.py -q
17 passed

test_spec_omits_protected_paths parses the written spec and asserts the key is absent (not present-and-empty, which #197 hard-errors on). test_val_split_clears_the_min_val_floor runs the real make_splits at the spec's seed/ratios and asserts len(val) > MIN_VAL_TASKS.

Full suite

$ PYTHONPATH=core python -m pytest core/tests -q
196 passed in 64.71s

$ python -m compileall -q core/cap_evolve core/tests
compileall clean

…able project (#133)

Closes #133.

There was no path from a fresh install to a real run without hand-editing
capevolve.yaml, and no path at all without paid credentials — the only
zero-friction option was `examples/toy_calc/run.sh`, which runs inside the repo
and scaffolds nothing you own. `quickstart` closes that: pick a free or local
preset, get a project that is ALREADY `cap-evolve check`-green, and the next
command is `cap-evolve run`.

Presets (a dict in `quickstart.PRESETS`, deliberately not a plugin framework):

  mock (default)  $0, fully offline, no credential — deterministic stand-in
  local           $0, a local OpenAI-compatible server (Ollama/llama.cpp/vLLM)
  free            $0, Gemini free tier via its OpenAI-compatible endpoint

Distinct from `intake`, and no code is shared with it. `intake` is the guided
INTERVIEW: it mines the working dir, asks what capability to optimize, and leaves
an adapter STUB you must implement before `implement-and-check` opens the gate.
`quickstart` asks nothing and writes a working adapter, so a user sees a sealed
test number before hitting a credential or cost wall. A quickstart project is a
normal project intake could have produced.

Non-interactive contract: `--yes`, `--preset`, or a non-TTY stdin/stderr all mean
"use defaults and never read stdin", so a piped or CI invocation cannot hang. The
TTY decision reuses #215's `eventstream.capability` ladder rather than sniffing
isatty locally.

Secrets: a credential's env var NAME is resolved via #190's `model_config` (so
provider-scoping is not re-implemented) and only PRESENCE is reported — never a
value, a prefix, or a length. URL userinfo is stripped at resolution, a
non-default base URL renders as `<custom>`, and everything printed goes through
`dashboard.redact`. The scaffolded adapter stores the variable's NAME and reads
`os.environ` at run time; nothing written contains a credential.

Health checks come from #121's `doctor` (`run_doctor` + branch on `rep.ok`), not
a reimplementation. The spec is produced by patching the shipped
`templates/project/capevolve.yaml`, which OMITS `protected_paths` — #197 makes an
empty list a hard error — and the seed task set is 16 tasks so the val split
clears #195's `MIN_VAL_TASKS` floor with room to spare.

Stdout is exactly one JSON object (#217); the human summary goes to stderr.
Stdlib only, zero new runtime deps.

cli.py touched in three places only, 12 insertions and 1 deletion: the
`_cmd_quickstart` handler, one `COMMANDS` row, and replacing the literal usage
string with one joined from `COMMANDS` (#214 removes that block entirely; the
interim version is COMMANDS-derived so it cannot drift either).
Copilot AI review requested due to automatic review settings July 30, 2026 22:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

Every command, with full output. Worktree /tmp/wt-133b at feat/issue-133-quickstart (51c154d1), base origin/main 21fe3e49. Python /tmp/ce-venv/bin/python.


1. Baseline before any change — 179 tests

$ cd /tmp/wt-133b && PYTHONPATH=/tmp/wt-133b/core /tmp/ce-venv/bin/python -m pytest core/tests -q
........................................................................ [ 40%]
........................................................................ [ 80%]
...................................                                      [100%]
179 passed in 66.03s (0:01:06)

2. #124 — the branch does not exist

$ git branch -r | grep -iE "124|cheap-onramp"
$ echo "exit=$?"
exit=1

Nothing on origin. Not built on; PRESETS is a dict so #124's preset is one added row.


3. THE MONEY EVIDENCE — quickstart → check → run → sealed test number

Fresh scratch dir, whole transcript, nothing elided:

$ cd /tmp && rm -rf /tmp/ev && mkdir /tmp/ev && cd /tmp/ev
$ export PYTHONPATH=/tmp/wt-133b/core CAPEVOLVE_CORE=/tmp/wt-133b/core CAPEVOLVE_SKILLS_DIR=/tmp/wt-133b/skills

$ cap-evolve quickstart --yes
exit=0
--- stderr (human) ---
quickstart: preset mock scaffolded in /private/tmp/ev
  16 tasks (4 val) · optimizer mock · fully offline, $0, no credential — a deterministic stand-in agent
next:
  export CAPEVOLVE_MOCK_SCRIPT=/private/tmp/ev/.capevolve/mock_script.json
  cd /private/tmp/ev
  cap-evolve check .capevolve/project
  cap-evolve run
--- stdout (machine) ---
{
  "preset": "mock",
  "dir": "/private/tmp/ev",
  "created": [
    ".capevolve/mock_script.json",
    ".capevolve/project/adapters/adapter.py",
    ".capevolve/project/adapters/tasks.jsonl",
    ".capevolve/project/capevolve.yaml",
    "seed_capability/prompt.txt"
  ],
  "provider": {
    "provider": "mock",
    "credential_env": "",
    "credential_present": false,
    "base_url": "",
    "reason": "offline preset — no credential, no endpoint"
  },
  "model": "",
  "base_url": "",
  "val_tasks": 4,
  "tasks": 16,
  "next": [
    "export CAPEVOLVE_MOCK_SCRIPT=/private/tmp/ev/.capevolve/mock_script.json",
    "cd /private/tmp/ev",
    "cap-evolve check .capevolve/project",
    "cap-evolve run"
  ],
  "ok": true
}

$ python -c "import json;d=json.load(open('/tmp/ev-out'));print('parsed OK — exactly one object; keys:',sorted(d))"
parsed OK — exactly one object; keys: ['base_url', 'created', 'dir', 'model', 'next', 'ok', 'preset', 'provider', 'tasks', 'val_tasks']

$ cap-evolve check .capevolve/project
{
  "ok": true,
  "stubs": [],
  "problems": [],
  "notes": [
    "tasks('val') -> 16 task(s)",
    "scorer deterministic (probe reward=0.0000)",
    "materialize() callable (dry-run into temp copy; host untouched)"
  ]
}
exit=0

$ export CAPEVOLVE_MOCK_SCRIPT=/tmp/ev/.capevolve/mock_script.json
$ cap-evolve run --run-ts demo --dashboard off
exit=0
--- stdout ---
{
  "run_dir": ".capevolve/run_demo",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_demo/dashboard.html"
}

Sealed number: test_reward 1.0 from baseline_val 0.0 (test_delta 1.0), gate-accepted over 3 iterations, $0, zero model calls. The project quickstart wrote — not toy_calc.


4. Stdout is exactly one JSON object; human output on stderr

The two streams were captured to separate files above, so the separation is structural, not inferred:

$ cap-evolve quickstart --yes 2>/tmp/ev-err >/tmp/ev-out
$ python -c "import json; json.load(open('/tmp/ev-out'))"     # no "Extra data"
parsed OK

$ grep -c "quickstart: preset" /tmp/ev-out
0
$ grep -c "quickstart: preset" /tmp/ev-err
1

5. Piped / non-TTY stdin does not hang, and uses defaults

$ cd /tmp && rm -rf /tmp/qs-piped && mkdir /tmp/qs-piped && cd /tmp/qs-piped
$ time (echo "hello" | cap-evolve quickstart 2>&1 | tail -8)      # NO --yes, NO --preset
  "next": [
    "export CAPEVOLVE_MOCK_SCRIPT=/private/tmp/qs-piped/.capevolve/mock_script.json",
    "cd /private/tmp/qs-piped",
    "cap-evolve check .capevolve/project",
    "cap-evolve run"
  ],
  "ok": true
}
0.06s user 0.02s system 88% cpu 0.086 total

$ cd /tmp/qs-closed
$ time (cap-evolve quickstart < /dev/null 2>&1 | grep -E '"preset"|"ok"')
  "preset": "mock",
  "ok": true
0.06s user 0.02s system 95% cpu 0.078 total

0.086s and 0.078s. The piped "hello" was never read — preset is the default mock, not free. Four stdin shapes are covered by test_never_hangs_and_uses_defaults with timeout=60, so a regression to blocking-read fails CI rather than hanging a user.


6. Appears in #214's generated listing with ZERO edits

$ git checkout -b v214 && git merge --no-edit origin/feat/issue-137-cli-ergonomics
Auto-merging core/cap_evolve/cli.py
CONFLICT (content): Merge conflict in core/cap_evolve/cli.py

$ git diff --diff-filter=U --name-only
core/cap_evolve/cli.py

$ awk '/^<<<<<</,/^>>>>>>/' core/cap_evolve/cli.py
<<<<<<< HEAD
    if not argv or argv[0] in ("-h", "--help"):
        # Generated from COMMANDS, never a literal list: #214 replaces this whole block
        # with a docstring-driven listing for exactly this reason (five parallel branches
        # adding a subcommand all conflicted on the literal string). Until it lands,
        # joining COMMANDS keeps a newly registered subcommand visible with zero edits.
        print(f"usage: cap-evolve {{{'|'.join(COMMANDS)}}} [args]", file=sys.stderr)
        return 0 if argv else 2
=======
    if not argv or argv[0] in ("-h", "--help", "help"):
        # No args is a usage ERROR (exit 2); an explicit --help is a successful request.
        print(_usage(), file=sys.stderr if not argv else sys.stdout)
        return 2 if not argv else 0
    if argv[0] in ("-V", "--version"):
        return _cmd_version([])
>>>>>>> origin/feat/issue-137-cli-ergonomics

One conflict, one hunk, resolution = take #214's side wholesale. My handler and COMMANDS row merged cleanly, and _usage() is untouched:

$ cap-evolve --help
usage: cap-evolve {version|splits|check|quickstart|run|estimate|dashboard} [args]

commands:
  version     Print the installed cap-evolve version as JSON.
  splits      Compute the seeded train/val/test split for a set of task ids.
  check       Verify a project's adapter is fully implemented and deterministic.
  quickstart  Scaffold a ready-to-run project from a free/local preset (zero questions).
  run         Sequence the whole optimization run: baseline → algorithm → finalize → report.
  estimate    Pre-run cost estimate without spending anything.
  dashboard   Launch (or focus) the live dashboard server over a base dir of runs.

run `cap-evolve <command> --help` for a command's flags and examples.

The quickstart row and its description come straight from the handler docstring.

#214's own tests, with quickstart registered:

$ pytest core/tests/test_documented_cli.py -q
...........                                                              [100%]
11 passed in 6.12s

Includes test_every_subcommand_renders_help (per-subcommand --help + correct prog=), test_top_level_help_lists_exactly_the_real_commands, and test_documented_cap_evolve_subcommands_resolve — the last one scans **/*.md + site/*.html, so it also proves the commands in my new docs/GETTING_STARTED.md section resolve.

Full suite on the #214 merge:

$ pytest core/tests -q
207 passed in 72.57s (0:01:12)

Per-subcommand --help:

$ cap-evolve quickstart --help
usage: cap-evolve quickstart [-h] [--dir DIR] [--preset {free,local,mock}]
                             [--yes] [--model MODEL] [--base-url BASE_URL]
                             [--force] [--no-doctor]

Scaffold a ready-to-run project from a free/local preset.

options:
  -h, --help            show this help message and exit
  --dir DIR             where to scaffold (default: .)
  --preset {free,local,mock}
                        skip the question (default: mock)
  --yes, -y             never prompt; accept every default
  --model MODEL         override the preset's target model
  --base-url BASE_URL   override the preset's endpoint (URL userinfo is stripped)
  --force               overwrite an existing project
  --no-doctor           skip the health check

examples:
  cap-evolve quickstart                      # one question (or defaults)
  cap-evolve quickstart --yes                # zero questions, mock preset
  cap-evolve quickstart --preset local       # local OpenAI-compatible server
  cap-evolve quickstart --preset free --dir ./demo
presets: mock (fully offline, $0, no credential — a deterministic stand-in agent) | local ($0 — a local OpenAI-compatible server (Ollama, llama.cpp, vLLM)) | free ($0 on the free tier — Gemini via its OpenAI-compatible endpoint)

7. #193/#121 doctor — used, not reimplemented

$ git merge --no-edit origin/feat/issue-121-doctor
$ cd /tmp/qs-doc && cap-evolve quickstart --yes
exit=0
--- health, from doctor.run_doctor(), in stdout ---
{
  "ok": true,
  "failed": []
}
--- generated listing with BOTH new subcommands ---
usage: cap-evolve {version|splits|check|doctor|quickstart|run|estimate|dashboard} [args]

commands:
  version     Print the installed cap-evolve version as JSON.
  splits      Compute the seeded train/val/test split for a set of task ids.
  check       Verify a project's adapter is fully implemented and deterministic.
  doctor      Diagnose the install and this dir's health; nonzero on a hard failure.
  quickstart  Scaffold a ready-to-run project from a free/local preset (zero questions).
  run         Sequence the whole optimization run: baseline → algorithm → finalize → report.
  estimate    Pre-run cost estimate without spending anything.
  dashboard   Launch (or focus) the live dashboard server over a base dir of runs.

quickstart calls doctor.run_doctor() and branches on rep.ok (exactly what #193 separated format_report for); on failure format_report goes to stderr so stdout stays one JSON object.


8. #190 provider creds — used, and the canary test

$ git merge --no-edit origin/feat/issue-134-provider-creds
 7 files changed, 1236 insertions(+), 4 deletions(-)
 create mode 100644 core/cap_evolve/model_config.py

Clean merge (no conflict with mine). Canaries — note the innocent env names:

$ export GEMINI_API_KEY='AIzaSyCANARY9bare1high2entropy3value4here5xyzQ'    # bare high-entropy
$ export BUILD_NUMBER='7f3c9a21-4b8e-4d1f-9c2a-6e5b7d8f0a13'               # UUID, innocent name
$ export DEPLOY_TAG='ghp_CANARYghp0123456789abcdefghijklmnopqrs'           # ghp_, innocent name
$ export REGION_HINT='cGFzc3dvcmQ6Y2FuYXJ5d2F0c29ueDEyMzQ1Njc4OTBhYmNkZWY=' # watsonx-style opaque
$ export TELEMETRY_ID='sk-CANARYsk0123456789abcdefghijklmnopqrstuvwxyz'    # sk-, innocent name
$ export OPENAI_BASE_URL='https://gw-user:s3cr3tCANARYtoken@internal.gw.example.corp/v1'

$ cap-evolve quickstart --yes --preset free --dir /tmp/qs-canary
exit=0

--- provider block on stdout ---
{
  "provider": "gemini",
  "credential_env": "GEMINI_API_KEY",
  "credential_present": true,
  "base_url": "<custom>",
  "base_url_source": "<custom>",
  "sources": {
    "provider": "CLI flag",
    "base_url": "<custom>"
  },
  "reason": "provider 'gemini' from CLI flag"
}

$ grep -rniE 'AIzaSyCANARY|7f3c9a21|ghp_CANARY|cGFzc3dvcmQ6|sk-CANARY|s3cr3tCANARY|internal\.gw\.example|gw-user' \
    /tmp/qs-canary /tmp/canary-stdout.json /tmp/canary-stderr.txt
NO LEAK anywhere

The scaffolded adapter carries the env var name only:

$ grep -n "_BASE_URL\|_CRED_ENV\|_MODEL =" /tmp/qs-canary/.capevolve/project/adapters/adapter.py
21:_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai'
22:_MODEL = 'gemini-2.0-flash'
23:_CRED_ENV = 'GEMINI_API_KEY'          # an env var NAME, never a value
45:    cred = os.environ.get(_CRED_ENV) if _CRED_ENV else None

test_no_canary_leaks_into_output_or_files runs this for all three presets and also checks 16- and 12-char prefixes, since a truncated fragment still identifies the account and no longer matches a shape rule.

One over-redaction found and fixed. On the first canary run, redact() masked the credential_* keys themselves (they look secret to the key heuristic):

"credential_env": "«redacted»",
"credential_present": "«redacted»",

That hides the one actionable fact (which var to export) for zero security gain — a NAME and a bool. Fixed the same way #121's doctor does: re-stamp exactly those two fields after redact(), nothing else. test_credential_presence_is_reported_but_never_the_value locks both halves (presence visible, value absent).


9. #197 protected_paths and #195 val floor

$ pytest core/tests/test_quickstart.py -q
.................                                                        [100%]
17 passed in 5.10s
  • test_spec_omits_protected_paths — parses the written spec with the real specfile.read_yaml and asserts the key is absent (not present-and-empty, which Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197 hard-errors on). Guards a future template change too, since quickstart patches the shipped template rather than authoring a spec.
  • test_val_split_clears_the_min_val_floor — runs the real splits.make_splits at the spec's seed/ratios; 16 tasks → val=4, strictly above MIN_VAL_TASKS=2. 8 tasks would land on the floor exactly, one rounding change from a hard fail, which is why the seed set is 16.

10. Full suite + compileall on the branch

$ PYTHONPATH=/tmp/wt-133b/core /tmp/ce-venv/bin/python -m pytest core/tests -q
........................................................................ [ 36%]
........................................................................ [ 73%]
....................................................                     [100%]
196 passed in 64.71s (0:01:04)

$ /tmp/ce-venv/bin/python -m compileall -q core/cap_evolve core/tests
compileall clean

196 = 179 baseline + 17 new, 0 failed.


11. Exact cli.py diff — 12 insertions, 1 deletion

$ git diff -U0 origin/main -- core/cap_evolve/cli.py
@@ -468,0 +469,6 @@ def _cmd_run(argv):
+def _cmd_quickstart(argv):
+    """Scaffold a ready-to-run project from a free/local preset (zero questions)."""
+    from .quickstart import _main
+    return _main(argv)
+
+
@@ -645,0 +652 @@ COMMANDS = {
+    "quickstart": _cmd_quickstart,
@@ -655 +662,5 @@ def main(argv=None) -> int:
-        print("usage: cap-evolve {version|splits|check|run|estimate|dashboard} [args]", file=sys.stderr)
+        # Generated from COMMANDS, never a literal list: #214 replaces this whole block
+        # with a docstring-driven listing for exactly this reason (five parallel branches
+        # adding a subcommand all conflicted on the literal string). Until it lands,
+        # joining COMMANDS keeps a newly registered subcommand visible with zero edits.
+        print(f"usage: cap-evolve {{{'|'.join(COMMANDS)}}} [args]", file=sys.stderr)

The third hunk removes a literal list rather than adding one.

$ git diff --stat origin/main
 core/cap_evolve/cli.py       |  13 ++++++++++++-
 core/cap_evolve/quickstart.py| 430 +++++++++++++++++++++++++++++++++++++++
 core/tests/test_quickstart.py| 264 ++++++++++++++++++++++++
 docs/GETTING_STARTED.md      |  37 ++++++++++++++++++++++++++++++++++-

⚠️ Pre-existing conflict found between #214 and #190 (not mine)

With #214 + #190 both merged, #217's own contract test fails — #190 prints a second object ({"step": "provider"}) to cap-evolve run's stdout:

$ pytest core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object -q
E  json.decoder.JSONDecodeError: Extra data: line 2 column 1 (char 273)
1 failed

Reproduced with my changes stashed, i.e. on #214 + #121 + #190 alone:

$ git stash -u -q && pytest core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object -q
1 failed in 3.85s
^^^ WITHOUT my changes

Actual output of cap-evolve run on that merge:

{"step": "provider", "provider": "mock", "credential_env": "", ...}
{
  "run_dir": ".capevolve/run_demo2",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  ...
}

Two objects. Not mine to fix — flagging for whoever merges second. test_quickstart.py deliberately tolerates it (takes the last object from run's stdout, with a comment saying why) so this file cannot fail on a merge order it does not control; quickstart's own stdout is asserted strictly single-object.

@skillberry-bot

Copy link
Copy Markdown
Contributor

Automatic Labeling Failed

An error occurred while trying to automatically label this pull request. Please check the workflow logs for details and add labels manually.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #248

Verdict: CHANGES REQUESTED — one blocking defect. The central claim reproduces exactly as written when you follow the JSON next list, but the documented four-command path in docs/GETTING_STARTED.md:52-56 silently produces test_reward 0.0, not the sealed 1.0 the doc promises. That is the one thing this PR exists to make true, and the doc omits the step that makes it true.

Everything else held up under attack: 196/207 tests reproduce, no canary of mine leaked, stdout is one object in every mode I could construct, the over-redaction fix is safe, all four _optional() fallbacks are correct (not merely non-crashing), and quickstart appears in #214's generated listing with zero edits.


Blocking

B1. docs/GETTING_STARTED.md:52-56 — the documented happy path yields test_reward 0.0, silently.

The doc block is:

mkdir ~/my-run && cd ~/my-run
cap-evolve quickstart --yes
cap-evolve check .capevolve/project      # already green
cap-evolve run                           # sealed test number, $0

quickstart's own JSON next list (quickstart.py:410) correctly emits export CAPEVOLVE_MOCK_SCRIPT=... as step 1. The doc drops it. Run exactly the four documented commands:

$ cd /tmp/p-g && cap-evolve quickstart --yes && cap-evolve check .capevolve/project && cap-evolve run
check exit=0
baseline_val= 0.0  test_reward= 0.0  <- DOCS SAY "sealed test number"

vs. with the export the JSON told you to make:

WITH export -> baseline= 0.0 test_reward= 1.0

Root cause, not symptom. _mock_apply.py:24-31 (_find_script) looks in exactly three places: $CAPEVOLVE_MOCK_SCRIPT, <workdir>/mock_script.json, <workdir>/../mock_script.json. The optimizer workdir is <run_dir>/work/<cid> (harness.py:1245), so workdir.parent is <run_dir>/work — and quickstart writes the script to <dest>/.capevolve/mock_script.json (quickstart.py:382-383), which is reachable from neither. Without the env var the mock optimizer takes its no mock_script.json found; no edits made branch (_mock_apply.py:68-70) and returns exit 0. Every candidate is then byte-identical to its parent, the gate correctly rejects both, and the run reports a perfectly well-formed test_reward: 0.0:

$ cat .capevolve/run_nf/rejected.jsonl
{"candidate_id": "cand_0001", ..., "reason": "paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=4)", "val": 0.0}
{"candidate_id": "cand_0002", ..., "reason": "paired Δ̄=+0.0000 <= 0 ...", "val": 0.0}

Consequence. The first-run experience this PR is for ends in test_reward: 0.0 with a green check, exit 0, no warning on either stream, and a JOURNAL that says (no handover written by the optimizer). A new user reads that as "cap-evolve doesn't work", and there is nothing in the output pointing at the missing export. This is worse than a hard failure.

Fix (either, or both — the first is a one-line doc edit and is enough for the doc claim):

  1. docs/GETTING_STARTED.md:52-56 — add the export line, matching what the JSON next already says:
    cap-evolve quickstart --yes
    export CAPEVOLVE_MOCK_SCRIPT=$PWD/.capevolve/mock_script.json
    cap-evolve check .capevolve/project
    cap-evolve run
  2. Preferable, since it removes the footgun rather than documenting it: make the mock optimizer's silent no-op non-silent. _mock_apply.py:68-70 already knows it found nothing — that branch should be a warning on stderr ("no mock_script.json found; set CAPEVOLVE_MOCK_SCRIPT — this run will make no edits"), so any project that hits this path says so instead of reporting a clean zero. That fixes it for toy_calc, quickstart, and every future preset at once, in one place, which is the smaller diff than documenting the export in every doc that will ever mention it.

Non-blocking

N1. quickstart.py:390 + #190's PUBLIC_BASE_URLS — after #190 merges, both non-mock presets' own default endpoints render as <custom>.

_PUBLIC_DEFAULTS (quickstart.py:103) is derived from PRESETS, so the local fallback prints the presets' own URLs verbatim — correct. But safe_url() (quickstart.py:56-59) prefers dashboard.safe_url once #190 lands, and #190's allowlist (dashboard.py:118-124) contains neither http://127.0.0.1:11434/v1 nor https://generativelanguage.googleapis.com/v1beta/openai (it has .../v1beta, without the /openai suffix). Measured on the real merge:

local  http://127.0.0.1:11434/v1                                 ->  '<custom>'
free   https://generativelanguage.googleapis.com/v1beta/openai    ->  '<custom>'
$ cap-evolve quickstart --yes --preset local
base_url shown: '<custom>'

Consequence: a user setting up the local preset is shown <custom> for a loopback address they typed themselves and that the adapter has in plaintext one file away — no confidentiality gained, debuggability lost, and the only place the endpoint is visible becomes the adapter source. Not a leak, so non-blocking. Fix: pass the preset defaults through #190's own public= parameter, which exists for exactly this (safe_url(url, public=dashboard.PUBLIC_BASE_URLS | _PUBLIC_DEFAULTS)), or ask #190 to add the two preset URLs to PUBLIC_BASE_URLS. Worth coordinating before #190 merges rather than after.

N2. quickstart.py:454-459 — a failed doctor report does not affect the exit code.

_health() prints format_report to stderr when rep.ok is false and stores {"ok": false, ...} in the JSON, but rec["ok"] = True is set unconditionally and _main returns 0. A CI job that shells quickstart and branches on $? cannot see a hard health failure; it has to parse .health.ok. Fix: either return 1 when health["ok"] is false, or document that the exit code covers scaffolding only and health is advisory (the --no-doctor flag suggests the latter is intended — say so in the --help epilog).

N3. quickstart.py:369-370--model and --base-url are accepted and written into the adapter for the mock preset, where _RUNNER == "mock" never reads either.

$ cap-evolve quickstart --yes --preset mock --model gpt-4o
model reported: 'gpt-4o'
_RUNNER = 'mock'
_MODEL = 'gpt-4o'      # dead

Consequence: someone passing --model to the default preset gets a JSON record and an adapter constant claiming a model that is never called, and a run that costs $0 and calls nothing — looks like the model was used. Fix: one line in _main — warn on stderr when --model/--base-url is passed with a preset whose runner is mock.

N4. quickstart.py:391val_tasks re-derives the split arithmetic instead of asking splits.

max(1, round(_N_TASKS * 0.25)) duplicates make_splits's partitioning by hand. It happens to agree today (make_splits(16 ids, ratios=(0.5,0.25,0.25))train/val/test = 8 4 4), and the test asserts the real function separately, so the reported number is right. But the two can drift and the human summary (quickstart.py:475) is what a user reads. Fix: len(make_splits([t["id"] for t in _tasks()], seed=0, ratios=(0.5,0.25,0.25)).val) — the import is already stdlib-internal and the test already does exactly this.

N5. quickstart.py:351-352--force over a path where .capevolve/project is a file fails with a raw errno.

$ cap-evolve quickstart --yes --force        # .capevolve/project is a regular file
{"ok": false, "error": "[Errno 20] Not a directory: '.capevolve/project/adapters'"}

Still one JSON object, still exit 1, so the contracts hold — but the message names an internal path and not the actual problem. Fix: in the project.exists() and not force guard, also reject project.exists() and not project.is_dir() regardless of --force, with "…exists and is not a directory".


Does quickstart→run actually work from clean?

Yes — with the export from the JSON next list. No, if you follow the docs (B1).

Full transcript, clean scratch dir, nothing elided:

$ rm -rf /tmp/rv-ev && mkdir -p /tmp/rv-ev && cd /tmp/rv-ev
$ export PYTHONPATH=/tmp/rv-248/core CAPEVOLVE_CORE=/tmp/rv-248/core CAPEVOLVE_SKILLS_DIR=/tmp/rv-248/skills
$ cap-evolve quickstart --yes >/tmp/rv-ev-out.json 2>/tmp/rv-ev-err.txt
exit=0

--- stderr (human) ---
quickstart: preset mock scaffolded in /private/tmp/rv-ev
  16 tasks (4 val) · optimizer mock · fully offline, $0, no credential — a deterministic stand-in agent
next:
  export CAPEVOLVE_MOCK_SCRIPT=/private/tmp/rv-ev/.capevolve/mock_script.json
  cd /private/tmp/rv-ev
  cap-evolve check .capevolve/project
  cap-evolve run

--- stdout: json.loads, strict ---
ONE OBJECT OK, keys: ['base_url','created','dir','model','next','ok','preset','provider','tasks','val_tasks']

$ cap-evolve check .capevolve/project
{
  "ok": true,
  "stubs": [],
  "problems": [],
  "notes": [
    "tasks('val') -> 16 task(s)",
    "scorer deterministic (probe reward=0.0000)",
    "materialize() callable (dry-run into temp copy; host untouched)"
  ]
}
check exit=0

check-green before the run, with stubs: [] — that is the claim that distinguishes this from intake, and it holds. The distinction is real in code, not just in docs: the written adapter (quickstart.py:127-217) implements all three required methods with working bodies — tasks() parses the written tasks.jsonl, score() does exact-match with per-task feedback, and run_target() has two real branches (an offline deterministic stand-in, and a stdlib-urllib OpenAI-compatible chat call). No IMPLEMENT_MARKER, no NotImplementedError. check confirms it independently.

$ export CAPEVOLVE_MOCK_SCRIPT=/tmp/rv-ev/.capevolve/mock_script.json
$ cap-evolve run --run-ts demo --dashboard off
{
  "run_dir": ".capevolve/run_demo",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {"1": 1.0, "2": 0.0},
  "iterations": 3,
  "dashboard": ".capevolve/run_demo/dashboard.html"
}
run exit=0

baseline_val 0.0 → sealed test_reward 1.0, $0, zero model calls — reproduced verbatim. Headroom is genuine, not rigged: the seed prompt is vague, the mock runner rambles ("Well, 7 - 7 works out to something around there."), and the scripted edit adds the explicit output contract that makes it compute. Also reproduced on the #193 merge (baseline= 0.0 test_reward= 1.0).

Re-run / existing-state probes — this is where I expected to find bad state, and mostly didn't:

probe result verdict
quickstart --yes twice, same dir exit 1, {"ok": false, "error": ".capevolve/project already exists — pass --force to overwrite"}, single object, first project untouched correct
existing .capevolve/ with a junk mock_script.json, no project exit 0, scaffolds, overwrites the junk script with a valid one acceptable — but it is an unannounced overwrite of a file outside .capevolve/project, and the --force guard does not cover it. Worth a line in created/stderr.
.capevolve/project exists as a file exit 1, correct refusal message correct
same, with --force exit 1, [Errno 20] Not a directory (N5) contract holds, message poor
read-only cwd (chmod 500) exit 1, {"ok": false, "error": "[Errno 13] Permission denied: '.capevolve'"}, single object, nothing written correct — actionable, no partial state
$HOME unset (env -u HOME) exit 0, ok=true, full scaffold correct — nothing reads $HOME
--force over a good project exit 0, adapter replaced correct

No partial-write state found in any failure path: scaffold() resolves the provider and validates the preset before it touches the filesystem, and the read-only case dies on the first mkdir with nothing on disk.

Presets under adversity:

  • local with no server running: check green, run exit 0, test_reward 0.0. Every rollout trace reads runner error: HTTPError with the scorer's per-task feedback intact — a scored failure, not a crash, which is the deliberate design at quickstart.py:202-203. Defensible. The needs field is honest ("a local server answering at the base URL (e.g. \ollama serve`)") and is echoed into nextas# target runner needs: ... (quickstart.py:411-412`).
  • free with no credential: exit 0, credential_env: "", credential_present: false, and # target runner needs: GEMINI_API_KEY (or GOOGLE_API_KEY) exported in next. But the adapter is written with _CRED_ENV = '' — baked at scaffold time, so exporting the key afterwards does not help; you must re-scaffold with --force. The needs line makes it discoverable, so not blocking, but see the presets section.

Canary sweep

My own canaries, none of them theirs — five shapes × secret-looking and innocent names, plus URL userinfo. Swept all three presets, both streams, and every written file (values, 16-char prefix, 12-char prefix), on the branch alone and again on the real #190 merge so the model_config path was exercised, not just the fallback.

shape env var name value (truncated) stdout stderr written files
bare high-entropy, vendor prefix ANTHROPIC_API_KEY (secret-looking) sk-ant-api03-RVWzz7Qp4mN8kL2xJ9v… no no no
UUID CI_JOB_TOKEN (innocent-ish) 9d4f8e2a-1c7b-4a35-8f60-2b9e5c1d7a48 no no no
ghp_ ARTIFACT_LABEL (innocent) ghp_RVCANARY8899zzyyxxwwvvuuttssrrqq no no no
watsonx-style opaque base64 CACHE_BUCKET (innocent) eyJhbGciOiJSVkNBTkFSWSIsInR5cCI6… no no no
Google/AIzaSy GEMINI_API_KEY (secret-looking, and actually resolved) AIzaSyRVone2three4five6seven8nine0… no no no
sk- project key LOCALE_PREF (innocent) sk-RVproj0011223344556677889900aa… no no no
URL userinfo (user) in --base-url + OPENAI_BASE_URL svc-acct no no no
URL userinfo (password) same RVdeepSecret42 no no no
endpoint hostname same gw.internal.acme-corp.net no (<custom>) no yes — adapter _BASE_URL, by design
$ grep -rniE '<all values>|<16-char prefixes>|<12-char prefixes>' /tmp/m190-{mock,local,free} /tmp/m190-*.out /tmp/m190-*.err
NO LEAK
$ grep -rn "gw.internal.acme-corp.net" /tmp/m190-*.out /tmp/m190-*.err
hostname NOT in streams

Zero leaks. The hostname-in-adapter case is correct and correctly scoped: the adapter must be able to reach the endpoint, and the credential inside the URL dies at quickstart.py:356-362 before anything is written. Length is not leaked either — no field carries it. The interesting result is the innocent names: ARTIFACT_LABEL, CACHE_BUCKET, LOCALE_PREF and CI_JOB_TOKEN are invisible to dashboard._key_is_secret's name heuristic (dashboard.py:64-71), and they still don't leak — because quickstart never puts an environment value into the record in the first place. That is the right architecture: it is not relying on redaction to catch them.


Is tolerating #190's stdout violation defensible?

Yes. Defensible, and I'd keep it exactly as written.

I reproduced the conflict, and I reproduced it without #248 in the tree:

$ git checkout -b rv-noqs origin/feat/issue-137-cli-ergonomics && git merge origin/feat/issue-134-provider-creds
$ pytest core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object -q
E  json.decoder.JSONDecodeError: Extra data: line 2 column 1 (char 273)
1 failed in 3.62s        # ← #214 + #190 only. No quickstart anywhere.

So the defect is entirely #190's, exists without this PR, and #248 cannot fix it — the offending print is in #190's cli.py, a file #248 barely touches.

The mitigation is narrow in the way that matters. test_quickstart.py:36-48 (_last_json_object) is used at exactly one call site — run's stdout in the money test (test_quickstart.py:88) — and it is used to extract the final report, which is genuinely the last object whether or not #190 prepends one. quickstart's own stdout is asserted strictly, with a bare json.loads that raises on any second object (test_quickstart.py:255, test_stdout_is_exactly_one_json_object_and_human_output_is_on_stderr), and again in test_never_hangs_and_uses_defaults (:122).

It does not mask the defect either: #217's own test_run_stdout_is_a_single_json_object still fails loudly on the #214+#190 merge, which is the test that owns that contract. #248's tolerance means only that this file doesn't also fail on a merge order it has no control over. The inline comment (test_quickstart.py:81-87) states the reasoning and names #217 as the owner. A test failing on someone else's regression, in a file about a different subject, is noise that trains people to ignore red — the wrong lever. The orchestrator's ruling (#190's line moves to stderr) is the right fix, and it lands in #190.


_optional() fallbacks

Tested each dependency absent (they all are, on #248's own branch — so the fallback path is what the branch ships and what CI covers) and each present (real merges of #190 and #193).

dependency absent → behaviour correct?
eventstream (#215) interactive() falls back to sys.stdin.isatty() and sys.stderr.isatty(), wrapped in try/except → False. Verified False under both-non-TTY, and test_interactive_requires_a_tty_on_both_streams covers all four TTY combinations. Yes — and it fails safe: the degradation is "never prompt", which cannot hang. The and (not or) matches #215's own rung semantics.
model_config (#190) _resolve_provider uses a hardcoded name table (quickstart.py:289) and reports reason: "model_config unavailable — env-name lookup only". Verified: free preset → credential_env: "GEMINI_API_KEY", credential_present: true, no value. Userinfo stripping falls back to a manual partition("//") split (:360-362) — verified it strips svc-acct:RVdeepSecret42@. Yes — same shape, same fields, no value; the reason string makes the degradation visible rather than silent. Not a worse default: still name-only, still provider-scoped by construction (a two-row literal table cannot cross providers).
doctor (#121/#193) _health() returns None, and _main omits the health key rather than emitting a fake {"ok": true}. Verified: absent → no health key; present → health: {"ok": true, "failed": []}. Yes — this is the one that would be a silent degradation if it stubbed ok: true, and it deliberately doesn't. Omission is honest.
dashboard.safe_url (#190) Local fallback is a strict allowlist over _PUBLIC_DEFAULTS, not a heuristic. Verified: preset URLs verbatim, https://internal.acme/v1<custom>, http://127.0.0.1:8080/v1<custom> (a different loopback port is still <custom>). Yes, and stricter than the real thing — the fallback is more conservative than #190's, which is the correct direction for a security fallback. The post-merge loosening is N1, and it's in the wrong direction — worth fixing before #190 lands.

I checked the one thing that would break this whole pattern: quickstart calls mc.strip_url_userinfo (quickstart.py:359), and on #190 that function lives in dashboard, not model_config. It resolves anyway, because #190 imports it into model_config's namespace (model_config.py:51). Verified on the real merge: strip_url_userinfo: True, safe_url: True. This is a latent coupling to #190's import list, not its public API — if #190 ever switches to from . import dashboard + dashboard.strip_url_userinfo(...), mc.strip_url_userinfo disappears and quickstart's elif fallback silently takes over. Not a bug today; worth one line of comment at :359.


#197 / #195 compliance

Both verified with the real functions, not by reading the template.

$ python -c "from cap_evolve import quickstart, specfile; ...; read_yaml(spec)"
protected_paths in spec: False
optimizer_skill= mock dataset_source= adapter max_iterations= 3 stall= 2

protected_paths is absent, not present-and-empty — which is what #197 hard-errors on. It holds through #190's merge too (#190 adds 10 lines to templates/project/capevolve.yaml and none of them is protected_paths). Patching the shipped template rather than authoring a spec is the right call for exactly this reason.

$ make_splits(16 ids, seed=0, ratios=(0.5,0.25,0.25))  ->  train/val/test = 8 4 4
$ make_splits(8 ids,  seed=0, ratios=(0.5,0.25,0.25))  ->  val = 2   # exactly #195's floor

val=4 clears MIN_VAL_TASKS=2 with margin. The comment at quickstart.py:105-108 explaining why the seed set is 16 and not 8 is load-bearing and correct — I confirmed 8 tasks lands val on the floor exactly, one rounding change from a hard fail. Note MIN_VAL_TASKS does not exist yet on any branch I can see, so the test's getattr(_splits, "MIN_VAL_TASKS", 2) is doing real work; when #195 lands with a different floor the assertion adapts.


Presets

PRESETS (quickstart.py:68-96) is a plain dict and genuinely one-row-extensible: a preset is consumed only through row["runner"]/["base_url"]/["model"]/["optimizer"]/["needs"]/["summary"], --preset choices come from sorted(PRESETS), _PUBLIC_DEFAULTS is derived from it, and the --help epilog is generated from it. I confirmed #124's branch is absent (git branch -r | grep -iE "124|cheap-onramp" → nothing), so the claim isn't validated against a real second consumer — but adding a row requires editing nothing else, and I traced every read.

Usable, or aspirational?

  • mock — genuinely usable, end to end, offline, and it is the default. This is the preset the central claim rests on.
  • local — usable if you have a server. Fails gracefully without one (scored 0.0 with runner error: HTTPError per rollout, not a crash), and needs is honest and surfaced in next. Aspirational in one respect: the model is pinned to qwen2.5:3b, which most people won't have pulled even with ollama serve up — that produces a 404 from a running server, indistinguishable in the trace from a dead one (both are runner error: HTTPError). Non-blocking; worth mentioning ollama pull qwen2.5:3b in needs.
  • free — usable, but with the credential-baking wrinkle above: _CRED_ENV is fixed at scaffold time (quickstart.py:370), so scaffolding before exporting GEMINI_API_KEY writes _CRED_ENV = '' and the adapter then sends no Authorization header at all — a 401 that presents as runner error: HTTPError, identical to a dead endpoint, with no hint that the key was the problem. The needs line and the # target runner needs: echo make it discoverable, so not blocking. Cleanest fix: have the adapter resolve the name at run time (_CRED_ENV or os.environ lookup over the provider's known names) rather than baking a possibly-empty string, so exporting the key afterwards just works.

A missing prerequisite therefore fails with an actionable message at scaffold time (needs, in both the JSON and stderr) and a confusing failure at run time (HTTPError). The first is good; the second is the honest limit of a scaffold that can't probe. --no-doctor exists and #193's doctor is wired in, which is the right place for a real reachability probe — worth a follow-up to have quickstart --preset local actually probe the endpoint via #190's model_config.probe() (which exists) and say "nothing is answering at 127.0.0.1:11434" before you spend a run finding out.


Test quality

17 cases from 12 functions (4 from one parametrize, 3 from another). They assert outcomes, not presence, which is the right instinct — test_scaffolded_project_runs_to_a_sealed_test_number drives the actual pipeline and asserts baseline_val == 0.0 and test_reward == 1.0, so a scaffold with no headroom or a broken adapter fails there rather than in a user's terminal. test_never_hangs_and_uses_defaults uses timeout= as the assertion, which is the only way to test "does not hang". The canary test checks 16- and 12-char prefixes, not just whole values, which is the failure mode that got past three earlier tests in this epic.

Untested paths, in rough priority order:

  1. _ask_preset (quickstart.py:262-274) is never executed. It is only reachable when interactive() is true, which no test can be. So the one interactive path the command has — the prompt, the default-on-empty, the default-on-invalid-answer, and the except → default when stdin vanishes mid-prompt — is entirely uncovered. Cheap fix: call _ask_preset directly with monkeypatch.setattr(sys, "stdin", io.StringIO("free\n")) and assert "free", then """mock", then "garbage""mock". Three asserts, no subprocess.
  2. _patch_spec (quickstart.py:303-323) — the whole reason Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197 compliance holds, and only tested via its output on the current template. I probed it directly: it correctly skips comments and indented keys, and appends the missing key at top level (which the flat reader then wins on — verified read_yaml returns the appended max_iterations: 3 over an indented max_iterations: 10). Behaviour is right; it just isn't pinned.
  3. _template_spec's installed-wheel fallback (quickstart.py:333-340) — a second, hand-maintained copy of the spec keys that no test ever renders. If it drifts from the template, wheel installs get a subtly different project than repo installs and nothing catches it. One test asserting the fallback string parses and has the same key set as the template would close it.
  4. _next_steps, safe_url's fallback branch, _optional with a dependency present, and the _resolve_provider except branch (:298-300) — all exercised by me manually, none pinned.

Merge-order note

compileall clean. Suite: 196 passed on the branch (179 baseline + 17 new, as claimed), 207 passed with #214 merged (as claimed). test_dashboard_launch.py did not flake for me in any of the three runs (#200).

Recommended order: #214#197#195#193#190#215#245#248 last.

Rationale, from the merges I actually performed:


Verification I re-ran

$ cd /tmp/rv-248 && PYTHONPATH=/tmp/rv-248/core pytest core/tests -q
196 passed in 80.23s (0:01:20)

$ git merge origin/feat/issue-137-cli-ergonomics      # one conflict, one hunk, take #214's side
$ PYTHONPATH=/tmp/rv-248/core pytest core/tests -q
207 passed in 71.78s (0:01:11)

$ python -m compileall -q core/cap_evolve core/tests
compileall clean

$ pytest core/tests/test_quickstart.py core/tests/test_doctor.py -q     # on the #193 merge
62 passed in 6.50s

$ cap-evolve --help                                    # #214 merged, ZERO edits to #214
usage: cap-evolve {version|splits|check|quickstart|run|estimate|dashboard} [args]
commands:
  ...
  quickstart  Scaffold a ready-to-run project from a free/local preset (zero questions).
  ...

$ cap-evolve --help                                    # #193 merged (no #214): still COMMANDS-derived
usage: cap-evolve {version|splits|check|quickstart|doctor|run|estimate|dashboard} [args]

# stdout is exactly one JSON object — every mode, via json.loads
default                exit=0   ONE OBJECT
yes                    exit=0   ONE OBJECT
preset-free            exit=0   ONE OBJECT
preset-local           exit=0   ONE OBJECT
no-doctor              exit=0   ONE OBJECT
force                  exit=0   ONE OBJECT
piped-stdin            exit=0   ONE OBJECT      (preset used: mock — the pipe was never read)
existing-project       exit=1   ONE OBJECT      (ok=False, actionable error)
read-only cwd          exit=1   ONE OBJECT      (ok=False, Permission denied)
unknown-preset         exit=2   EMPTY stdout    (argparse; stderr: "invalid choice: 'nope'")

# money path, clean scratch dir
baseline_val= 0.0  test_reward= 1.0             # with the export from `next`
baseline_val= 0.0  test_reward= 0.0             # following docs/GETTING_STARTED.md:52-56  ← B1

# #190's stdout violation, WITHOUT #248 in the tree
$ git checkout -b rv-noqs origin/feat/issue-137-cli-ergonomics && git merge origin/feat/issue-134-provider-creds
$ pytest core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object -q
1 failed in 3.62s

# my canaries, on the #190 merge (real model_config path)
NO LEAK
hostname NOT in streams

# _optional() fallbacks, all four absent on this branch
doctor: ABSENT (fallback active)  -> _health() -> None, no `health` key emitted
model_config: ABSENT              -> credential_env='GEMINI_API_KEY', present=True, no value
eventstream: ABSENT               -> interactive() False under non-TTY
dashboard.safe_url: absent        -> 'https://internal.acme/v1' -> '<custom>', preset URLs verbatim

# #197 / #195
protected_paths in spec: False
train/val/test = 8 4 4      (8 tasks would give val=2, exactly #195's floor)

BLOCKING: docs/GETTING_STARTED.md's 4-command path omitted
`export CAPEVOLVE_MOCK_SCRIPT=...`, so following the docs literally sealed
`test_reward 0.0` instead of the promised 1.0 — with exit 0, a green `check`,
and not a word on either stream. Two halves, both fixed:

1. The export is in the documented path, matching the `next` list quickstart
   already printed, with a line saying what happens if you skip it.

2. The `mock` optimizer's no-script branch WARNS on stderr instead of
   returning 0 with only a JSON `note` nobody surfaces. Fixed once, in
   `_mock_apply.py`, so `toy_calc` benefits identically. Three layers of
   `capture_output=True` (run-optimizer, harness, cli) were swallowing the
   optimizer's stderr on success; each now relays it. stdout is untouched —
   still exactly one report object (#217).

Review findings:
1. `safe_url` checked our OWN preset defaults first, so both non-mock presets'
   shipped endpoints print verbatim instead of masking the value quickstart
   itself chose. Everything else still delegates to #190 / falls back to
   `<custom>`.
2. A `doctor` failure deliberately does not change the exit code: the
   scaffold succeeded, health is advisory. Made explicit in code and docs.
3. `--model`/`--base-url` with `mock` are refused, not silently dropped — a
   dead flag is the same defect family as a silent no-op optimizer.
4. `val_tasks` asks `make_splits` instead of re-deriving `round(n * 0.25)`.
5. `--force` over a plain file gives an actionable message, not `[Errno 20]`.
6. An existing `.capevolve/mock_script.json` is inside the `--force` guard;
   it used to be replaced unannounced, outside the project dir the guard
   covered.

Also: the `free` adapter resolves its credential at RUN time from
provider-scoped names fixed at scaffold time, so exporting the key after
scaffolding works without a `--force` re-scaffold. `local`'s `needs` mentions
`ollama pull`. And a coupling note at the `strip_url_userinfo` call: it
resolves only because #190 imports it into `model_config`'s namespace, so the
lookup is a `getattr` and the degradation is a documented branch.

6 new tests (202 passed, was 196).
Copilot AI review requested due to automatic review settings July 30, 2026 23:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

New commit ba8b4c7, not an amend. Everything below is literal terminal output.


B1 (blocking) — the documented path, copy-pasted verbatim

I put docs/GETTING_STARTED.md:51-58 into a file with set -x and nothing added, then ran it:

+ mkdir /tmp/dp-home/my-run
+ cd /tmp/dp-home/my-run
+ cap-evolve quickstart --yes
quickstart: preset mock scaffolded in /private/tmp/dp-home/my-run
  16 tasks (4 val) · optimizer mock · fully offline, $0, no credential — a deterministic stand-in agent
next:
  export CAPEVOLVE_MOCK_SCRIPT=/private/tmp/dp-home/my-run/.capevolve/mock_script.json
  cd /private/tmp/dp-home/my-run
  cap-evolve check .capevolve/project
  cap-evolve run
{
  "preset": "mock",
  ...
  "val_tasks": 4,
  "tasks": 16,
  "ok": true
}
+ export CAPEVOLVE_MOCK_SCRIPT=/tmp/dp-home/my-run/.capevolve/mock_script.json
+ cap-evolve check .capevolve/project
{
  "ok": true,
  "stubs": [],
  "problems": [],
  "notes": [
    "tasks('val') -> 16 task(s)",
    "scorer deterministic (probe reward=0.0000)",
    "materialize() callable (dry-run into temp copy; host untouched)"
  ]
}
+ cap-evolve run
{
  "run_dir": ".capevolve/run_20260731_023502",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {"1": 1.0, "2": 0.0},
  "iterations": 3
}

test_reward 1.0, from the docs alone. Same result on the #214 merge (baseline_val 0.0 → test_reward 1.0).

The export now sits in the documented block, matching the next list quickstart already printed, with a line saying what happens if you skip it.

B1, second half — the silent branch is no longer silent

You were right that this belongs in one place. _mock_apply.py:67 now warns, and I found the reason a warning there was not enough on its own: three layers of capture_output=True were swallowing the optimizer's stderr on success — run-optimizer/scripts/run.py:298, harness.py:450, cli.py:436. Each now relays it. stdout is untouched.

Without the export, from a clean dir:

$ cap-evolve quickstart --yes && cap-evolve run --run-ts nos --dashboard off
--- STDERR ---
mock optimizer: NO EDIT SCRIPT FOUND — proposing no edits, so this run cannot improve on its baseline.
  looked at: $CAPEVOLVE_MOCK_SCRIPT=<unset>, /private/tmp/dp-nos2/.capevolve/run_nos/work/cand_0001/mock_script.json, /private/tmp/dp-nos2/.capevolve/run_nos/work/mock_script.json
  fix: export CAPEVOLVE_MOCK_SCRIPT=/path/to/mock_script.json
mock optimizer: NO EDIT SCRIPT FOUND — ... (cand_0002)
--- STDOUT ---
ONE OBJECT OK, test_reward= 0.0

The wrong number still comes out, but it is no longer wearing success's clothes — and stdout is still exactly one object.

toy_calc benefits identically. Same run.sh with only its export line commented out:

$ bash examples/toy_calc/run-nos.sh
mock optimizer: NO EDIT SCRIPT FOUND — proposing no edits, so this run cannot improve on its baseline.
  looked at: $CAPEVOLVE_MOCK_SCRIPT=<unset>, .../run_demo/work/cand_0001/mock_script.json, .../run_demo/work/mock_script.json
  fix: export CAPEVOLVE_MOCK_SCRIPT=/path/to/mock_script.json
{"best_id": "seed", "baseline_val": 0.0, "test_reward": 0.0, ...}

(that probe script is not committed)


The 6 non-blocking findings

N1 — safe_url masked our own preset defaults. Fixed: _PUBLIC_DEFAULTS is checked first, before any delegation, so the values quickstart itself shipped print verbatim. Everything else still goes to #190's rule, falling back to <custom>. The fix is ordering, so it survives #190 landing either side.

local  http://127.0.0.1:11434/v1
free   https://generativelanguage.googleapis.com/v1beta/openai
'https://internal.acme/v1'      '<custom>'
'http://127.0.0.1:8080/v1'      '<custom>'      # a different loopback port is still custom

N2 — a doctor failure does not change the exit code. Right, and now deliberate. quickstart's contract is "the project was scaffolded", and it was: the scaffold is on disk and check-green. Health is advisory about the environment, reported in health and printed to stderr, and check/run are the gates that must actually refuse. Exiting non-zero would make a usable scaffold look like a failed command. Stated in a comment at the call site and in GETTING_STARTED.md rather than left implicit.

N3 — --model/--base-url were silently dead with mock. Now refused. Agreed it is the same family as the blocking finding.

$ cap-evolve quickstart --yes --model gpt-4o
{"ok": false, "error": "--model has no effect with preset 'mock' (offline stand-in, no endpoint and no model). Use --preset local or --preset free, or drop the flag."}
exit=1

Refuse rather than warn: with mock the flag can never mean anything, so a warning would just be a message nobody has a reason to act on. They still work on local/free (asserted).

N4 — val_tasks duplicated the split math. Now _val_tasks() calls make_splits and counts. reported: 4 | make_splits: 4, pinned by a test.

N5 — --force over a file gave a raw errno. Now:

{"ok": false, "error": "/tmp/filecase/.capevolve/project exists but is a file, not a directory — --force overwrites a project, not a file; remove it first"}

N6 (the wrinkle) — the junk mock_script.json overwrite. Brought inside the --force guard, which never saw it because it only covered the project dir:

$ cap-evolve quickstart --yes --dir /tmp/junk          # junk script present
{"ok": false, "error": "/tmp/junk/.capevolve/mock_script.json already exists — pass --force to overwrite"}
exit=1
still mine: 1
$ cap-evolve quickstart --yes --force --dir /tmp/junk
exit=0 | hand_edited remaining: 0

Also from your presets section: the free credential-baking wrinkle is fixed in code rather than documented. The adapter now resolves the credential at run time from provider-scoped names fixed at scaffold time (_CRED_NAMES, single-sourced with the model_config-absent fallback so cross-provider reuse stays impossible). Verified — scaffold with no key, then export:

resolved after-the-fact export: EXPORTED_AFTER_SCAFFOLD_123
no key -> None

No --force re-scaffold needed. local's needs now says ollama pull qwen2.5:3b too.


Coupling notes recorded

1. mc.strip_url_userinfo depends on #190's import list, not its API. The function is defined in dashboard; it resolves as mc.strip_url_userinfo only because #190 does from .dashboard import ... at model_config.py:51. If #190 ever switches to from . import dashboard + dashboard.strip_url_userinfo(...), the attribute disappears. The call is now a getattr with the note inline, so the degradation to the manual fallback is a documented branch rather than a silent one.

2. free baking _CRED_ENV at scaffold time — fixed in code (above), so there is nothing left to document as a caveat. The names are still baked (deliberately: that is what keeps a preset provider-scoped); only the lookup moved to run time.


Corrected merge order

#214#197#195#193#190#215#245#248 last.

Your correction stands and I re-verified the part that motivates it: #214 must go first. If #193 lands before it, #248-vs-#193 is 3 conflicts including the usage line, where #193 reintroduces the literal {version|splits|check|doctor|run|estimate|dashboard} — exactly the string #214 exists to delete. With #214 first, #193 and #248 are conflict-free with each other.

#248-vs-#214 is still one conflict, one hunk, take #214's side wholesale — confirmed again on this commit, in a throwaway worktree:

CONFLICT (content): Merge conflict in core/cap_evolve/cli.py     # the only one

The three sys.stderr.write(proc.stderr) relays merge cleanly; the conflicting hunk is still just the interim usage block #214 replaces.


Verification re-run

$ PYTHONPATH=.../core pytest core/tests -q
202 passed in 68.15s              # 196 + 6 new, 0 failed

$ git merge origin/feat/issue-137-cli-ergonomics   # one conflict, take #214's side
$ PYTHONPATH=.../core pytest core/tests -q
213 passed in 74.13s              # 207 + 6 new, 0 failed

$ python -m compileall -q core/cap_evolve skills
compileall clean

# stdout is exactly one JSON object — every mode, via json.loads
default          exit=0  ONE OBJECT
yes              exit=0  ONE OBJECT
preset-free      exit=0  ONE OBJECT
preset-local     exit=0  ONE OBJECT
no-doctor        exit=0  ONE OBJECT
dead-model       exit=1  ONE OBJECT      # N3, new
dead-baseurl     exit=1  ONE OBJECT      # N3, new
force            exit=0  ONE OBJECT
existing         exit=1  ONE OBJECT
piped            exit=0  ONE OBJECT  preset=mock
readonly         exit=1  ONE OBJECT
unknown-preset   exit=2  stdout_bytes=0  # argparse, unchanged

# canaries — 5 shapes under secret-looking AND innocent names + URL userinfo,
# all three presets, both streams, every written file, whole values + 16- and 12-char prefixes
NO LEAK
hostname in streams: NONE (renders as <custom>)
hostname in adapters (expected, by design): ['free:adapter.py', 'local:adapter.py']

# the four _optional() fallbacks, all still absent on this branch
doctor             -> ABSENT   _health() -> None, `health` key OMITTED (not a faked ok:true)
model_config       -> ABSENT   free -> credential_env=GEMINI_API_KEY present=True, value in dict: False
eventstream        -> ABSENT   interactive() under non-TTY -> False
dashboard.safe_url -> ABSENT   preset URLs verbatim, everything else <custom>
userinfo manual fallback       -> 'u:p@' stripped before anything is written

# quickstart → check → run from a clean dir
baseline_val 0.0 -> test_reward 1.0     (branch, and on the #214 merge)

test_dashboard_launch.py did not flake in any run here (#200).

Files touched

file why
docs/GETTING_STARTED.md B1: the missing export, plus N2/N3 and ollama pull
skills/optimizers/run-optimizer/scripts/_mock_apply.py B1: the no-script branch warns
skills/optimizers/run-optimizer/scripts/run.py B1: relay the agent's stderr on success
core/cap_evolve/harness.py B1: relay the optimizer's stderr on success
core/cap_evolve/cli.py B1: relay the algorithm's stderr on success
core/cap_evolve/quickstart.py N1–N6, free run-time credential, coupling note
core/tests/test_quickstart.py 6 new tests; the canary sweep now also covers mock's refusal path

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add cap-evolve quickstart: interactive setup with free/local provider presets

3 participants