Skip to content

CLI ergonomics: per-subcommand --help, did-you-mean, arg validation, UTF-8 hardening - #214

Open
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-137-cli-ergonomics
Open

CLI ergonomics: per-subcommand --help, did-you-mean, arg validation, UTF-8 hardening#214
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-137-cli-ergonomics

Conversation

@OsherElhadad

@OsherElhadad OsherElhadad commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #107

The framework enforced an authoring budget on user skills and violated it in its own bundled skills, and nothing ran that lint in CI. This wires the existing validator over skills/ in CI and brings the violators into compliance.

What the lint is

skills/_registry/lint_skills.py points the same shipped validator (skills/capabilities/skill-package/scripts/abstract.py) at skills/*/*/SKILL.md and exits nonzero on a violation. New CI job lint-skills runs it plus build_manifest.py.

Two deliberate deltas from the in-run validate():

  1. Structural warnings become ERRORS — body over MAX_BODY_LINES=500 / MAX_BODY_TOKENS=5000, references/ nested more than one level, a long reference with no early TOC, a broken reference link. An optimizer mid-run should not be hard-blocked by a style budget; the repo's own skills have no such excuse.
  2. Empty / still-templated references fail — per CONTRIBUTING's "references one level deep with a TOC if long, and only when filled": a stub (<5 lines) or a TODO/TBD/FIXME/<placeholder> reference.

Discovery is dynamic — a glob("*/*/SKILL.md"), never a committed list, so a new skill is linted the day it lands. MIN_SKILLS = 20 is the anti-vacuity floor: a renamed or deleted skill dir makes the glob return fewer packages and the lint fails loudly instead of silently shrinking its own coverage (the failure mode #189's manifest-reading counts guard and #192's 5-host denylist both had).

What the lint does NOT catch

Honesty about its reach:

  • Description style stays advisory (printed as advise, never fails): first-person POV, all-caps CRITICAL/ALWAYS/MUST/NEVER, "should say WHEN to use", and the 1,536-char listing-truncation risk. These are judgement calls, not measurable violations. 10 skills currently carry the "say WHEN" advisory — deliberately not "fixed", because rewording 10 descriptions to satisfy a regex is exactly the cosmetic churn this repo's own skills warn against.
  • No semantic review. It cannot tell whether a reference is good, whether a claim is grounded, or whether the split preserved meaning. That is what the content-loss check below and human review are for.
  • No check.py behavior. test_manifest.py covers existence; each skill's own check.py covers behavior. This lint is authoring-shape only.
  • Line/token counts are the validator's own arithmetic (body.count("\n")+1, len(body)//4), not a real tokenizer. ~4 chars/token is the documented approximation in abstract.py.
  • references/ is linted for shape, not size — a 400-line reference with a TOC passes by design (progressive disclosure means the body is the recurring cost).

Before → after

Lint over ALL skills — BEFORE
skill authoring lint — 20 skill package(s) under /private/tmp/wt-107/skills
  ERROR   capabilities/tools: SKILL.md body is 673 lines (>500); split detail into references/ (progressive disclosure)
  ERROR   capabilities/tools: SKILL.md body is ~10934 tokens (>5000); it is a recurring per-session cost — move detail into references/
  ERROR   orchestrate/using-cap-evolve: description must not contain XML tags
  advise  algorithms/evograph: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  algorithms/hill-climb: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  optimizers/run-optimizer: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  orchestrate/using-cap-evolve: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/baseline: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/diagnose: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/finalize: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/gate: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/implement-and-check: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/intake: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/report: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
FAIL — 3 authoring violation(s) in 2 skill(s)
rc=1
Lint over ALL skills — AFTER (clean)
skill authoring lint — 20 skill package(s) under /private/tmp/wt-107/skills
  advise  algorithms/evograph: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  algorithms/hill-climb: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  optimizers/run-optimizer: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/baseline: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/diagnose: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/finalize: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/gate: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/implement-and-check: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/intake: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
  advise  phases/report: description should say WHEN to use the skill ('Use when …') — it is the primary triggering signal
OK — 20 skill packages pass the authoring bar (10 advisory note(s))
rc=0

Line counts, every SKILL.md (file lines / body lines)

SKILL.md before after body before body after body tokens before → after
capabilities/tools 681 208 673 200 ~10934 → ~3193
phases/intake 258 255 250 247 ~4378 → ~4310 (from #185)
capabilities/mcp-tool 235 235 227 227 ~2922
capabilities/system-prompt 198 198 190 190 ~2993
phases/diagnose 179 179 171 171 ~2975
algorithms/evograph 149 149 134 134 ~2248
algorithms/agent-optimize 141 141 134 134 ~1826
capabilities/skill-package 122 122 114 114 ~1739
algorithms/gepa 113 113 105 105 ~1543
optimizers/run-optimizer 107 107 100 100 ~1195
phases/report 106 106 98 98 ~1392
phases/implement-and-check 98 98 90 90 ~1312
algorithms/skillopt 87 87 80 80 ~1125
orchestrate/orchestrate 85 85 77 77 ~1468
phases/gate 83 83 75 75 ~1002
phases/baseline 79 79 71 71 ~993
phases/evaluate 70 70 62 62 ~832
phases/finalize 63 63 55 55 ~739
orchestrate/using-cap-evolve 60 60 51 51 ~636
algorithms/hill-climb 56 56 49 49 ~680

mcp-tool (227), system-prompt (190), diagnose (171) and intake (247) are all comfortably under the 500-line / 5000-token bar as measured by the validator, so the issue's "as needed" list needs no split. tools was the only body over budget; using-cap-evolve was the only hard problem.

The split

tools/SKILL.md 681 → 208 lines. Detail moved one level deep into two new references, each with a TOC, and SKILL.md links every section it moved:

  • references/edit-playbook.md (474 lines) — the eight levers in depth, the in-body-guard before/after diffs, "generalize never hardcode", the deterministic-CODE argument, the three code-bearing patterns, the full symptom→fix table and full trigger list (both preserved verbatim from the old body), the safe tool-replacement protocol, worked composite bodies, the concrete before/afters, and good-vs-bad edits.
  • references/documentation.md (127 lines) — how a tool-using model actually reads a tool definition (select vs. fill), the reader-capability-tier adjustment, response/error design, and the per-tool documentation checklist.

SKILL.md keeps the decision rule: the core rule, a 12-row failure-signature → edit table, the action/policy table, the lean condensed doc+return rules, and the failure-modes list — each pointing at the reference that holds the depth.

Also fixed a real bug in the shipped validator. The broken-reference-link check did not strip a Markdown #anchor before the existence test, so any deep link into a reference read as broken. My first split hit it (references/edit-playbook.md#the-safe-tool-replacement-protocol reported as "does not exist"), so it is fixed at root in abstract.py rather than worked around.

using-cap-evolve's description carried literal "<X>" / "<phase>", which the validator correctly reads as XML tags — a hard problem, not a warning, and a real discoverability hazard since the description is injected into a listing. Reworded to plain text; no content change.

Coordination / expected merge order

This branch is based on origin/main and merges in origin/refactor/issue-106-deleak-intake (PR #185), whose tools/SKILL.md References hunk and references/optimizer-playbook.md I build on directly — my References section links it as a sixth reference.

Expected merge order: #185#181#198 → this PR.

The lint may flag a body that #181/#198 grow — but intake is at 247 body lines against a 500 bar, so there is ~250 lines of headroom.

Verification

Every claim below is pasted output; full transcript in the 🔬 Evidence comment.

Lint fails CI on a deliberate violation, passes after revert

===== 1) DELIBERATE OVERSIZED BODY =====
  ERROR   phases/gate: SKILL.md body is 575 lines (>500); split detail into references/ (progressive disclosure)
  ERROR   phases/gate: SKILL.md body is ~8725 tokens (>5000); it is a recurring per-session cost — move detail into references/
FAIL — 2 authoring violation(s) in 1 skill(s)
rc=1

===== 2) DELIBERATE EMPTY PLACEHOLDER REFERENCE =====
  ERROR   phases/gate: references/notes.md is an empty/stub reference (<5 lines) — fill it or delete it
  ERROR   phases/gate: references/notes.md still contains the template placeholder 'TODO'
FAIL — 2 authoring violation(s) in 1 skill(s)
rc=1

===== 3) REVERTED — lint passes again =====
OK — 20 skill packages pass the authoring bar (10 advisory note(s))
rc=0

The guard is NOT vacuous — removing OR renaming a skill fires the count assertion

===== remove skills/phases/gate =====
  ERROR   discovery: found 19 skill packages, expected at least 20 — a skill was renamed/removed and lint coverage silently dropped
rc=1

===== rename the dir + its SKILL.md (dir count unchanged) =====
  ERROR   discovery: found 19 skill packages, expected at least 20 — a skill was renamed/removed and lint coverage silently dropped
rc=1

Manifest builds; every skill's check.py prints "ok": true — 20/20, full output in Evidence.

wrote skills/_registry/manifest.json (20 skill(s))
build_manifest rc=0

Moved content still reaches the optimizer — real zero-API cap-evolve run on examples/toy_calc with the mock optimizer and capabilities: [system-prompt, tools]:

