Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,49 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Added
- **`proofpath update`** installs the latest release from PyPI, and
**`proofpath update --check`** only says whether there is one. In the TUI they are
`/update` and `/update --check`. Running the command is the consent, so nothing
asks. uv tool, pipx and pip installs are each updated with their own tool; pip
falls back to `uv pip` when it fails and uv is on PATH. An editable install is
never touched: the command names its source directory and says to update it with
`git pull` and `uv sync`.
- **The installed extras are kept.** They are written into the install spec
(`proofpath[browser]`), never with a version pin. A plain `uv tool upgrade` was
measured to drop packages added to the tool's environment after it was installed,
which would have removed the browser wheels you had consented to.
- **The new version is read back from a fresh process.** An update the resolver held
back below the latest version is a failure that names the version it installed.
On Windows a failed command adds "close proofpath and run the command above",
since a running `proofpath.exe` can be locked.
- **Temporary environments are left alone.** In a `uvx` or `pipx run` environment
nothing runs. The command says that `uvx proofpath@latest` or
`pipx run --no-cache proofpath` already fetches the newest.
- **Commands paste back on every platform.** Each command is logged as `$ <cmd>`; the
one printed for running by hand has no `$`. On Windows both use `cmd.exe` quoting,
and so does the browser install's log.
The version check after the update runs the interpreter isolated (`-I`), so a
checkout in the working directory cannot answer for the install.
- **In the TUI, an update and a run never overlap.** A bare `/update` is refused while
a run or a mirrored verb is still going, because the install replaces the code they
are executing; `/update --check` is not refused. While an update is installing,
every `/check` and mirrored verb is refused until it finishes, including a second
`/update`.
- Exit codes: `0` for up to date, updated, ahead of PyPI, editable, temporary, or
(with `--check`) an update available. `1` means the check or the update failed.
`permissions.network` is read by the fetch ladder's own rule: `deny`, and `ask`
without a terminal, send nothing to PyPI and fail rather than report "up to date".
`ask` in a terminal goes ahead with a note.

### Changed
- **At 80x24 the `/` list now caps.** `/update` makes eleven verbs and 80x24 has
room for ten rows. The last two, `/quit` and `/update`, sit behind `... 2 more`.
`update` is last on purpose: the banner's hint line already names `/quit`, and
`/help` stays visible. One more row, at 80x25, shows them all.
- `packaging` is a declared dependency. It was already installed through other
packages; `update` now imports it directly.

## [0.4.10] - 2026-10-01

An opt-in, larger NLI model for scientific sources, chosen by a measured bake-off and
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ Both themes open with the wordmark: `rich` in block glyphs through five crimson
`plain` in ASCII. Under it, or beside it on a wide terminal, the version and the hint; a
rule closes the banner.

**Updating.** `proofpath update` installs the latest release from PyPI, and
`proofpath update --check` only says whether there is one (`/update` and
`/update --check` in the TUI). It updates uv tool, pipx and pip installs with their
own tool and keeps the extras you have installed, such as `browser`, by naming them
in the install spec. An editable checkout is never touched: update it with `git pull`
and `uv sync`. A temporary `uvx` or `pipx run` environment is not updated either:
`uvx proofpath@latest` or `pipx run --no-cache proofpath` already fetches the newest.
Known limits: `uv tool install --upgrade` does not keep the options stored in uv's
tool receipt, such as a pinned `--python`, and it drops packages added with `--with`.
`pipx install --force` drops packages added with `pipx inject`, the `pip_args` the
install was made with, and a custom `--python`. Reinstall those by hand afterwards.

Bare `proofpath` opens the terminal UI ([a recorded session in both themes](docs/eval/2026-09-15-tui-v2-live.md),
with SVG screenshots of a real run on v0.4.1, before the wordmark, in [`rich`](docs/eval/tui-raven-rich.svg) and
[`plain`](docs/eval/tui-raven-plain.svg)). Each run is one panel in its own
Expand All @@ -49,7 +61,7 @@ coverage; the docked footer draws it as a bar and never scrolls away.

