Skip to content

release: wishful 0.4.0 — release readiness + post-release quality, reviewed - #10

Merged
pyros-projects merged 57 commits into
mainfrom
fix/003-v0-3-0-release-readiness
Jun 11, 2026
Merged

release: wishful 0.4.0 — release readiness + post-release quality, reviewed#10
pyros-projects merged 57 commits into
mainfrom
fix/003-v0-3-0-release-readiness

Conversation

@pyros-projects

Copy link
Copy Markdown
Owner

Summary

Releases wishful 0.4.0 — the 0.3.0 release-readiness work plus the full post-0.3.0 quality plan, shipped together. 0.3.0 stays in the changelog as an internal milestone that was never published; 0.4.0 is the first public release of both batches (the evolve() return-type change is breaking, hence the minor bump).

Two plans executed end to end:

  • Plan 001 (release readiness): real-model proof gate (smoke harness with committed proof bundle, docs/proofs/0.3.0/), gpt-5.5 empty-content fix (max_tokens 4096→16384 — reasoning models spend budget on hidden tokens), import-time LLM timeouts, cache-poisoning fixes, validator hardening with honest best-effort-blocklist framing, logging citizenship, argparse CLI with --json, WishfulError base.
  • Plan 002 (post-0.3.0 quality): dynamic-mode economics (1 generation per call, free attribute probes, pinned attribute contract), explore winner-merge that preserves siblings, bounded candidate execution (timeout + SystemExit containment), untruncated failure messages, wishful-owned persistent event loop replacing nest_asyncio (litellm 1.88.1 spike showed per-call asyncio.run() still abandons its logging coroutines), spec-003 architecture seams (core/execution.py with compile_and_exec + casefile callback slot, EvolutionResult wrapper, versioned __wishful_evolution__, settings-backed context_radius, injectable MagicLoader.generate_fn, absolute cache_dir), nested-wish rejection before any LLM spend, mypy 2.1, examples 15/16, test-debt closure.

A 10-reviewer multi-agent code review ran on the final diff; all validated findings are fixed in fix(review) (loop dead-thread/fork detection, re-entrant explore deadlock via asyncio.to_thread, commit-guard rollback on exec failure, EvolutionResult copy/pickle recursion guard, SystemExit containment in the shared exec path).

Breaking changes (see changelog for the full list)

  • evolve() returns EvolutionResult (callable wrapper; use result.fn for the bare function); evolve(verbose=) removed
  • __wishful_evolution__ gained schema_version
  • wishful.settings.cache_dir is always absolute
  • log_to_file defaults off; WISHFUL_MODEL beats DEFAULT_MODEL
  • nest-asyncio removed from dependencies

Testing

  • 373 passed, 4 skipped (gated smoke), 5 xfail (documented validator residuals); ruff clean; mypy 2.1 clean; wheel builds
  • Real-model smoke harness 4/4 against gpt-5.5 (committed proof, docs/proofs/0.3.0/) and 4/4 against gpt-4.1 post-quality-work
  • Every plan-002 feature individually smoke-tested against live gpt-4.1, including the re-entrant-explore deadlock fix (6.2s vs full-timeout burn pre-fix)
  • New regression suites: test_execution.py (validate-before-exec canary, SystemExit containment), test_evolve_result.py (wrapper contract, copy semantics), loop-death recovery, commit-guard rollback, cross-process cache atomicity (spawn-context, CI deadline)

Post-Deploy Monitoring & Validation

Library release (no deployed runtime). Validation window: first week of 0.4.0 adoption.

  • Healthy signals: pip install wishful==0.4.0 + import wishful clean; examples 00–16 run green with a real key; wishful --version prints 0.4.0.
  • Failure signals to watch in issues: hangs inside explore() (would implicate the owned-loop redesign — the watchdog should instead raise RuntimeError: …event-loop thread died); RecursionError around EvolutionResult; ImportError: nested wishful module reports from users who relied on dotted wish names; complaints about evolve() return type from 0.2.x users (expected — documented breaking change).
  • Rollback/mitigation: revert to the 0.2.x line on PyPI (pip install wishful==0.2.*); no data migrations, cache files are forward-compatible (worst case wishful clear).
  • Owner: @pyros-projects

Known Residuals (accepted advisories from the review)

  • explore records a False-returning test as status error rather than failed in CSV/display (cosmetic; full message preserved)
  • Nested-wish guard misses the already-imported-parent path and multi-line parenthesized imports (falls back to Python's stock "is not a package" error; no LLM cost either way)
  • Parallel explore() calls writing one module can lose the other's winner (pre-existing read-merge-write race; single-writer-per-module assumed until spec-003's evidence layout)
  • Settings readers are lock-free (writers serialize); configure_logging runs outside the lock
  • run_user_callable cannot cancel a running thread — timed-out candidates run to completion concurrently (documented, inherent CPython limit)
  • AST validator remains a best-effort blocklist, not a sandbox (documented residual, xfail-pinned)