{"run_dir": ".capevolve/run_matproof", "best_id": "cand_0001",
 "baseline_val": 0.0, "test_reward": 1.0, "test_delta": 1.0, "iterations": 3}

$ find . -path "*guidance/tools*"
./.capevolve/run_matproof/work/cand_0001/guidance/tools/SKILL.md
./.capevolve/run_matproof/work/cand_0001/guidance/tools/references/documentation.md   <-- NEW
./.capevolve/run_matproof/work/cand_0001/guidance/tools/references/edit-playbook.md   <-- NEW
./.capevolve/run_matproof/work/cand_0001/guidance/tools/references/optimizer-playbook.md
./.capevolve/run_matproof/work/cand_0001/guidance/tools/references/{concepts,examples,pitfalls}.md

$ diff -r skills/capabilities/tools/references $G/references ; echo rc=$?
rc=0
$ diff skills/capabilities/tools/SKILL.md $G/SKILL.md ; echo rc=$?
rc=0

6ad9307…  skills/capabilities/tools/references/edit-playbook.md
6ad9307…  …/guidance/tools/references/edit-playbook.md
a0f9287…  skills/capabilities/tools/references/documentation.md
a0f9287…  …/guidance/tools/references/documentation.md

harness.py's copytree(..., ignore=ignore_patterns("__pycache__","scripts","*.pyc")) has no references pattern, so the property #185's reviewer verified holds for the newly-split files too — byte-identical, diff rc=0.

No substantive claim lost in the split. A 12-gram content-word coverage check of the old 673-line body against new SKILL.md + all references leaves 15 spans ≥30 words; every one is a heading-join artifact or prose I condensed while concepts.md §5 / pitfalls.md already carry the depth. Spot-checked each compressed claim survives by name (safety boundary, blast radius, visible refusals rather than silent no-ops, Composite-tool sprawl, rarely-but-critically needed, fight reasoning models, Over-describing into contradiction, Schema changes that break callers, in sync) — all present. The first pass did drop the mis-selection / bad-argument-filling / fumbled-chain trigger bullets; the check caught it and they are back as table rows plus the verbatim full trigger list in the playbook.

Tests

$ PYTHONPATH=core python -m pytest core/tests -q
185 passed in 58.83s