Paste a path, a URL or a claim and it runs — a post of several lines pastes whole: the
bar holds it behind a one-line summary, `Enter` checks it, `Esc` drops it. Every
one-shot verb is a slash command (`/check`, `/resolve`, `/fetch`, `/config`, `/cache`),
one-shot verb is a slash command (`/check`, `/resolve`, `/fetch`, `/config`, `/cache`, `/update`),
runs can be started while others are in flight and stopped with `/cancel #n` — a
stopped run keeps what it had decided. Type `/` and every command is listed above the
bar with what it wants; `Tab` completes, `↑`/`↓` pick. `/config` opens the settings as
Expand Down
16 changes: 16 additions & 0 deletions docs/superpowers/OPEN-ITEMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,3 +610,19 @@ five packages. The user's decisions:
| 20.10 | Eval honesty | add a `--judge` flag to `scripts/eval_averitec.py`; do one run with the browser allowed; show the 0.404 search score in the README "Measured" table |
| 20.11 | NLI bake-off result | **Winner: `large-fever` (MoritzLaurer DeBERTa-v3-large mnli-fever-anli-ling-wanli) × k=2 × `max`.** SciFact dev macro-F1 0.697, against 0.580 for the `base` × k=1 × `max` baseline and 0.608 for the best `base` row. Accuracy 0.697. AVeriTeC 3-way 0.438, or 0.475 on the readable subset, against a 0.708 majority baseline. Cost: 643 MB and 54 ms/pair, against 244 MB and 19 ms/pair. Full report: `docs/eval/2026-10-01-nli-bakeoff.md`. Next: (a) and (b) are settled by the accurate-NLI design (`docs/superpowers/specs/2026-10-01-accurate-nli-design.md`), which adds the winner as an opt-in "accurate" profile behind a consent download (spec §7.1); (c) stays open. (a) **settled** — rule-decided rows are left out of the tier walk (the numeric layer fired 4–6 times on train with 0 correct and pinned `high` at 1.000000); the accurate cuts are `high` 0.999142. (b) **settled** — the `medium` cut still sits on `decide`, and the report now says so itself (`NO_LOW_TIER`, default runs included). (c) **still open** — on AVeriTeC the winner is below `base` × k=2 (0.475 against 0.537), and `Supported` is still 2/19; the README recommends the profile for scientific sources, not for news |
| 20.12 | Accurate-NLI follow-ups | (1) A shared `browser.read_terminal_answer` helper: `model_gate.ask_model_terminal` repeats `browser.ask_terminal`'s prompt loop and imports the private `_CHOICE_ANSWERS` to do it. (2) The `-q` `nli` line carries the fallback reason but not the advice. (3) Ctrl-C and `/stop` do not interrupt a running 643 MB download. (4) An "installed" but corrupt accurate model fails the run instead of falling back. (5) A multilingual NLI model is still deferred (out of scope in the accurate-NLI design); non-English claims still need the judge to translate |

## 21. `proofpath update` — 2026-10-05

The user asked for an update command like `claude update`. They picked the recommended
option on three of the four questions. On the fourth they chose "every install method"
over "uv tool and pipx only". Design: `docs/superpowers/specs/2026-10-05-update-command-design.md`.

- **Where:** CLI `proofpath update [--check]` and TUI `/update`, through one
`proofpath.commands` function. No check runs at startup.
- **Which installs:** uv tool, pipx and pip are updated. An editable install is never
touched; it is told how to update itself.
- **Extras:** the extras that are installed go into the install spec
(`proofpath[browser]`). Measured the same day: `uv tool upgrade` removes packages added
to the tool environment with pip, and the browser extra is installed that way on
consent.
- **Behaviour:** running the command updates straight away; `--check` only reports.
144 changes: 144 additions & 0 deletions docs/superpowers/specs/2026-10-05-update-command-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# `proofpath update` — design (2026-10-05)

The user asked for an update command like `claude update`. This spec records what they
decided and how it works. OPEN-ITEMS §21 points here.

## 1. Decisions (user, 2026-10-05)

| # | Question | Decision |
|---|---|---|
| 1 | Where | `proofpath update [--check]` in the CLI and `/update` in the TUI. Both call one function in `proofpath.commands`. There is no update check at startup. |
| 2 | Which installs | uv tool, pipx and pip installs are all updated. An editable (development) install is never touched: the command says how to update it. |
| 3 | Extras | The extras that are installed are written into the install spec (`proofpath[browser]`). That way the tool's own record keeps them, and a later plain `uv tool upgrade` keeps them too. |
| 4 | Behaviour | Running the command is the consent: it updates straight away. `--check` only reports. |

### Why extras need handling (measured 2026-10-05)

On consent, the browser extra is installed with `pip install` into the running
environment (`browser.install_commands`). A spike in an isolated `UV_TOOL_DIR` ran
three steps: it installed a tool, added `six` to its environment with
`uv pip install --python`, then ran `uv tool upgrade`. **`six` was gone after the
upgrade.** A plain `uv tool upgrade proofpath` would therefore remove the browser
wheels the user had consented to. The next blocked page would ask, and download, all
over again. The browser binary is not affected: it lives in the `ms-playwright` cache,
outside the environment. Neither is the accurate NLI model, which sits in the cache
directory.

## 2. Behaviour