🤖 Generated with Claude Code

pyros-projects and others added 30 commits June 11, 2026 03:59
Capture the 2026-06-11 whole-codebase review (74 validated findings) plus
the two-plan remediation: plan 001 (P0/P1 + quick wins, release-gated) and
plan 002 (post-0.3.0 quality). Add STRATEGY.md anchor and the real-model
smoke proof bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… nest-asyncio

- litellm >=1.83.14 (lock 1.88.1): day-0 gpt-5.5 support + completion->responses
  bridge; excludes the quarantined 1.82.7/1.82.8 builds
- remove pyglove (declared but imported nowhere)
- add nest-asyncio (was imported at runtime in explore but undeclared)
- uv_build cap lifted to <1.0.0; rich >=14, ruff >=0.15; locks refreshed
- mypy stays 1.x; 2.x adoption deferred to the companion plan
- add Settings.request_timeout (WISHFUL_REQUEST_TIMEOUT, default 300s) passed
  to litellm.completion/acompletion so an import can no longer hang on litellm's
  multi-minute default
- empty-content responses retry once, then raise GenerationError naming the
  model with a reasoning-model max_tokens hint (gpt-5.5 returns empty when the
  budget is spent on hidden reasoning tokens)
- _FAKE_MODE becomes _is_fake_mode(), read per call so tests toggle without
  module reimport; drop pragma:no-cover on the now-tested error paths