179 baseline + 6 new (core/tests/test_skill_authoring_lint.py), 0 failed. The environmentally-flaky test_dashboard_launch.py::test_maybe_launch_spawns_when_available (#200) passed here.

$ python -m compileall -q core skills && echo OK
compileall OK

Files touched

File Change
skills/_registry/lint_skills.py new — the dogfooding lint
core/tests/test_skill_authoring_lint.py new — 6 tests guarding the lint itself
skills/capabilities/tools/references/edit-playbook.md new — 474 lines of moved depth + TOC
skills/capabilities/tools/references/documentation.md new — 127 lines of moved depth + TOC
.github/workflows/ci.yml new lint-skills job (lint + manifest rebuild)
skills/capabilities/tools/SKILL.md 681 → 208 lines; links what moved
skills/capabilities/skill-package/scripts/abstract.py strip #anchor before the broken-link existence check
skills/orchestrate/using-cap-evolve/SKILL.md description: remove literal <X>/<phase> XML tags

Plus the merged-in #185 commits (tools/SKILL.md References hunk, references/optimizer-playbook.md, intake/SKILL.md) and the regenerated skills/_registry/manifest.json.
........................................................................ [ 38%]
........................................................................ [ 77%]
.......................................... [100%]
186 passed in 65.54s (0:01:05)


Baseline on `main` is 179; this branch is **187 passed, 0 failed** (+8 new tests). `test_dashboard_launch.py::test_maybe_launch_spawns_when_available` (flaky per #200) passed here.

### Documented-CLI checker

core/tests/test_documented_cli.py::test_every_subcommand_renders_help PASSED [ 14%]
core/tests/test_documented_cli.py::test_top_level_help_lists_exactly_the_real_commands PASSED [ 28%]
core/tests/test_documented_cli.py::test_documented_cap_evolve_subcommands_resolve PASSED [ 42%]
core/tests/test_documented_cli.py::test_unknown_subcommand_suggests_and_exits_nonzero PASSED [ 57%]
core/tests/test_documented_cli.py::test_run_rejects_negative_budget PASSED [ 71%]
core/tests/test_documented_cli.py::test_cli_survives_an_ascii_stdout PASSED [ 85%]
core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object PASSED [100%]
============================== 7 passed in 4.46s ===============================


### compileall

core skills: clean (exit 0)

Copilot AI review requested due to automatic review settings July 29, 2026 23:37

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.

@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.

…UTF-8 hardening

Closes #137.

- Every subcommand gets a real `--help` with a description and examples, built
  through one `_parser()` helper so the shape can't drift per command.
- The top-level listing is GENERATED from `COMMANDS` + each handler's docstring,
  so adding a subcommand can no longer leave the usage string stale (the one real
  conflict across this review batch was five branches editing that literal).
- Unknown subcommand: `difflib.get_close_matches` suggestion, exit 2, nothing on
  stdout. A phase-skill name (`cap-evolve finalize`, #203) is redirected to the
  runnable `scripts/run.py` path instead of a bare "unknown command".
- `cap-evolve run` rejects negative budget caps (0 means unlimited, so a negative
  value is a typo that would make the cap never bind) — exit 2, before any spend.
- stdout stays a SINGLE JSON object: the dashboard-launch status line moved to
  stderr, where #116 already routes human progress. It used to be printed to
  stdout, so `cap-evolve run | json.loads` raised "Extra data" on every run.
- UTF-8 hardening at entry: reports carry the arrow/delta/check glyphs, and an
  ascii stream (PYTHONIOENCODING=ascii, LC_ALL=C) used to raise
  UnicodeEncodeError mid-print — killing a run whose budget was already spent.
- Extends #198's core/tests/test_documented_cli.py rather than adding a second
  checker: the `cap-evolve` front door (subcommands, --help, exit codes, the
  stdout contract) alongside #198's phase-script flag scanner.
@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

Every command below was run in a clean worktree off origin/main at 6fca097, with
PYTHONPATH=/tmp/wt-137/core and python == /tmp/ce-venv/bin/python.

1. Top-level help — generated from COMMANDS, no literal list

$ cap-evolve --help
usage: cap-evolve {version|splits|check|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.
  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.
$ echo $?
0

Bare invocation is a usage ERROR (stderr, exit 2) — no silent default run:

$ cap-evolve
usage: cap-evolve {version|splits|check|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.
  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.
$ echo $?
2

2. cap-evolve <cmd> --help for EVERY subcommand

$ cap-evolve version --help
usage: cap-evolve version [-h]

Print the installed cap-evolve version as JSON.

options:
  -h, --help  show this help message and exit

examples:
  cap-evolve version
[exit 0]
$ cap-evolve splits --help
usage: cap-evolve splits [-h] --ids IDS [--seed SEED] [--ratios RATIOS]

Compute the seeded train/val/test split for a set of task ids.

options:
  -h, --help       show this help message and exit
  --ids IDS        comma-separated task ids OR @path to a file of ids (one per
                   line)
  --seed SEED      split seed (default: 0)
  --ratios RATIOS  train,val,test (default: 0.5,0.25,0.25)

examples:
  cap-evolve splits --ids a,b,c,d
  cap-evolve splits --ids @ids.txt --seed 7 --ratios 0.6,0.2,0.2
[exit 0]
$ cap-evolve check --help
usage: cap-evolve check [-h] [project]

Verify a project's adapter is fully implemented and deterministic.

positional arguments:
  project     project dir (default: .capevolve/project)

options:
  -h, --help  show this help message and exit

examples:
  cap-evolve check
  cap-evolve check path/to/.capevolve/project
[exit 0]
$ cap-evolve run --help
usage: cap-evolve run [-h] [--spec SPEC] [--project PROJECT]
                      [--skills-dir SKILLS_DIR] [--plan-only] [--dry-run]
                      [--run-ts RUN_TS] [--resume]
                      [--reuse-baseline REUSE_BASELINE]
                      [--max-iterations MAX_ITERATIONS]
                      [--max-metric-calls MAX_METRIC_CALLS]
                      [--max-usd MAX_USD]
                      [--max-optimizer-usd MAX_OPTIMIZER_USD] [--stall STALL]
                      [--optimizer-max-turns OPTIMIZER_MAX_TURNS]
                      [--dashboard {auto,report-only,off}]
                      [--dashboard-port DASHBOARD_PORT]

Sequence the whole optimization run: baseline → algorithm → finalize → report.

Prints a single JSON object on stdout (the final report); human progress goes to
stderr, so `cap-evolve run > out.json` stays machine-readable.

options:
  -h, --help            show this help message and exit
  --spec SPEC           run spec YAML (default:
                        .capevolve/project/capevolve.yaml)
  --project PROJECT     project dir (default: .capevolve/project)
  --skills-dir SKILLS_DIR
                        skills dir (default: $CAPEVOLVE_SKILLS_DIR or auto-
                        discovered)
  --plan-only           print the command plan, don't execute
  --dry-run             print a pre-run cost estimate (call counts + $ range)
                        and exit
  --run-ts RUN_TS       run timestamp to create/reopen (default: now, or
                        latest with --resume)
  --resume              continue an interrupted run from its last completed
                        state instead of starting fresh: reopens the run dir
                        (--run-ts, else the latest under the base), skips the
                        baseline if done, and picks the loop up at iteration
                        N+1 from the current best. Explicit budget flags
                        extend it.
  --reuse-baseline REUSE_BASELINE
                        prior run dir: reuse its baseline
                        (split/baseline/seed/val-rollouts) and skip the
                        baseline eval
  --max-iterations MAX_ITERATIONS
                        override the spec's iteration cap (0 = unlimited)
  --max-metric-calls MAX_METRIC_CALLS
                        override the spec's metric-call cap (0 = unlimited)
  --max-usd MAX_USD     override the spec's cumulative runner $ cap (0 =
                        unlimited)
  --max-optimizer-usd MAX_OPTIMIZER_USD
                        override the spec's cumulative optimizer $ cap (0 =
                        unlimited)
  --stall STALL         stop after N iterations with no accepted improvement
                        (0 = off)
  --optimizer-max-turns OPTIMIZER_MAX_TURNS
                        per-iteration cap passed to the optimizer agent CLI
                        (e.g. claude --max-turns)
  --dashboard {auto,report-only,off}
                        live dashboard: auto (default, launch at run start),
                        report-only, or off
  --dashboard-port DASHBOARD_PORT
                        dashboard server port (default 7878)

examples:
  cap-evolve run --spec .capevolve/project/capevolve.yaml
  cap-evolve run --plan-only          # show the plan, spend nothing
  cap-evolve run --dry-run            # pre-run cost estimate
  cap-evolve run --resume --max-iterations 20
[exit 0]
$ cap-evolve estimate --help
usage: cap-evolve estimate [-h] [--spec SPEC] [--project PROJECT]
                           [--price-in PRICE_IN] [--price-out PRICE_OUT]

Pre-run cost estimate without spending anything.

options:
  -h, --help            show this help message and exit
  --spec SPEC           run spec YAML (default:
                        .capevolve/project/capevolve.yaml)
  --project PROJECT     project dir (default: .capevolve/project)
  --price-in PRICE_IN   optimizer/runner input $/MTok
  --price-out PRICE_OUT
                        optimizer/runner output $/MTok

examples:
  cap-evolve estimate
  cap-evolve estimate --price-in 3 --price-out 15
[exit 0]
$ cap-evolve dashboard --help
usage: cap-evolve dashboard [-h] [--base BASE] [--port PORT] [--no-open]

Launch (or focus) the live dashboard server over a base dir of runs.

options:
  -h, --help   show this help message and exit
  --base BASE  dir containing run_* dirs
  --port PORT  server port (default: 7878)
  --no-open    don't open a browser

examples:
  cap-evolve dashboard
  cap-evolve dashboard --base .capevolve --port 7879 --no-open
[exit 0]

3. did-you-mean + exit codes

$ cap-evolve chekc
cap-evolve: unknown command 'chekc'
did you mean: check?
available commands: version, splits, check, run, estimate, dashboard
[exit 2]

$ cap-evolve runn
cap-evolve: unknown command 'runn'
did you mean: run?
available commands: version, splits, check, run, estimate, dashboard
[exit 2]

$ cap-evolve estimat
cap-evolve: unknown command 'estimat'
did you mean: estimate?
available commands: version, splits, check, run, estimate, dashboard
[exit 2]

$ cap-evolve dashbaord
cap-evolve: unknown command 'dashbaord'
did you mean: dashboard?
available commands: version, splits, check, run, estimate, dashboard
[exit 2]

$ cap-evolve doctr
cap-evolve: unknown command 'doctr'
available commands: version, splits, check, run, estimate, dashboard
[exit 2]

$ cap-evolve xyzzy
cap-evolve: unknown command 'xyzzy'
available commands: version, splits, check, run, estimate, dashboard
[exit 2]

Phase-skill names (#203) are redirected to the runnable script, not merely rejected:

$ cap-evolve finalize
cap-evolve: unknown command 'finalize'
'finalize' is a phase SKILL, not a cap-evolve subcommand — run it as
  python $CAPEVOLVE_SKILLS_DIR/phases/finalize/scripts/run.py --run-dir <dir>
[exit 2]

$ cap-evolve report
cap-evolve: unknown command 'report'
'report' is a phase SKILL, not a cap-evolve subcommand — run it as
  python $CAPEVOLVE_SKILLS_DIR/phases/report/scripts/run.py --run-dir <dir>
[exit 2]

$ cap-evolve baseline
cap-evolve: unknown command 'baseline'
'baseline' is a phase SKILL, not a cap-evolve subcommand — run it as
  python $CAPEVOLVE_SKILLS_DIR/phases/baseline/scripts/run.py --run-dir <dir>
[exit 2]

$ cap-evolve intake
cap-evolve: unknown command 'intake'
'intake' is a phase SKILL, not a cap-evolve subcommand — run it as
  python $CAPEVOLVE_SKILLS_DIR/phases/intake/scripts/run.py --run-dir <dir>
[exit 2]

Nothing on stdout in either case (so a JSON-parsing pipe sees empty, not garbage):

$ cap-evolve chekc 2>/dev/null | xxd | head -1        # empty
$ cap-evolve chekc 2>/dev/null | wc -c
       0

4. Argument validation — a negative cap exits 2 before any spend

$ cap-evolve run --max-iterations -5
cap-evolve run: error: --max-iterations must be >= 0 (0 = unlimited), got -5
[exit 2]
$ cap-evolve run --max-usd -5
cap-evolve run: error: --max-usd must be >= 0 (0 = unlimited), got -5.0
[exit 2]
$ cap-evolve run --max-optimizer-usd -5
cap-evolve run: error: --max-optimizer-usd must be >= 0 (0 = unlimited), got -5.0
[exit 2]
$ cap-evolve run --stall -5
cap-evolve run: error: --stall must be >= 0 (0 = unlimited), got -5
[exit 2]
$ cap-evolve run --max-metric-calls -5
cap-evolve run: error: --max-metric-calls must be >= 0 (0 = unlimited), got -5
[exit 2]
$ cap-evolve run --optimizer-max-turns -5
cap-evolve run: error: --optimizer-max-turns must be >= 0 (0 = unlimited), got -5
[exit 2]

$ cap-evolve run --max-usd 0     # 0 is legitimately "unlimited" — must NOT error
0
(0 occurrences of the error = accepted)

5. stdout stays machine-readable — real zero-API run on examples/toy_calc

Spec: the shipped templates/project/capevolve.yaml (optimizer_skill: mock, algorithm_skill: hill-climb). Zero API calls, deterministic.

$ bash examples/toy_calc/run.sh 2>/tmp/err.txt | python -c "
import json,sys
d = json.load(sys.stdin)                 # raises on ANY extra data
print(\"json.loads OK — keys:\", sorted(d))
print(json.dumps(d, indent=2))"
json.loads OK — keys: ['baseline_val', 'best_id', 'dashboard', 'dashboard_server', 'iterations', 'run_dir', 'test_baseline_reward', 'test_delta', 'test_pass_k', 'test_reward']
{
  "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",
  "dashboard_server": "skipped"
}
[exit 0]

The human/progress output that used to pollute stdout is now on stderr:

$ cat /tmp/err.txt
Working directory: /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/toy_calc.XXXXXX.Knogt8CCja
{"dashboard": "skipped", "reason": "capevolve-dashboard not installed (pip install -e dashboard/backend)"}

For contrast, the same run on origin/main — stdout holds TWO concatenated JSON
objects, so json.loads fails (this is the regression this PR fixes, reproduced in a
second worktree checked out at origin/main):

$ cd /tmp/wt-main-137          # worktree at origin/main
$ python -m cap_evolve.cli run --spec .../capevolve.yaml --project .../project --run-ts demo 2>/dev/null
{"dashboard": "http://127.0.0.1:7878"}
{
  "run_dir": ".capevolve/run_demo",
...

$ python -c "import json,sys; json.load(sys.stdin)" < that_stdout
Traceback (most recent call last):
  ...
    raise JSONDecodeError("Extra data", s, end)
json.decoder.JSONDecodeError: Extra data: line 2 column 1 (char 39)

Note the first object is the dashboard launch line. On main this bug fires whenever
the dashboard is launched or skipped — i.e. on every default cap-evolve run.

6. UTF-8 robustness

First, that the hazard is real — an ascii stream raises on the glyphs the reports carry:

$ PYTHONIOENCODING=ascii LC_ALL=C python -c "print('baseline → best Δ=0.25 ✓')"
    print('baseline \u2192 best \u0394=0.25 \u2713')
    ~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeEncodeError: 'ascii' codec can't encode character '\u2192' in position 9: ordinal not in range(128)
[exit 1]

And that cap-evolve survives it (the listing contains ):

$ PYTHONIOENCODING=ascii LC_ALL=C LANG=C cap-evolve --help
usage: cap-evolve {version|splits|check|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.
  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.
[exit 0]

$ PYTHONIOENCODING=ascii LC_ALL=C cap-evolve finalize      # em-dash in the message
cap-evolve: unknown command 'finalize'
'finalize' is a phase SKILL, not a cap-evolve subcommand — run it as
  python $CAPEVOLVE_SKILLS_DIR/phases/finalize/scripts/run.py --run-dir <dir>
[exit 2]

$ PYTHONIOENCODING=ascii LC_ALL=C cap-evolve version
{"cap-evolve": "0.1.0"}

$ LC_ALL=C LANG=C cap-evolve run --help | tail -6

examples:
  cap-evolve run --spec .capevolve/project/capevolve.yaml
  cap-evolve run --plan-only          # show the plan, spend nothing
  cap-evolve run --dry-run            # pre-run cost estimate
  cap-evolve run --resume --max-iterations 20
[exit 0]

Full zero-API run under an ascii locale — completes, stdout still parses:

$ PYTHONIOENCODING=ascii LC_ALL=C bash examples/toy_calc/run.sh 2>/dev/null | python -c "import json,sys; print(json.load(sys.stdin)['test_reward'])"
test_reward = 1.0
[exit 0]

7. Documented-CLI checker (extends #198's file, does not duplicate it)

$ python -m pytest core/tests/test_documented_cli.py -v
core/tests/test_documented_cli.py::test_every_subcommand_renders_help PASSED [ 14%]
core/tests/test_documented_cli.py::test_top_level_help_lists_exactly_the_real_commands PASSED [ 28%]
core/tests/test_documented_cli.py::test_documented_cap_evolve_subcommands_resolve PASSED [ 42%]
core/tests/test_documented_cli.py::test_unknown_subcommand_suggests_and_exits_nonzero PASSED [ 57%]
core/tests/test_documented_cli.py::test_run_rejects_negative_budget PASSED [ 71%]
core/tests/test_documented_cli.py::test_cli_survives_an_ascii_stdout PASSED [ 85%]
core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object PASSED [100%]
============================== 7 passed in 3.74s ===============================

0 findings. The scanner is load-bearing, not vacuous — 60+ documented
`cap-evolve <word>` invocations are checked. Proof it actually fails on a
phantom command (#203's acceptance criterion "prove it by adding one"):

$ echo "Run \`cap-evolve frobnicate\` to seal the run." >> docs/INSTALL.md
$ python -m pytest core/tests/test_documented_cli.py::test_documented_cap_evolve_subcommands_resolve -q
E       AssertionError: documented cap-evolve subcommands that do not exist:
E         docs/INSTALL.md: `cap-evolve frobnicate` is not a command
E       assert not ['docs/INSTALL.md: `cap-evolve frobnicate` is not a command']
1 failed in 0.07s
$ git checkout -- docs/INSTALL.md   # reverted

And that "documentation OF an absence" is correctly NOT flagged
(skills/algorithms/agent-optimize/SKILL.md:91 legitimately says
"There is no cap-evolve status command"):

$ grep -n "cap-evolve status" skills/algorithms/agent-optimize/SKILL.md
91:There is no `cap-evolve status` command — you read what already exists. **Every 2–3 rounds**,
# ...and the checker still passes (see above)

8. Full test suite

$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 38%]
........................................................................ [ 77%]
..........................................                               [100%]
186 passed in 64.04s (0:01:04)
count
baseline on main 179
this branch 186 passed, 0 failed
new tests added 7

test_dashboard_launch.py::test_maybe_launch_spawns_when_available (environmentally
flaky on port 7878, #200, unrelated) passed on this run.

9. compileall

$ python -m compileall -q core skills; echo $?
0

10. Merge sequencing against the five sibling cli.py branches

Each merged individually onto this branch (aborted after):

$ git merge origin/fix/issue-109-optimizer-context
  → clean
$ git merge origin/feat/issue-134-provider-creds
  → clean
$ git merge origin/feat/issue-132-model-tiering
  → clean
$ git merge origin/feat/issue-116-follow-tail
CONFLICT (content): Merge conflict in core/cap_evolve/cli.py
$ git merge origin/feat/issue-121-doctor
CONFLICT (content): Merge conflict in core/cap_evolve/cli.py
$ git merge origin/docs/issue-104-gate-modes
CONFLICT (add/add): Merge conflict in core/tests/test_documented_cli.py

Both cli.py conflicts are the same two hunks: the module-docstring subcommand list
and the literal usage string in main(). This PR deletes both, so the resolution is
"take mine" in each case and the merged result loses nothing:

The test_documented_cli.py add/add with #198 is the two complementary halves of the
same file — concatenate them, no line overlaps (mine is the cap-evolve front door,
theirs is the phase-script flag scanner).

Verified by actually performing the merge with #116 and #121 on top and resolving both
hunks as "take mine". The generated listing then picks up tail and doctor with
zero edits from me
— which is the entire point of generating it:

$ cap-evolve --help          # 137 + 116 + 121 merged, resolution = take mine
usage: cap-evolve {version|splits|check|doctor|run|estimate|dashboard|tail} [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     see `cap-evolve doctor --help`
  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.
  tail       Attach to an existing/ongoing run dir and print its event stream.

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

$ cap-evolve doctr
cap-evolve: unknown command 'doctr'
did you mean: doctor?
available commands: version, splits, check, doctor, run, estimate, dashboard, tail

$ cap-evolve tail --help | head -4
usage: cap-evolve tail [-h] [--base BASE] [--from-start]
                       [--idle-timeout IDLE_TIMEOUT] [--no-color]
                       [run_dir]


$ cap-evolve doctor --help | head -4
usage: cap-evolve doctor [-h] [--json] [cwd]

install/health diagnostic; exits non-zero on hard failure

Post-merge test suite (137 + 116 + 121, both conflicts resolved as "take mine"):

$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 53%]
........................................................................ [ 80%]
......................................................                   [100%]
270 passed in 71.89s (0:01:11)

270 passed, 0 failed with all three branches combined.

Recommended merge order

#199 (109) → #205 (132) → #190 (134) → #191 (116) → #193 (121) → #137
              #198 (104) anywhere; then concatenate the two test_documented_cli.py halves

#137 last of the cli.py five. It removes both copies of the subcommand list, so
anything merged after it needs no usage-string edit at all — whereas merging it first
means #116 and #121 each re-add a list this PR deleted.

Files touched

File Δ What
core/cap_evolve/cli.py +180/−37 _parser, _did_you_mean, _usage, _harden_utf8, _PHASE_SCRIPTS, main(), per-command parser construction, dashboard print → stderr
core/tests/test_documented_cli.py +155 (new here; shared with #198) 7 tests
core/cap_evolve/__main__.py +14/−3 _cmd_splits takes prog=, gains a description/examples/flag help (it is the same parser cap-evolve splits delegates to — one parser, two front doors, no second copy)
examples/toy_calc/run.sh +4/−2 Working directory: echo → stderr so the example is pipe-safe

Copilot AI review requested due to automatic review settings July 29, 2026 23:49
@OsherElhadad
OsherElhadad force-pushed the feat/issue-137-cli-ergonomics branch from f0f3d06 to ea7b33e Compare July 29, 2026 23:49

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 — PR #214

Verdict: CHANGES REQUESTED — two blocking items. The central claim (#217, stdout is one
JSON object) verifies end-to-end in both dashboard branches, the generated listing really
does pick up tail/doctor with zero edits, and 187 tests + compileall pass. What blocks is
(a) the flagship cap-evolve finalize redirect prints a command that does not run — a
wrong remediation at the SEAL step, exactly the failure mode #203 was filed for — and (b)
_harden_utf8 reconfigures stderr, which breaks #215's ASCII test on the merged tree
(reproduced below), contradicting this PR's own docstring at cli.py:721.

Blocking

B1 — cli.py:691-692: the phase-script redirect prints a non-runnable command for 5 of 8 phases.
The message is a fixed template --run-dir <dir>, but only diagnose, finalize, report
take --run-dir alone; finalize also requires --project, and intake /
implement-and-check / gate don't accept --run-dir at all. Literal transcript, run against
a real completed run dir:

$ python skills/phases/finalize/scripts/run.py --run-dir /tmp/realrun/.capevolve/run_z
usage: finalize [-h] --run-dir RUN_DIR --project PROJECT [--n-trials N_TRIALS]
finalize: error: the following arguments are required: --project     [exit 2]

$ python skills/phases/intake/scripts/run.py --run-dir /tmp/x
intake: error: unrecognized arguments: --run-dir /tmp/x               [exit 2]
$ python skills/phases/implement-and-check/scripts/run.py --run-dir /tmp/x
implement-and-check: error: unrecognized arguments: --run-dir /tmp/x  [exit 2]
$ python skills/phases/gate/scripts/run.py --run-dir /tmp/x
gate: error: the following arguments are required: --current, --candidate   [exit 2]
$ python skills/phases/baseline/scripts/run.py --run-dir /tmp/x
baseline: error: the following arguments are required: --project, --capability  [exit 2]

Consequence: an agent following orchestrate (which says "seal once with cap-evolve finalize") types it, gets this advice, runs it verbatim, and gets a second exit-2 — after the
whole budget is spent. That is #203's own acceptance criterion inverted: the PR replaced a
bare error with a confidently wrong one, which is worse, because the agent now believes it
has a command and will retry it. finalize is the single most-cited phase in the docs (8 of
the 68 scanned cap-evolve <word> sites), so this is the common case, not an edge.

Fix (lazy): per-phase flags in the table, since the data already exists —

_PHASE_SCRIPTS = {"intake": "", "implement-and-check": "--project <project>",
                  "baseline": "--project <project> --capability <cap>",
                  "diagnose": "--run-dir <dir>", "evaluate": "--run-dir <dir> --project <project> --candidate <c>",
                  "gate": "--current <a> --candidate <b>",
                  "finalize": "--run-dir <dir> --project <project>", "report": "--run-dir <dir>"}

and render ... /scripts/run.py {_PHASE_SCRIPTS[name]}. Cheaper alternative if you don't want
to maintain a second copy of the flags: drop the flags from the message entirely and print
... /scripts/run.py --help — always correct, always runnable, zero drift. Either beats a
fabricated invocation. A test asserting the printed command exits 0 (or at least is not
"unrecognized arguments") would have caught this;
test_unknown_subcommand_suggests_and_exits_nonzero only substring-matches the path.

B2 — cli.py:723: _harden_utf8 reconfigures stderr, breaking #215 on the merged tree.

for stream in (sys.stdout, sys.stderr):   # cli.py:723

CPython opens stderr with errors="backslashreplace", so a bad write there never raises —
#215's eventstream._encodable() (eventstream.py:279-294) pre-checks the stream encoding
to decide whether to transliterate ±+/-, precisely because catching is impossible.
_harden_utf8 runs first at main() entry (cli.py:737), so under PYTHONIOENCODING=ascii
#215 sees encoding == "utf-8", _encodable returns True, transliteration never fires, and
raw ± reaches an ASCII terminal. Reproduced on the merged tree (#137 + #215, both
docstring/main() hunks resolved take-mine), a test that passes in both branches alone:

$ PYTHONPATH=core python -m pytest core/tests/test_eventstream.py -q
FAILED core/tests/test_eventstream.py::test_run_follow_survives_ascii_io_encoding
>       assert proc.stderr.encode("ascii", "strict")
E       UnicodeEncodeError: 'ascii' codec can't encode character '\xb1' in position 101
1 failed, 62 passed in 11.78s

Your own docstring already states the correct scope — cli.py:721: "ponytail: the CLI-level
guard only; the TUI ladder is #144's job."
The code contradicts it. Per the epic-#127 ruling,
_harden_utf8 keeps stdout and yields stderr. Verified sufficient — the one-token change
for stream in (sys.stdout,) on the merged tree:

$ PYTHONPATH=core python -m pytest core/tests/test_eventstream.py core/tests/test_documented_cli.py -q
71 passed in 15.67s
$ PYTHONPATH=core python -m pytest core/tests -q        # full merged suite
250 passed in 80.40s

So the ruled resolution holds and needs nothing further. Please apply it in this PR (one
line + a comment naming #144's ownership of stderr) rather than leaving it as a merge-time
decision someone has to rediscover.

Non-blocking

N1 — cli.py:707: _usage() raises IndexError on a whitespace-only docstring, on Python
< 3.13.
if fn.__doc__ is truthy for " ", then .strip().splitlines()[0] indexes an
empty list. PEP 563-era docstring stripping hides this on 3.13+, but core/pyproject.toml:10
declares requires-python = ">=3.10":

$ python3.12 -c "
def f(): '''   '''
print((f.__doc__ or '').strip().splitlines()[0] if f.__doc__ else 'fallback')"
IndexError: list index out of range
$ python3.12 -c "def f(): '''   '''
print(repr(f.__doc__))"   →  '   '        # vs 3.13+: ''

Consequence: on 3.10–3.12 a sibling branch registering a handler with a placeholder docstring
takes down every cap-evolve invocation, including --help. Fix: one_line = next(iter((fn.__doc__ or "").strip().splitlines()), f"see \cap-evolve {name} --help`")— one line, no branch, no version dependency. (This is the exact "new subcommand registered with one COMMANDS line" scenario the comment atcli.py:705-706` is defending, so it's worth hardening.)

N2 — cli.py:158: --dashboard-port is the one numeric flag excluded from the negative
guard
, and it fails much later and much worse than the budget flags:

$ cap-evolve run --spec ... --project ... --dashboard-port -5 --plan-only
OverflowError: bind(): port must be 0-65535        # uncaught traceback, no exit code

Because maybe_launch runs at cli.py:223-225 before the --plan-only early return
(cli.py:280), even a spend-nothing invocation trips it. Fix: add dashboard_port to the
validation loop at cli.py:171-176 with a 1 <= v <= 65535 check (it isn't a "0 = unlimited"
flag, so it wants its own branch).

N3 — test_documented_cli.py:41 (_DENIES): the absence-exclusion is far broader than the
one shape it was written for.
The regex fires on any no|not|never|doesn't|non-existent
anywhere on the line, and 19 of the 68 scanned sites are silently skipped — including 4 real
cap-evolve finalize instructions:

SKIPPED skills/orchestrate/orchestrate/SKILL.md: 5. **Seal once...** call `cap-evolve finalize` ... A run with no finalize has no result.
SKIPPED skills/algorithms/evograph/SKILL.md: ... never score test until the final `cap-evolve finalize` ...
SKIPPED docs/TROUBLESHOOTING.md: `cap-evolve dashboard --base .capevolve --port 7878`. No backend? ...
total silently skipped: 19

Demonstrated abusable — a planted phantom is caught, but the same phantom on a line containing
any deny-word sails through:

$ echo 'Run `cap-evolve frobnicate` to seal the run.' >> docs/INSTALL.md
E   docs/INSTALL.md: `cap-evolve frobnicate` is not a command       # caught ✓
$ echo 'You should not forget to run `cap-evolve frobnicate` daily.' >> docs/INSTALL.md
1 passed                                                            # missed ✗

Consequence: ~28% of the surface is unchecked, and the guard's own protection is one stray
"not" away from silence. Fix: anchor the exclusion to the actual shape — require the deny word
to immediately precede the backtick (r"\b(?:no|not|never|non-existent)\s+(?:such\s+)?(?:?cap-evolve)"`) — or scope it to the same clause instead of the whole line. Not blocking: with
the tighter regex the scan is still 0 findings today, so this is future-proofing.

N4 — test_documented_cli.py:139-169: the new e2e test spawns a real dashboard server and
opens a browser.
It passes --run-ts t but not --dashboard off, so maybe_launch runs
with open_browser=True; observed as a leaked uvicorn on 7879/7880 after the test:

$ pytest core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object -q ; ps aux | grep capevolve_dashboard
python -m uvicorn capevolve_dashboard.asgi:app --host 127.0.0.1 --port 7879 ...
python -m uvicorn capevolve_dashboard.asgi:app --host 127.0.0.1 --port 7880 ...

This is #200's flake mechanism (port contention) newly imported into #137's file, and a
browser tab per CI run on a dev machine. Fix: add "--dashboard", "off" to the argv. The
stdout-contract assertion is unaffected — better, actually, because it then also covers the
{"dashboard": "off"} branch deterministically. Consider a second parametrization with
--dashboard auto if you want the launch branch covered too (I verified both by hand; see
below).

N5 — cli.py:180 and cli.py:302/316/340/460/483: error payloads still go to stdout.
Every failure path prints its {"step": ..., "error": ...} to stdout, then returns 1. Not new
in this PR and arguably fine (a JSON error object is still one parseable object on stdout, and
a caller checking the exit code is correct either way). Flagging it only because #217's
acceptance asks for the sweep: if the intent is "stdout carries the report, stderr carries
everything else," these belong on stderr. If the intent is "stdout is always exactly one JSON
object," they're already compliant. Worth one sentence in the PR body stating which contract
you mean, so #217 can be closed unambiguously.

Nits

  • cli.py:747: _did_you_mean is called for status, a name the docs explicitly disclaim
    (agent-optimize/SKILL.md:91 "There is no cap-evolve status command"). It falls through to
    the bare available-commands list, which is right; but statu suggests estimate (difflib
    0.6 cutoff on a 5-char stem), which is noise. Harmless.
  • __main__.py:57: python -m cap_evolve's usage string is still a hand-written literal
    ({version|splits|check}) while cli.py generates its own. Same drift class this PR set out
    to kill, one file over. Its COMMANDS is only 3 entries and none of the five sibling branches
    touch it, so the drift risk is genuinely low — but it undercuts the design claim slightly.
  • test_documented_cli.py:99: assert seen > 50 is a good canary; 68 today, so 25% headroom
    before a docs deletion silently trips it. Fine.

stdout contract

Both dashboard branches verified with a real zero-API cap-evolve run on examples/toy_calc
(mock optimizer, hill-climb), stdout piped through json.load — which raises on ANY extra
data.

Dashboard skipped (capevolve-dashboard not importable):

$ bash examples/toy_calc/run.sh 2>/tmp/e_skip.txt >/tmp/o_skip.txt ; echo exit=$?
exit=0
$ python -c "import json;print('OK keys:',sorted(json.load(open('/tmp/o_skip.txt'))))"
OK keys: ['baseline_val', 'best_id', 'dashboard', 'dashboard_server', 'iterations',
          'run_dir', 'test_baseline_reward', 'test_delta', 'test_pass_k', 'test_reward']
$ cat /tmp/e_skip.txt
Working directory: /var/folders/.../toy_calc.XXXXXX.AUachhb2ha
{"dashboard": "skipped", "reason": "capevolve-dashboard not installed (pip install -e dashboard/backend)"}

Dashboard launched (stub capevolve_dashboard on PYTHONPATH so is_available() is True —
maybe_launch returns a URL and the Popen fires):

$ ... python -m cap_evolve.cli run --spec ... --project ... --run-ts launched
exit=0
$ python -c "import json;d=json.load(open('/tmp/o_launch.txt'));print('OK keys:',sorted(d));print('dashboard_server=',d['dashboard_server'])"
OK keys: ['baseline_val', 'best_id', 'dashboard', 'dashboard_server', 'iterations',
          'run_dir', 'test_baseline_reward', 'test_delta', 'test_pass_k', 'test_reward']
dashboard_server= http://127.0.0.1:7878
$ cat /tmp/e_launch.txt
{"dashboard": "http://127.0.0.1:7878"}

Both parse. The fix at cli.py:229 is correct and the PR's #217 report is accurate. Also
verified --plan-only (stdout parses, launch line on stderr) and the ASCII-locale run
(PYTHONIOENCODING=ascii LC_ALL=C bash run.sh | json.loadtest_reward=1.0).

Broader print( audit. No new stdout writer was introduced: --help, usage,
did-you-mean, and the phase redirect all go to stderr, and cap-evolve chekc 2>/dev/null | wc -c
is 0 bytes.

Location stream verdict
cli.py:65,83,196,487,665 stdout, one JSON object ✅ the contract
cli.py:229 stderr (was stdout) ✅ the fix
cli.py:180,282,302,316,340,377,460,483 stdout, one JSON object, then return 1 ⚠️ see N5 — one object, but they're errors
cli.py:507 (_cmd_dashboard) stdout ✅ that command's own report
cli.py:741,747 (usage / did-you-mean) stderr, except --help→stdout ✅ conventional
__main__.py:18,43 / :57,62 stdout JSON / stderr usage
phase scripts/run.py × 8 every one is a single json.dumps on stdout ✅ CONTRIBUTING.md:30 holds

Verified per-phase against a real run dir: report, diagnose → single JSON, exit 0. Two
documented exceptions, neither introduced here and neither a violation:

  • report/scripts/run.py:41 (--terminal) prints an ANSI report instead of JSON. That's the
    flag's documented purpose ("instead of the JSON summary"), and cap-evolve run never passes
    it. Still technically an argv under which a scripts/run.py emits non-JSON on stdout —
    worth one line in CONTRIBUTING.md carving it out, so the contract statement stays true.
  • finalize/scripts/run.py on an already-sealed run raises TestSealError as an uncaught
    traceback (exit 1, nothing on stdout). Correct behaviour (the seal must not be re-burnable),
    just not a JSON error object. Pre-existing; cli.py:465-471 already guards it on --resume.

Algorithm/optimizer run.py files (hill-climb, gepa, skillopt, evograph,
agent-optimize, run-optimizer, orchestrate, using-cap-evolve, the 4 capability
scripts) — all single-json.dumps-on-stdout, including their error paths. Clean.

Generated listing

Yes — verified by actually merging, not by reading. #191 and #193 each conflict on exactly
the two hunks the PR predicts (module docstring; the literal usage string in main()), and
both resolve "take mine" with nothing lost. On the merged tree (#137 + #191 + #193):

$ cap-evolve --help
usage: cap-evolve {version|splits|check|doctor|run|estimate|dashboard|tail} [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     see `cap-evolve doctor --help`
  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.
  tail       Attach to an existing/ongoing run dir and print its event stream.

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

$ cap-evolve doctr
cap-evolve: unknown command 'doctr'
did you mean: doctor?                                                     [exit 2]

$ PYTHONPATH=core python -m pytest core/tests -q
270 passed in 78.85s

Zero manual edits to the listing — the design claim holds, and doctor's missing docstring
degrades to the see \cap-evolve doctor --help`fallback as intended. Note the merge does silently discard #191's and #218's *prose* abouttail's exit codes (0/2/3, and 4=STALLED / 5=CRASHED) that lived in the deleted module docstring; that content belongs in _cmd_tail`'s
own docstring/epilog, so whoever resolves should move it rather than drop it.

Help exit codes and degradation. --help/help → 0 on stdout; bare invocation → 2 on
stderr; --version/-V → 0; misuse (splits without --ids, run --bogus) → 2 on stderr.
cap-evolve <cmd> --help exits 0 with a correct usage: cap-evolve <cmd> prog line for all 8
commands on the merged tree. Docstring degradation: no docstring → fallback row ✅; very long
one-liner → not wrapped, runs past 80 cols (cosmetic, argparse doesn't wrap epilogs either);
multi-line → first line only ✅; whitespace-only → IndexError (N1).

The #215 UTF-8 collision

Reproduced, and the ruled resolution verified sufficient — see B2 for the failing
transcript and the passing one. Two additional points on question 8:

Reconfiguring stdout to UTF-8 is right, and it is load-bearing. Unlike stderr, stdout is
opened strict, so a bad write there does raise — verified by reverting the stdout half:

$ # _harden_utf8 changed to (sys.stderr,) only:
$ PYTHONIOENCODING=ascii LC_ALL=C python -m cap_evolve.cli --help
UnicodeEncodeError: 'ascii' codec can't encode character '→' in position 358
  File "core/cap_evolve/cli.py", line 741, in main

So keeping stdout is not merely harmless, it's what makes --help survive an ASCII locale
(the listing carries from _cmd_run's docstring). It also doesn't endanger #217: nothing
in the tree passes ensure_ascii=False (grep -rn ensure_ascii core skills → no hits), so
every stdout JSON object is pure ASCII on the wire regardless of encoding, and the
errors="backslashreplace" fallback would emit inside a JSON string — still valid
JSON. I checked that explicitly:

wire bytes: {"note": "baseline → best"}
parses

Verdict: keep stdout, drop stderr. One line.

Merge-order note

Trial-merged this branch against all eight cli.py-touching siblings:

branch result
#199 fix/issue-109-optimizer-context CLEAN
#205 feat/issue-132-model-tiering CLEAN
#190 feat/issue-134-provider-creds CLEAN
#221 feat/issue-130-plateau-detection CLEAN
#191 feat/issue-116-follow-tail CONFLICT cli.py — 2 hunks, both take-mine
#193 feat/issue-121-doctor CONFLICT cli.py — same 2 hunks
#215 feat/issue-144-tty-ladder CONFLICT cli.py — same 2 hunks + the B2 semantic conflict
#218 feat/issue-118-stall-detection CONFLICT cli.py — same 2 hunks

I agree with #137 last, and would extend the tail:
#199 → #205 → #190 → #221 → #191 → #193 → #218 → #137 → #215

Rationale: every conflict is the same two hunks that #137 deletes, so putting #137 after the
list-adders makes each of those merges a mechanical "take mine" and no later branch needs a
usage-string edit at all. #218 before #137 because it stacks on #191's tail prose (same
hunk), so resolving both against #137 once is cheaper than twice. #215 after #137
specifically so the B2 stderr decision is made with #215's _encodable in front of you — if
#137 lands with the stderr line still in, whoever merges #215 must know the epic-#127 ruling
or they'll ship the silent-mojibake regression with a green-looking test count. If B2 is fixed
in this PR (recommended), the order of the last two stops mattering.

The test_documented_cli.py add/add with #198's half is two non-overlapping halves of one
file — concatenate; verified no test-name collisions (#198's are
test_documented_run_py_invocations_parse / test_gate_check_py_passes, both absent here). No
duplication.

Verification I re-ran

$ PYTHONPATH=/tmp/rv-214/core python -m pytest core/tests -q
187 passed in 72.41s          # main = 179, so 8 new — matches the claim exactly
                             # (PR body says 7 + "186"; the file collects 8, and 179+8=187)
$ python -m compileall -q core skills ; echo $?
0
$ PYTHONPATH=core python -m pytest core/tests/test_documented_cli.py -q
8 passed in 4.23s

test_dashboard_launch.py::test_maybe_launch_spawns_when_available (#200) passed here.

Documented-CLI checker — load-bearing, confirmed: 68 invocations scanned
(check×35, run×16, finalize×8, estimate×4, report×3, version, dashboard),
0 findings; planted cap-evolve frobnicate fails it (transcript in N3); the deny-word bypass
is the caveat.

Negative budgets — all six exit 2 with nothing on stdout, and 0 still means unlimited:

max-iterations  exit=2 stdout=0 :: cap-evolve run: error: --max-iterations must be >= 0 (0 = unlimited), got -5
max-metric-calls / max-usd / max-optimizer-usd / stall / optimizer-max-turns — same shape
$ cap-evolve run ... --max-usd 0 --max-iterations 0 --stall 0 --plan-only
0-unlimited accepted; budget= {'max_iterations': 0, 'stall': 0, 'max_metric_calls': 0,
                               'max_usd': 0.0, 'max_optimizer_usd': 0.0, 'optimizer_max_turns': 0}

--dashboard-port -5 is the gap (N2).

did-you-mean — 13 typos, all exit 2 with 0 bytes on stdout: chekc→check, runn/rn→run,
estimat→estimate, dashbaord/dashbord→dashboard, versoin→version, spilts→splits,
checkk→check; xyzzy/doctr/status correctly fall back to the bare list.

__main__.py parity — python -m cap_evolve splits --ids a,b,c,d and python -m cap_evolve.cli splits --ids a,b,c,d produce byte-identical output and exit 0; only the
prog differs (cap_evolve splits vs cap-evolve splits), which is correct. One parser, two
front doors.

examples/toy_calc/run.sh — exit 0, stdout is exactly the report object (head -c 80 shows
{ first), Working directory: on stderr. Pipe-safe.

Test quality

The stdout contract is asserted by parsing, not substring-matching
(test_documented_cli.py:167: report = json.loads(out.stdout)) — correct, and it drives the
real zero-API run rather than mocking. test_top_level_help_lists_exactly_the_real_commands
calls _usage() directly and derives expectations from COMMANDS, so it can't rot.
test_every_subcommand_renders_help deliberately doesn't require an examples: epilog, with
the merge-tax reasoning spelled out — good call, and test_help_carries_examples covers the
six this PR authored.

Untested paths: the printed phase-redirect command is never executed (B1's root cause —
the test asserts the string, not that it runs); _harden_utf8's fallback branch (a
non-reconfigurable stream) is unreachable in-process, so the except at cli.py:728-731 is
untested; the dashboard launched stdout branch (only "skipped"/"off" is exercised in CI —
I covered it by hand above); --dashboard-port validation (N2); whitespace-only docstring
(N1); -V/--version and the help alias have no test despite being new in main().

… to stdout

Review fixes for #137 (PR #214).

B1 — the `cap-evolve <phase>` redirect printed a fixed `--run-dir <dir>` template
that was wrong for 5 of the 8 phases: finalize also needs --project, and
intake/implement-and-check/gate reject --run-dir entirely. A confidently wrong
remediation at the SEAL step is worse than a bare 'unknown command'. _PHASE_SCRIPTS
is now a name -> required-flags map derived from each script's real argparse, and the
message also points at `--help` and the phase's SKILL.md. New
test_phase_redirect_commands_are_runnable executes every rendered command and fails
on any structural argparse rejection.

B2 — _harden_utf8 reconfigured stderr, which defeated #215's eventstream._encodable()
pre-check (it reads stderr.encoding to decide whether to transliterate +/-), silently
shipping mojibake to an ASCII terminal on the merged tree. stderr is already opened
errors=backslashreplace by CPython and belongs to #144's TUI ladder; stdout is opened
strict and IS load-bearing (ASCII --help carries a right-arrow). Narrowed to
(sys.stdout,).

N1 — a whitespace-only docstring is truthy but strips to '', so .splitlines()[0]
raised IndexError and took down every invocation including --help on 3.10-3.12
(requires-python >= 3.10). Uses next(iter(...), fallback).

N2 — --dashboard-port had no validation and reached bind() as an uncaught
OverflowError; because the launch precedes the --plan-only return, even a
spend-nothing preview crashed. Now a 1-65535 check in the same pre-spend block.

N3 — the documented-CLI absence-exclusion was line-wide, silently skipping 19 of the
scanned sites (including four real `cap-evolve finalize` instructions) and disabling
itself for any line containing a stray 'not'. Anchored to the text immediately before
the backtick: 3 skips now, all genuine absence statements, and the bypass shape is
caught.

N4 — the e2e stdout-contract test spawned a real uvicorn and opened a browser,
importing #200's port-contention flake. Passes --dashboard off.

N5 — CONTRIBUTING.md now states the contract explicitly (stdout is exactly one JSON
object on success AND failure) and carves out the two audited exceptions:
`report --terminal` and finalize's TestSealError on an already-sealed run.

Also re-homes #116/#118's `cap-evolve tail` exit-code prose into the module docstring
as a merge note, so it is not lost when the deleted subcommand list is resolved.
Copilot AI review requested due to automatic review settings July 30, 2026 11:09

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

Both blocking items fixed, all 5 non-blocking addressed, 3 nits answered. Pushed as
f59e571. Every number below is literal output from this run.

Merge order (restated, per the review)

#199 → #205 → #190 → #221 → #191 → #193 → #218 → #137 → #215

B2 is fixed in this PR, so the order of the last two no longer carries the mojibake risk
the review flagged.


B1 — the 8 phase redirect commands are now runnable

_PHASE_SCRIPTS went from a tuple to a name → required-flags map, derived by reading each
phase's own argparse. Message now also names --help and the phase's SKILL.md:

$ cap-evolve finalize
cap-evolve: unknown command 'finalize'
'finalize' is a phase SKILL, not a cap-evolve subcommand — run it as
  python $CAPEVOLVE_SKILLS_DIR/phases/finalize/scripts/run.py --run-dir <dir> --project <project>
  (python $CAPEVOLVE_SKILLS_DIR/phases/finalize/scripts/run.py --help for all flags; phases/finalize/SKILL.md for context)
                                                                          [exit 2]

All 8 executed against a real completed run dir (examples/toy_calc + mock,
--run-ts fx, run_fx), with the placeholders substituted:

phase printed flags executed exit verdict
intake (none) run.py 0
implement-and-check (none) run.py 0 / 1¹ ✅ argparse accepts
baseline --project <project> --capability <seed_capability> --project …/project --capability …/seed_capability 0
diagnose --run-dir <dir> --run-dir …/run_fx 0
evaluate --run-dir <dir> --project <project> --candidate <id> … --candidate seed 0
gate --current <val> --candidate <val> --current 0.5 --candidate 0.9 0
finalize --run-dir <dir> --project <project> --run-dir <unsealed> --project …/project 0
report --run-dir <dir> --run-dir …/run_fx 0

¹ implement-and-check exits 1 on my hand-built fixture only because that project has no
optimizer/INSTRUCTIONS.md (pipeline_selftest finding) — argparse accepts the invocation,
which is what B1 is about. Literal transcript:

### intake
$ python skills/phases/intake/scripts/run.py
    "run: cap-evolve check .capevolve/project  (implement-and-check gates this)"  }   [exit 0]

### implement-and-check
$ python skills/phases/implement-and-check/scripts/run.py
"pipeline_selftest": {"ok": false, "problems": [
  "optimizer_instructions_file points at a missing file: optimizer/INSTRUCTIONS.md"]}    [exit 1]

### baseline
$ python skills/phases/baseline/scripts/run.py --project .../project --capability .../seed_capability
  "seconds": 0.00039768218994140625 } }                                                  [exit 0]

### diagnose
$ python skills/phases/diagnose/scripts/run.py --run-dir .../run_fx
  "kept_good": [] }                                                                      [exit 0]

### evaluate
$ python skills/phases/evaluate/scripts/run.py --run-dir .../run_fx --project .../project --candidate seed
  "seconds": 0.00048089027404785156 }                                                    [exit 0]

### gate
$ python skills/phases/gate/scripts/run.py --current 0.5 --candidate 0.9
  "reason": "Δ=+0.4000 > 0 (SE=0 → STRICT fallback, warned)", "delta": 0.4      [exit 0]

### finalize   (on the sealed run_fx — the seal correctly refuses)
$ python skills/phases/finalize/scripts/run.py --run-dir .../run_fx --project .../project
cap_evolve.splits.TestSealError: TEST split already scored once this run.                 [exit 1]

### finalize   (same command, an UNSEALED run dir)
$ python skills/phases/finalize/scripts/run.py --run-dir .../run_20260730_135024 --project .../project
  "baseline_id": "seed", "test_delta": 0.0 }                                             [exit 0]

### report
$ python skills/phases/report/scripts/run.py --run-dir .../run_fx
  "iterations": 3, "dashboard": ".../run_fx/dashboard.html" }                            [exit 0]

New test_phase_redirect_commands_are_runnable renders each message, pulls the flags back out
of it, and hands them to the real script — failing on unrecognized arguments, the following arguments are required, expected one argument, or invalid choice. So the table can't drift
from the scripts again. (The old test only substring-matched the path, which is exactly why
this shipped.)


B2 — _harden_utf8 narrowed to (sys.stdout,)

Applied, with the docstring now stating why stderr is out of scope (#144's _encodable
owns it). Verified on the merged tree (#137 + #215, both docstring/main() hunks
take-mine, #215's crash-log paragraph preserved).

Beforefor stream in (sys.stdout, sys.stderr):

$ PYTHONPATH=core python -m pytest core/tests/test_eventstream.py -q
>       assert proc.stderr.encode("ascii", "strict")           # pure ASCII on the wire
E       UnicodeEncodeError: 'ascii' codec can't encode character '\xb1' in position 101
FAILED core/tests/test_eventstream.py::test_run_follow_survives_ascii_io_encoding
1 failed, 62 passed in 13.90s

Afterfor stream in (sys.stdout,):

$ PYTHONPATH=core python -m pytest core/tests/test_eventstream.py core/tests/test_documented_cli.py -q
71 passed in 16.28s

$ PYTHONPATH=core python -m pytest core/tests -q          # full merged suite
1 failed, 249 passed in 82.44s
#   the 1 = test_dashboard_launch.py::test_maybe_launch_spawns_when_available (#200's flake,
#   see below) — it also fails on origin/main and in #215 alone, unrelated to this change

stdout hardening kept, and confirmed load-bearing — ASCII --help on the merged tree:

$ PYTHONIOENCODING=ascii LC_ALL=C LANG=C python -m cap_evolve.cli --help
usage: cap-evolve {version|splits|check|run|estimate|dashboard|tail} [args]
  run        Sequence the whole optimization run: baseline → algorithm → finalize → report.
  tail       Attach to an existing/ongoing run dir and print its event stream.        [exit 0]

and on this branch: [exit 0]. Reverting the stdout half reproduces the review's
UnicodeEncodeError on that .


Numbered response to all 10 findings

1. B1 — non-runnable remediation for 5/8 phases. Fixed as above. Took the per-phase-flags
route rather than bare --help, because the flags and a --help pointer and the SKILL.md
pointer together cost nothing more and the pinning test removes the drift objection.

2. B2 — _harden_utf8 stderr collision. Fixed: (sys.stdout,), docstring rewritten to
name #144's ownership of stderr and to record why stdout must stay.

3. N1 — whitespace-only docstring → IndexError. Fixed with
next(iter(...), fallback). Confirmed on 3.12 (a requires-python >= 3.10 version):

$ python3.12 -c "..."
Python 3.12.9
__doc__ = '   '
OLD: IndexError: list index out of range
NEW: see `cap-evolve X --help`

$ PYTHONPATH=core python3.12 -c "register a blankdoc handler; print(cli._usage())"
  blankdoc   see `cap-evolve blankdoc --help`         # degrades, no crash

(3.14, this venv, strips it at compile time and hid the bug — matching the review's read.)
New test_usage_survives_a_whitespace_only_docstring registers such a handler and asserts the
fallback row; it passes on both interpreters.

4. N2 — --dashboard-port excluded from the guard, and ordering. Added a 1 <= v <= 65535
branch in the same pre-spend validation block (not the >= 0 loop — a port is not a
"0 = unlimited" flag). It now fires before maybe_launch, so --plan-only is usable:

$ cap-evolve run ... --dashboard-port -5 --plan-only
cap-evolve run: error: --dashboard-port must be 1-65535, got -5             [exit 2, stdout 0 bytes]
$ cap-evolve run ... --dashboard-port 70000 --plan-only
cap-evolve run: error: --dashboard-port must be 1-65535, got 70000          [exit 2, stdout 0 bytes]
$ cap-evolve run ... --dashboard-port 7999 --plan-only
plan-only parses, budget= {'max_iterations': 10, 'stall': 2, 'max_metric_calls': 0, ...}   [exit 0]

New test_run_rejects_a_bad_dashboard_port covers both out-of-range directions.

5. N3 — line-wide absence-exclusion. Tightened: the deny word must immediately precede
the invocation, and the check now runs on line[:match.start()] per occurrence instead of the
whole line. Measured over the same corpus:

total sites: 87            # (68 after the old regex's 19 skips — same corpus)
old (line-wide) skipped: 19
new (anchored)  skipped: 3
  SKIP docs/AGENT_ORCHESTRATION.md: > **Note — there is no `cap-evolve finalize` subcommand.** ...
  SKIP skills/algorithms/agent-optimize/SKILL.md: There is no `cap-evolve status` command — ...
  SKIP skills/algorithms/agent-optimize/SKILL.md: (There is **no `cap-evolve finalize` subcommand** ...

19 → 3, and all 3 are genuine "there is no cap-evolve X" statements. The four real
cap-evolve finalize instructions the review found are now scanned. Planted phantom, both
shapes:

$ echo 'Run `cap-evolve frobnicate` to seal the run.' >> docs/INSTALL.md
FAILED test_documented_cap_evolve_subcommands_resolve                      # caught ✓
$ echo 'You should not forget to run `cap-evolve frobnicate` daily.' >> docs/INSTALL.md
FAILED test_documented_cap_evolve_subcommands_resolve                      # bypass CLOSED ✓

(the second one passed before — that was the review's demonstration.)

6. N4 — e2e spawned a real dashboard + browser. Fixed: --dashboard off in the argv, as
suggested. It now also covers the off branch deterministically. No leak:

$ pytest core/tests/test_documented_cli.py::test_run_stdout_is_a_single_json_object -q
1 passed in 3.97s
uvicorn procs before=6 after=6         # 6 = other worktrees' leaked servers, unchanged by this test

Declined the --dashboard auto parametrization: launching a real server is precisely #200's
flake, and #200 owns that branch.

7. N5 — stdout error payloads. Judgement: leave them on stdout, and state the contract.
The contract this repo means is "stdout is exactly one JSON object" — which the error paths
already satisfy (one object, then exit 1), and which is what json.loads(stdout) relies on in
the tests and in examples/toy_calc/run.sh. Moving them to stderr would make a failed run's
stdout empty and force every caller to branch on the exit code before parsing. Documented in
CONTRIBUTING.md so #217 can be closed unambiguously.

8. The two non-violating exceptions, now written into CONTRIBUTING.md beside the house
rule so the next reader doesn't re-open them:

  • report --terminal prints an ANSI report instead of the JSON summary — the flag's
    documented purpose, and cap-evolve run never passes it.
  • finalize on an already-sealed run raises TestSealError (exit 1, stdout empty) rather than
    a JSON error object. The seal must not be re-burnable; cli.py already guards it on
    --resume. Reproduced above in the B1 table.

9. tail exit-code prose re-homed. The #191/#218 text lived in the subcommand list this
PR deletes. It is now preserved verbatim in cli.py's module docstring as an explicit merge
note, with instructions to move it into _cmd_tail's own docstring/epilog when that branch
lands:

MERGE NOTE for #116/#118 (``cap-evolve tail``): ... must be re-homed into ``_cmd_tail``'s own
docstring/epilog when that branch lands, not dropped. Verbatim, so it is not lost:

    exit 0 = run finished OR still working — do NOT branch on 0 to mean "finished";
    2 = not a possible run dir; 3 = --idle-timeout elapsed with no events and nothing
    provably dead; 4 = STALLED, 5 = CRASHED.

(That's #218's superset wording, which subsumes #191's.) The same docstring now also states
cap-evolve's own exit codes (0/1/2), which the deleted list never did.

10. Nits. All three accepted as described, none actioned:

  • statuestimate is difflib noise at 0.6 cutoff on a 5-char stem; status itself
    correctly falls through to the bare list. Raising the cutoff would cost real suggestions
    (rnrun). Left alone.
  • __main__.py:57's hand-written usage literal: agreed it's the same drift class. Out of scope
    here — its COMMANDS is 3 entries, no sibling branch touches it, and pulling it into
    cli._usage() means reconciling two different prog values. Worth its own issue.
  • assert seen > 50: agreed, 87 today so the headroom is now larger, not smaller.

Corrected test counts

Using the reviewer's measurement (main = 179):

$ PYTHONPATH=/tmp/fx-214/core python -m pytest core/tests -q
1 failed, 189 passed in 68.76s
  • 190 collected on this branch = 179 (main) + 11 new — the review's corrected 8, plus
    the 3 added here (test_phase_redirect_commands_are_runnable,
    test_usage_survives_a_whitespace_only_docstring, test_run_rejects_a_bad_dashboard_port).
    The PR body's earlier "186/7" was wrong; 179 + 8 = 187 was right, and it is now 190.
  • The 1 failure is test_dashboard_launch.py::test_maybe_launch_spawns_when_availabletest_maybe_launch_spawns_when_available asserts a hard-coded port and fails whenever 7878 is in use #200's
    port flake, not mine.
    Ports 7878/7879/7880 are held by uvicorns leaked from other
    worktrees on this machine, and it fails identically on origin/main and in #215 alone:
$ lsof -i :7878 -i :7879 -i :7880
Python 29431 ... TCP localhost:7878 (LISTEN)
Python 30128 ... TCP localhost:7879 (LISTEN)
Python 35848 ... TCP localhost:7880 (LISTEN)
$ cd <origin/main worktree> && pytest core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available -q
1 failed in 0.03s          # pre-existing

With those ports free it passes (the reviewer saw it green). This PR's own file is clean:

$ PYTHONPATH=core python -m pytest core/tests/test_documented_cli.py -q
11 passed in 5.33s
$ python -m compileall -q core skills ; echo $?
0

Real e2e, json.loads, both dashboard branches

$ cap-evolve run --spec ... --project ... --run-ts fx --dashboard off > /tmp/o.json 2>/tmp/e.txt
exit=0
$ python -c "import json;print('OK keys:',sorted(json.load(open('/tmp/o.json'))))"
OK keys: ['baseline_val','best_id','dashboard','dashboard_server','iterations','run_dir',
          'test_baseline_reward','test_delta','test_pass_k','test_reward']

The skipped and launched branches are unchanged by this commit and were verified in the
review itself (both parse); the off branch is now the one pinned in CI, and --plan-only
still parses (transcript under N2).


Files touched

file change
core/cap_evolve/cli.py B1 _PHASE_SCRIPTS map + message; B2 (sys.stdout,) + docstring; N1 next(iter(...)); N2 port guard; module docstring: exit codes + tail merge note
core/tests/test_documented_cli.py N3 anchored _DENIES + per-occurrence check; N4 --dashboard off; 3 new tests
CONTRIBUTING.md N5 stdout contract stated; the two audited exceptions carved out

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.

Dogfood the skill-package authoring lint in CI; split oversized tools/intake SKILL.md

3 participants