1. **Current version.** `proofpath.__version__`.
2. **Latest version.** `GET https://pypi.org/pypi/proofpath/json` with `httpx` and a
10 s timeout, reading `info.version`. The request carries no contact address and no
key. `permissions.network` is decided by `fetch.network_permission`, the fetch
ladder's own rule, not a copy of it. `deny` sends nothing, and neither does `ask`
without a terminal (product rule 4): the command says why. `ask` with a terminal
goes ahead, and a note in the result says so. The CLI passes `is_interactive()`;
the TUI passes `True`. Running the command is consent to *install* (decision 4),
but it is not an answer to `ask` where no terminal could have been asked. A refused
or failed request fails rather than reporting "up to date": absence of evidence is
not evidence (product rule 2).
3. **Comparison.** `packaging.version.Version`. Add `packaging` to the declared
dependencies: it is already installed through other packages, but the code imports
it directly. There are three outcomes:
- up to date
- an update is available
- this build is newer than PyPI's (a development build). In that case nothing is run.
4. **Install detection**, in this order:
1. *editable*: the distribution's `direct_url.json` has `dir_info.editable: true`
2. *ephemeral*: a temporary environment, judged from `sys.prefix` alone. A `uvx` /
`uv tool run` environment lives in uv's cache, so an `archive-v0` path component
means uv. `pipx install` keeps every venv in `<pipx home>/venvs/<package>`, and
`pipx run` keeps its venvs in pipx's cache instead. That cache is
`~/Library/Caches/pipx/<hash>` on macOS and `...\AppData\Local\pipx\pipx\Cache\<hash>`
on Windows; with `PIPX_HOME` set it is `$PIPX_HOME/.cache/<hash>`. Both kinds
write `pipx_metadata.json`. So a prefix with `pipx_metadata.json` whose parent
directory is not named `venvs` is a `pipx run` venv. This check comes before
*pipx*.
3. *uv tool*: `Path(sys.prefix) / "uv-receipt.toml"` exists
4. *pipx*: `Path(sys.prefix) / "pipx_metadata.json"` exists
5. *pip*: anything else, run as `sys.executable`
5. **Extras detection.**
- `browser`: `scrapling` and `patchright` are both importable (`find_spec`).
- `gpu`: `torch` and `sentence_transformers` are both importable.
- For a uv tool install, the extras already listed for `proofpath` in the receipt's
`requirements` are added to these (their union).
- `dev` is never carried over.
6. **The command.** The spec is `proofpath[a,b]`, or `proofpath` with no extras. It is
**never version-pinned**: a pin would freeze the uv receipt or pipx metadata, and a
later upgrade would not move.
- uv tool: `uv tool install --upgrade <spec>`. Needs `uv` on PATH.
- pipx: `pipx install --force <spec>`. Needs `pipx` on PATH.
- pip: `<sys.executable> -m pip install --upgrade <spec>`. When that fails and `uv`
is on PATH, it falls back to `uv pip install --python <sys.executable> --upgrade <spec>`,
the same fallback `browser.install` uses.
- editable: nothing runs. The message names the source directory and says to update
it with `git pull` and `uv sync`.
- ephemeral: nothing runs. Updating a cached environment in place would be undone
when the cache is pruned. The message says this is a temporary environment and
that `uvx proofpath@latest` (uv) or `pipx run --no-cache proofpath` (pipx) already
fetches the newest. `pipx run` reuses its cached venv for days unless told not to,
hence `--no-cache`.
- A required tool that is missing is a failure. The message prints the command to
run by hand.
7. **Running it.** The runner is injected, as in `browser.install`. Output is captured,
never inherited: the TUI owns the terminal. Each command that runs is logged as
`$ <cmd>`. The command meant for pasting has no `$`: what `--check` would run, or
what to run by hand after a failure. Both are joined by `shell.display_command`:
`subprocess.list2cmdline` on Windows, `shlex.join` elsewhere. `shlex.join` would
wrap `proofpath[gpu]` in single quotes, and `cmd.exe` keeps them. `shell` is a
leaf module that `browser.install` logs through as well, so the browser install's
log pastes on Windows too.
8. **Verification.** After a zero exit, the installed version is read in a fresh
process:
- uv tool and pipx: `<venv python> -I -c "import importlib.metadata as m; print(m.version('proofpath'))"`
- pip: `sys.executable`, with the same `-I`

`-I` runs the interpreter isolated: no `PYTHON*` variables, no working directory on
`sys.path`. A proofpath checkout the user is standing in cannot answer for the
install.