The split-based stripper left 'python' as the first source line of a standard
```python response, which parsed as a bare name and crashed at exec (then got
cached). Replace with a regex that extracts only fenced content, joins multiple
blocks, drops the info string, and passes fence-free responses through.
- _generate_and_cache validates generated source BEFORE writing it, so a
  failed generation never survives as a cache entry (was: write-then-exec,
  which permanently poisoned the cache on any post-write failure)
- malformed generations retry once then raise GenerationError; policy
  violations (SecurityError) raise immediately without caching
- validator re-raises SyntaxError as SyntaxError (not ImportError), reviving
  the loader's regenerate-once path under default (safety-on) settings
- cache writes are atomic (temp file + os.replace); empty/torn cache files
  read as a miss; cache-hit source is still re-validated on load
- add safety-ON integration tests (the composition bug the conftest masked)
module_path() previously stripped both the static and dynamic prefixes, so
wishful.static.foo and wishful.dynamic.foo mapped to the same .wishful/foo.py.
A regenerate(), review-rejection, or explore-winner write against one namespace
then deleted or overwrote the other's cache. Static now stays at <cache>/<name>.py
(documented, user-editable); dynamic moves under <cache>/_dynamic/. All call
sites route through module_path(), so the fix is namespace-wide.

Pre-0.3.0 dynamic caches sat at the static path; they regenerate under _dynamic/
on next import (changelog notes the relocation). No destructive migration: the
README documents an edit-and-commit cache workflow, so quarantining existing
files would be hostile; the only residue is an orphaned same-named static file.
…ache

- underscore-prefixed names (dunders, _private, IPython _repr_html_ probes)
  raise AttributeError immediately in both the static __getattr__ and the
  dynamic proxy — a hasattr()/getattr() probe no longer costs a paid LLM call
- a regeneration triggered by a public attribute miss is committed only when
  the new source actually defines the requested symbol (checked via AST);
  otherwise the existing module and cache are left untouched
- split _generate_validated (validate, no write) from _generate_and_cache so
  the getattr path can validate then conditionally commit
Close the demonstrated bypasses: __import__/compile as calls, importlib/builtins/
ctypes imports, __builtins__ subscript and getattr gadgets, globals()/vars()
subscripts, non-literal open() modes, and attribute calls on unbound
os/subprocess/sys/importlib/ctypes. Local bindings are tracked so a shadowed
'os = platform.system()' is not a false positive.

Aliased/computed access still slips through — AST scanning cannot catch it. That
residual is now an xfail corpus, and the README/AGENTS safety sections say plainly
this is defense-in-depth, not a sandbox: generated code runs in-process. Validator
coverage 97% (negative + positive + binding-form corpus).
…adless

- review now runs inside _exec_source between validate and exec, so approval
  guards real execution on the initial import, the syntax-retry, and all four
  regeneration paths (it used to run AFTER exec — the code had already run)
- review=True with non-interactive stdin raises an actionable ImportError
  before any LLM call; EOF at the prompt is treated as rejection
- notebooks (ipykernel/IPython) count as promptable so review stays usable for
  the core audience; only CI/pytest/piped stdin fail closed
- compile before validate so malformed generations retry uniformly regardless
  of the safety flag
Remove the global allow_unsafe=True from conftest so every pipeline test exercises
the validator that ships — the masked composition bugs (dead retry, cache
poisoning) lived exactly where the suite had safety off. Add an opt-in
unsafe_settings fixture for the rare test that needs the bypass, a posture guard
against regressing the default, and an end-to-end clean-generation import under
validation. Update the syntax-retry logging assertion to the safety-on path.
- track wishful's own loguru sink ids and remove only those on reconfigure;
  never bare logger.remove(), which deleted sinks the host app installed
- log_to_file now defaults off (WISHFUL_LOG_TO_FILE=1 to opt in) so a bare
  'import wishful' creates no files or directories in the user's CWD
- wrap log dir/file creation: a read-only or full filesystem degrades to
  console-only logging with one warning instead of crashing mid-import
- compare dataclass fields against dataclasses.MISSING; the old self-comparison
  (field.default_factory is not field.default_factory, always False) made the
  factory branch dead and emitted <_MISSING_TYPE> repr into the LLM prompt
- resolve generics via get_origin/get_args before the bare __name__ shortcut,
  which on 3.10+ dropped type args (list[str] -> list, Optional[int] -> Optional)
- use inspect.isclass / a captured _NONE_TYPE since this module shadows builtin
  type with its decorator; resolve string annotations via get_type_hints
- rewrite the F811 duplicate-class test into a real last-registration-wins check
An adversarial verification pass over the U4-U9 changes confirmed five P0
bypasses and two correctness regressions:

- locals() subscript reached __builtins__ (only globals()/vars() were blocked) —
  add locals() to the blocked-subscript set, killing the locals()['__builtins__']
  .eval/.exec/.compile/__import__ gadget chains
- aliased and default-arg dangerous builtins (f = open; f(p,'w'); def g(x=open))
  bypassed the call-site checks — block bare references to open/getattr/eval/exec/
  compile/__import__/globals/vars/locals/__builtins__ that are not a call target.
  This also closes the previously-documented aliased-getattr residual.
- _exec_source caught only SyntaxError, so a SecurityError on a rejected static
  cache left the poisoned file in place; now delete it and re-raise
- explore() winner is re-validated under current settings before caching
- _source_defines now recognizes tuple/list/starred unpacking targets, so a
  regeneration that binds the requested symbol via unpacking is no longer
  wrongly rejected (a U6 regression)

Only two genuinely-computed residuals remain (globals().get('__builtins__'),
runtime-built attribute names), documented as xfail. Validator coverage 97%.
… version

- __version__ derives from importlib.metadata (was hardcoded '0.1.0' vs 0.2.4)
- [project.scripts] wishful = wishful.__main__:main so the documented 'wishful'
  command works, not just 'python -m wishful'
- argparse CLI: --json on inspect/clear/regen, --version, exit codes (0 ok,
  1 error, 2 usage); regen validates the module name against an allowlist so a
  crafted name can't map to an arbitrary file (the path-traversal guard ships
  with the CLI even though the broad guard is deferred)
- .env.template documents all ~13 environment variables
…iformly

- swap model precedence so the wishful-specific WISHFUL_MODEL wins over the
  generic DEFAULT_MODEL (which stays as the fallback); warn once when both are
  set and disagree, since the swap otherwise changes the active model silently
- move every env-derived Settings field to default_factory so a fresh
  Settings() (as reset_defaults does) re-reads the current environment
  uniformly — previously only cache_dir re-read, the rest froze at import
…riginal fitness

- timeout_per_variant now bounds the mutation call and the user-supplied
  test/fitness callables via a per-variant ThreadPoolExecutor; a timeout or any
  BaseException (incl. SystemExit) is recorded as a failed variant and the loop
  continues instead of hanging or killing the host
- remove the verbose parameter, which was accepted but never used (passing it
  now raises TypeError); re-add when there is real progress output to gate
- scoring the original function is wrapped so a fitness() that raises on the
  original is recorded as failed instead of crashing the whole run
…shful.cache

- add WishfulError base; GenerationError/SecurityError extend (WishfulError,
  ImportError) so except ImportError still works, ExplorationError/EvolutionError
  extend WishfulError. Callers can catch the whole family with one except.
- drop 'type' from __all__ so 'from wishful import *' no longer shadows the
  builtin; wishful.type still works as an attribute
- rename the cache alias (manager as _cache) so importing wishful no longer
  overwrites the wishful.cache subpackage attribute; import wishful.cache.manager
  works again
- annotate reimport() -> ModuleType; verify subpackage __all__ names resolve

Full mypy clean is deferred to the companion plan with mypy 2.x.
- delete explore/strategies.py (selection logic duplicated inline in explorer.py,
  0% coverage, no importers)
- delete ExploreProgressPrinter (~100 lines, never instantiated)
- ruff --fix sweep + hand-fixes: reorder loader/__init__ imports to the top,
  drop an unused test var, replace == True/False comparisons in example 13;
  ignore E402 in examples/ where mid-file wishful imports are the demonstration
…gate

- tests/smoke/: pytest -m smoke, skipped unless WISHFUL_SMOKE=1 and provider
  credentials are present. Four cases (static import, dynamic call, explore,
  evolve) exercise the real-model paths and record a proof entry; the bundle is
  written to docs/proofs/<version>/summary.json at session end
- proof content is metadata only (model, timing, pass/fail, attempts, short
  result summary) — never prompts or generated source, so it can't leak
- always-on harness tests verify the gate and the proof schema without API calls
- CI runs the suite under a coverage gate (fail_under=80) plus ruff; register
  the smoke marker and omit generated cache from coverage
- RELEASE_CHECKLIST.md and docs/proofs/README.md document the flake policy,
  secret scan, and proof convention
- badges: 154->263 tests, 78%->86% coverage
- README/AGENTS: project tree adds evolve/, exceptions.py, logging.py; drop the
  deleted strategies.py; full 15-example list; public API + Settings fields
  updated (WishfulError, request_timeout, log_to_file default off, console
  script + --json CLI, model precedence); uv run python everywhere
- docs-site: 0.3.0 changelog entry, Explore in the sidebar, log_to_file/
  request_timeout/precedence corrections
- gitignore + git rm the stale committed coverage.json
U1a falsification finding: the litellm bump was necessary but not sufficient.
gpt-5.5 is a reasoning model that spends part of its token budget on hidden
reasoning, so the old max_tokens=4096 left too little for output — producing
empty content (example 09) or truncated/invalid syntax (example 00). Raising
the default to 16384 fixes both classes (example 09 now passes end to end).

Also harden two examples for the headless smoke sweep:
- example 10: rich prompts fall back to defaults without a TTY and the section
  loop is bounded, so it runs headless instead of raising EOFError
- example 00: the count_headers hint steers the model to open()+str methods and
  away from os, which the safety validator (correctly) blocks
Bump to 0.3.0 with the kept real-model proof. The smoke harness passes 4/4
against gpt-5.5 (docs/proofs/0.3.0/summary.json): static import, dynamic call,
explore, and evolve all generate validated output end to end.

U1a falsification (docs/proofs/0.3.0/examples-sweep.md): the litellm 1.88 bump
alone did not fix the gpt-5.5 empty-content failures — raising max_tokens to
16384 (reasoning models spend budget on hidden tokens) is the real fix. The
previously-broken core examples (00, 09) now pass; the heavy dynamic-creative
examples (08, 10) are slow pending the plan-002 dynamic-economics fix, and the
LLM-as-judge example (13) is excluded per plan 001 U1a.

Closes plan 001 (P0/P1 remediation + quick wins).
Follow-up to the 0.3.0 branch review: the hardened validator still let the
classic Python sandbox escape through —
  ().__class__.__bases__[0].__subclasses__()[N].__init__.__globals__['__builtins__']['eval']
and nested ns['__builtins__']['eval'] subscripts. These reach eval/exec/RCE and
were undocumented, contradicting the 'blocks dangerous patterns' claim.

- block escape-dunder attribute access (__subclasses__/__bases__/__base__/__mro__/
  __globals__/__code__/__closure__/__builtins__/__subclasshook__/__getattribute__)
- block subscripts with a forbidden string key (__builtins__/eval/exec/...) regardless
  of the base object
- common introspection (obj.__class__.__name__) still passes; only the escape
  primitives are blocked
- README narrows the residual claim to genuinely computed access; validator
  coverage 98%
Follow-up to the 0.3.0 branch review:
- evolve()'s per-variant timeout used a ThreadPoolExecutor whose worker threads
  are non-daemon, so a timed-out (uncancellable) callable would block interpreter
  exit and accumulate across runs. Use a daemon thread instead: a runaway
  candidate is abandoned cleanly without leaking into shutdown.
- the CLI regen allowlist accepted 'wishful' and 'wishful.text', letting
  'wishful regen wishful' target framework packages. A wishful.* name must now be
  a fully-qualified static/dynamic module; bare names map to the static namespace.
…ction

The cache entry described the old colliding behavior; it now documents the
namespace isolation fix. Add an explicit Breaking changes section (max_tokens
default raise, evolve verbose removal, log_to_file default, model precedence)
and note the validator's introspection-gadget blocking.
…arning

Document the 0.3.0 finding for future LLM-integration work: reasoning models
(gpt-5.x) need a generous max_tokens because hidden reasoning tokens consume the
budget (empty content = fully spent, truncated = nearly spent), and pre-register
load-bearing dependency bets so a dedicated step can falsify them early rather
than at the release gate. Surface docs/solutions/ in AGENTS.md.
Second re-verification found RCE through getattr with a non-literal attribute
name: getattr(x, a) where a='__class__', getattr(o, 'sy'+'stem'), and
getattr(globals().get('__builtins__'), 'open') all bypassed the escape-attr and
constant checks (which only saw .attr syntax / literal strings).

- getattr/setattr/delattr/hasattr now require a literal attribute name; a
  variable/concatenated/computed name is blocked outright
- the literal name is checked against escape dunders + dangerous builtins + open
- reaching builtins as the target (getattr(__builtins__/globals()/vars()/locals(),
  ...)) is blocked
- the documented residual is narrowed honestly to library-level reflection
  (operator.attrgetter, value-level mapping indirection) that AST cannot model
…klist docs

Final security gate found two more vectors:
- type.__dict__['__subclasses__'] reached the escape dunders via subscript (I
  blocked the attribute form but not the subscript form) — add the escape dunders
  to the forbidden subscript keys so the two forms mirror
- pathlib.Path.write_text + runpy.run_path achieved RCE — block the runpy/pickle/
  marshal/shutil/code/codeop/socket/multiprocessing import family and the
  write_text/write_bytes/run_path/exec_module method names (regardless of base);
  reads (Path.read_text, json.loads) stay allowed

These two are fixed, but they expose the fundamental truth: a blocklist over
Python is incomplete by nature. The docstring and README now say so plainly —
the validator is a seatbelt, and the real boundary for untrusted code is the
review gate plus an environment where code execution is acceptable (or an
out-of-process sandbox). Further stdlib/reflection vectors are that documented,
fundamental residual.
…ntity

Plan 002 U1. DynamicProxyModule.__getattribute__ eagerly called
_regenerate_for_proxy on every attribute access, then _call_with_runtime
generated again with the real args — every dynamic call paid for TWO LLM
generations, and hasattr/dir probes paid for one. This is the economics issue
behind the slow examples 08/10 in the 0.3.0 release.

- __getattribute__ is now lazy: a public name returns a callable that
  regenerates exactly once, with runtime args, when invoked. Attribute access
  and hasattr/dir cost zero generations.
- _exec_source preserves __name__/__spec__/__loader__/__package__/__file__/
  __builtins__ across the clear_first re-exec, so a dynamic module keeps its
  identity after regeneration.
- delete the now-dead _regenerate_for_proxy.
- tests assert the new per-call count, zero-cost probes, and identity survival.
pyros-projects and others added 27 commits June 11, 2026 06:43
Plan 002 U3 (depth):
- cache.module_path/dynamic_snapshot_path now validate every name component is a
  plain identifier and resolve the final path inside the cache dir (symlink-safe),
  so a crafted name passed to the public regenerate()/reimport() API can't escape
  the cache directory. The CLI already pre-validated; this closes the API path.
- prompt/context bodies (which can carry the caller's source and secrets) are
  redacted from logs unless WISHFUL_LOG_PROMPTS=1, even at DEBUG. Metadata
  (module, model, lengths, function names) still logs.

Remaining U3 sub-item (untrusted-context prompt delimiting) tracked as follow-up.
Plan 002 U2 (partial). explore() wrote winner.__wishful_source__ over the whole
module file, so exploring one function wiped every other symbol already cached
there. Now the winner is merged: the explored function is replaced and all
sibling defs/classes are preserved (re-validated before write).

Also make the defaults headless-aware:
- verbose defaults to stdout.isatty() (quiet in CI/scripts)
- save_results defaults to WISHFUL_EXPLORE_SAVE_RESULTS (on unless '0')

Remaining U2 (asyncio runtime refactor off nest_asyncio, bounded candidate
executor, full VariantResult.error_message) tracked as follow-up.
The ce-code-review loop gate confirmed 3 P1 + several P2 issues introduced by
this branch's own work. Fixes:

P1 — validator over-correction (security): blocking ALL non-literal getattr
names broke the extremely common getattr(obj, field) / {f: getattr(o,f)} idiom
by default. Revert to blocking only forbidden *literal* names + builtins targets;
computed/variable getattr is now a documented residual (consistent with the
'best-effort blocklist, not a sandbox' stance). Real gadgets stay blocked.

P1 — explore() merge broke on 'from __future__': appending a winner whose source
starts with __future__ after a sibling put it mid-file (compiles to SyntaxError,
silently invalidating the cache). _merge_into_module now hoists/dedupes __future__
to the top and compile()-checks the result, falling back to the winner alone.

P1 — duplicate 'import pytest' in test_cache.py failed the new CI ruff gate on
its own diff. Removed.

P2 — explore() raised SecurityError and discarded a valid winner when a
pre-existing dangerous sibling was in the module; now falls back to caching the
winner alone and still returns it (contract honored). Tested.

P2 — subscript-key blocklist over-blocked benign data access (config['system'],
row['eval']); keep only the dunder escape keys, plain words dropped. The real
builtins gadgets are still caught by the base/dunder checks.

P2 — README max_tokens default corrected 4096 -> 16384.

304 tests pass, ruff clean.
After bypass-hunting closed real RCE gadgets in the validator, the tightened
blocklist over-blocked common legitimate code (getattr(obj, field),
config['system']). The bypass-hunting passes never caught it — only a paired
false-positive/usability review did. Document the lesson: tightening a blocklist
against an exploit needs a before/after benign-corpus diff, and ambiguous forms
belong in the documented residual.
The static review-gating paths were tested but the dynamic per-call
regeneration path (_call_with_runtime -> _exec_source -> _maybe_review) was not.
Add a test asserting a runtime dynamic call routes through the review prompt and
a rejection raises before the freshly generated source executes.
Plan 002 U6 (type debt). The bulk (24) were the deliberate 'type' shadow in
types/registry.py — the public @wishful.type decorator shadows the builtin, so
every 'x: type' annotation resolved to the function. Capture _Class = type before
the shadow for plain annotations and use typing.Type[T] for parameterized ones;
runtime is unchanged (annotations are deferred).

The rest:
- discovery._matches_import_from: narrow Optional[str] with an explicit guard so
  mypy sees the non-None path (also clearer than relying on bool() short-circuit)
- progress: annotate the mixed-renderable list as list[Any]
- justified ignores for two genuinely-dynamic attributes (builtins._wishful_settings
  stash, fn.__wishful_source__ marker)
- [tool.mypy] override so the stub-less nest_asyncio import is quiet

mypy src/wishful: Success, no issues. ruff clean, 305 tests pass.
A relative cache_dir (the default '.wishful') silently relocated if the process
chdir'd after configuring. Pin it to an absolute path when set via configure().
…platform.system()

Add posix/nt/_posixsubprocess to the import deny-list — `from posix import system`
reached os.system with safety ON. Drop the blanket .system/.popen/.spawn method
block (and the system/popen getattr-literal entries) that rejected the legitimate
platform.system(); the dangerous os forms stay caught by the import ban plus the
unbound-base check. Remove the now-dead _FORBIDDEN_ATTR_STRINGS set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
configure_logging() left loguru's bootstrap sink 0 in place, so every record
printed twice (its stderr default plus wishful's Rich console sink) and wishful's
DEBUG internals leaked to the host stderr even at WARNING. Remove sink 0 once on
first configure; host-added sinks (ids >= 1) are never touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…untime error

Thread from_cache through _exec_source: rejecting the review prompt for a cache-hit
(or hand-edited) module no longer deletes the user's file, and a freshly generated
module that raises at exec is removed from the static cache so the import can
recover instead of breaking permanently. Drop the pre-delete in _regenerate_with —
a transient generation failure no longer wipes the working cache (atomic write
overwrites on success).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hermetic tests

The mutation call inherited the global 300s request timeout, so a timed-out
variant's abandoned daemon thread kept its HTTP call alive far past
timeout_per_variant. Thread an optional per-call timeout through
generate_module_code -> mutate_with_llm and cap it at the per-variant budget.
Stub the LLM in the three TestEvolveContract tests so they stop making real paid
OpenAI calls (conftest never forced fake mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The winner is appended after the kept siblings, so a winner helper sharing a
sibling's top-level name silently rebinds it. Detect the collision and log a
warning instead of wiping the sibling silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…S.md

Capture the symmetric deny-list lesson from the validator review (under-block via
the os/subprocess C-level aliases vs over-block via blanket name bans) as a
best-practice doc, cross-linked to the false-positive-review sibling. Seed
CONCEPTS.md with the safety-area core nouns: Validator, Review gate, Residual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…claim

Correct max_tokens 4096 -> 16384 (AGENTS, configuration.mdx, .env.template), test
counts 261/263 -> 326 (README badge + tree, AGENTS), the stale 'tests run
allow_unsafe=True' line (U9 flipped the suite to safety-on), and cli.mdx's
'static and dynamic share the same cache file' claim. Document
WISHFUL_CONTEXT_RADIUS and WISHFUL_EXPLORE_SAVE_RESULTS; add a CONCEPTS.md pointer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dynamic proxy semantics are now documented and pinned by tests: attribute
access is lazy and free (hasattr always True, wrappers for every public
name), generation happens only at call time, and dynamic modules expose
functions only.

_call_with_runtime gets the same commit-guard _dynamic_getattr already had:
a generation that fails to define the called symbol no longer replaces the
module namespace or the snapshot — the caller gets AttributeError and the
previous state survives.

Also corrects the stale cache-layout line in how-it-works (dynamic snapshots
live on a disjoint path since the namespace split).

Smoke-tested against openai/gpt-4.1 (dynamic call + contract assertions).
…xception

explore's user callables ran inline and unbounded — a while-True test hung
the run and sys.exit() in a candidate killed the host. They now run through
run_user_callable (daemon worker + per-variant timeout + BaseException
containment), extracted to core/execution.py as the shared seam evolve
already used; evolver imports it from there.
VariantResult.error_message kept only the first 80 chars (60 for compile
errors), so ExplorationError.failures lost the diagnosis. Full text is now
stored; the Rich table already truncates for display.
…syncio

Spike result (2026-06-11, litellm 1.88.1, real gpt-4.1): per-call
asyncio.run() still abandons litellm's Logging.async_success_handler
coroutines — the loop dies before fire-and-forget logging runs. So the
plan's conservative design applies: ONE persistent loop on a
wishful-owned daemon thread. _run_async submits via
run_coroutine_threadsafe and blocks, which also makes explore() safe
inside a running loop (Jupyter) without nest_asyncio patching the host.

The module-level coroutine-warning filter narrows from all coroutines to
litellm's Logging.* only, so a user's own forgotten await still warns.

nest-asyncio removed from dependencies and the mypy overrides.

Smoke-tested against openai/gpt-4.1: two sequential explores plus one
inside asyncio.run().
…d radius

- compile_and_exec in core/execution.py is the one compile path for
  explorer and evolver, with a post-exec callback slot for deferred
  casefile writes (never inside an import lock).
- evolve() returns EvolutionResult: callable, proxies the winner's
  __name__/__doc__/__wishful_source__/__wishful_evolution__ via
  __getattr__, sets __wrapped__ for inspect.signature, carries
  history/best_score. No accept() ships — spec-003 Open Decision 1 is
  unresolved and a no-op would be a trap.
- __wishful_evolution__ is a versioned TypedDict (schema_version=1).
- context_radius moves into Settings (configure/reset/env aware);
  set_context_radius is a thin wrapper.
- explore's generation calls carry registered @wishful.type schemas and
  output bindings (verified end-to-end: gpt-4.1 produced the registered
  dataclass).
- configure()/reset_defaults() mutate settings under a lock; 8-thread
  consistency test.
- cache_dir resolves absolute at Settings construction, so os.chdir()
  can't move the cache even for import-only users.
- MagicLoader.generate_fn is the injection seam for generation; the
  monkeypatch-tolerant resolver stays as documented fallback.
- explore's CSV writer marked legacy pending spec-003 Open Decision 4.
- conftest clears the type registry around every test.

Smoke-tested against openai/gpt-4.1 (evolve wrapper + typed explore).
…#62 test debt

- import wishful.static.a.b now fails at discovery time with an error that
  names the problem and a flattening hint — before the parent burns an LLM
  call (previously: parent generated, then an opaque 'X is not a package').
- mypy floor bumped to 2.1 (clean, no new findings).
- #62 bundle: finder install() idempotence, _ensure_symbols fresh-generation
  failure path, cache helper lifecycle (has_cached/delete/snapshot paths),
  evolve argument validation, Windows path normalization in _is_user_frame,
  registry TypedDict-vs-dataclass discrimination and pydantic-v1 fallback.

(The coroutine-warning filter was already narrowed to litellm's Logging.*
in the owned-loop commit — GC-time warnings can't be scoped tighter.)

Smoke-tested against openai/gpt-4.1: nested rejection in 0ms, zero cache
artifacts.
…e race)

- #44: __wishful_source__ is attached unconditionally to explore-returned
  functions (empty string over AttributeError) — the documented attribute
  is never absent.
- #48 decided: evolve keeps the sync LLM path (worker threads + per-variant
  timeout bound the sync call; the async path belongs to explore's owned
  loop). Decision documented at the call site and pinned by a test that
  bombs if evolve ever reaches the async client.
- The cross-process cache-race gap gets its test: two spawn-context writer
  processes hammer one cache path while the parent reads — os.replace
  atomicity means a reader sees a complete payload or a miss, never a torn
  file.
- 12_explore: return_all=True section (pick your own winner).
- 14_evolve: keep_history/history_limit on the main call, EvolutionResult
  evidence (best_score, history), and an EvolutionError try/except demo.
- 15_cli_and_config (new): CLI walkthrough incl. --json and exit codes,
  configure(model/temperature/max_tokens), reset_defaults(). LLM-free.
- 16_safety_and_review (new): SecurityError on a planted poisoned cache
  file, allow_unsafe walkthrough, review=True TTY-gated demo with honest
  best-effort-blocklist framing. LLM-free.
- 11_logging rewritten to earn its name (it was a mislabeled copy of the
  JSON example): debug, log_level, log_to_file, log_prompts demonstrated
  distinctly.
- 09_context_shenanigans: docstring + section commentary.
- README example count/tree updated (17 examples).

Verified: 11/14/15/16 green under WISHFUL_FAKE_LLM=1; 09/12 green against
real openai/gpt-4.1.
Every member starts with an underscore, which the first clause of
__getattribute__ already catches — the set membership check was
unreachable.
Validated P1s (independent validators + repros):
- The owned explore loop now tracks its runner thread and owner pid:
  a dead thread or a fork is detected and a fresh loop starts instead
  of run_coroutine_threadsafe enqueueing work that never runs;
  _run_async waits with a watchdog poll so a mid-run thread death
  raises instead of hanging forever.
- explore's test/benchmark callables are awaited via asyncio.to_thread:
  run_user_callable's worker.join no longer blocks the owned loop, so a
  candidate that itself calls explore() completes instead of
  deadlocking until timeout (verified vs gpt-4.1: 6.2s, was full-burn),
  and concurrent explores no longer starve.

Verified P2s:
- _commit_regeneration (shared by all three regen paths) rolls back
  the module namespace AND the previous cache/snapshot file when a
  symbol-defining generation raises at exec — the commit-guard
  contract now holds end to end (repro: module was gutted to
  _wishful_loader).
- EvolutionResult.__getattr__ reads fn via __dict__ — copy/deepcopy/
  unpickle no longer recurse infinitely; exported as
  wishful.EvolutionResult (parity with EvolutionError).
- compile_and_exec contains module-level SystemExit (no imports
  needed, so the validator can't block it) as ValueError.
- Settings.copy()/reset_defaults() are dataclasses.fields-driven;
  writer-lock docstrings no longer overclaim reader atomicity.
- module_path/dynamic_snapshot_path read settings.cache_dir once, so a
  concurrent configure(cache_dir=...) can't trip a spurious escape
  error; cross-process race test gained a CI deadline.
- Example 16 evicts the allow_unsafe demo module from sys.modules and
  pins its preconditions against ambient env.

Docs/standards sync: AGENTS.md (deps, context_radius, public API,
test/example counts, new test files), README count, CONCEPTS.md
nested-wish entry, changelog 0.3.1-unreleased section covering every
user-visible change since the 0.3.0 release, version bumped to 0.3.1
per AGENTS.md's per-commit patch-bump rule.

Tests: test_evolve.py split (1132 -> 971 lines) into
test_evolve_result.py; new test_execution.py unit-tests the seam incl.
validate-before-exec canary; loop-death recovery, re-entrant explore,
commit-guard rollback, and copy-semantics regressions pinned.
373 passed; ruff + mypy clean; loop fixes smoke-tested vs gpt-4.1.
evolve()'s return-type change (EvolutionResult) is breaking, so the
post-0.3.0 work ships as a minor bump per pre-1.0 semver. 0.3.0 stays
in the changelog as an internal milestone with a note that it was
never published; 0.4.0 is the first public release of both batches.
… dep

CI (every PR + main): ruff, mypy, bandit (B102/B110 acknowledged in
pyproject — exec'ing generated code is the product), pytest with the
80% coverage gate on py3.12+3.13, the offline-safe examples sweep,
sdist/wheel build with a clean-venv import check, and the docs build.
Minimal permissions, concurrency cancellation, cached uv.

release.yml replaces publish.yml: publishing now happens on a v* tag
after a full gate re-run and a tag==pyproject version check — the old
workflow published on every main push touching pyproject.toml, which
with the bump-every-commit convention meant every merged PR would have
shipped to PyPI.

smoke.yml (manual): runs the credentialed real-model harness and
uploads the metadata-only proof bundle.

audit.yml: weekly + manifest-change pip-audit of the locked tree.

docs-site: drop @astrojs/check — it pulled volar-service-emmet's
git+ssh dependency (github:ramya-rao-a/css-parser), which broke npm ci
locally (npm's git-dep preparation flag conflict) and could never
install on CI runners. astro build doesn't use it; lockfile now has
zero git deps. All CI commands verified locally (suite 87% coverage,
build, docs: 11 pages).
First catch of the new audit gate: pip-audit flagged known
vulnerabilities in transitive deps (aiohttp 3.13.2 et al., all with
released fixes). uv lock --upgrade within existing manifest
constraints; litellm stays at 1.88.1, so the owned-loop design
verification is unaffected. Suite, ruff, mypy re-verified green;
pip-audit clean locally.
@pyros-projects
pyros-projects merged commit e5ca6dc into main Jun 11, 2026
7 checks passed
@pyros-projects
pyros-projects deleted the fix/003-v0-3-0-release-readiness branch June 11, 2026 10:43
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.

1 participant