The code imported in the running process is still the old version and cannot answer
this. If the new version is not the latest (for example, the resolver held it back),
the result is a failure that says which version was installed.
9. **Windows.** A running `proofpath.exe` can be locked while it is being replaced. A
failed command on Windows adds this line: "close proofpath and run the command
above". CI cannot test this live, so a unit test covers the message.
10. **Exit codes.** `0` means up to date, updated, or (with `--check`) an update is
available. It also means ahead of PyPI, editable, and ephemeral: in those three
cases nothing went wrong, and there was nothing for this command to run. `1` means
the check or the update failed. Usage errors keep typer's `2`.
11. **TUI.** `/update` and `/update --check` are mirrored verbs: in `MIRRORED` and
`VERBS`, and in the `/help` list. They run on the mirror worker like `/fetch`.
After a successful update the block says "restart proofpath to use vX".
Nothing prompts. A bare `/update` is refused while anything is still running:
an unfinished run, or a mirrored verb whose worker is still out. Its install
replaces the venv under the code those are executing, so the block says so in one
line. `/update --check` installs nothing and is never refused. The guard works
the other way too. While a bare `/update` is installing, `/check` (implicit or
typed) and every mirrored verb are refused with "an update is installing; wait
for it to finish". That includes a second `/update` and `/update --check`.
`update` is last
in `VERBS`: at 80x24 the `/` list holds ten of the eleven rows. The cap then hides
`/quit`, which the banner's hint line names anyway, and `/update`. It never
hides `/help`.

## 3. Tests (no network)

- PyPI responses come from `respx`. Install kinds are faked with tmp dirs and
monkeypatched `sys.prefix`/`sys.executable`. Extras use monkeypatched `find_spec`.
The runner is a fake that records commands.
- Each install kind builds the right command. Extras are carried over, including the
union with the receipt, and `dev` never is. The spec is never pinned.
- An editable install never runs anything. `network=deny` sends no request.
- A PyPI failure exits `1` with the reason. An up-to-date or ahead build runs nothing.
`--check` runs nothing.
- When a command fails, the output says so and prints the command, with the Windows
line on Windows. When verification sees an old version, that is a failure.
- The CLI is tested through `CliRunner`. In the TUI, `/update --check` renders in a
command block.

## 4. Out of scope

- An update check at startup (declined, decision 1).
- Keeping uv receipt options such as a pinned `--python` across `uv tool install
--upgrade`. This is a known limit, written in the README.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ dependencies = [
"numpy>=1.26",
"platformdirs>=4.3",
"orjson>=3.11",
# Pulled in by other packages already; declared because update.py compares
# versions with it directly.
"packaging>=24.0",
"tomli>=2.0; python_version < '3.11'",
]

Expand Down
31 changes: 4 additions & 27 deletions src/proofpath/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

import importlib.util
import os
import shlex
import shutil
import subprocess
import sys
Expand All @@ -26,6 +25,7 @@
import typer

from proofpath.config import ConfigError, Decision, Permission, resolve_permission, set_value
from proofpath.shell import run_logged

Answer = Literal["once", "always", "no", "never"]
PROMPT_CHOICES = ("y", "a", "n", "never")
Expand Down Expand Up @@ -185,29 +185,6 @@ def install_commands() -> list[list[str]]:
]


def _run_logged(
cmd: list[str],
log: list[str],
run: Callable[..., subprocess.CompletedProcess[str]],
) -> subprocess.CompletedProcess[str]:
# Quoted, not merely spaced: the log is what the user is shown when an install
# fails, and a line they can paste back into a shell says more than one they
# cannot. The -c program and the pip spec are each one argument.
log.append(f"$ {shlex.join(cmd)}")
try:
result = run(cmd, capture_output=True, text=True, check=False)
except OSError as exc:
# e.g. the interpreter/binary named in cmd does not exist on this machine.
log.append(f"exit ? ({type(exc).__name__}: {exc})")
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr=str(exc))
log.append(f"exit {result.returncode}")
if result.returncode != 0:
stderr_lines = (result.stderr or "").strip().splitlines()
if stderr_lines:
log.append(stderr_lines[-1])
return result


def install(
log: list[str],
*,
Expand All @@ -217,16 +194,16 @@ def install(
fail. ``pip`` missing in a uv-managed venv falls back to ``uv pip install`` only
when ``uv`` is on PATH; otherwise the install fails outright."""
pip_cmd, uv_cmd, module_cmd = install_commands()
result = _run_logged(pip_cmd, log, run)
result = run_logged(pip_cmd, log, run)
if result.returncode != 0:
if not shutil.which("uv"):
return False
result = _run_logged(uv_cmd, log, run)
result = run_logged(uv_cmd, log, run)
if result.returncode != 0:
return False
# No retry behind this one: see ``install_commands``. A failed browser download
# is reported with its log, not paid for twice.
return _run_logged(module_cmd, log, run).returncode == 0
return run_logged(module_cmd, log, run).returncode == 0


def fetch_with_browser(url: str, *, timeout: float = 60.0) -> tuple[int, bytes, str]:
Expand Down
Loading
Loading