diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2619315..e4f90e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,6 +102,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + cp docs/probbit.1 dist/ # the manual page (`probbit man`), also inside every archive under docs/ ls -la dist if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then gh release upload "$GITHUB_REF_NAME" dist/* --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4e596..37ae746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ All notable changes to this project are documented here. The format follows persona-confidence hold, an engine-unreleased trait and a lost connection. It works for any persona read from the strand. The page is embedded in the binary with no external assets; the terminal view (`--once`, `--follow`, `--plain`) is unchanged. The monitor's layout gains `caps.prediction_error` from the persona's `expectation.pe_cap`. docs/persona.md §5.8. +- `probbit help [COMMAND [SUBCOMMAND]]`: the same text as `COMMAND [SUBCOMMAND] --help`; `probbit help` alone is + `probbit --help`. The persona and live subcommands have their own help (usage, flags, exit codes). +- Did-you-mean: an unknown command or subcommand names the closest one (`unknown command "persna"; did you mean + "persona"?`, Damerau-Levenshtein distance up to 2, or a unique prefix). The exit code stays 2. +- `probbit completions bash|zsh|fish`: a static completion script with every command, subcommand and flag. +- `probbit man` and `docs/probbit.1`: the manual page. Releases attach it; `install.sh` installs it to + `~/.local/share/man/man1/` (`PROBBIT_MAN_DIR` to choose), never failing the install over it. +- `--json` on every command: errors as one JSON object on stdout even at a terminal, for a bad command or flag too + (code `flag`, or `command` with a `suggestion`). ### Changed - `persona lint` with rules (`--never RULE` / `--props FILE`) no longer reports green on a rule it could not settle. When @@ -25,6 +34,13 @@ All notable changes to this project are documented here. The format follows read on a number input to the grid (as a fraction of that input's max, after the given points, each once; `search.grid` lists them). A rule in force only between two default grid points (for example stakes 0.7 to 0.8) is now reached by the random search too. Personas whose rules and habits read no number thresholds off the grid get the same search, byte for byte. +- One command table in the CLI renders every help screen, the completions and the man page, and the parser reads its flags + from it: what is documented is what is accepted. +- Help fits 100 columns: `probbit --help` starts with the version, groups the commands and ends with examples; + `monitor --help` has usage, modes, flags and demo sections; the terminal hero screen fits 100 columns. +- Errors: one human line on stderr, always. A bad input's `{"error"}` object goes to stdout when stdout is not a terminal (as + before for every program and wrapper) or with `--json`; at a terminal without `--json` only the line is printed, no longer + both (docs/CHEATSHEET.md "Errors"). ### Fixed - `install.sh`: when the install directory is not on `PATH`, the hint names the file the user's shell reads: `~/.zshrc` for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 360de3f..12207e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,10 @@ and clean-prefix shell, PowerShell 5.1/7 and npm installs using locally packaged `node --test npm/test-wrapper.cjs` needs no npm dependencies. Set `PROBBIT_BIN` to the release executable and run `python3 -m unittest discover -s python -p 'test_*.py'` for the integration contracts. +The command table in `probbit-cli/src/main.rs` (`COMMANDS`) is the one source of every `--help`, `probbit help`, the shell +completions and the man page; the parser reads its flags from it. After changing it or bumping the version, regenerate the +committed man page: `cargo run -p probbit-cli -- man > docs/probbit.1` (test `committed_man_page_is_generated`). + ## Ground rules - **No external crates on the shipped path.** `cargo build` must keep working offline. diff --git a/README.md b/README.md index 8196e6a..ccd8a76 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,20 @@ there are no external crates. At a terminal, `install.sh` ends with probbit's own hero screen. Platform notes and the installer tests: [PORTABILITY.md](PORTABILITY.md). +`install.sh` also installs the manual page (`man probbit`) to `~/.local/share/man/man1/` (`PROBBIT_MAN_DIR` to choose; a +failure there installs the binary all the same); every archive carries it as `docs/probbit.1`, and `probbit man` prints it. +Shell completions come from the binary itself: + +| shell | command | +|---|---| +| bash | `probbit completions bash > ~/.local/share/bash-completion/completions/probbit` | +| zsh | `probbit completions zsh > ~/.zfunc/_probbit` (with `fpath=(~/.zfunc $fpath)` before `compinit` in `~/.zshrc`) | +| fish | `probbit completions fish > ~/.config/fish/completions/probbit.fish` | + +`probbit --help` lists every command by group; `probbit help COMMAND [SUBCOMMAND]` (the same as `probbit COMMAND --help`) +gives a command's flags with their defaults, its exit codes and examples. A mistyped command gets a suggestion: +`probbit persna` -> `unknown command "persna"; did you mean "persona"?` (exit 2). + ## 60-second tour ``` @@ -323,7 +337,8 @@ Problem: } ``` - The document is checked strictly (types, unknown fields, duplicate ids, empty domains; finite numbers only): bad input exits 2 - with one `{"error":{"code","path","message"}}` object on stdout ([docs/probbit-ir-json.md](docs/probbit-ir-json.md) "Input contract"). + with one `{"error":{"code","path","message"}}` object on stdout ([docs/probbit-ir-json.md](docs/probbit-ir-json.md) "Input contract"), + whenever stdout is not a terminal (any program calling probbit) or with `--json`; at a terminal, only the stderr line. - `scores`: finite numbers with |x| <= 1e9 (natural-log odds; docs/probbit-ir-json.md "Numeric contract"), one per worker the task may go to (log-odds from your judge, an LLM, a heuristic, a price). A worker missing from `scores` is not allowed unless listed in `allowed` (then it enters with score 0). - `allowed` (optional): hard rule. `cap`: hard per-worker quota. `clamp` (optional): force this task to one worker (what-if). diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 014a388..6e59500 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -2,6 +2,17 @@ The CLI's own help and docs, shortened; `probbit --help` has every flag. +## Help, completions, the manual page +| command | does | +|---|---| +| `probbit --help` | every command by group (persona, live, monitor, decide/run, mcp, other), the exit codes, six examples | +| `probbit help COMMAND [SUBCOMMAND]` | the same text as `probbit COMMAND [SUBCOMMAND] --help`: usage, flags with defaults, exit codes | +| `probbit completions bash\|zsh\|fish` | a completion script for the shell (commands, subcommands, flags); install lines in its `--help` | +| `probbit man` | the manual page (roff); `install.sh` puts it in `~/.local/share/man/man1/`, so `man probbit` works | + +Help, completions and the man page all come from one command table, so they list exactly the flags the parser accepts. A +mistyped command or subcommand exits 2 with the closest name: `did you mean "persona"?`. + ## Verdicts (`decide`, `run`, `evaluate`; a persona turn's `engine.verdict`) | verdict | means | exit | |---|---|---| @@ -32,12 +43,24 @@ The exact tiers run before the sampler: ## Exit codes - `decide`, `run`, `evaluate`: 0 answer (exact | diagnostics_passed | partial), 1 infeasible (a proof), 2 bad input (one - `{"error"}` object on stdout) or bad flag (stderr), 3 refused / declined / non-finite result. + `{"error"}` object on stdout, see Errors) or bad flag (stderr), 3 refused / declined / non-finite result. - `persona`: 0 done; 1 lint found an unresolved contradiction or (with rules) a counterexample, fuzz found a counterexample or prove left a rule unknown; 2 bad persona / state / inputs / script / rule, or bad flag. - `live`: 0 done; 1 verify: a line differs; 2 a bad persona, state, event or flag. - `monitor`: 0 every line replays; 1 a line differs; 2 a bad flag, a port that cannot be had, or a file that is not a strand. +## Errors +One channel, the same for every command (0.8.2): +- **stderr**: one human line, always: `probbit: bad JSON: expected string at byte 1`. +- **stdout**, bad input (a document, persona, state, event; numeric failures; live refusals): one + `{"error": {"code", "path", "message"}}` object when stdout is **not a terminal** (a pipe, a file, every wrapper: Python, MCP, + npm, the examples), or with **`--json`**. At a terminal without `--json`: the line only, never both. +- **stdout**, bad command or flag (unknown command, subcommand or flag; a bad flag value): nothing, so a piped caller tells it + from bad input by an empty stdout. With `--json`: `{"error": {"code": "flag", ...}}`, or for an unknown command or + subcommand `{"error": {"code": "command", ..., "suggestion": "persona"}}` (`null` when nothing is close). +- `--json` is accepted by every command; it changes no answer (for `persona fuzz` / `prove` it also selects the JSON report). +- An unknown command or subcommand names the closest one: `probbit: unknown command "persna"; did you mean "persona"?` + ## `probbit persona PERSONA` | sub | does | |---|---| diff --git a/docs/agents.md b/docs/agents.md index b484e35..1697064 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -10,7 +10,7 @@ an agent harness with a shell tool. No server, no bindings, no network. The docu |---|---|---| | 0 | an answer: verdict `exact`, `diagnostics_passed` or `partial` | act on `released`; escalate `escalated` (empty unless `partial`) | | 1 | `infeasible`: no plan satisfies the rules (a proof) | relax a rule or a cap; it is an answer, not a crash | -| 2 | bad input: one `{"error": {"code", "path", "message"}}` object on stdout; a bad flag prints one line on stderr instead | fix the document or the flag | +| 2 | bad input: one `{"error": {"code", "path", "message"}}` object on stdout (piped, as an agent runs it; `--json` at a terminal); a bad flag prints one line on stderr instead | fix the document or the flag | | 3 | `refused` / `declined` (the gate or a cap said no), or `{"error": {"code": "numeric"}}` | escalate the whole decision; any retained candidate plan is diagnostic only | Decision documents that carry a `plan` also carry `plan_status` (`released`, `partial`, or `diagnostic`) diff --git a/docs/probbit-ir-json.md b/docs/probbit-ir-json.md index eec5180..5c7246c 100644 --- a/docs/probbit-ir-json.md +++ b/docs/probbit-ir-json.md @@ -121,7 +121,8 @@ number that is not a finite double, nesting deeper than 256, more than 65,535 va size limits in "Limits in v1", stdin above `--max-input-mb`). Unreadable stdin and invalid UTF-8 are `schema` errors too (0.2.1; they printed a stderr line only). `path` locates the offending field (`""` = the whole document, as for JSON syntax errors and non-finite numbers, whose message gives the byte -offset). Flag errors keep their convention: exit 2, a message on stderr, nothing on stdout. The same strict reader parses +offset). The object goes to stdout when stdout is not a terminal or with `--json` (0.8.2; at a terminal the stderr line alone). Flag +errors keep their convention: exit 2, a message on stderr, nothing on stdout (with `--json`: one object, code `flag`). The same strict reader parses both front-ends (`probbit-cli/src/json.rs`; CLI test `input_contract_rejects_malformed_documents`, which includes the 2026-09-30 external review's cases: `"allowed": "A"` used to enable every scored worker and answer `exact`; a cap object where the caps array belongs was dropped; duplicate value names emitted duplicate keys). diff --git a/docs/probbit.1 b/docs/probbit.1 new file mode 100644 index 0000000..4561c5b --- /dev/null +++ b/docs/probbit.1 @@ -0,0 +1,872 @@ +.TH PROBBIT 1 "" "probbit 0.8.1" "probbit manual" +.SH NAME +probbit \- a virtual probabilistic (p\-bit) processor: joint decisions under hard rules, with gated odds +.SH SYNOPSIS +.nf +\fBprobbit\fR persona SUB PERSONA [flags] + (SUB: init turn replay explain diff lint fuzz prove check compile describe) +\fBprobbit\fR live PERSONA [\-\-seed N | \-\-state FILE] [\-\-strand FILE] + [\-\-events FILE [\-\-watch]] [\-\-clock real|fixed] + [\-\-checkpoint\-every K] +\fBprobbit\fR live PERSONA \-\-demo week [\-\-seed N] [\-\-strand FILE] [\-\-plain] +\fBprobbit\fR live verify STRAND [\-\-from\-checkpoint] +\fBprobbit\fR live control STRAND pause|resume|retire \-\-by WHO \-\-reason TEXT + [\-\-at TIME] +\fBprobbit\fR monitor STRAND [\-\-follow] [\-\-once] [\-\-plain] [\-\-fps N] [\-\-serve] + [\-\-open] [\-\-port N] +\fBprobbit\fR monitor \-\-demo [week|drives] [\-\-once] [\-\-plain] [\-\-serve] + [\-\-open] [\-\-port N] +\fBprobbit\fR decide [flags] < router.json +\fBprobbit\fR run [flags] < program.json +\fBprobbit\fR evaluate [flags] < request.json +\fBprobbit\fR demo [flags] > router.json +\fBprobbit\fR ir [\-\-max\-input\-mb N] [\-\-plain] < router.json +\fBprobbit\fR mcp +\fBprobbit\fR stats [flags] +\fBprobbit\fR help [COMMAND [SUBCOMMAND]] +\fBprobbit\fR completions bash|zsh|fish +\fBprobbit\fR man +\fBprobbit\fR version +.fi +.SH DESCRIPTION +probbit reads a JSON problem on stdin and writes a JSON answer on stdout: exact where the structure allows, sampled otherwise, and a sampled answer passes a published diagnostics gate or is refused. The persona, live and monitor commands are the individuality layer: a persona file, an individual's state and a turn's inputs give one stance with exact odds, and a strand logs a life that replays byte for byte. +.PP +Every command takes \-\-help (\-h): its usage, every flag with its default and its exit codes; probbit help COMMAND [SUBCOMMAND] prints the same. An unknown command or subcommand exits 2 and names the closest one. +.PP +Errors: one human line on stderr, always. A bad input document also gives one {"error": {"code", "path", "message"}} object on stdout when stdout is not a terminal, or with \-\-json. A bad command or flag prints nothing on stdout unless \-\-json is given (code "flag", or "command" with a "suggestion"). \-\-json is accepted by every command and changes no answer. +.SH COMMANDS +.SS persona +the individuality layer: persona + state + inputs \-> one stance +.PP +.nf +\fBprobbit\fR persona SUB PERSONA [flags] + (SUB: init turn replay explain diff lint fuzz prove check compile describe) +.fi +.PP +The individuality layer (docs/persona.md): a persona file (YAML subset or JSON: traits with priors, moods with inertia, couplings, per\-turn inputs, history, habits = hard rules) + an individual's state + this turn's inputs \-> ONE probbit\-ir program, run in process (`probbit run \-\-op decide` at fixed work) \-> the stance: a level per trait with exact odds, the habits in force and the ones that bound, a refusal when the engine cannot vouch, a why and a short stance line for any model's prompt. +.PP +Documents are canonical JSON (keys sorted); a turn is a pure function of (persona, state, inputs, version). +.PP +Flags: +.RS 4 +.TP +\fB\-\-pretty\fR +indent a single JSON document on stdout (init, turn, diff, lint, check, compile, describe, fuzz, prove); fuzz/prove \-\-pretty implies \-\-json. State files stay canonical. Replay stays JSONL; explain stays plain text. +.RE +.PP +Subcommands: +.RS 4 +.TP +\fBinit\fR PERSONA [\-\-seed N] [\-\-out STATE] +a new individual (genes from the seed, resting stance); stdout or \-\-out +.RS 4 +.TP +\fB\-\-seed\fR \fIN\fR +the individual: an integer seed from 0 to 2^53 (default: the persona's seed) +.TP +\fB\-\-out\fR \fISTATE\fR +write the state to STATE instead of stdout +.RE +.TP +\fBturn\fR PERSONA \-\-state STATE [\-\-inputs JSON|FILE] [\-\-out STATE2] [\-\-timing] [\-\-no\-inertia] +the stance on stdout; the new state replaces STATE (or goes to \-\-out) +.RS 4 +.TP +\fB\-\-state\fR \fISTATE\fR +the individual's stored state (`probbit persona init \-\-out` writes one) +.TP +\fB\-\-inputs\fR \fIJSON|FILE\fR +this turn's inputs: a JSON object, inline or a file (default {}) +.TP +\fB\-\-out\fR \fISTATE2\fR +write the new state to STATE2 instead of replacing STATE +.TP +\fB\-\-timing\fR +add a separate, non\-canonical `timing` object (compile, engine and decode ms) +.TP +\fB\-\-no\-inertia\fR +moods carry nothing over from the previous turn (inertia 0) +.RE +.TP +\fBreplay\fR PERSONA [\-\-seed N] \-\-script JSON|FILE [\-\-out TRACE] [\-\-no\-inertia] [\-\-timing] +init, then every turn of the script: one stance per line (JSONL) +.RS 4 +.TP +\fB\-\-seed\fR \fIN\fR +the individual: an integer seed from 0 to 2^53 (default: the persona's seed) +.TP +\fB\-\-script\fR \fIJSON|FILE\fR +the turns: a JSON list of per\-turn input objects, or {"turns": [...]}; inline or a file +.TP +\fB\-\-out\fR \fITRACE\fR +write the JSONL trace to TRACE instead of stdout +.TP +\fB\-\-no\-inertia\fR +moods carry nothing over from the previous turn (inertia 0) +.TP +\fB\-\-timing\fR +add a separate, non\-canonical `timing` object (compile, engine and decode ms) +.RE +.TP +\fBexplain\fR PERSONA [\-\-seed N] \-\-script JSON|FILE \-\-turn K +turn K in words: every contribution, the odds, the twin +.RS 4 +.TP +\fB\-\-seed\fR \fIN\fR +the individual: an integer seed from 0 to 2^53 (default: the persona's seed) +.TP +\fB\-\-script\fR \fIJSON|FILE\fR +the turns: a JSON list of per\-turn input objects, or {"turns": [...]}; inline or a file +.TP +\fB\-\-turn\fR \fIK\fR +the turn to explain, counted from 0 (required) +.RE +.TP +\fBdiff\fR PERSONA [\-\-seed A] [\-\-other PERSONA2] [\-\-seed2 B] \-\-script JSON|FILE +distance between two individuals +.RS 4 +.TP +\fB\-\-seed\fR \fIA\fR +the first individual's seed (default: the persona's seed) +.TP +\fB\-\-other\fR \fIPERSONA2\fR +the second individual's persona (default: the same persona) +.TP +\fB\-\-seed2\fR \fIB\fR +the second individual's seed (default: its persona's seed) +.TP +\fB\-\-script\fR \fIJSON|FILE\fR +the turns: a JSON list of per\-turn input objects, or {"turns": [...]}; inline or a file +.RE +.TP +\fBlint\fR PERSONA [\-\-never RULE | \-\-props FILE] [\-\-seeds 0\-99] [\-\-threads N] +contradicting habits (every conditional habit and pair, every prev level); warnings: planned levels that are not their trait's most likely one; with rules: prove each one, fuzz the unknown ones; exit 1 on a counterexample, exit 3 when a rule stays unknown and the search finds none ("unproven": not broken, not proved either) +.RS 4 +.TP +\fB\-\-never\fR \fIRULE\fR +a rule in habit syntax, {when: {...}, then: {...}}: JSON or one line of the YAML subset +.TP +\fB\-\-props\fR \fIFILE\fR +rules from a file ({"props": [...]}, each with an optional id; YAML subset, or .json) +.TP +\fB\-\-seeds\fR \fILIST\fR +the individuals: 0\-99 (default), 7, 1,4,9 or 0\-9,20\-29; seeds 0 to 2^53, at most 100000 +.TP +\fB\-\-threads\fR \fIN\fR +search threads, 1 to 1024 (default: the logical CPUs) +.RE +.TP +\fBfuzz\fR PERSONA (\-\-never RULE | \-\-props FILE) [\-\-seeds 0\-99] [\-\-fuzz\-seed N] [\-\-scripts N] [\-\-depth N] [\-\-beam N] [\-\-grid LIST] [\-\-hours LIST] [\-\-threads N] [\-\-json] [\-\-fixture\-src env:synthetic|human:synthetic] +search event scripts for the shortest one whose stance breaks a rule (habit syntax: {when: {...}, then: {...}}); shrunk (\[u00A7]5.6). \-\-fixture\-src explicitly authorizes synthetic\-source fixtures for strict replay; never use them to relabel production events. The persona must allow that source. +.RS 4 +.TP +\fB\-\-never\fR \fIRULE\fR +a rule in habit syntax, {when: {...}, then: {...}}: JSON or one line of the YAML subset +.TP +\fB\-\-props\fR \fIFILE\fR +rules from a file ({"props": [...]}, each with an optional id; YAML subset, or .json) +.TP +\fB\-\-seeds\fR \fILIST\fR +the individuals: 0\-99 (default), 7, 1,4,9 or 0\-9,20\-29; seeds 0 to 2^53, at most 100000 +.TP +\fB\-\-fuzz\-seed\fR \fIN\fR +the search's own seed (default 0): the same inputs give the same report on any number of threads +.TP +\fB\-\-scripts\fR \fIN\fR +random scripts per individual before the beam, 0 to 1000000 (default 60) +.TP +\fB\-\-depth\fR \fIN\fR +events per script, 1 to 64 (default 8) +.TP +\fB\-\-beam\fR \fIN\fR +width of the odds\-guided beam, 0 to 64 (default 4) +.TP +\fB\-\-grid\fR \fILIST\fR +comma\-separated fractions of a number input's max to try, 0 to 1 (default 0,0.5,1) +.TP +\fB\-\-hours\fR \fILIST\fR +comma\-separated idle hours to try between events (default 1,12,48) +.TP +\fB\-\-threads\fR \fIN\fR +search threads, 1 to 1024 (default: the logical CPUs) +.TP +\fB\-\-json\fR +the JSON document instead of the readable report +.TP +\fB\-\-fixture\-src\fR \fIenv:synthetic|human:synthetic\fR +label exported counterexamples with that synthetic source so their replay commands run +.RE +.TP +\fBprove\fR PERSONA (\-\-never RULE | \-\-props FILE) [\-\-seeds 0\-99] [\-\-threads N] [\-\-json] +per rule: held by construction (a habit implies it), proved for every event sequence (a sound bound), or unknown (the cell it fails; fuzz it) (\[u00A7]5.6) +.RS 4 +.TP +\fB\-\-never\fR \fIRULE\fR +a rule in habit syntax, {when: {...}, then: {...}}: JSON or one line of the YAML subset +.TP +\fB\-\-props\fR \fIFILE\fR +rules from a file ({"props": [...]}, each with an optional id; YAML subset, or .json) +.TP +\fB\-\-seeds\fR \fILIST\fR +the individuals: 0\-99 (default), 7, 1,4,9 or 0\-9,20\-29; seeds 0 to 2^53, at most 100000 +.TP +\fB\-\-threads\fR \fIN\fR +search threads, 1 to 1024 (default: the logical CPUs) +.TP +\fB\-\-json\fR +the JSON document instead of the readable report +.RE +.TP +\fBcheck\fR PERSONA +valid? digest and sizes +.TP +\fBcompile\fR PERSONA \-\-state STATE [\-\-inputs JSON|FILE] +the turn's probbit\-ir program (its sha256 = engine.program) +.RS 4 +.TP +\fB\-\-state\fR \fISTATE\fR +the individual's stored state (`probbit persona init \-\-out` writes one) +.TP +\fB\-\-inputs\fR \fIJSON|FILE\fR +this turn's inputs: a JSON object, inline or a file (default {}) +.RE +.TP +\fBdescribe\fR PERSONA +traits, moods, inputs, habits, agenda +.RE +.PP +\fIscripts\fR: a script is a JSON list of per\-turn input objects, or {"turns": [...]}. Resource controls as for run (PROBBIT_THREADS, ...). +.PP +\fIexit\fR: 0 done (every turn status, refusals and fallbacks included, is an answer; fuzz: no counterexample found; prove: every rule held or proved), 1 lint found an unresolved contradiction or (with rules) a counterexample, fuzz found a counterexample or prove left a rule unknown, 2 bad persona / state / inputs / script / rule (one {"error"} object on stdout, code "persona", when stdout is not a terminal or with \-\-json) or bad flag (stderr), 3 lint (with rules): a rule the bound leaves unknown and the search cannot break ("unproven", not broken). +.PP +Examples: +.RS 4 +.nf +probbit persona init examples/persona/tutor.yaml \-\-seed 2 \-\-out pip.json +probbit persona turn examples/persona/tutor.yaml \-\-state pip.json \-\-inputs '{"loss": true}' +probbit persona lint examples/persona/tutor.yaml +.fi +.RE +.SS live +a resident individual: JSONL events in, one stance per event out +.PP +.nf +\fBprobbit\fR live PERSONA [\-\-seed N | \-\-state FILE] [\-\-strand FILE] + [\-\-events FILE [\-\-watch]] [\-\-clock real|fixed] + [\-\-checkpoint\-every K] +\fBprobbit\fR live PERSONA \-\-demo week [\-\-seed N] [\-\-strand FILE] [\-\-plain] +\fBprobbit\fR live verify STRAND [\-\-from\-checkpoint] +\fBprobbit\fR live control STRAND pause|resume|retire \-\-by WHO \-\-reason TEXT + [\-\-at TIME] +.fi +.PP +A resident individual (docs/persona.md \[u00A7]5.7): JSONL events (one object of inputs per line) from \-\-events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e\-6 h), so moods decay by their half\-lives between events; feedback moves the learned deltas of a persona with a learning block (\[u00A7]2.8). +.PP +\-\-strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used, the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it. +.PP +Flags: +.RS 4 +.TP +\fB\-\-seed\fR \fIN\fR +a new individual (default: the persona's seed) +.TP +\fB\-\-state\fR \fIFILE\fR +a stored individual instead; rewritten after every event +.TP +\fB\-\-strand\fR \fIFILE\fR +log the life to FILE: a new file gets the header; an existing strand is continued from \-\-state (the state after its last line); a strand is never rewritten, just appended to +.TP +\fB\-\-events\fR \fIFILE\fR +read events from FILE (default stdin) +.TP +\fB\-\-watch\fR +follow \-\-events FILE as lines are appended (tail \-f); stops when the file is removed +.TP +\fB\-\-clock\fR \fIreal|fixed\fR +real (default): elapsed hours from a monotonic clock started with the run; fixed: each event carries its own elapsed_hours (default 0), so the run is a pure function of its events +.TP +\fB\-\-demo\fR \fIweek\fR +one individual's scripted week on the fixed clock (7 days, events hourly 09:00\-15:00): praise for short answers moves the learned verbosity deltas to their cap, a quiet night relaxes the mood to its resting level, a campaign praising jokes raises humour while failure turns stay joke\-free; a persona without a learning block gets the demo's (said in the opening line). Bars on stderr at a terminal, paced 1 s per hour (nights fast\-forward in 2 s); otherwise one line per event on stdout, no waiting. With \-\-seed, \-\-strand +.TP +\fB\-\-plain\fR +no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged +.TP +\fB\-\-checkpoint\-every\fR \fIK\fR +with \-\-strand: a checkpoint line (the whole state) after every K\-th event (default 1000; 0: none); `verify \-\-from\-checkpoint` and `monitor` start at the last one +.RE +.PP +Subcommands: +.RS 4 +.TP +\fBverify\fR STRAND [\-\-from\-checkpoint] +replay a strand: the earliest line that differs; from the header (every checkpoint checked), or from the last checkpoint +.RS 4 +.TP +\fB\-\-from\-checkpoint\fR +start at the last checkpoint instead of the header +.RE +.TP +\fBcontrol\fR STRAND pause|resume|retire \-\-by WHO \-\-reason TEXT [\-\-at TIME] +pause, resume or retire an individual; appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status +.RS 4 +.TP +\fB\-\-by\fR \fIWHO\fR +who decided it: human:ID or env:ID (required) +.TP +\fB\-\-reason\fR \fITEXT\fR +why (required) +.TP +\fB\-\-at\fR \fITIME\fR +when (default: now, UTC) +.RE +.RE +.PP +\fIthe safety kit\fR: (docs/persona.md \[u00A7]5.7) one writer per strand: `\-\-strand` takes STRAND.lock (a second writer exits 4 and changes nothing; a lock whose process is gone is taken over). With `reward_from` in the persona (\[u00A7]2.10), a reward from `src: self`, without a src or from an undeclared one is refused like a bad event. +.PP +\fIexit\fR: 0 done (live: every event answered; verify: every line replays; control: the line appended), 1 verify: a line differs (its number and what), 2 a bad persona, state, event (one {"error"} object on stdout per bad event when stdout is not a terminal or with \-\-json; the run goes on), control line or flag, 4 refused: the strand's writer lock is held by another writer, or the individual is paused or retired (one {"error"} object per refused event, as for 2; nothing is written). +.PP +Examples: +.RS 4 +.nf +probbit live examples/persona/tutor.yaml \-\-seed 2 \-\-demo week +probbit live examples/persona/tutor.yaml \-\-clock fixed \-\-strand pip.strand < events.jsonl +probbit live verify pip.strand +.fi +.RE +.SS monitor +an individual's inner state, live: terminal bars or a page on 127.0.0.1 +.PP +.nf +\fBprobbit\fR monitor STRAND [\-\-follow] [\-\-once] [\-\-plain] [\-\-fps N] [\-\-serve] + [\-\-open] [\-\-port N] +\fBprobbit\fR monitor \-\-demo [week|drives] [\-\-once] [\-\-plain] [\-\-serve] + [\-\-open] [\-\-port N] +.fi +.PP +Watch an individual's inner state (docs/persona.md \[u00A7]5.8): replays the strand (`probbit live \-\-strand` writes it) with the rules of `probbit live verify`, recomputing every stance document from the strand alone, and draws the latest event as horizontal bars: the stance (each trait's levels with their reported odds, the level taken highlighted, the phrase it says), the moods with a sparkline of the last 50 events, the senses (the event's inputs, the history features), the habits in force and the ones that bound, the learned deltas, the drives when the documents carry them, and the stance line. +.PP +It changes no file (\-\-demo: its week goes through a temporary one, removed at once) and sends nothing anywhere. +.PP +\fImodes\fR: one frame on stdout, then exit (\-\-once; the default without \-\-follow) \[u00B7] the terminal, redrawn as the strand grows (\-\-follow) \[u00B7] a page in the browser (\-\-serve; \-\-open also opens it). +.PP +Flags: +.RS 4 +.TP +\fB\-\-follow\fR +keep watching: lines appended to the strand are replayed and drawn within a second (a line counts once its newline is written); a truncated or rotated strand is replayed from the start, with a warning; Ctrl\-C quits +.TP +\fB\-\-once\fR +one frame on stdout, then exit (the default without \-\-follow) +.TP +\fB\-\-plain\fR +plain ASCII, no colour (NO_COLOR=1: no colour; PROBBIT_THEME=plain or TERM=dumb: as \-\-plain) +.TP +\fB\-\-fps\fR \fIN\fR +with \-\-follow, at most N redraws per second (1\-60, default 10) +.TP +\fB\-\-serve\fR +the page instead of the terminal, following the strand (or looping the selected synthetic demo): its URL http://127.0.0.1:PORT/ is the line on stdout. GET / the page (one file; it fetches no fonts, styles or scripts), /events its server\-sent events (the layout, the latest frame, then a frame per event, a heartbeat every 15 s; events that land within one 100 ms poll, or while a page falls behind, come as the latest frame), /doc/N event N's stance document, as `probbit live` printed it (the latest 10000 are kept). It binds 127.0.0.1 and no other address (there is no \-\-host), answers requests addressed to 127.0.0.1 or localhost, and sends nothing anywhere; Ctrl\-C quits +.TP +\fB\-\-open\fR +serve the page (as \-\-serve does) and open it in the default browser (BROWSER if set; else open on macOS, start on Windows, xdg\-open elsewhere); a browser that does not start fails nothing +.TP +\fB\-\-port\fR \fIN\fR +with \-\-serve or \-\-open, the port (default 0: a free one) +.TP +\fB\-\-demo\fR \fI[week|drives]\fR +synthetic data, never a live individual (default week); see demo below +.RE +.PP +\fIdemo\fR: the tutor's week is the one `probbit live examples/persona/tutor.yaml \-\-seed 2 \-\-demo week` writes, replayed in memory: at a terminal or with \-\-serve paced 1 s per hour, each night in 2 s (under a minute); otherwise, or with \-\-once, its last frame. `\-\-demo drives` plays a short goal\-signal scenario with wanting, afterglow and prediction errors. The page labels each loop and its restart; Ctrl\-C quits. +.PP +\fIexit\fR: 0 every line replays, 1 a line differs (the frame names it and shows the event before it), 2 a bad flag, a port that cannot be had, or a file that cannot be read or is not a strand. +.PP +Examples: +.RS 4 +.nf +probbit monitor \-\-demo \-\-open +probbit monitor pip.strand \-\-follow +probbit monitor pip.strand \-\-serve \-\-port 8080 +.fi +.RE +.SS decide +route tasks to workers: a router document in, a JSON decision out +.PP +.nf +\fBprobbit\fR decide [flags] < router.json +.fi +.PP +Route tasks to workers: a router document (workers + caps, tasks + allowed + scores, affinity; README) on stdin, a JSON decision on stdout. Exact where structure allows, else sampled with the diagnostics gate. +.PP +Flags: +.RS 4 +.TP +\fB\-\-budget\-ms\fR \fIN\fR +sampler wall\-clock budget, ms (default 200) +.TP +\fB\-\-seed\fR \fIN\fR +random seed (default 7) +.TP +\fB\-\-exact\-limit\fR \fIN\fR +plans the exact enumeration may count before it declines (default 2000000) +.TP +\fB\-\-exact\-ms\fR \fIN\fR +wall\-clock cap on the exact tiers that run before the sampler (opt\-in; absent = no cap) +.TP +\fB\-\-frontier\-states\fR \fIN\fR +state cap of the frontier DP and the components tier (default 4096; 0 = off) +.TP +\fB\-\-polish\-ms\fR \fIN\fR +plan polish budget, ms (default 50; 0 = off) +.TP +\fB\-\-polish\-sweeps\fR \fIN\fR +fixed\-work plan polish (deterministic) +.TP +\fB\-\-sweeps\fR \fIN\fR +fixed work per chain instead of the wall\-clock budget (with \-\-polish\-ms 0: a pure function of input + seed) +.TP +\fB\-\-collective\fR \fIon|off\fR +global two\-value flips + label swaps (default on) +.TP +\fB\-\-cluster\fR \fIon|off\fR +Wolff cluster move (default on) +.TP +\fB\-\-cycles\fR \fIon|off\fR +three\-cycle rotations (default on) +.TP +\fB\-\-chains\fR \fIN\fR +sampler chains, 1..=100000 (default 4) +.TP +\fB\-\-threads\fR \fIN\fR +worker threads for the chains, the gate and the polish, 1..=1024 (default min(cores, 4)) +.TP +\fB\-\-cpu\-limit\fR \fIPCT\fR +per\-thread duty cycle 1..100 (default 100) +.TP +\fB\-\-mem\-limit\-mb\fR \fIN\fR +cap on stored samples (default 1024; 0 = none) +.TP +\fB\-\-priority\fR \fIlow|normal\fR +OS scheduling priority (default normal) +.TP +\fB\-\-mode\fR \fIauto|exact|sample\fR +which tiers may answer (default auto) +.TP +\fB\-\-max\-input\-mb\fR \fIN\fR +largest stdin document read, MB (default 256; 0 = no limit) +.TP +\fB\-\-pretty\fR +indented JSON +.TP +\fB\-\-progress\fR \fI[MS]\fR +one JSONL telemetry line on stderr every MS ms (default 100) +.TP +\fB\-\-summary\fR +compact answer: verdict, counts, gate, the 5 worst released and escalated items, telemetry (no per\-item tables); with \-\-pretty and a terminal on stderr, also a boxed summary there +.TP +\fB\-\-top\fR +live monitor on stderr while it runs (tier, sweeps, updates/s, CPU, peak RSS, gate; 10 Hz), erased at exit; only with a terminal on stderr; stdout unchanged +.TP +\fB\-\-plain\fR +no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged +.RE +.PP +\fIcontrols\fR: flag > PROBBIT_* environment > probbit.json > default. +.PP +\fIexit\fR: 0 answer (exact | diagnostics_passed | partial), 1 infeasible (a proof), 2 bad input (one {"error"} object on stdout when stdout is not a terminal, or with \-\-json) or bad flag (stderr), 3 refused / declined / non\-finite result. +.PP +Examples: +.RS 4 +.nf +probbit demo \-\-tasks 300 | probbit decide \-\-summary \-\-pretty +probbit decide \-\-sweeps 6400 \-\-polish\-ms 0 < router.json +.fi +.RE +.SS run +run a probbit\-ir program (docs/probbit\-ir\-json.md), a JSON answer out +.PP +.nf +\fBprobbit\fR run [flags] < program.json +.fi +.PP +Run a probbit\-ir program (variables, values, scores, rules; docs/probbit\-ir\-json.md) on stdin; JSON answer on stdout. +.PP +Flags: +.RS 4 +.TP +\fB\-\-budget\-ms\fR \fIN\fR +sampler wall\-clock budget, ms (default 200) +.TP +\fB\-\-seed\fR \fIN\fR +random seed (default 7) +.TP +\fB\-\-exact\-limit\fR \fIN\fR +plans the exact enumeration may count before it declines (default 2000000) +.TP +\fB\-\-exact\-ms\fR \fIN\fR +wall\-clock cap on the exact tiers that run before the sampler (opt\-in; absent = no cap) +.TP +\fB\-\-frontier\-states\fR \fIN\fR +state cap of the frontier DP and the components tier (default 4096; 0 = off) +.TP +\fB\-\-polish\-ms\fR \fIN\fR +plan polish budget, ms (default 50; 0 = off) +.TP +\fB\-\-polish\-sweeps\fR \fIN\fR +fixed\-work plan polish (deterministic) +.TP +\fB\-\-sweeps\fR \fIN\fR +fixed work per chain instead of the wall\-clock budget (with \-\-polish\-ms 0: a pure function of input + seed) +.TP +\fB\-\-collective\fR \fIon|off\fR +global two\-value flips + label swaps (default on) +.TP +\fB\-\-cluster\fR \fIon|off\fR +Wolff cluster move (default on) +.TP +\fB\-\-cycles\fR \fIon|off\fR +three\-cycle rotations (default on) +.TP +\fB\-\-chains\fR \fIN\fR +sampler chains, 1..=100000 (default 4) +.TP +\fB\-\-threads\fR \fIN\fR +worker threads for the chains, the gate and the polish, 1..=1024 (default min(cores, 4)) +.TP +\fB\-\-cpu\-limit\fR \fIPCT\fR +per\-thread duty cycle 1..100 (default 100) +.TP +\fB\-\-mem\-limit\-mb\fR \fIN\fR +cap on stored samples (default 1024; 0 = none) +.TP +\fB\-\-priority\fR \fIlow|normal\fR +OS scheduling priority (default normal) +.TP +\fB\-\-op\fR \fIdecide|exact|sample\fR +decide = exact tiers, then the sampler; exact / sample = only those (default decide) +.TP +\fB\-\-deadline\-ms\fR \fIN\fR +whole\-call deadline (exact N/4, then sampler + polish); answer has `deadline` + `phases` +.TP +\fB\-\-max\-input\-mb\fR \fIN\fR +largest stdin document read, MB (default 256; 0 = no limit) +.TP +\fB\-\-pretty\fR +indented JSON +.TP +\fB\-\-progress\fR \fI[MS]\fR +one JSONL telemetry line on stderr every MS ms (default 100) +.TP +\fB\-\-summary\fR +compact answer: verdict, counts, gate, the 5 worst released and escalated items, telemetry (no per\-item tables); with \-\-pretty and a terminal on stderr, also a boxed summary there +.TP +\fB\-\-top\fR +live monitor on stderr while it runs (tier, sweeps, updates/s, CPU, peak RSS, gate; 10 Hz), erased at exit; only with a terminal on stderr; stdout unchanged +.TP +\fB\-\-plain\fR +no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged +.RE +.PP +\fIcontrols\fR: flag > PROBBIT_* environment > probbit.json > default. +.PP +\fIexit\fR: 0 answer (exact | diagnostics_passed | partial), 1 infeasible (a proof), 2 bad input (one {"error"} object on stdout when stdout is not a terminal, or with \-\-json) or bad flag (stderr), 3 refused / declined / non\-finite result. +.PP +Examples: +.RS 4 +.nf +probbit run \-\-pretty < examples/knapsack\-20.json +probbit run \-\-deadline\-ms 50 < program.json +.fi +.RE +.SS evaluate +the decision\-API adapter: request + judge's answers + rules \-> joint answer +.PP +.nf +\fBprobbit\fR evaluate [flags] < request.json +.fi +.PP +The decision\-API adapter: a System One request (questions noul | choice | score) plus a "probbit" block (the judge's answers, rules over question ids) on stdin; the rule\-abiding joint answer in the judge's response shape, odds per question and the gate's verdict on stdout. Every `probbit run` flag applies. docs/probbit\-ir\-json.md "Decision API". +.PP +Flags: +.RS 4 +.TP +\fB\-\-budget\-ms\fR \fIN\fR +sampler wall\-clock budget, ms (default 200) +.TP +\fB\-\-seed\fR \fIN\fR +random seed (default 7) +.TP +\fB\-\-exact\-limit\fR \fIN\fR +plans the exact enumeration may count before it declines (default 2000000) +.TP +\fB\-\-exact\-ms\fR \fIN\fR +wall\-clock cap on the exact tiers that run before the sampler (opt\-in; absent = no cap) +.TP +\fB\-\-frontier\-states\fR \fIN\fR +state cap of the frontier DP and the components tier (default 4096; 0 = off) +.TP +\fB\-\-polish\-ms\fR \fIN\fR +plan polish budget, ms (default 50; 0 = off) +.TP +\fB\-\-polish\-sweeps\fR \fIN\fR +fixed\-work plan polish (deterministic) +.TP +\fB\-\-sweeps\fR \fIN\fR +fixed work per chain instead of the wall\-clock budget (with \-\-polish\-ms 0: a pure function of input + seed) +.TP +\fB\-\-collective\fR \fIon|off\fR +global two\-value flips + label swaps (default on) +.TP +\fB\-\-cluster\fR \fIon|off\fR +Wolff cluster move (default on) +.TP +\fB\-\-cycles\fR \fIon|off\fR +three\-cycle rotations (default on) +.TP +\fB\-\-chains\fR \fIN\fR +sampler chains, 1..=100000 (default 4) +.TP +\fB\-\-threads\fR \fIN\fR +worker threads for the chains, the gate and the polish, 1..=1024 (default min(cores, 4)) +.TP +\fB\-\-cpu\-limit\fR \fIPCT\fR +per\-thread duty cycle 1..100 (default 100) +.TP +\fB\-\-mem\-limit\-mb\fR \fIN\fR +cap on stored samples (default 1024; 0 = none) +.TP +\fB\-\-priority\fR \fIlow|normal\fR +OS scheduling priority (default normal) +.TP +\fB\-\-op\fR \fIdecide|exact|sample\fR +decide = exact tiers, then the sampler; exact / sample = only those (default decide) +.TP +\fB\-\-deadline\-ms\fR \fIN\fR +whole\-call deadline (exact N/4, then sampler + polish); answer has `deadline` + `phases` +.TP +\fB\-\-max\-input\-mb\fR \fIN\fR +largest stdin document read, MB (default 256; 0 = no limit) +.TP +\fB\-\-pretty\fR +indented JSON +.TP +\fB\-\-progress\fR \fI[MS]\fR +one JSONL telemetry line on stderr every MS ms (default 100) +.TP +\fB\-\-summary\fR +compact answer: verdict, counts, gate, the 5 worst released and escalated items, telemetry (no per\-item tables); with \-\-pretty and a terminal on stderr, also a boxed summary there +.TP +\fB\-\-top\fR +live monitor on stderr while it runs (tier, sweeps, updates/s, CPU, peak RSS, gate; 10 Hz), erased at exit; only with a terminal on stderr; stdout unchanged +.TP +\fB\-\-plain\fR +no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged +.TP +\fB\-\-program\fR +print the compiled probbit\-ir program instead of running it (`probbit evaluate \-\-program | probbit run` = the same answer) +.RE +.PP +\fIcontrols\fR: flag > PROBBIT_* environment > probbit.json > default. +.PP +\fIexit\fR: 0 answer (exact | diagnostics_passed | partial), 1 infeasible (a proof), 2 bad input (one {"error"} object on stdout when stdout is not a terminal, or with \-\-json) or bad flag (stderr), 3 refused / declined / non\-finite result. +.PP +Examples: +.RS 4 +.nf +probbit evaluate \-\-pretty < examples/evaluate/support\-12.json +.fi +.RE +.SS demo +emit an agent\-routing problem as JSON (\-\-live: watch it get routed) +.PP +.nf +\fBprobbit\fR demo [flags] > router.json +.fi +.PP +Emit a synthetic agent\-routing document (JSON) for `probbit decide`. +.PP +Flags: +.RS 4 +.TP +\fB\-\-tasks\fR \fIN\fR +tasks to generate, 1..3333333 (default 12) +.TP +\fB\-\-seed\fR \fIN\fR +random seed (default 7) +.TP +\fB\-\-hard\fR +tight quotas + strong affinity +.TP +\fB\-\-live\fR +the router story on stderr: exact tiers, then a live field of p\-bits from the real chains, the gate, the verdict (default when stdout and stderr are both terminals; \-\-tasks defaults to 300; stdout, if not a terminal, gets the JSON problem) +.TP +\fB\-\-plain\fR +no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged +.RE +.PP +Examples: +.RS 4 +.nf +probbit demo \-\-tasks 300 \-\-hard > router.json +probbit demo \-\-live +.fi +.RE +.SS ir +a router document as probbit\-ir v0 text (the hardware\-facing form) +.PP +.nf +\fBprobbit\fR ir [\-\-max\-input\-mb N] [\-\-plain] < router.json +.fi +.PP +Print a router document as probbit\-ir v0 text (the hardware\-facing form). +.PP +Flags: +.RS 4 +.TP +\fB\-\-max\-input\-mb\fR \fIN\fR +largest stdin document read, MB (default 256; 0 = no limit) +.TP +\fB\-\-plain\fR +no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged +.RE +.PP +Examples: +.RS 4 +.nf +probbit demo | probbit ir +.fi +.RE +.SS mcp +a Model Context Protocol server on stdio: the commands as agent tools +.PP +.nf +\fBprobbit\fR mcp +.fi +.PP +A Model Context Protocol server on stdio (JSON\-RPC 2.0, one message per line; logs on stderr). Tools probbit_decide, probbit_run, probbit_stats, probbit_demo, probbit_evaluate, probbit_persona_init, probbit_persona_turn, probbit_persona_fuzz, probbit_live_event, probbit_live_verify: the commands' own JSON in and out. Exits when stdin closes. docs/agents.md. +.PP +Examples: +.RS 4 +.nf +claude mcp add probbit \-\- probbit mcp +.fi +.RE +.SS stats +the spec sheet: machine, build, controls + source, measured updates/s +.PP +.nf +\fBprobbit\fR stats [flags] +.fi +.PP +The processor's spec sheet: machine, build, effective controls + their source, measured updates/s. +.PP +Flags: +.RS 4 +.TP +\fB\-\-chains\fR \fIN\fR +sampler chains, 1..=100000 (default 4) +.TP +\fB\-\-threads\fR \fIN\fR +worker threads for the chains, the gate and the polish, 1..=1024 (default min(cores, 4)) +.TP +\fB\-\-cpu\-limit\fR \fIPCT\fR +per\-thread duty cycle 1..100 (default 100) +.TP +\fB\-\-mem\-limit\-mb\fR \fIN\fR +cap on stored samples (default 1024; 0 = none) +.TP +\fB\-\-priority\fR \fIlow|normal\fR +OS scheduling priority (default normal) +.TP +\fB\-\-sweeps\fR \fIN\fR +sweeps of the throughput measurement (default 2000) +.TP +\fB\-\-pretty\fR +indented JSON +.TP +\fB\-\-plain\fR +no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged +.RE +.PP +\fIcontrols\fR: flag > PROBBIT_* environment > probbit.json > default. +.PP +Examples: +.RS 4 +.nf +probbit stats \-\-pretty +.fi +.RE +.SS help +this screen, or a command's or subcommand's help (as COMMAND \-\-help) +.PP +.nf +\fBprobbit\fR help [COMMAND [SUBCOMMAND]] +.fi +.PP +Print the help of a command or subcommand, the same text as `probbit COMMAND [SUBCOMMAND] \-\-help`; alone, the same as `probbit \-\-help`. +.PP +Examples: +.RS 4 +.nf +probbit help persona fuzz +.fi +.RE +.SS completions +a completion script for bash, zsh or fish: commands, subcommands, flags +.PP +.nf +\fBprobbit\fR completions bash|zsh|fish +.fi +.PP +Print a static completion script for the shell: the commands, subcommands and flags (with their help where the shell shows it), generated from the same table as this help, so it lists exactly what the parser accepts. Regenerate it after an upgrade. +.PP +\fIinstall\fR: bash: probbit completions bash > ~/.local/share/bash\-completion/completions/probbit zsh: probbit completions zsh > ~/.zfunc/_probbit, with fpath=(~/.zfunc $fpath) before compinit in ~/.zshrc fish: probbit completions fish > ~/.config/fish/completions/probbit.fish +.PP +\fIexit\fR: 0 the script, 2 no shell or an unknown one (the list of shells on stderr). +.PP +Examples: +.RS 4 +.nf +probbit completions zsh > ~/.zfunc/_probbit +source <(probbit completions bash) +.fi +.RE +.SS man +the manual page (roff), generated from this table: probbit man | man \-l \- +.PP +.nf +\fBprobbit\fR man +.fi +.PP +Print the manual page in roff (man(7) macros): NAME, SYNOPSIS, DESCRIPTION, COMMANDS (every command, subcommand and flag of this help), EXIT STATUS, ENVIRONMENT, EXAMPLES, SEE ALSO. The release archives carry it as docs/probbit.1 and install.sh installs it to ~/.local/share/man/man1/. +.PP +Examples: +.RS 4 +.nf +probbit man | man \-l \- +probbit man > ~/.local/share/man/man1/probbit.1 +.fi +.RE +.SS version +print the version (also \-\-version, \-V) +.PP +.nf +\fBprobbit\fR version +.fi +.PP +Print the version. +.SH EXIT STATUS +0 done or an answer; 1 an answer that says no (infeasible, a line that differs, a counterexample, a rule left unknown); 2 a bad command, flag or input (a line on stderr; for bad input also one {"error"} object on stdout when stdout is not a terminal); 3 refused / declined / non\-finite (decide, run, evaluate), or a rule left unproven (persona lint); 4 refused by a lock, a pause or a retirement (live). Each command's \-\-help has its own list. +.SH ENVIRONMENT +PROBBIT_CHAINS, PROBBIT_THREADS, PROBBIT_CPU_LIMIT, PROBBIT_MEM_LIMIT_MB, PROBBIT_PRIORITY: the processor controls (flag > environment > probbit.json, or the file PROBBIT_CONFIG names > default). NO_COLOR=1 or PROBBIT_THEME=plain: no colour on a terminal. +.SH EXAMPLES +.nf +probbit demo | probbit decide \-\-summary \-\-pretty +probbit run \-\-pretty < examples/knapsack\-20.json +probbit persona init examples/persona/tutor.yaml \-\-out pip.json +probbit live examples/persona/tutor.yaml \-\-seed 2 \-\-demo week +probbit monitor \-\-demo \-\-open +claude mcp add probbit \-\- probbit mcp +.fi +.SH SEE ALSO +README.md, docs/CHEATSHEET.md, docs/persona.md, docs/probbit\-ir\-json.md and docs/agents.md in the release archive or at https://github.com/BitmapAsset/probbit diff --git a/install.sh b/install.sh index 14aa5bc..8cd2435 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,8 @@ # PROBBIT_DOWNLOAD_BASE the archive is fetched from $PROBBIT_DOWNLOAD_BASE//probbit--.tar.gz # (default: https://github.com/BitmapAsset/probbit/releases/download); needs PROBBIT_VERSION # PROBBIT_TARGET Rust target triple to fetch instead of the detected one +# PROBBIT_MAN_DIR where the manual page probbit.1 goes, when the archive has one (default: ~/.local/share/man/man1; +# a failure there is reported and installs nothing less) # Needs curl or wget, tar, and one of sha256sum, shasum or openssl. set -eu @@ -129,6 +131,16 @@ main() { cp "$tmp/$name/probbit" "$dir/.probbit.$$" && chmod 755 "$dir/.probbit.$$" && mv -f "$dir/.probbit.$$" "$dir/probbit" \ || die "could not install into $dir" say " installed $dir/probbit ($ran)" + # the manual page (archives from 0.8.2 carry docs/probbit.1): never fatal, never outside PROBBIT_MAN_DIR or HOME + if [ -f "$tmp/$name/docs/probbit.1" ]; then + mandir="${PROBBIT_MAN_DIR:-}" + if [ -z "$mandir" ] && [ -n "${HOME:-}" ]; then mandir="$HOME/.local/share/man/man1"; fi + if [ -n "$mandir" ] && mkdir -p "$mandir" 2> /dev/null && cp "$tmp/$name/docs/probbit.1" "$mandir/probbit.1" 2> /dev/null; then + say " installed $mandir/probbit.1 (man probbit)" + else + say " skipped the manual page (set PROBBIT_MAN_DIR to a directory you can write; or: probbit man)" + fi + fi cmd=probbit case ":$PATH:" in diff --git a/probbit-cli/src/json.rs b/probbit-cli/src/json.rs index 9022ffc..6cebfaf 100644 --- a/probbit-cli/src/json.rs +++ b/probbit-cli/src/json.rs @@ -21,7 +21,8 @@ pub fn parse(src: &str) -> Result { } /// A structured input error (docs/probbit-ir-json.md "Input contract"): `probbit decide`, `run` and `ir` print it as ONE JSON object -/// `{"error":{"code","path","message"}}` on stdout (plus a human line on stderr) and exit 2. `code`: `schema` (bad JSON, a wrong +/// `{"error":{"code","path","message"}}` on stdout (plus a human line on stderr; at a terminal without --json, the line only: +/// main.rs `error_object`) and exit 2. `code`: `schema` (bad JSON, a wrong /// type, an unknown, duplicate or missing field), `value` (well-typed but unusable: an unknown or duplicate name, an empty domain, /// a number out of its range), `limit` (beyond a documented numeric or size limit). `path`: `tasks[3].allowed`, "" = the document. #[derive(Debug)] diff --git a/probbit-cli/src/main.rs b/probbit-cli/src/main.rs index 685c823..0a07669 100644 --- a/probbit-cli/src/main.rs +++ b/probbit-cli/src/main.rs @@ -28,11 +28,13 @@ mod run; mod sys; mod theme; mod tui; +mod usage; mod yaml; use json::{num, obj, str as jstr, Json}; pub(crate) use router::{demo_doc, from_json, item_bars, DEMO_TPL}; use probbit_decide::*; use std::io::{Read, Write}; +use usage::{Command, Flag, Sub}; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -41,7 +43,34 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); /// 2>&1 >/dev/null | true`, or `--progress` into `head -c 1`: exit 134 with panic = abort, the decision lost to a log line). A /// failed write to stderr is ignored: the command keeps its stdout and its exit code. fn err_line(s: &str) { let _ = writeln!(std::io::stderr(), "{s}"); } -fn fail(msg: &str) -> ! { tui::top_stop(); err_line(&format!("probbit: {msg}")); exit(2) } +/// The error channel (R94, docs/CHEATSHEET.md "Errors"): a human line on stderr, always. A bad input document also gets its +/// `{"error"}` object on stdout when stdout is not a terminal (every wrapper reads it there: python/, mcp.rs, npm, the examples) or +/// with `--json`; at a terminal without `--json`, only the line. Usage errors (`fail`: an unknown command, subcommand or flag, a bad +/// flag value) stay off stdout unless `--json` is given, so a piped caller tells them from input errors by an empty stdout. +static JSON_FLAG: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +fn json_flag() -> bool { JSON_FLAG.load(std::sync::atomic::Ordering::Relaxed) } +/// An input error's object goes to stdout (see `JSON_FLAG`) +fn error_object(e: &Json) { if json_flag() || !theme::stdout_is_terminal() { emit(&json::write(e, false)); } } +fn fail(msg: &str) -> ! { fail_as("flag", msg, None) } +/// `fail` with the code of the `--json` object and, for an unknown command or subcommand, its `suggestion` (a name or null) +fn fail_as(code: &str, msg: &str, suggestion: Option>) -> ! { + tui::top_stop(); err_line(&format!("probbit: {msg}")); + if json_flag() { + let mut e = vec![("code", jstr(code)), ("path", jstr("")), ("message", jstr(msg))]; + if let Some(s) = suggestion { e.push(("suggestion", s.map_or(Json::Null, jstr))); } + emit(&json::write(&obj(vec![("error", obj(e))]), false)); + } + exit(2) +} +/// An unknown command (`sub` None) or subcommand of `cmd`: exit 2 with the closest name (`usage::suggest`) if there is one: +/// `probbit: unknown command "persna"; did you mean "persona"? see `probbit --help`` +fn unknown(cmd: Option<&Command>, word: &str) -> ! { + let (what, cands, see): (String, Vec<&str>, String) = match cmd { + None => ("unknown command".into(), COMMANDS.iter().map(|c| c.name).collect(), "probbit --help".into()), + Some(c) => (format!("unknown {} subcommand", c.name), c.subs.iter().map(|s| s.name).collect(), format!("probbit {} --help", c.name)) }; + let s = usage::suggest(word, &cands); + fail_as("command", &match s { Some(s) => format!("{what} {word:?}; did you mean {s:?}? see `{see}`"), None => format!("{what} {word:?}; see `{see}`") }, Some(s)) +} /// Exit with `code`, releasing a strand's writer lock this process holds first (live.rs `Lock`; `std::process::exit` skips `Drop`) fn exit(code: i32) -> ! { live::release_held(); std::process::exit(code) } /// `--budget-ms inf` / `1e300` aborted (exit 134: the deadline Duration overflowed) and `NaN` never stopped sampling @@ -82,10 +111,10 @@ fn emit_raw(s: &str) { } fn emit(s: &str) { emit_raw(&format!("{s}\n")) } -/// Bad input (JSON, schema, values): ONE structured error object on stdout, a human line on stderr, exit 2. +/// Bad input (JSON, schema, values): a human line on stderr and ONE structured error object on stdout (`error_object`), exit 2. fn bad_input(what: &str, e: json::InErr) -> ! { err_line(&format!("probbit: {what}{}{}", if e.path.is_empty() { String::new() } else { format!("{}: ", e.path) }, e.msg)); - emit(&json::write(&e.to_json(), false)); exit(2) + error_object(&e.to_json()); exit(2) } /// Emit a decision; returns its exit code. Every number in it must be finite (docs/probbit-ir-json.md "Numeric contract"). A /// gate diagnostic that could not be estimated (R-hat / bound infinite: too few samples, chains stuck at different values) is @@ -152,7 +181,7 @@ fn finish(mut doc: Json, code: i32, view: &View, bars: &[f64]) -> i32 { g.push(("non_finite".to_string(), Json::Arr(bad.iter().map(|p| jstr(&p["gate.".len()..])).collect()))); } } } else { let e = json::InErr { code: "numeric", path: bad[0].clone(), msg: format!("{} computed quantit{} not finite (first: {}); no answer is emitted", bad.len(), if bad.len() == 1 { "y is" } else { "ies are" }, bad[0]) }; - err_line(&format!("probbit: numeric failure: {}", e.msg)); emit(&json::write(&e.to_json(), false)); return 3; + err_line(&format!("probbit: numeric failure: {}", e.msg)); error_object(&e.to_json()); return 3; } } if view.summary { doc = summary_doc(&doc, bars, view.cmd); } @@ -171,70 +200,237 @@ fn arg(args: &[String], name: &str, default: T) -> T { Some(v) => v.parse().unwrap_or_else(|_| fail(&format!("{name}: cannot read {v:?} (integer flags take plain digits, e.g. 10000 not 1e4)"))), None => fail(&format!("{name} needs a value")) } } } -/// Flags taking a value, per command (the controls are shared by decide, run and stats). -const CONTROLS: [&str; 5] = ["--chains", "--threads", "--cpu-limit", "--mem-limit-mb", "--priority"]; -/// Per command: (flags taking a value, switches); `check_flags` enforces exactly these and `help` lists exactly these (R19.8). -fn flags_of(cmd: &str) -> (Vec<&'static str>, Vec<&'static str>) { - match cmd { - "decide" => ([&SEARCH[..], &CONTROLS[..], &["--mode", "--max-input-mb"]].concat(), vec!["--pretty", "--progress", "--summary", "--top", "--plain"]), - "run" => ([&SEARCH[..], &CONTROLS[..], &["--op", "--deadline-ms", "--max-input-mb"]].concat(), vec!["--pretty", "--progress", "--summary", "--top", "--plain"]), - "evaluate" => ([&SEARCH[..], &CONTROLS[..], &["--op", "--deadline-ms", "--max-input-mb"]].concat(), vec!["--pretty", "--progress", "--summary", "--top", "--plain", "--program"]), - "stats" => ([&CONTROLS[..], &["--sweeps"]].concat(), vec!["--pretty", "--plain"]), - "demo" => (vec!["--tasks", "--seed"], vec!["--hard", "--live", "--plain"]), - "ir" => (vec!["--max-input-mb"], vec!["--plain"]), - _ => (vec![], vec![]), - } -} -/// One line per flag for `help` (R19.8, P3.1); `help` panics on a flag without a line (test `every_command_has_help`). -const FLAG_HELP: [(&str, &str); 29] = [ - ("--budget-ms", "N sampler wall-clock budget, ms (default 200)"), ("--seed", "N random seed (default 7)"), - ("--exact-limit", "N plans the exact enumeration may count before it declines (default 2000000)"), - ("--exact-ms", "N wall-clock cap on the exact tiers that run before the sampler (opt-in; absent = no cap)"), - ("--frontier-states", "N state cap of the frontier DP and the components tier (default 4096; 0 = off)"), - ("--polish-ms", "N plan polish budget, ms (default 50; 0 = off)"), ("--polish-sweeps", "N fixed-work plan polish (deterministic)"), - ("--sweeps", "N fixed work per chain instead of the wall-clock budget (with --polish-ms 0: a pure function of input + seed)"), - ("--collective", "on|off global two-value flips + label swaps (default on)"), ("--cluster", "on|off Wolff cluster move (default on)"), - ("--cycles", "on|off three-cycle rotations (default on)"), ("--chains", "N sampler chains, 1..=100000 (default 4)"), - ("--threads", "N worker threads for the chains, the gate and the polish, 1..=1024 (default min(cores, 4))"), - ("--cpu-limit", "PCT per-thread duty cycle 1..100 (default 100)"), ("--mem-limit-mb", "N cap on stored samples (default 1024; 0 = none)"), - ("--priority", "low|normal OS scheduling priority (default normal)"), ("--mode", "auto|exact|sample which tiers may answer (default auto)"), - ("--op", "decide|exact|sample decide = exact tiers, then the sampler; exact / sample = only those (default decide)"), - ("--deadline-ms", "N whole-call deadline (exact N/4, then sampler + polish); answer has `deadline` + `phases`"), - ("--pretty", "indented JSON"), ("--progress", "[MS] one JSONL telemetry line on stderr every MS ms (default 100)"), - ("--tasks", "N tasks to generate, 1..3333333 (default 12)"), ("--hard", "tight quotas + strong affinity"), - ("--max-input-mb", "N largest stdin document read, MB (default 256; 0 = no limit)"), - ("--summary", "compact answer: verdict, counts, gate, the 5 worst released and escalated items, telemetry (no per-item tables);\n with --pretty and a terminal on stderr, also a boxed summary there"), - ("--top", "live monitor on stderr while it runs (tier, sweeps, updates/s, CPU, peak RSS, gate; 10 Hz), erased at exit;\n only with a terminal on stderr; stdout unchanged"), - ("--live", "the router story on stderr: exact tiers, then a live field of p-bits from the real chains, the gate, the verdict\n (default when stdout and stderr are both terminals; --tasks defaults to 300; stdout, if not a terminal, gets the JSON problem)"), - ("--plain", "no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged"), - ("--program", "print the compiled probbit-ir program instead of running it (`probbit evaluate --program | probbit run` = the same answer)"), +/// THE command table (R94): every command, subcommand and flag the parser accepts, with its help. `--help`, `probbit help`, the +/// completions and the man page render from it (usage.rs) and `check_flags` enforces it (`flags_of`, `sub_flags`), so what is +/// documented is what is accepted. Tests: the unit tests below and tests/cli_table.rs. +const F_SEARCH: &[Flag] = &[ + Flag { name: "--budget-ms", arg: "N", help: "sampler wall-clock budget, ms (default 200)" }, + Flag { name: "--seed", arg: "N", help: "random seed (default 7)" }, + Flag { name: "--exact-limit", arg: "N", help: "plans the exact enumeration may count before it declines (default 2000000)" }, + Flag { name: "--exact-ms", arg: "N", help: "wall-clock cap on the exact tiers that run before the sampler (opt-in; absent = no cap)" }, + Flag { name: "--frontier-states", arg: "N", help: "state cap of the frontier DP and the components tier (default 4096; 0 = off)" }, + Flag { name: "--polish-ms", arg: "N", help: "plan polish budget, ms (default 50; 0 = off)" }, + Flag { name: "--polish-sweeps", arg: "N", help: "fixed-work plan polish (deterministic)" }, + Flag { name: "--sweeps", arg: "N", help: "fixed work per chain instead of the wall-clock budget (with --polish-ms 0: a pure function of input + seed)" }, + Flag { name: "--collective", arg: "on|off", help: "global two-value flips + label swaps (default on)" }, + Flag { name: "--cluster", arg: "on|off", help: "Wolff cluster move (default on)" }, + Flag { name: "--cycles", arg: "on|off", help: "three-cycle rotations (default on)" }, +]; +const F_CONTROLS: &[Flag] = &[ + Flag { name: "--chains", arg: "N", help: "sampler chains, 1..=100000 (default 4)" }, + Flag { name: "--threads", arg: "N", help: "worker threads for the chains, the gate and the polish, 1..=1024 (default min(cores, 4))" }, + Flag { name: "--cpu-limit", arg: "PCT", help: "per-thread duty cycle 1..100 (default 100)" }, + Flag { name: "--mem-limit-mb", arg: "N", help: "cap on stored samples (default 1024; 0 = none)" }, + Flag { name: "--priority", arg: "low|normal", help: "OS scheduling priority (default normal)" }, ]; -/// `probbit --help` / `-h` (R19.8, P3.1): what it does, every flag it accepts, exit codes. None = not a command. -fn help(cmd: &str) -> Option { - let (what, usage) = match cmd { - "decide" => ("Route tasks to workers: a router document (workers + caps, tasks + allowed + scores, affinity; README) on stdin,\n a JSON decision on stdout. Exact where structure allows, else sampled with the diagnostics gate.", "probbit decide [flags] < router.json"), - "run" => ("Run a probbit-ir program (variables, values, scores, rules; docs/probbit-ir-json.md) on stdin; JSON answer on stdout.", "probbit run [flags] < program.json"), - "evaluate" => ("The decision-API adapter: a System One request (questions noul | choice | score) plus a \"probbit\" block (the judge's\n answers, rules over question ids) on stdin; the rule-abiding joint answer in the judge's response shape, odds per question\n and the gate's verdict on stdout. Every `probbit run` flag applies. docs/probbit-ir-json.md \"Decision API\".", "probbit evaluate [flags] < request.json"), - "demo" => ("Emit a synthetic agent-routing document (JSON) for `probbit decide`.", "probbit demo [flags] > router.json"), - "ir" => ("Print a router document as probbit-ir v0 text (the hardware-facing form).", "probbit ir < router.json"), - "stats" => ("The processor's spec sheet: machine, build, effective controls + their source, measured updates/s.", "probbit stats [flags]"), - "mcp" => ("A Model Context Protocol server on stdio (JSON-RPC 2.0, one message per line; logs on stderr). Tools probbit_decide,\n probbit_run, probbit_stats, probbit_demo, probbit_evaluate, probbit_persona_init, probbit_persona_turn, probbit_persona_fuzz,\n probbit_live_event, probbit_live_verify: the commands' own JSON in and out. Exits when stdin closes. docs/agents.md.", "probbit mcp"), - "persona" => (PERSONA_HELP, "probbit persona PERSONA [flags]"), - "live" => (LIVE_HELP, "probbit live PERSONA [--seed N | --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real|fixed] [--checkpoint-every K] | probbit live PERSONA --demo week [--seed N] [--strand FILE] [--plain] | probbit live verify STRAND [--from-checkpoint] | probbit live control STRAND pause|resume|retire --by WHO --reason TEXT [--at TIME]"), - "version" => ("Print the version.", "probbit version"), _ => return None }; - let (vals, sw) = flags_of(cmd); let mut h = format!("usage: {usage}\n {what}\n"); - if !vals.is_empty() || !sw.is_empty() { h.push_str("flags:\n"); } - for f in vals.iter().chain(&sw) { - let d = if cmd == "stats" && *f == "--sweeps" { "N sweeps of the throughput measurement (default 2000)" } - else { FLAG_HELP.iter().find(|(k, _)| k == f).unwrap_or_else(|| panic!("no help line for {f}")).1 }; - h.push_str(&format!(" {f} {d}\n")); } - if ["decide", "run", "evaluate", "stats"].contains(&cmd) { h.push_str("controls: flag > PROBBIT_* environment > probbit.json > default.\n"); } - if cmd == "live" { h.push_str("exit: 0 done (live: every event answered; verify: every line replays; control: the line appended), 1 verify: a line differs\n (its number and what), 2 a bad persona, state, event (one {\"error\"} object on stdout per bad event; the run goes on), control\n line or flag, 4 refused: the strand's writer lock is held by another writer, or the individual is paused or retired (one\n {\"error\"} object per refused event; nothing is written).\n"); } - if cmd == "persona" { h.push_str("exit: 0 done (every turn status, refusals and fallbacks included, is an answer; fuzz: no counterexample found; prove: every rule\n held or proved), 1 lint found an unresolved contradiction or (with rules) a counterexample, fuzz found a counterexample or prove left a rule\n unknown, 2 bad persona / state / inputs / script / rule (one {\"error\"}\n object on stdout, code \"persona\") or bad flag (stderr), 3 lint (with rules): a rule the bound leaves unknown and the search\n cannot break (\"unproven\", not broken).\n"); } - if ["decide", "run", "evaluate"].contains(&cmd) { h.push_str("exit: 0 answer (exact | diagnostics_passed | partial), 1 infeasible (a proof), 2 bad input (one {\"error\"} object\n on stdout) or bad flag (stderr), 3 refused / declined / non-finite result.\n"); } - Some(h) -} -const SEARCH: [&str; 11] = ["--budget-ms", "--seed", "--exact-limit", "--exact-ms", "--frontier-states", "--polish-ms", "--polish-sweeps", "--sweeps", "--collective", "--cluster", "--cycles"]; +const F_MAX_INPUT: Flag = Flag { name: "--max-input-mb", arg: "N", help: "largest stdin document read, MB (default 256; 0 = no limit)" }; +const F_PRETTY: Flag = Flag { name: "--pretty", arg: "", help: "indented JSON" }; +const F_PLAIN: Flag = Flag { name: "--plain", arg: "", help: "no colour, no animation (as NO_COLOR=1 or PROBBIT_THEME=plain); output otherwise unchanged" }; +const F_PROGRESS: Flag = Flag { name: "--progress", arg: "[MS]", help: "one JSONL telemetry line on stderr every MS ms (default 100)" }; +const F_SUMMARY: Flag = Flag { name: "--summary", arg: "", help: "compact answer: verdict, counts, gate, the 5 worst released and escalated items, telemetry (no per-item tables); with --pretty and a terminal on stderr, also a boxed summary there" }; +const F_TOP: Flag = Flag { name: "--top", arg: "", help: "live monitor on stderr while it runs (tier, sweeps, updates/s, CPU, peak RSS, gate; 10 Hz), erased at exit; only with a terminal on stderr; stdout unchanged" }; +const F_OP: Flag = Flag { name: "--op", arg: "decide|exact|sample", help: "decide = exact tiers, then the sampler; exact / sample = only those (default decide)" }; +const F_DEADLINE: Flag = Flag { name: "--deadline-ms", arg: "N", help: "whole-call deadline (exact N/4, then sampler + polish); answer has `deadline` + `phases`" }; +const F_VIEW: &[Flag] = &[F_PRETTY, F_PROGRESS, F_SUMMARY, F_TOP, F_PLAIN]; +const EXIT_DECIDE: &str = "0 answer (exact | diagnostics_passed | partial), 1 infeasible (a proof), 2 bad input (one {\"error\"} object on stdout when stdout is not a terminal, or with --json) or bad flag (stderr), 3 refused / declined / non-finite result."; +const CONTROLS_NOTE: (&str, &str) = ("controls", "flag > PROBBIT_* environment > probbit.json > default."); +const F_P_SEED: Flag = Flag { name: "--seed", arg: "N", help: "the individual: an integer seed from 0 to 2^53 (default: the persona's seed)" }; +const F_P_SCRIPT: Flag = Flag { name: "--script", arg: "JSON|FILE", help: "the turns: a JSON list of per-turn input objects, or {\"turns\": [...]}; inline or a file" }; +const F_P_STATE: Flag = Flag { name: "--state", arg: "STATE", help: "the individual's stored state (`probbit persona init --out` writes one)" }; +const F_P_INPUTS: Flag = Flag { name: "--inputs", arg: "JSON|FILE", help: "this turn's inputs: a JSON object, inline or a file (default {})" }; +const F_P_TIMING: Flag = Flag { name: "--timing", arg: "", help: "add a separate, non-canonical `timing` object (compile, engine and decode ms)" }; +const F_P_NO_INERTIA: Flag = Flag { name: "--no-inertia", arg: "", help: "moods carry nothing over from the previous turn (inertia 0)" }; +const F_P_NEVER: Flag = Flag { name: "--never", arg: "RULE", help: "a rule in habit syntax, {when: {...}, then: {...}}: JSON or one line of the YAML subset" }; +const F_P_PROPS: Flag = Flag { name: "--props", arg: "FILE", help: "rules from a file ({\"props\": [...]}, each with an optional id; YAML subset, or .json)" }; +const F_P_SEEDS: Flag = Flag { name: "--seeds", arg: "LIST", help: "the individuals: 0-99 (default), 7, 1,4,9 or 0-9,20-29; seeds 0 to 2^53, at most 100000" }; +const F_P_THREADS: Flag = Flag { name: "--threads", arg: "N", help: "search threads, 1 to 1024 (default: the logical CPUs)" }; +const F_P_JSON: Flag = Flag { name: "--json", arg: "", help: "the JSON document instead of the readable report" }; +const EXIT_PERSONA: &str = "0 done (every turn status, refusals and fallbacks included, is an answer; fuzz: no counterexample found; prove: every rule held or proved), 1 lint found an unresolved contradiction or (with rules) a counterexample, fuzz found a counterexample or prove left a rule unknown, 2 bad persona / state / inputs / script / rule (one {\"error\"} object on stdout, code \"persona\", when stdout is not a terminal or with --json) or bad flag (stderr), 3 lint (with rules): a rule the bound leaves unknown and the search cannot break (\"unproven\", not broken)."; +const EXIT_LIVE: &str = "0 done (live: every event answered; verify: every line replays; control: the line appended), 1 verify: a line differs (its number and what), 2 a bad persona, state, event (one {\"error\"} object on stdout per bad event when stdout is not a terminal or with --json; the run goes on), control line or flag, 4 refused: the strand's writer lock is held by another writer, or the individual is paused or retired (one {\"error\"} object per refused event, as for 2; nothing is written)."; +pub(crate) static COMMANDS: &[Command] = &[ + Command { name: "persona", aliases: &[], group: "persona", summary: "the individuality layer: persona + state + inputs -> one stance", + usage: &["persona SUB PERSONA [flags] (SUB: init turn replay explain diff lint fuzz prove check compile describe)"], + about: "The individuality layer (docs/persona.md): a persona file (YAML subset or JSON: traits with priors, moods with inertia, couplings, per-turn inputs, history, habits = hard rules) + an individual's state + this turn's inputs -> ONE probbit-ir program, run in process (`probbit run --op decide` at fixed work) -> the stance: a level per trait with exact odds, the habits in force and the ones that bound, a refusal when the engine cannot vouch, a why and a short stance line for any model's prompt.\nDocuments are canonical JSON (keys sorted); a turn is a pure function of (persona, state, inputs, version).", + before: &[], + flags: &[&[Flag { name: "--pretty", arg: "", help: "indent a single JSON document on stdout (init, turn, diff, lint, check, compile, describe, fuzz, prove); fuzz/prove --pretty implies --json. State files stay canonical. Replay stays JSONL; explain stays plain text." }]], + subs: &[ + Sub { name: "init", args: "PERSONA [--seed N] [--out STATE]", help: "a new individual (genes from the seed, resting stance); stdout or --out", + flags: &[F_P_SEED, Flag { name: "--out", arg: "STATE", help: "write the state to STATE instead of stdout" }] }, + Sub { name: "turn", args: "PERSONA --state STATE [--inputs JSON|FILE] [--out STATE2] [--timing] [--no-inertia]", help: "the stance on stdout; the new state replaces STATE (or goes to --out)", + flags: &[F_P_STATE, F_P_INPUTS, Flag { name: "--out", arg: "STATE2", help: "write the new state to STATE2 instead of replacing STATE" }, F_P_TIMING, F_P_NO_INERTIA] }, + Sub { name: "replay", args: "PERSONA [--seed N] --script JSON|FILE [--out TRACE] [--no-inertia] [--timing]", help: "init, then every turn of the script: one stance per line (JSONL)", + flags: &[F_P_SEED, F_P_SCRIPT, Flag { name: "--out", arg: "TRACE", help: "write the JSONL trace to TRACE instead of stdout" }, F_P_NO_INERTIA, F_P_TIMING] }, + Sub { name: "explain", args: "PERSONA [--seed N] --script JSON|FILE --turn K", help: "turn K in words: every contribution, the odds, the twin", + flags: &[F_P_SEED, F_P_SCRIPT, Flag { name: "--turn", arg: "K", help: "the turn to explain, counted from 0 (required)" }] }, + Sub { name: "diff", args: "PERSONA [--seed A] [--other PERSONA2] [--seed2 B] --script JSON|FILE", help: "distance between two individuals", + flags: &[Flag { name: "--seed", arg: "A", help: "the first individual's seed (default: the persona's seed)" }, + Flag { name: "--other", arg: "PERSONA2", help: "the second individual's persona (default: the same persona)" }, + Flag { name: "--seed2", arg: "B", help: "the second individual's seed (default: its persona's seed)" }, F_P_SCRIPT] }, + Sub { name: "lint", args: "PERSONA [--never RULE | --props FILE] [--seeds 0-99] [--threads N]", + help: "contradicting habits (every conditional habit and pair, every prev level); warnings: planned levels that are not their trait's most likely one; with rules: prove each one, fuzz the unknown ones; exit 1 on a counterexample, exit 3 when a rule stays unknown and the search finds none (\"unproven\": not broken, not proved either)", + flags: &[F_P_NEVER, F_P_PROPS, F_P_SEEDS, F_P_THREADS] }, + Sub { name: "fuzz", args: "PERSONA (--never RULE | --props FILE) [--seeds 0-99] [--fuzz-seed N] [--scripts N] [--depth N] [--beam N] [--grid LIST] [--hours LIST] [--threads N] [--json] [--fixture-src env:synthetic|human:synthetic]", + help: "search event scripts for the shortest one whose stance breaks a rule (habit syntax: {when: {...}, then: {...}}); shrunk (§5.6). --fixture-src explicitly authorizes synthetic-source fixtures for strict replay; never use them to relabel production events. The persona must allow that source.", + flags: &[F_P_NEVER, F_P_PROPS, F_P_SEEDS, + Flag { name: "--fuzz-seed", arg: "N", help: "the search's own seed (default 0): the same inputs give the same report on any number of threads" }, + Flag { name: "--scripts", arg: "N", help: "random scripts per individual before the beam, 0 to 1000000 (default 60)" }, + Flag { name: "--depth", arg: "N", help: "events per script, 1 to 64 (default 8)" }, + Flag { name: "--beam", arg: "N", help: "width of the odds-guided beam, 0 to 64 (default 4)" }, + Flag { name: "--grid", arg: "LIST", help: "comma-separated fractions of a number input's max to try, 0 to 1 (default 0,0.5,1)" }, + Flag { name: "--hours", arg: "LIST", help: "comma-separated idle hours to try between events (default 1,12,48)" }, + F_P_THREADS, F_P_JSON, + Flag { name: "--fixture-src", arg: "env:synthetic|human:synthetic", help: "label exported counterexamples with that synthetic source so their replay commands run" }] }, + Sub { name: "prove", args: "PERSONA (--never RULE | --props FILE) [--seeds 0-99] [--threads N] [--json]", + help: "per rule: held by construction (a habit implies it), proved for every event sequence (a sound bound), or unknown (the cell it fails; fuzz it) (§5.6)", + flags: &[F_P_NEVER, F_P_PROPS, F_P_SEEDS, F_P_THREADS, F_P_JSON] }, + Sub { name: "check", args: "PERSONA", help: "valid? digest and sizes", flags: &[] }, + Sub { name: "compile", args: "PERSONA --state STATE [--inputs JSON|FILE]", help: "the turn's probbit-ir program (its sha256 = engine.program)", flags: &[F_P_STATE, F_P_INPUTS] }, + Sub { name: "describe", args: "PERSONA", help: "traits, moods, inputs, habits, agenda", flags: &[] }, + ], + list_subs: false, shared: true, + after: &[("scripts", "a script is a JSON list of per-turn input objects, or {\"turns\": [...]}. Resource controls as for run (PROBBIT_THREADS, ...)."), ("exit", EXIT_PERSONA)], + examples: &["probbit persona init examples/persona/tutor.yaml --seed 2 --out pip.json", + "probbit persona turn examples/persona/tutor.yaml --state pip.json --inputs '{\"loss\": true}'", + "probbit persona lint examples/persona/tutor.yaml"], + run: persona_cmd }, + Command { name: "live", aliases: &[], group: "live", summary: "a resident individual: JSONL events in, one stance per event out", + usage: &["live PERSONA [--seed N | --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real|fixed] [--checkpoint-every K]", + "live PERSONA --demo week [--seed N] [--strand FILE] [--plain]", + "live verify STRAND [--from-checkpoint]", + "live control STRAND pause|resume|retire --by WHO --reason TEXT [--at TIME]"], + about: "A resident individual (docs/persona.md §5.7): JSONL events (one object of inputs per line) from --events FILE or stdin, one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e-6 h), so moods decay by their half-lives between events; feedback moves the learned deltas of a persona with a learning block (§2.8).\n--strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used, the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it.", + before: &[], + flags: &[&[ + Flag { name: "--seed", arg: "N", help: "a new individual (default: the persona's seed)" }, + Flag { name: "--state", arg: "FILE", help: "a stored individual instead; rewritten after every event" }, + Flag { name: "--strand", arg: "FILE", help: "log the life to FILE: a new file gets the header; an existing strand is continued from --state (the state after its last line); a strand is never rewritten, just appended to" }, + Flag { name: "--events", arg: "FILE", help: "read events from FILE (default stdin)" }, + Flag { name: "--watch", arg: "", help: "follow --events FILE as lines are appended (tail -f); stops when the file is removed" }, + Flag { name: "--clock", arg: "real|fixed", help: "real (default): elapsed hours from a monotonic clock started with the run; fixed: each event carries its own elapsed_hours (default 0), so the run is a pure function of its events" }, + Flag { name: "--demo", arg: "week", help: "one individual's scripted week on the fixed clock (7 days, events hourly 09:00-15:00): praise for short answers moves the learned verbosity deltas to their cap, a quiet night relaxes the mood to its resting level, a campaign praising jokes raises humour while failure turns stay joke-free; a persona without a learning block gets the demo's (said in the opening line). Bars on stderr at a terminal, paced 1 s per hour (nights fast-forward in 2 s); otherwise one line per event on stdout, no waiting. With --seed, --strand" }, + F_PLAIN, + Flag { name: "--checkpoint-every", arg: "K", help: "with --strand: a checkpoint line (the whole state) after every K-th event (default 1000; 0: none); `verify --from-checkpoint` and `monitor` start at the last one" }, + ]], + subs: &[ + Sub { name: "verify", args: "STRAND [--from-checkpoint]", help: "replay a strand: the earliest line that differs; from the header (every checkpoint checked), or from the last checkpoint", + flags: &[Flag { name: "--from-checkpoint", arg: "", help: "start at the last checkpoint instead of the header" }] }, + Sub { name: "control", args: "STRAND pause|resume|retire --by WHO --reason TEXT [--at TIME]", + help: "pause, resume or retire an individual; appends a control line under the lock: while paused or retired every event is refused (exit 4); no credit crosses a control line; retire is final; `verify` replays them and reports the status", + flags: &[Flag { name: "--by", arg: "WHO", help: "who decided it: human:ID or env:ID (required)" }, + Flag { name: "--reason", arg: "TEXT", help: "why (required)" }, + Flag { name: "--at", arg: "TIME", help: "when (default: now, UTC)" }] }, + ], + list_subs: true, shared: false, + after: &[("the safety kit", "(docs/persona.md §5.7) one writer per strand: `--strand` takes STRAND.lock (a second writer exits 4 and changes nothing; a lock whose process is gone is taken over). With `reward_from` in the persona (§2.10), a reward from `src: self`, without a src or from an undeclared one is refused like a bad event."), + ("exit", EXIT_LIVE)], + examples: &["probbit live examples/persona/tutor.yaml --seed 2 --demo week", + "probbit live examples/persona/tutor.yaml --clock fixed --strand pip.strand < events.jsonl", + "probbit live verify pip.strand"], + run: live_cmd }, + Command { name: "monitor", aliases: &[], group: "monitor", summary: "an individual's inner state, live: terminal bars or a page on 127.0.0.1", + usage: &["monitor STRAND [--follow] [--once] [--plain] [--fps N] [--serve] [--open] [--port N]", + "monitor --demo [week|drives] [--once] [--plain] [--serve] [--open] [--port N]"], + about: "Watch an individual's inner state (docs/persona.md §5.8): replays the strand (`probbit live --strand` writes it) with the rules of `probbit live verify`, recomputing every stance document from the strand alone, and draws the latest event as horizontal bars: the stance (each trait's levels with their reported odds, the level taken highlighted, the phrase it says), the moods with a sparkline of the last 50 events, the senses (the event's inputs, the history features), the habits in force and the ones that bound, the learned deltas, the drives when the documents carry them, and the stance line.\nIt changes no file (--demo: its week goes through a temporary one, removed at once) and sends nothing anywhere.", + before: &[("modes", "one frame on stdout, then exit (--once; the default without --follow) · the terminal, redrawn as the strand grows (--follow) · a page in the browser (--serve; --open also opens it).")], + flags: &[&[ + Flag { name: "--follow", arg: "", help: "keep watching: lines appended to the strand are replayed and drawn within a second (a line counts once its newline is written); a truncated or rotated strand is replayed from the start, with a warning; Ctrl-C quits" }, + Flag { name: "--once", arg: "", help: "one frame on stdout, then exit (the default without --follow)" }, + Flag { name: "--plain", arg: "", help: "plain ASCII, no colour (NO_COLOR=1: no colour; PROBBIT_THEME=plain or TERM=dumb: as --plain)" }, + Flag { name: "--fps", arg: "N", help: "with --follow, at most N redraws per second (1-60, default 10)" }, + Flag { name: "--serve", arg: "", help: "the page instead of the terminal, following the strand (or looping the selected synthetic demo): its URL http://127.0.0.1:PORT/ is the line on stdout. GET / the page (one file; it fetches no fonts, styles or scripts), /events its server-sent events (the layout, the latest frame, then a frame per event, a heartbeat every 15 s; events that land within one 100 ms poll, or while a page falls behind, come as the latest frame), /doc/N event N's stance document, as `probbit live` printed it (the latest 10000 are kept). It binds 127.0.0.1 and no other address (there is no --host), answers requests addressed to 127.0.0.1 or localhost, and sends nothing anywhere; Ctrl-C quits" }, + Flag { name: "--open", arg: "", help: "serve the page (as --serve does) and open it in the default browser (BROWSER if set; else open on macOS, start on Windows, xdg-open elsewhere); a browser that does not start fails nothing" }, + Flag { name: "--port", arg: "N", help: "with --serve or --open, the port (default 0: a free one)" }, + Flag { name: "--demo", arg: "[week|drives]", help: "synthetic data, never a live individual (default week); see demo below" }, + ]], + subs: &[], list_subs: false, shared: false, + after: &[("demo", "the tutor's week is the one `probbit live examples/persona/tutor.yaml --seed 2 --demo week` writes, replayed in memory: at a terminal or with --serve paced 1 s per hour, each night in 2 s (under a minute); otherwise, or with --once, its last frame. `--demo drives` plays a short goal-signal scenario with wanting, afterglow and prediction errors. The page labels each loop and its restart; Ctrl-C quits."), + ("exit", "0 every line replays, 1 a line differs (the frame names it and shows the event before it), 2 a bad flag, a port that cannot be had, or a file that cannot be read or is not a strand.")], + examples: &["probbit monitor --demo --open", "probbit monitor pip.strand --follow", "probbit monitor pip.strand --serve --port 8080"], + run: monitor::cmd }, + Command { name: "decide", aliases: &[], group: "decide/run", summary: "route tasks to workers: a router document in, a JSON decision out", + usage: &["decide [flags] < router.json"], + about: "Route tasks to workers: a router document (workers + caps, tasks + allowed + scores, affinity; README) on stdin, a JSON decision on stdout. Exact where structure allows, else sampled with the diagnostics gate.", + before: &[], flags: &[F_SEARCH, F_CONTROLS, &[Flag { name: "--mode", arg: "auto|exact|sample", help: "which tiers may answer (default auto)" }, F_MAX_INPUT], F_VIEW], subs: &[], list_subs: false, shared: false, + after: &[CONTROLS_NOTE, ("exit", EXIT_DECIDE)], + examples: &["probbit demo --tasks 300 | probbit decide --summary --pretty", "probbit decide --sweeps 6400 --polish-ms 0 < router.json"], + run: decide_cmd }, + Command { name: "run", aliases: &[], group: "decide/run", summary: "run a probbit-ir program (docs/probbit-ir-json.md), a JSON answer out", + usage: &["run [flags] < program.json"], + about: "Run a probbit-ir program (variables, values, scores, rules; docs/probbit-ir-json.md) on stdin; JSON answer on stdout.", + before: &[], flags: &[F_SEARCH, F_CONTROLS, &[F_OP, F_DEADLINE, F_MAX_INPUT], F_VIEW], subs: &[], list_subs: false, shared: false, + after: &[CONTROLS_NOTE, ("exit", EXIT_DECIDE)], + examples: &["probbit run --pretty < examples/knapsack-20.json", "probbit run --deadline-ms 50 < program.json"], + run: run_cmd }, + Command { name: "evaluate", aliases: &[], group: "decide/run", summary: "the decision-API adapter: request + judge's answers + rules -> joint answer", + usage: &["evaluate [flags] < request.json"], + about: "The decision-API adapter: a System One request (questions noul | choice | score) plus a \"probbit\" block (the judge's answers, rules over question ids) on stdin; the rule-abiding joint answer in the judge's response shape, odds per question and the gate's verdict on stdout. Every `probbit run` flag applies. docs/probbit-ir-json.md \"Decision API\".", + before: &[], flags: &[F_SEARCH, F_CONTROLS, &[F_OP, F_DEADLINE, F_MAX_INPUT], F_VIEW, + &[Flag { name: "--program", arg: "", help: "print the compiled probbit-ir program instead of running it (`probbit evaluate --program | probbit run` = the same answer)" }]], + subs: &[], list_subs: false, shared: false, after: &[CONTROLS_NOTE, ("exit", EXIT_DECIDE)], + examples: &["probbit evaluate --pretty < examples/evaluate/support-12.json"], + run: evaluate_cmd }, + Command { name: "demo", aliases: &[], group: "decide/run", summary: "emit an agent-routing problem as JSON (--live: watch it get routed)", + usage: &["demo [flags] > router.json"], + about: "Emit a synthetic agent-routing document (JSON) for `probbit decide`.", + before: &[], flags: &[&[Flag { name: "--tasks", arg: "N", help: "tasks to generate, 1..3333333 (default 12)" }, + Flag { name: "--seed", arg: "N", help: "random seed (default 7)" }, + Flag { name: "--hard", arg: "", help: "tight quotas + strong affinity" }, + Flag { name: "--live", arg: "", help: "the router story on stderr: exact tiers, then a live field of p-bits from the real chains, the gate, the verdict (default when stdout and stderr are both terminals; --tasks defaults to 300; stdout, if not a terminal, gets the JSON problem)" }, + F_PLAIN]], + subs: &[], list_subs: false, shared: false, after: &[], examples: &["probbit demo --tasks 300 --hard > router.json", "probbit demo --live"], + run: demo_cmd }, + Command { name: "ir", aliases: &[], group: "decide/run", summary: "a router document as probbit-ir v0 text (the hardware-facing form)", + usage: &["ir [--max-input-mb N] [--plain] < router.json"], + about: "Print a router document as probbit-ir v0 text (the hardware-facing form).", + before: &[], flags: &[&[F_MAX_INPUT, F_PLAIN]], subs: &[], list_subs: false, shared: false, after: &[], examples: &["probbit demo | probbit ir"], + run: ir_cmd }, + Command { name: "mcp", aliases: &[], group: "mcp", summary: "a Model Context Protocol server on stdio: the commands as agent tools", + usage: &["mcp"], + about: "A Model Context Protocol server on stdio (JSON-RPC 2.0, one message per line; logs on stderr). Tools probbit_decide, probbit_run, probbit_stats, probbit_demo, probbit_evaluate, probbit_persona_init, probbit_persona_turn, probbit_persona_fuzz, probbit_live_event, probbit_live_verify: the commands' own JSON in and out. Exits when stdin closes. docs/agents.md.", + before: &[], flags: &[], subs: &[], list_subs: false, shared: false, after: &[], examples: &["claude mcp add probbit -- probbit mcp"], + run: mcp_cmd }, + Command { name: "stats", aliases: &[], group: "other", summary: "the spec sheet: machine, build, controls + source, measured updates/s", + usage: &["stats [flags]"], + about: "The processor's spec sheet: machine, build, effective controls + their source, measured updates/s.", + before: &[], flags: &[F_CONTROLS, &[Flag { name: "--sweeps", arg: "N", help: "sweeps of the throughput measurement (default 2000)" }, F_PRETTY, F_PLAIN]], + subs: &[], list_subs: false, shared: false, after: &[CONTROLS_NOTE], examples: &["probbit stats --pretty"], + run: stats_cmd }, + Command { name: "help", aliases: &[], group: "other", summary: "this screen, or a command's or subcommand's help (as COMMAND --help)", + usage: &["help [COMMAND [SUBCOMMAND]]"], + about: "Print the help of a command or subcommand, the same text as `probbit COMMAND [SUBCOMMAND] --help`; alone, the same as `probbit --help`.", + before: &[], flags: &[], subs: &[], list_subs: false, shared: false, after: &[], examples: &["probbit help persona fuzz"], + run: help_cmd }, + Command { name: "completions", aliases: &[], group: "other", summary: "a completion script for bash, zsh or fish: commands, subcommands, flags", + usage: &["completions bash|zsh|fish"], + about: "Print a static completion script for the shell: the commands, subcommands and flags (with their help where the shell shows it), generated from the same table as this help, so it lists exactly what the parser accepts. Regenerate it after an upgrade.", + before: &[], flags: &[], subs: &[], list_subs: false, shared: false, + after: &[("install", "bash: probbit completions bash > ~/.local/share/bash-completion/completions/probbit\nzsh: probbit completions zsh > ~/.zfunc/_probbit, with fpath=(~/.zfunc $fpath) before compinit in ~/.zshrc\nfish: probbit completions fish > ~/.config/fish/completions/probbit.fish"), + ("exit", "0 the script, 2 no shell or an unknown one (the list of shells on stderr).")], + examples: &["probbit completions zsh > ~/.zfunc/_probbit", "source <(probbit completions bash)"], + run: completions_cmd }, + Command { name: "man", aliases: &[], group: "other", summary: "the manual page (roff), generated from this table: probbit man | man -l -", + usage: &["man"], + about: "Print the manual page in roff (man(7) macros): NAME, SYNOPSIS, DESCRIPTION, COMMANDS (every command, subcommand and flag of this help), EXIT STATUS, ENVIRONMENT, EXAMPLES, SEE ALSO. The release archives carry it as docs/probbit.1 and install.sh installs it to ~/.local/share/man/man1/.", + before: &[], flags: &[], subs: &[], list_subs: false, shared: false, after: &[], + examples: &["probbit man | man -l -", "probbit man > ~/.local/share/man/man1/probbit.1"], + run: man_cmd }, + Command { name: "version", aliases: &["--version", "-V"], group: "other", summary: "print the version (also --version, -V)", + usage: &["version"], about: "Print the version.", + before: &[], flags: &[], subs: &[], list_subs: false, shared: false, after: &[], examples: &[], + run: version_cmd }, +]; +/// The command `name` names (a spelling in `aliases` too) +pub(crate) fn command(name: &str) -> Option<&'static Command> { COMMANDS.iter().find(|c| c.name == name || c.aliases.contains(&name)) } +/// (flags taking a value, switches) of a flag list; `check_flags` enforces exactly these +fn split_flags<'a>(fs: impl Iterator) -> (Vec<&'static str>, Vec<&'static str>) { + let (mut v, mut s) = (vec![], vec![]); for f in fs { if f.takes_value() { v.push(f.name) } else { s.push(f.name) } } (v, s) +} +/// A command's own flags (not its subcommands') +fn flags_of(cmd: &str) -> (Vec<&'static str>, Vec<&'static str>) { split_flags(command(cmd).map_or(&[][..], |c| c.flags).iter().flat_map(|g| g.iter())) } +/// A subcommand's flags, the command's shared ones included (persona's --pretty); None = no such subcommand +fn sub_flags(cmd: &str, sub: &str) -> Option<(Vec<&'static str>, Vec<&'static str>)> { + let c = command(cmd)?; let s = c.sub(sub)?; Some(split_flags(c.sub_flags(s))) +} /// `--collective on|off` (default on, R19 P1.1): the sampler's global two-value flip (`Problem::collective` / `Model::collective`). fn collective_arg(args: &[String]) -> bool { match arg(args, "--collective", "on".to_string()).as_str() { "on" => true, "off" => false, _ => fail("--collective must be on or off") } @@ -262,7 +458,7 @@ fn check_flags(args: &[String], values: &[&str], switches: &[&str]) { if seen.contains(&a) { fail(&format!("{a} given twice")) } seen.push(a); i += 2; } else if a == "--progress" && switches.contains(&a) { if seen.contains(&a) { fail(&format!("{a} given twice")) } seen.push(a); i += if args.get(i + 1).is_some_and(|v| v.parse::().is_ok()) { 2 } else { 1 }; } - else if switches.contains(&a) { i += 1; } + else if switches.contains(&a) || a == "--json" { i += 1; } // --json: every command (the error channel, `JSON_FLAG`) else { fail(&format!("unknown argument {a:?} for `probbit {}` (see `probbit {} --help`)", args[0], args[0])) } } } /// Optional config file: `$PROBBIT_CONFIG`, else `./probbit.json` if present (keys chains, threads, cpu_limit, mem_limit_mb, priority). @@ -475,7 +671,6 @@ fn evaluate_cmd(args: &[String]) { let c = finish(evaluate::respond(&r, doc), code, &View::new(args, "evaluate"), &bars); if c != 0 { std::process::exit(c); } } -const PERSONA_HELP: &str = "The individuality layer (docs/persona.md): a persona file (YAML subset or JSON: traits with priors, moods with\n inertia, couplings, per-turn inputs, history, habits = hard rules) + an individual's state + this turn's inputs -> ONE probbit-ir\n program, run in process (`probbit run --op decide` at fixed work) -> the stance: a level per trait with exact odds, the habits\n in force and the ones that bound, a refusal when the engine cannot vouch, a why and a short stance line for any model's prompt.\n Documents are canonical JSON (keys sorted); a turn is a pure function of (persona, state, inputs, version).\n --pretty indents a single JSON document on stdout (init, turn, diff, lint, check, compile, describe, fuzz, prove);\n fuzz/prove --pretty implies --json. State files stay canonical. Replay stays JSONL; explain stays plain text.\nsubcommands:\n init PERSONA [--seed N] [--out STATE] a new individual (genes from the seed, resting stance); stdout or --out\n turn PERSONA --state STATE [--inputs JSON|FILE] [--out STATE2] [--timing] [--no-inertia]\n the stance on stdout; the new state replaces STATE (or goes to --out)\n replay PERSONA [--seed N] --script JSON|FILE [--out TRACE] [--no-inertia] [--timing]\n init, then every turn of the script: one stance per line (JSONL)\n explain PERSONA [--seed N] --script JSON|FILE --turn K turn K in words: every contribution, the odds, the twin\n diff PERSONA [--seed A] [--other PERSONA2] [--seed2 B] --script JSON|FILE distance between two individuals\n lint PERSONA [--never RULE | --props FILE] [--seeds 0-99] [--threads N]\n contradicting habits (every conditional habit and pair, every prev level);\n warnings: planned levels that are not their trait's most likely one;\n with rules: prove each one, fuzz the unknown ones; exit 1 on a counterexample,\n exit 3 when a rule stays unknown and the search finds none (\"unproven\": not\n broken, not proved either)\n fuzz PERSONA (--never RULE | --props FILE) [--seeds 0-99] [--fuzz-seed N] [--scripts N] [--depth N] [--beam N]\n [--grid LIST] [--hours LIST] [--threads N] [--json] [--fixture-src env:synthetic|human:synthetic]\n search event scripts for the shortest one whose stance breaks a rule\n (habit syntax: {when: {...}, then: {...}}); shrunk (§5.6). --fixture-src\n explicitly authorizes synthetic-source fixtures for strict replay; never\n use them to relabel production events. The persona must allow that source.\n prove PERSONA (--never RULE | --props FILE) [--seeds 0-99] [--threads N] [--json]\n per rule: held by construction (a habit implies it), proved for every\n event sequence (a sound bound), or unknown (the cell it fails; fuzz it) (§5.6)\n check PERSONA valid? digest and sizes\n compile PERSONA --state STATE [--inputs JSON|FILE] the turn's probbit-ir program (its sha256 = engine.program)\n describe PERSONA traits, moods, inputs, habits, agenda\n A script is a JSON list of per-turn input objects, or {\"turns\": [...]}. Resource controls as for run (PROBBIT_THREADS, ...)."; /// The persona engine of this process: `persona::run_program` with the resource controls `probbit run` would use (flag > env > /// config > default; resolved once), and --priority low applied if asked for. They never change an answer at fixed work. pub(crate) fn persona_engine() -> impl Fn(&Json, &persona::Flags) -> Json { @@ -557,17 +752,16 @@ fn fuzz_cmd(args: &[String], path: &str, p: &persona::Persona, eng: fuzz::SyncEn err_line(&format!("fuzz: {turns} turns in {secs:.2} s ({:.0} turns/s, {} search thread{})", turns as f64 / secs.max(1e-9), s.threads.min(s.seeds.len().max(1)), if s.threads.min(s.seeds.len().max(1)) == 1 { "" } else { "s" })); if res.iter().any(|per| per.iter().any(Option::is_some)) { std::process::exit(1) } } -const LIVE_HELP: &str = "A resident individual (docs/persona.md §5.7): JSONL events (one object of inputs per line) from --events FILE or stdin,\n one stance per event on stdout (canonical JSON). The clock stamps each event's elapsed_hours (quantised to 1e-6 h), so moods\n decay by their half-lives between events; feedback moves the learned deltas of a persona with a learning block (§2.8).\n --strand FILE logs the life: a header (persona document, initial state, engine version), then per event the inputs as used,\n the stance and state digests and the sha256 of the line before. `probbit live verify STRAND` replays it.\nflags:\n --seed N a new individual (default: the persona's seed)\n --state FILE a stored individual instead; rewritten after every event\n --strand FILE log the life to FILE: a new file gets the header; an existing strand is continued from --state\n (the state after its last line); a strand is never rewritten, just appended to\n --events FILE read events from FILE (default stdin)\n --watch follow --events FILE as lines are appended (tail -f); stops when the file is removed\n --clock real|fixed real (default): elapsed hours from a monotonic clock started with the run; fixed: each event carries\n its own elapsed_hours (default 0), so the run is a pure function of its events\n --demo week one individual's scripted week on the fixed clock (7 days, events hourly 09:00-15:00): praise for short\n answers moves the learned verbosity deltas to their cap, a quiet night relaxes the mood to its resting\n level, a campaign praising jokes raises humour while failure turns stay joke-free; a persona without a\n learning block gets the demo's (said in the opening line). Bars on stderr at a terminal, paced 1 s per hour\n (nights fast-forward in 2 s); otherwise one line per event on stdout, no waiting. With --seed, --strand\n --plain no colour, no bars, no pacing (as NO_COLOR=1)\n --checkpoint-every K with --strand: a checkpoint line (the whole state) after every K-th event (default 1000; 0: none);\n `verify --from-checkpoint` and `monitor` start at the last one\nthe safety kit (docs/persona.md §5.7):\n one writer per strand: `--strand` takes STRAND.lock (a second writer exits 4 and changes nothing; a lock whose process is\n gone is taken over). With `reward_from` in the persona (§2.10), a reward from `src: self`, without a src or from an\n undeclared one is refused like a bad event.\n probbit live control STRAND pause|resume|retire --by human:ID|env:ID --reason TEXT [--at TIME]\n append a control line under the lock: while paused or retired every event is refused (exit 4);\n no credit crosses a control line; retire is final; `verify` replays them and reports the status\n probbit live verify STRAND [--from-checkpoint] replay from the header (every checkpoint checked), or from the last checkpoint"; /// `probbit live PERSONA [--seed N | --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real|fixed] [--checkpoint-every K]`; /// `probbit live PERSONA --demo week`; `probbit live verify STRAND [--from-checkpoint]`; `probbit live control STRAND pause|resume|retire --by WHO --reason TEXT` fn live_cmd(args: &[String]) { let engine = persona_engine(); let eng: persona::Engine = &engine; let opt = |f: &str| -> Option { args.iter().position(|x| x == f).and_then(|k| args.get(k + 1)).cloned() }; // an error object on stdout, a line on stderr, then the exit: 4 for a lock held by another writer or a paused / retired individual - let refuse = |what: &str, e: json::InErr| -> ! { err_line(&format!("probbit: {what}{}: {}", e.path, e.msg)); emit(&json::write(&e.to_json(), false)); + let refuse = |what: &str, e: json::InErr| -> ! { err_line(&format!("probbit: {what}{}: {}", e.path, e.msg)); error_object(&e.to_json()); exit(if ["locked", "paused", "retired"].contains(&e.code) { 4 } else { 2 }) }; if args.get(1).map(String::as_str) == Some("verify") { - let mut a = vec!["live verify".to_string()]; a.extend(args.iter().skip(3).cloned()); check_flags(&a, &[], &["--from-checkpoint"]); + let mut a = vec!["live verify".to_string()]; a.extend(args.iter().skip(3).cloned()); let (v, w) = sub_flags("live", "verify").unwrap(); check_flags(&a, &v, &w); let Some(path) = args.get(2).filter(|a| !a.starts_with("--")) else { fail("live verify: the strand file: probbit live verify STRAND") }; let text = std::fs::read_to_string(path).unwrap_or_else(|e| fail(&format!("live verify: cannot read {path}: {e}"))); let res = if args.iter().any(|a| a == "--from-checkpoint") { live::verify_from_checkpoint(&text, eng) } else { live::verify(&text, eng) }; @@ -577,7 +771,7 @@ fn live_cmd(args: &[String]) { return; } if args.get(1).map(String::as_str) == Some("control") { - let mut a = vec!["live control".to_string()]; a.extend(args.iter().skip(4).cloned()); check_flags(&a, &["--by", "--reason", "--at"], &[]); + let mut a = vec!["live control".to_string()]; a.extend(args.iter().skip(4).cloned()); let (v, w) = sub_flags("live", "control").unwrap(); check_flags(&a, &v, &w); let usage = "live control: probbit live control STRAND pause|resume|retire --by human:ID --reason TEXT"; let (Some(path), Some(what)) = (args.get(2).filter(|a| !a.starts_with("--")), args.get(3).filter(|a| !a.starts_with("--"))) else { fail(usage) }; let (Some(by), Some(reason)) = (opt("--by"), opt("--reason")) else { fail(&format!("{usage} (--by and --reason are required)")) }; @@ -585,7 +779,9 @@ fn live_cmd(args: &[String]) { return; } let Some(path) = args.get(1).filter(|a| !a.starts_with("--")) else { fail("live: the persona file goes before the flags: probbit live PERSONA [flags] (or probbit live verify STRAND, probbit live control STRAND ...)") }; - let mut a = vec!["live".to_string()]; a.extend(args.iter().skip(2).cloned()); check_flags(&a, &["--seed", "--state", "--strand", "--events", "--clock", "--demo", "--checkpoint-every"], &["--watch", "--plain"]); + // `probbit live verfy x.strand`: no persona file of that name, and a subcommand close to it + if !std::path::Path::new(path).exists() { let c = command("live").unwrap(); if usage::suggest(path, &c.subs.iter().map(|s| s.name).collect::>()).is_some() { unknown(Some(c), path) } } + let mut a = vec!["live".to_string()]; a.extend(args.iter().skip(2).cloned()); let (v, w) = flags_of("live"); check_flags(&a, &v, &w); let (p, doc) = persona::load(path).unwrap_or_else(|e| bad_input("live: ", e)); if let Some(d) = opt("--demo") { if d != "week" { fail(&format!("live: --demo week, not {d:?}")) } @@ -625,7 +821,7 @@ fn live_cmd(args: &[String]) { match &cp { Some(c) => log(&[&line, c]), None => log(&[&line]) } if let Some(f) = &state_file { put(Some(f), &persona::canon(&lv.st.to_json(&lv.p))); } emit(&persona::canon(&stance)); } Err(e) => { if ["paused", "retired"].contains(&e.code) { refused += 1 } else { bad += 1 } - let e = json::InErr { path: format!("events[{i}].{}", e.path), ..e }; err_line(&format!("probbit: live: {}: {}", e.path, e.msg)); emit(&json::write(&e.to_json(), false)); } } }; + let e = json::InErr { path: format!("events[{i}].{}", e.path), ..e }; err_line(&format!("probbit: live: {}: {}", e.path, e.msg)); error_object(&e.to_json()); } } }; match (opt("--events"), watch) { // --watch: follow the file as lines are appended (a line counts once its newline is written); stops when the file is removed (Some(f), true) => { let mut r = std::io::BufReader::new(std::fs::File::open(&f).unwrap_or_else(|e| fail(&format!("live: cannot read {f}: {e}")))); @@ -646,19 +842,14 @@ fn live_cmd(args: &[String]) { /// `probbit persona PERSONA [flags]` (docs/persona.md) fn persona_cmd(args: &[String]) { let sub = args.get(1).map_or("", String::as_str); - let (vals, sw): (&[&str], &[&str]) = match sub { - "init" => (&["--seed", "--out"], &[]), "turn" => (&["--state", "--inputs", "--out"], &["--timing", "--no-inertia"]), - "replay" => (&["--seed", "--script", "--out"], &["--no-inertia", "--timing"]), "explain" => (&["--seed", "--script", "--turn"], &[]), - "diff" => (&["--seed", "--other", "--seed2", "--script"], &[]), "compile" => (&["--state", "--inputs"], &[]), "check" | "describe" => (&[], &[]), "lint" => (&["--never", "--props", "--seeds", "--threads"], &[]), - "fuzz" => (&["--seeds", "--never", "--props", "--fuzz-seed", "--scripts", "--depth", "--beam", "--grid", "--hours", "--threads", "--fixture-src"], &["--json"]), - "prove" => (&["--seeds", "--never", "--props", "--threads"], &["--json"]), - _ => fail("persona: a subcommand: init, turn, replay, explain, diff, lint, fuzz, prove, check, compile or describe (probbit persona --help)") }; + let Some((vals, switches)) = sub_flags("persona", sub) else { + if sub.is_empty() || sub.starts_with('-') { fail("persona: a subcommand: init, turn, replay, explain, diff, lint, fuzz, prove, check, compile or describe (probbit persona --help)") } + unknown(command("persona"), sub) }; let pretty = args.iter().any(|a| a == "--pretty"); if pretty && sub == "replay" { fail("persona replay emits one canonical JSON object per line (JSONL); --pretty is for single-document commands such as persona turn") } if pretty && sub == "explain" { fail("persona explain already emits readable text; --pretty is for JSON commands") } - let mut switches = sw.to_vec(); switches.push("--pretty"); let Some(path) = args.get(2).filter(|a| !a.starts_with("--")) else { fail(&format!("persona {sub}: the persona file comes first: probbit persona {sub} PERSONA [flags]")) }; - let mut a = vec![format!("persona {sub}")]; a.extend(args[3..].iter().cloned()); check_flags(&a, vals, &switches); + let mut a = vec![format!("persona {sub}")]; a.extend(args[3..].iter().cloned()); check_flags(&a, &vals, &switches); let need = |f: &str| -> String { args.iter().position(|x| x == f).and_then(|k| args.get(k + 1)).cloned().unwrap_or_else(|| fail(&format!("persona {sub}: {f} is required"))) }; let opt = |f: &str| -> Option { args.iter().position(|x| x == f).and_then(|k| args.get(k + 1)).cloned() }; let (p, _) = persona::load(path).unwrap_or_else(|e| bad_input("persona: ", e)); @@ -729,34 +920,132 @@ fn persona_cmd(args: &[String]) { } } +fn ir_cmd(args: &[String]) { + let (v, w) = flags_of("ir"); check_flags(args, &v, &w); let j = read_doc(args); + let n = from_json(&j).unwrap_or_else(|e| bad_input("bad problem: ", e)); emit_raw(&probbit_decide::ir::to_ir(&n.p)); +} +fn run_cmd(args: &[String]) { + let (v, w) = flags_of("run"); check_flags(args, &v, &w); let tp = std::time::Instant::now(); let j = read_doc(args); + let tc = std::time::Instant::now(); let parse_ms = (tc - tp).as_secs_f64() * 1e3; + let p = run::from_json(&j).unwrap_or_else(|e| bad_input("bad program: ", e)); let compile_ms = tc.elapsed().as_secs_f64() * 1e3; + let (doc, code, bars) = run_program(args, p, "run", tp, parse_ms, compile_ms); + let c = finish(doc, code, &View::new(args, "run"), &bars); if c != 0 { std::process::exit(c); } +} +fn mcp_cmd(args: &[String]) { check_flags(args, &[], &[]); mcp::serve() } +fn version_cmd(args: &[String]) { check_flags(args, &[], &[]); emit(&format!("probbit {VERSION}")) } +/// `probbit --help` piped (and `probbit help`): the grouped usage; at a terminal the hero screen +fn top_help() -> String { usage::top_help(COMMANDS, VERSION) } +/// `probbit completions bash|zsh|fish` (usage::completions) +fn completions_cmd(args: &[String]) { + let shells = usage::SHELLS.join(", "); + let Some(sh) = args.get(1).filter(|a| !a.starts_with('-')) else { fail(&format!("completions: a shell: {shells} (probbit completions zsh)")) }; + let mut a = vec!["completions".to_string()]; a.extend(args[2..].iter().cloned()); check_flags(&a, &[], &[]); + match usage::completions(sh, COMMANDS) { Some(s) => emit_raw(&s), None => fail(&format!("completions: unknown shell {sh:?}; one of {shells}")) } +} +/// `probbit man` (usage::man; docs/probbit.1 is its output) +fn man_cmd(args: &[String]) { check_flags(args, &[], &[]); emit_raw(&usage::man(COMMANDS, VERSION)) } +/// `probbit help [COMMAND [SUBCOMMAND]]`: the same text as `probbit COMMAND [SUBCOMMAND] --help`; alone, as `probbit --help` +/// (the hero screen at a terminal, else the usage). Exit 0; an unknown command or subcommand exits 2. +fn help_cmd(args: &[String]) { + let words: Vec<&str> = args[1..].iter().map(String::as_str).filter(|a| *a != "--plain").collect(); + match words[..] { + [] => if theme::stdout_is_terminal() { emit_raw(&tui::hero(theme::stdout(args))) } else { emit_raw(&top_help()) }, + [name, ..] if words.len() <= 2 => { + let Some(c) = command(name) else { unknown(None, name) }; + match words.get(1) { + None => emit_raw(&usage::command_help(c)), + Some(s) => match c.sub(s) { Some(s) => emit_raw(&usage::sub_help(c, s)), + None => unknown(Some(c), s) } } + } + _ => fail("help: at most a command and a subcommand: probbit help [COMMAND [SUBCOMMAND]]"), + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); - if args.len() >= 2 && args[1..].iter().any(|a| a == "--help" || a == "-h") { if let Some(h) = help(&args[0]) { emit_raw(&h); return; } } - // At a terminal, `probbit` (no command) and `probbit --help` print the hero screen (exit 0); piped, `--help` prints USAGE and a - // bare `probbit` still exits 2 with USAGE on stderr, as before + JSON_FLAG.store(args.iter().any(|a| a == "--json"), std::sync::atomic::Ordering::Relaxed); + // `probbit CMD [SUB] ... --help`: the command's help, or the subcommand's when the word after the command is one of its subcommands + if args.len() >= 2 && args[1..].iter().any(|a| a == "--help" || a == "-h") { if let Some(c) = command(&args[0]) { + emit_raw(&match args.get(1).and_then(|s| c.sub(s)) { Some(s) => usage::sub_help(c, s), None => usage::command_help(c) }); return; } } + // At a terminal, `probbit` (no command) and `probbit --help` print the hero screen (exit 0); piped, `--help` prints the usage and a + // bare `probbit` still exits 2 with the usage on stderr, as before let bare = args.iter().all(|a| a == "--plain") || args.iter().all(|a| a == "--help" || a == "-h" || a == "--plain"); if bare && theme::stdout_is_terminal() { emit_raw(&tui::hero(theme::stdout(&args))); return; } match args.first().map(|s| s.as_str()) { - Some("decide") => decide_cmd(&args), - Some("demo") => demo_cmd(&args), - Some("ir") => { let (v, w) = flags_of("ir"); check_flags(&args, &v, &w); let j = read_doc(&args); - let n = from_json(&j).unwrap_or_else(|e| bad_input("bad problem: ", e)); emit_raw(&probbit_decide::ir::to_ir(&n.p)); } - Some("run") => { let (v, w) = flags_of("run"); check_flags(&args, &v, &w); let tp = std::time::Instant::now(); let j = read_doc(&args); - let tc = std::time::Instant::now(); let parse_ms = (tc - tp).as_secs_f64() * 1e3; - let p = run::from_json(&j).unwrap_or_else(|e| bad_input("bad program: ", e)); let compile_ms = tc.elapsed().as_secs_f64() * 1e3; - let (doc, code, bars) = run_program(&args, p, "run", tp, parse_ms, compile_ms); - let c = finish(doc, code, &View::new(&args, "run"), &bars); if c != 0 { std::process::exit(c); } } - Some("evaluate") => evaluate_cmd(&args), - Some("stats") => stats_cmd(&args), - Some("persona") => persona_cmd(&args), - Some("live") => live_cmd(&args), - Some("monitor") => monitor::cmd(&args), - Some("mcp") => { check_flags(&args, &[], &[]); mcp::serve() } - Some("version") | Some("--version") | Some("-V") => { check_flags(&args, &[], &[]); emit(&format!("probbit {VERSION}")) } // `probbit --help` / `-h` asked for the usage: stdout, exit 0 (it went to stderr with exit 2, as for a missing command) - Some("--help") | Some("-h") => { check_flags(&args, &[], &["--plain"]); emit_raw(USAGE); } - Some(cmd) => fail(&format!("unknown command {cmd:?}; see `probbit --help`")), - None => { let _ = std::io::stderr().write_all(USAGE.as_bytes()); std::process::exit(2) } + Some("--help") | Some("-h") => { check_flags(&args, &[], &["--plain"]); emit_raw(&top_help()); } + Some(name) => match command(name) { Some(c) => (c.run)(&args), None => unknown(None, name) }, + None => { let _ = std::io::stderr().write_all(top_help().as_bytes()); std::process::exit(2) } + } +} + +#[cfg(test)] +mod table_tests { + use super::*; + /// The flags of a synopsis: every `--word` in it + fn synopsis_flags(s: &str) -> Vec<&str> { + let mut v: Vec<&str> = s.split(|c: char| c == ' ' || c == '[' || c == ']' || c == '(' || c == ')').filter(|w| w.starts_with("--")).collect(); + v.sort_unstable(); v.dedup(); v + } + #[test] + fn names_are_unique_and_grouped() { + let mut seen: Vec<&str> = vec![]; + for c in COMMANDS { + for n in std::iter::once(&c.name).chain(c.aliases) { assert!(!seen.contains(n), "{n} twice"); seen.push(n); } + assert!(usage::GROUPS.contains(&c.group), "{}: group {}", c.name, c.group); + assert!(!c.summary.is_empty() && !c.about.is_empty() && !c.usage.is_empty(), "{}", c.name); + assert!(c.usage.iter().all(|u| u.split(' ').next() == Some(c.name)), "{}: a usage line names another command", c.name); + let mut subs: Vec<&str> = c.subs.iter().map(|s| s.name).collect(); subs.sort_unstable(); let n = subs.len(); subs.dedup(); assert_eq!(n, subs.len(), "{}", c.name); + let mut fl: Vec<&str> = c.flags().map(|f| f.name).collect(); fl.sort_unstable(); let n = fl.len(); fl.dedup(); assert_eq!(n, fl.len(), "{}: a flag twice", c.name); + for s in c.subs { let mut fl: Vec<&str> = c.sub_flags(s).map(|f| f.name).collect(); fl.sort_unstable(); let n = fl.len(); fl.dedup(); assert_eq!(n, fl.len(), "{} {}", c.name, s.name); } + } + } + /// A subcommand's synopsis names exactly its flags; a command's usage lines name only flags it (or a subcommand) accepts, + /// and every flag of a command without subcommands is in its usage or its usage says [flags] + #[test] + fn synopses_match_flags() { + for c in COMMANDS { + for s in c.subs { + let mut want: Vec<&str> = s.flags.iter().map(|f| f.name).collect(); want.sort_unstable(); + assert_eq!(synopsis_flags(s.args), want, "{} {}: synopsis {:?}", c.name, s.name, s.args); + } + let known: Vec<&str> = c.flags().chain(c.subs.iter().flat_map(|s| s.flags.iter())).map(|f| f.name).collect(); + for u in c.usage { for f in synopsis_flags(u) { assert!(known.contains(&f), "{}: usage names {f}, which the parser rejects", c.name); } } + for f in c.flags() { + assert!(f.name.starts_with("--") && !f.help.is_empty(), "{}: {}", c.name, f.name); + if c.subs.is_empty() && !c.usage.iter().any(|u| u.contains("[flags]")) { assert!(c.usage.iter().any(|u| u.contains(f.name)), "{}: {} not in the usage", c.name, f.name); } + } + } + } + /// The parser's flag lists are the table's: `flags_of` / `sub_flags` split them by arity + #[test] + fn parser_flags_are_the_tables() { + let (v, w) = flags_of("decide"); + assert!(v.contains(&"--budget-ms") && v.contains(&"--mode") && !v.contains(&"--progress") && w.contains(&"--progress") && w.contains(&"--top")); + let (v, w) = sub_flags("persona", "fuzz").unwrap(); assert!(v.contains(&"--fixture-src") && w.contains(&"--json") && w.contains(&"--pretty")); + let (v, w) = flags_of("monitor"); assert!(v.contains(&"--port") && w.contains(&"--demo"), "--demo takes an optional value: parsed as a switch"); + assert!(sub_flags("persona", "lnt").is_none()); + assert_eq!(sub_flags("live", "verify").unwrap(), (vec![], vec!["--from-checkpoint"]), "live's own flags are not verify's"); + for c in COMMANDS { let (v, w) = flags_of(c.name); assert_eq!(v.len() + w.len(), c.flags().count(), "{}", c.name); } + } + #[test] + fn did_you_mean() { + assert_eq!([usage::distance("persna", "persona"), usage::distance("rnu", "run"), usage::distance("lnt", "lint"), usage::distance("", "ir"), usage::distance("abc", "abc")], [1, 1, 1, 2, 0]); + let names: Vec<&str> = COMMANDS.iter().map(|c| c.name).collect(); + for (typo, want) in [("persna", Some("persona")), ("dcide", Some("decide")), ("rnu", Some("run")), ("pers", Some("persona")), ("monitr", Some("monitor")), + ("evalute", Some("evaluate")), ("verison", Some("version")), ("x", None), ("frobnicate", None), ("ab", None), ("helpme", Some("help")), ("statistics", None)] { + assert_eq!(usage::suggest(typo, &names), want, "{typo}"); + } + } + /// The dispatcher accepts exactly the table: `main` dispatches through `command()`, and its own match names no command + #[test] + fn dispatcher_is_the_table() { + let src = include_str!("main.rs").replace("\r\n", "\n"); // a Windows checkout has CRLF + let body = &src[src.find("\nfn main() {").unwrap()..]; let body = &body[..body.find("\n}\n").unwrap()]; + let lits: Vec<&str> = body.match_indices("Some(\"").map(|(i, _)| { let r = &body[i + 6..]; &r[..r.find('"').unwrap()] }).collect(); + assert!(lits.iter().all(|l| ["--help", "-h"].contains(l)), "main dispatches {lits:?} outside the table"); + for (old, n) in [("decide", 0), ("demo", 0), ("ir", 0), ("run", 0), ("evaluate", 0), ("stats", 0), ("persona", 0), ("live", 0), ("monitor", 0), ("mcp", 0), ("version", 0), ("--version", 0), ("-V", 0)] { + assert!(command(old).is_some(), "{old} (accepted before the table) is not in it {n}"); + } } } -const USAGE: &str = "usage: probbit decide [--budget-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--mode auto|exact|sample] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < problem.json\n probbit demo [--tasks N] [--seed N] [--hard] [--live]\n probbit ir [--max-input-mb N] < problem.json\n probbit run [--op decide|exact|sample] [--budget-ms N] [--deadline-ms N] [--seed N] [--exact-limit N] [--exact-ms N] [--frontier-states N] [--polish-ms N] [--polish-sweeps N] [--sweeps N] [--collective on|off] [--cluster on|off] [--cycles on|off] [--chains N] [--threads N] [--cpu-limit PCT] [--mem-limit-mb N] [--priority low|normal] [--max-input-mb N] [--progress [MS]] [--summary] [--top] [--pretty] < program.json (probbit-ir JSON v1)\n probbit evaluate [the run flags] [--program] < request.json (decision-API adapter: System One request + judge answers + rules)\n probbit stats [--sweeps N] [--pretty] (machine, build, effective controls + source, measured updates/s)\n probbit persona init|turn|replay|explain|diff|lint|fuzz|prove|check|compile|describe PERSONA [flags] (the individuality layer; probbit persona --help)\n probbit live PERSONA [--seed N | --state FILE] [--strand FILE] [--events FILE [--watch]] [--clock real|fixed] (a resident individual: JSONL events in, stances out)\n probbit live PERSONA --demo week [--seed N] [--strand FILE] [--plain] (a scripted week: learning to a cap, a night, rules that hold)\n probbit live verify STRAND [--from-checkpoint] (replay a strand; the earliest line that differs)\n probbit live control STRAND pause|resume|retire --by WHO --reason TEXT (a control line: pause, resume or retire an individual)\n probbit monitor STRAND [--follow] [--once] [--plain] [--fps N] [--serve] [--open] [--port N] | probbit monitor --demo [week|drives] [--open] (watch an individual's inner state live: bars in the terminal or a page on 127.0.0.1, replayed from the strand)\n probbit mcp (Model Context Protocol server on stdio)\n probbit version\n probbit --help | -h (--plain or NO_COLOR: no colour on a terminal)\n"; diff --git a/probbit-cli/src/monitor.rs b/probbit-cli/src/monitor.rs index f308d64..83b01d3 100644 --- a/probbit-cli/src/monitor.rs +++ b/probbit-cli/src/monitor.rs @@ -39,8 +39,6 @@ const KEEP: usize = 10_000; /// Connections served at once (more are refused with 503) const CONNS: usize = 64; -const HELP: &str = "usage: probbit monitor STRAND [--follow] [--once] [--plain] [--fps N] [--serve] [--open] [--port N] | probbit monitor --demo [week|drives] [--once] [--plain] [--serve] [--open] [--port N]\n Watch an individual's inner state (docs/persona.md §5.8): replays the strand (`probbit live --strand` writes it) with the\n rules of `probbit live verify`, recomputing every stance document from the strand alone, and draws the latest event as\n horizontal bars: the stance (each trait's levels with their reported odds, the level taken highlighted, the phrase it says), the\n moods with a sparkline of the last 50 events, the senses (the event's inputs, the history features), the habits in force and\n the ones that bound, the learned deltas, the drives when the documents carry them, and the stance line. In the terminal, or\n with --serve as a page in the browser. It changes no file (--demo: its week goes through a temporary one, removed at\n once) and sends nothing anywhere.\nflags:\n --follow keep watching: lines appended to the strand are replayed and drawn within a second (a line counts once its\n newline is written); a truncated or rotated strand is replayed from the start, with a warning; Ctrl-C quits\n --once one frame on stdout, then exit (the default without --follow)\n --plain plain ASCII, no colour (NO_COLOR=1: no colour; PROBBIT_THEME=plain or TERM=dumb: as --plain)\n --fps N with --follow, at most N redraws per second (1-60, default 10)\n --serve the page instead of the terminal, following the strand (or looping the selected synthetic demo): its URL\n http://127.0.0.1:PORT/ is the line on stdout. GET / the page (one file; it fetches no fonts, styles or\n scripts), /events its server-sent events (the layout, the latest frame, then a frame per event, a heartbeat\n every 15 s; events that land within one 100 ms poll, or while a page falls behind, come as the latest\n frame), /doc/N event N's stance document, as `probbit live` printed it (the latest 10000 are kept). It\n binds 127.0.0.1 and no other address (there is no --host), answers requests addressed to 127.0.0.1 or\n localhost, and sends nothing anywhere; Ctrl-C quits\n --open serve the page (as --serve does) and open it in the default browser (BROWSER if set; else open on macOS,\n start on Windows, xdg-open elsewhere); a browser that does not start fails nothing\n --port N with --serve or --open, the port (default 0: a free one)\n --demo [week|drives] synthetic data, never a live individual (default week). The tutor's week is the one\n `probbit live examples/persona/tutor.yaml --seed 2 --demo week` writes, replayed in memory: at a terminal or with --serve paced 1 s per hour, each night in 2 s (under a minute); otherwise, or\n with --once, its last frame. `--demo drives` plays a short goal-signal scenario with wanting, afterglow and\n prediction errors. The page labels each loop and its restart; Ctrl-C quits.\nexit: 0 every line replays, 1 a line differs (the frame names it and shows the event before it), 2 a bad flag, a port that\n cannot be had, or a file that cannot be read or is not a strand.\n"; - fn strs(j: Option<&Json>) -> Vec { j.and_then(Json::as_arr).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default() } /// A trait or a mood: its id, levels and per-level phrases, in the persona's order @@ -420,7 +418,6 @@ fn ascii(args: &[String]) -> bool { /// `probbit monitor STRAND [--follow] [--once] [--plain] [--fps N]` pub fn cmd(args: &[String]) { - if args.iter().any(|a| a == "--help" || a == "-h") { crate::emit_raw(HELP); return; } if args.iter().any(|a| ["--host", "--bind", "--address"].iter().any(|f| a == f || a.starts_with(&format!("{f}=")))) { crate::fail("monitor --serve binds 127.0.0.1 and no other address, so the page stays on this machine: there is no --host") } let path = args.get(1).filter(|a| !a.starts_with("--")).cloned(); let mut a = vec!["monitor".to_string()]; a.extend(args.iter().skip(if path.is_some() { 2 } else { 1 }).cloned()); @@ -432,7 +429,7 @@ pub fn cmd(args: &[String]) { a.remove(i + 1); } } - crate::check_flags(&a, &["--fps", "--port"], &["--follow", "--once", "--plain", "--demo", "--serve", "--open"]); + let (v, w) = crate::flags_of("monitor"); crate::check_flags(&a, &v, &w); let has = |f: &str| args.iter().any(|a| a == f); // --open opens the page, so it serves it: `--open` is `--serve --open` let serve_ = has("--serve") || has("--open"); diff --git a/probbit-cli/src/tui.rs b/probbit-cli/src/tui.rs index 63b4d14..c3643a6 100644 --- a/probbit-cli/src/tui.rs +++ b/probbit-cli/src/tui.rs @@ -49,29 +49,30 @@ fn date(t: u64) -> String { fn now_s() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()) } // ---------------- the hero screen ---------------- -/// `probbit` / `probbit --help` at a terminal: wordmark, the spec line (`spec_line`), the status line, three commands to try and -/// the usage block, in 9 lines. Plain text without a theme (NO_COLOR, --plain, PROBBIT_THEME=plain). +/// `probbit` / `probbit --help` at a terminal: wordmark, the status line, the spec lines (`spec_line`: the measured rate, then the +/// machine), three commands to try and the usage block, in 10 lines of at most 100 columns. Plain text without a theme (NO_COLOR, +/// --plain, PROBBIT_THEME=plain). pub fn hero(th: Option) -> String { const MARK: [[&str; 7]; 2] = [["█▀█", "█▀▀", "█▀█", "█▀▄", "█▀▄", "█", "▀█▀"], ["█▀▀", "█ ", "█▄█", "█▄▀", "█▄▀", "█", " █ "]]; let hues = [CYAN, CYAN, CYAN, CYAN, CYAN, MAGENTA, YELLOW]; let mark = |r: usize| (0..7).map(|k| bold(th, hues[k], MARK[r][k])).collect::>().join(" "); let dot = paint(th, DIM, "·"); - let spec = spec_line().split(" · ").map(|x| paint(th, GREY, x)).collect::>().join(&format!(" {dot} ")); + let (machine, rate) = spec_line(); let machine = machine.split(" · ").map(|x| paint(th, GREY, x)).collect::>().join(&format!(" {dot} ")); let status = format!("{} {dot} {} {dot} {}", bold(th, GREEN, "PROCESSOR ONLINE"), bold(th, CYAN, VERSION), paint(th, YELLOW, "exact → sample → gate")); let tries = [("probbit demo", "watch 300 agent tasks get routed, live"), ("probbit demo | probbit decide --summary --pretty", "one JSON verdict + a boxed summary"), - ("claude mcp add probbit -- probbit mcp", "hand the processor to an agent (MCP over stdio)")]; - let mut o = format!(" {} {status}\n {} {spec}\n\n", mark(0), mark(1)); + ("claude mcp add probbit -- probbit mcp", "give an agent the processor (MCP, stdio)")]; + let mut o = format!(" {} {status}\n {} {}\n{}{machine}\n\n", mark(0), mark(1), paint(th, GREY, &rate), " ".repeat(29)); for (k, (cmd, what)) in tries.iter().enumerate() { o += &format!(" {} {}{}{}\n", if k == 0 { bold(th, MAGENTA, "try") } else { " ".into() }, bold(th, CYAN, cmd), " ".repeat(51 - cmd.len()), paint(th, GREY, what)); } - o += &format!("\n {} probbit decide|run [flags] < doc.json {dot} probbit demo [--live] {dot} probbit stats {dot} probbit ir {dot} probbit mcp {dot} probbit version\n", bold(th, MAGENTA, "usage")); - o += &format!(" probbit --help: every flag, its default, the exit codes {dot} {}\n", paint(th, GREY, "no colour: NO_COLOR, --plain or PROBBIT_THEME=plain")); + o += &format!("\n {} probbit COMMAND [flags] {dot} probbit help [COMMAND] {dot} probbit completions bash|zsh|fish\n", bold(th, MAGENTA, "usage")); + o += &format!(" all commands: probbit --help | less {dot} {}\n", paint(th, GREY, "no colour: NO_COLOR, --plain or PROBBIT_THEME=plain")); o } -/// os/arch · logical cpus · target features · the last measured site updates/s and its date (`self_test_cached`). -fn spec_line() -> String { +/// (os/arch · logical cpus · target features, the last measured site updates/s and its date (`self_test_cached`)). +fn spec_line() -> (String, String) { let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); let feats = crate::target_features(); let rate = match self_test_cached() { Some((r, t)) => format!("{} site updates/s (self-test {} UTC)", si(r), date(t)), None => "updates/s not measured".into() }; - format!("{}/{} · {cores} logical cpus · {} · {rate}", std::env::consts::OS, std::env::consts::ARCH, if feats.is_empty() { "baseline".into() } else { feats.join(" ") }) + (format!("{}/{} · {cores} logical cpus · {}", std::env::consts::OS, std::env::consts::ARCH, if feats.is_empty() { "baseline".into() } else { feats.join(" ") }), rate) } /// `$XDG_CACHE_HOME/probbit/stats.json`, else `~/.cache/probbit/stats.json`, else `%LOCALAPPDATA%\probbit\stats.json` fn cache_file() -> Option { diff --git a/probbit-cli/src/usage.rs b/probbit-cli/src/usage.rs new file mode 100644 index 0000000..f03f77f --- /dev/null +++ b/probbit-cli/src/usage.rs @@ -0,0 +1,328 @@ +//! What the command table (`COMMANDS`, main.rs) renders: every `--help` / `probbit help` screen, the did-you-mean of an unknown +//! command or subcommand, `probbit completions bash|zsh|fish` and `probbit man`. The parser reads the same table (`flags_of`, +//! `sub_flags`), so the help, the completions and the man page list exactly what is accepted and cannot drift from it. + +/// A flag. `arg`: "" = a switch; "[X]" = an optional value (parsed as a switch: `--progress [MS]`, `--demo [week|drives]`); +/// anything else = the value it takes (`N`, `on|off`, `FILE`). +pub(crate) struct Flag { pub name: &'static str, pub arg: &'static str, pub help: &'static str } +impl Flag { pub fn takes_value(&self) -> bool { !self.arg.is_empty() && !self.arg.starts_with('[') } } +/// A subcommand: `args` is its synopsis after `probbit CMD SUB` (positionals and every flag of `flags`, test `synopses_match_flags`). +pub(crate) struct Sub { pub name: &'static str, pub args: &'static str, pub help: &'static str, pub flags: &'static [Flag] } +pub(crate) struct Command { + pub name: &'static str, + /// other spellings the dispatcher accepts (`--version`, `-V`) + pub aliases: &'static [&'static str], + /// the top-level help's group: persona, live, monitor, decide/run, mcp, other + pub group: &'static str, + /// one line for the top-level help, the completions and the man page's COMMANDS + pub summary: &'static str, + /// synopsis lines after `probbit ` (the first one names the command in the top-level help) + pub usage: &'static [&'static str], + /// what it does; "\n" starts a paragraph; wrapped to 100 columns when rendered + pub about: &'static str, + /// sections before the flags (title, text) + pub before: &'static [(&'static str, &'static str)], + pub flags: &'static [&'static [Flag]], + pub subs: &'static [Sub], + /// the subcommands get their own lines in the top-level help (live verify, live control) + pub list_subs: bool, + /// the command's flags apply to every subcommand too (persona's --pretty); else only to the command's own form (live PERSONA) + pub shared: bool, + /// sections after the flags and subcommands (controls, the safety kit, exit) + pub after: &'static [(&'static str, &'static str)], + pub examples: &'static [&'static str], + pub run: fn(&[String]), +} +impl Command { + pub fn flags(&self) -> impl Iterator { self.flags.iter().flat_map(|g| g.iter()) } + pub fn sub(&self, name: &str) -> Option<&'static Sub> { self.subs.iter().find(|s| s.name == name) } + /// The flags a subcommand accepts: its own, then the command's when they are shared + pub fn sub_flags(&self, s: &'static Sub) -> impl Iterator { + let own: &'static [&'static [Flag]] = if self.shared { self.flags } else { &[] }; s.flags.iter().chain(own.iter().flat_map(|g| g.iter())) + } +} + +pub(crate) const WIDTH: usize = 100; +/// Where a flag's or a subcommand's description starts +const COL: usize = 28; +/// The same in the top-level help +const TOP_COL: usize = 24; +pub(crate) const GROUPS: [&str; 6] = ["persona", "live", "monitor", "decide/run", "mcp", "other"]; + +/// `text` wrapped to `WIDTH`: the first line starts with `first` (already `first.len()` columns), the others with `indent` +/// spaces. "\n" in `text` starts a new line (indented). A word longer than a line is never split. +pub(crate) fn wrap(first: &str, text: &str, indent: usize) -> String { + let mut out = String::new(); + for (i, para) in text.split('\n').enumerate() { + let mut line = if i == 0 { first.to_string() } else { " ".repeat(indent) }; + let mut empty = i != 0 || first.trim().is_empty() || first.ends_with(' '); + for w in para.split(' ').filter(|w| !w.is_empty()) { + let len = line.chars().count(); + if !empty && len + 1 + w.chars().count() > WIDTH { out.push_str(line.trim_end()); out.push('\n'); line = " ".repeat(indent); empty = true; } + if !empty && !line.ends_with(' ') { line.push(' '); } + line.push_str(w); empty = false; + } + out.push_str(line.trim_end()); out.push('\n'); + } + out +} +/// A synopsis wrapped to `WIDTH` without splitting a [bracketed] or (grouped) part: continuation lines start at `indent`. +fn wrap_synopsis(first: &str, syn: &str, indent: usize) -> String { wrap_synopsis_to(first, syn, indent, WIDTH) } +fn wrap_synopsis_to(first: &str, syn: &str, indent: usize, width: usize) -> String { + let mut parts: Vec = vec![]; let mut depth = 0i32; + for w in syn.split(' ').filter(|w| !w.is_empty()) { + if depth > 0 { let l = parts.last_mut().unwrap(); l.push(' '); l.push_str(w); } else { parts.push(w.to_string()); } + depth += w.matches(['[', '(']).count() as i32 - w.matches([']', ')']).count() as i32; + } + let (mut out, mut line) = (String::new(), first.to_string()); + for p in parts { + if line.chars().count() + 1 + p.chars().count() > width && line.trim().len() > first.trim().len() { out.push_str(&line); out.push('\n'); line = " ".repeat(indent); } + if !line.ends_with(' ') { line.push(' '); } line.push_str(&p); + } + out.push_str(&line); out.push('\n'); out +} +/// ` left description`, the description from column `col` (on the next line when `left` is wider) +fn item_at(left: &str, help: &str, col: usize) -> String { + let n = left.chars().count(); + if n + 2 <= col { wrap(&format!("{left}{}", " ".repeat(col - n)), help, col) } else { format!("{left}\n{}", wrap(&" ".repeat(col), help, col)) } +} +fn item(left: &str, help: &str) -> String { item_at(left, help, COL) } +fn section(title: &str, text: &str) -> String { wrap(&format!("{title}: "), text, 2) } +fn flag_left(f: &Flag) -> String { if f.arg.is_empty() { format!(" {}", f.name) } else { format!(" {} {}", f.name, f.arg) } } + +/// `probbit CMD --help` = `probbit help CMD` +pub(crate) fn command_help(c: &Command) -> String { + let mut h = String::new(); + for (i, u) in c.usage.iter().enumerate() { h += &wrap_synopsis(if i == 0 { "usage: probbit" } else { " probbit" }, u, 15); } + h += &wrap(" ", c.about, 2); + for (t, x) in c.before { h += §ion(t, x); } + if c.flags().next().is_some() { h += "flags:\n"; for f in c.flags() { h += &item(&flag_left(f), f.help); } } + if !c.subs.is_empty() { + h += &format!("subcommands (probbit help {} SUBCOMMAND: its flags):\n", c.name); + for s in c.subs { h += &wrap_synopsis(&format!(" {}", s.name), s.args, 6); h += &wrap(&" ".repeat(6), s.help, 6); } + } + for (t, x) in c.after { h += §ion(t, x); } + if !c.examples.is_empty() { h += "examples:\n"; for e in c.examples { h += &format!(" {e}\n"); } } + h +} +/// `probbit CMD SUB --help` = `probbit help CMD SUB`: the subcommand's synopsis, what it does, its flags (and the command's own, +/// such as persona's --pretty), the command's exit codes +pub(crate) fn sub_help(c: &Command, s: &'static Sub) -> String { + let mut h = wrap_synopsis(&format!("usage: probbit {} {}", c.name, s.name), s.args, 15); + h += &wrap(" ", s.help, 2); + if c.sub_flags(s).next().is_some() { h += "flags:\n"; for f in c.sub_flags(s) { h += &item(&flag_left(f), f.help); } } + for (t, x) in c.after.iter().filter(|(t, _)| *t == "exit") { h += §ion(t, x); } + h +} +/// The first words of a synopsis: up to the first flag, option group, choice or redirection (`live verify STRAND +/// [--from-checkpoint]` -> `live verify STRAND`) +fn head(syn: &str) -> String { + syn.split(' ').take_while(|w| !w.is_empty() && !w.starts_with(['[', '-', '<', '>', '(']) && !w.contains('|')).collect::>().join(" ") +} +/// `probbit --help` piped, `probbit help`, the usage a bare `probbit` prints on stderr +pub(crate) fn top_help(cmds: &[Command], version: &str) -> String { + let mut h = format!("probbit {version} · a virtual p-bit processor: joint decisions under hard rules, with gated odds\n"); + h += "usage: probbit COMMAND [flags] · probbit help COMMAND [SUBCOMMAND] · probbit COMMAND --help\n"; + for g in GROUPS { + h += &format!("\n{g}\n"); + for c in cmds.iter().filter(|c| c.group == g) { + h += &item_at(&format!(" {}", head(c.usage[0])), c.summary, TOP_COL); + if c.list_subs { for s in c.subs { h += &item_at(&format!(" {} {} {}", c.name, s.name, head(s.args)), first_sentence(s.help), TOP_COL); } } + } + } + h += "\nevery command\n"; + h += &item_at(" --help, -h", "usage, every flag with its default, the exit codes (= probbit help COMMAND)", TOP_COL); + h += &item_at(" --json", "errors as one {\"error\"} object on stdout, at a terminal and for bad flags too", TOP_COL); + h += &item_at(" --plain", "no colour, no animation where it draws (as NO_COLOR=1, PROBBIT_THEME=plain)", TOP_COL); + h += "\n"; h += §ion("exit", EXIT); + h += "\nexamples:\n"; + for e in TOP_EXAMPLES { h += &format!(" {e}\n"); } + h +} +/// The exit codes of every command (the man page's EXIT STATUS, the top-level help) +pub(crate) const EXIT: &str = "0 done or an answer; 1 an answer that says no (infeasible, a line that differs, a counterexample, a rule left unknown); 2 a bad command, flag or input (a line on stderr; for bad input also one {\"error\"} object on stdout when stdout is not a terminal); 3 refused / declined / non-finite (decide, run, evaluate), or a rule left unproven (persona lint); 4 refused by a lock, a pause or a retirement (live). Each command's --help has its own list."; +pub(crate) const TOP_EXAMPLES: [&str; 6] = [ + "probbit demo | probbit decide --summary --pretty", + "probbit run --pretty < examples/knapsack-20.json", + "probbit persona init examples/persona/tutor.yaml --out pip.json", + "probbit live examples/persona/tutor.yaml --seed 2 --demo week", + "probbit monitor --demo --open", + "claude mcp add probbit -- probbit mcp", +]; +fn first_sentence(s: &str) -> &str { s.split(['\n', ';']).next().unwrap_or(s).trim() } + +/// Optimal-string-alignment (restricted Damerau-Levenshtein) distance: insertions, deletions, substitutions and swaps of two +/// neighbouring characters, 1 each +pub(crate) fn distance(a: &str, b: &str) -> usize { + let (a, b): (Vec, Vec) = (a.chars().collect(), b.chars().collect()); + let mut d = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + for (i, r) in d.iter_mut().enumerate() { r[0] = i; } for j in 0..=b.len() { d[0][j] = j; } + for i in 1..=a.len() { for j in 1..=b.len() { + let c = usize::from(a[i - 1] != b[j - 1]); + d[i][j] = (d[i - 1][j] + 1).min(d[i][j - 1] + 1).min(d[i - 1][j - 1] + c); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { d[i][j] = d[i][j].min(d[i - 2][j - 2] + 1); } + } } + d[a.len()][b.len()] +} +/// The candidate an unknown `word` most likely meant: the only one it is a prefix of (2 characters at least), else the closest +/// within distance 2 (1 for a word of 3 characters or fewer, so `x` does not become `ir`); ties go to the earlier candidate. +pub(crate) fn suggest<'a>(word: &str, cands: &[&'a str]) -> Option<&'a str> { + let pre: Vec<&&str> = cands.iter().filter(|c| word.chars().count() >= 2 && c.starts_with(word)).collect(); + if pre.len() == 1 { return Some(pre[0]); } + let max = if word.chars().count() <= 3 { 1 } else { 2 }; + cands.iter().map(|c| (distance(word, c), *c)).filter(|(d, _)| *d <= max).min_by_key(|(d, _)| *d).map(|(_, c)| c) +} + +/// The flag names a completion offers for a list of flags, plus --help and --json (every command takes them) +fn words(fs: &[&Flag]) -> Vec<&'static str> { + let mut v: Vec<&'static str> = fs.iter().map(|f| f.name).collect(); + for g in ["--help", "--json"] { if !v.contains(&g) { v.push(g); } } v +} +fn names(cmds: &[Command]) -> Vec<&'static str> { cmds.iter().map(|c| c.name).collect() } +fn quote_sh(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } +pub(crate) const SHELLS: [&str; 3] = ["bash", "zsh", "fish"]; +/// `probbit completions bash|zsh|fish`: a static script from the table (commands, subcommands, flags); None = an unknown shell +pub(crate) fn completions(shell: &str, cmds: &[Command]) -> Option { + match shell { "bash" => Some(bash(cmds)), "zsh" => Some(zsh(cmds)), "fish" => Some(fish(cmds)), _ => None } +} +/// bash: the words for the position (commands; a command's subcommands and flags; a subcommand's flags), files otherwise +/// (`complete -o default`) +fn bash(cmds: &[Command]) -> String { + let mut s = String::from("# bash completion for probbit (generated by `probbit completions bash`)\n"); + s += "# install: probbit completions bash > ~/.local/share/bash-completion/completions/probbit\n"; + s += "_probbit() {\n local cur=${COMP_WORDS[COMP_CWORD]} words=''\n"; + s += &format!(" if [ \"$COMP_CWORD\" -eq 1 ]; then\n words={}\n else\n case \"${{COMP_WORDS[1]}}\" in\n", quote_sh(&[names(cmds), vec!["--help", "--version"]].concat().join(" "))); + for c in cmds { + let own = words(&c.flags().collect::>()); + let first = match c.name { "help" => names(cmds), "completions" => SHELLS.to_vec(), _ => [c.subs.iter().map(|s| s.name).collect(), own.clone()].concat() }; + if c.subs.is_empty() { + s += &format!(" {}) if [ \"$COMP_CWORD\" -eq 2 ]; then words={}; else words={}; fi ;;\n", c.name, quote_sh(&first.join(" ")), quote_sh(&own.join(" "))); + continue; + } + s += &format!(" {})\n if [ \"$COMP_CWORD\" -eq 2 ]; then words={}\n else\n case \"${{COMP_WORDS[2]}}\" in\n", c.name, quote_sh(&first.join(" "))); + for sb in c.subs { s += &format!(" {}) words={} ;;\n", sb.name, quote_sh(&words(&c.sub_flags(sb).collect::>()).join(" "))); } + s += &format!(" *) words={} ;;\n esac\n fi ;;\n", quote_sh(&own.join(" "))); + } + s += " esac\n fi\n COMPREPLY=($(compgen -W \"$words\" -- \"$cur\"))\n}\ncomplete -o default -F _probbit probbit\n"; + s +} +/// zsh: `_describe` for commands and subcommands, `_arguments` for the flags (with their help and what their value is) +fn zsh(cmds: &[Command]) -> String { + let esc = |t: &str| t.replace('\'', "'\\''").replace('[', "\\[").replace(']', "\\]"); + let name = |t: &str| esc(t).replace(':', "\\:"); + let spec = |f: &Flag| { + let help = esc(first_sentence(f.help)); let a = f.arg.trim_start_matches('[').trim_end_matches(']'); + let action = if ["FILE", "STATE", "STRAND", "TRACE", "PERSONA"].iter().any(|k| a.contains(k)) { "_files".to_string() } + else if !a.is_empty() && a.chars().all(|c| c.is_ascii_lowercase() || c == '|' || c == ':') { format!("({})", a.split('|').map(|x| x.replace(':', "\\:")).collect::>().join(" ")) } + else { " ".to_string() }; + if f.arg.is_empty() { format!("'{}[{help}]'", f.name) } + else { format!("'{}[{help}]{}{}:{action}'", f.name, if f.takes_value() { ":" } else { "::" }, a.replace(':', "\\:").replace('|', "\\|")) } + }; + let args = |fs: Vec<&Flag>| -> String { + let mut a: Vec = fs.iter().map(|f| spec(f)).collect(); + if !fs.iter().any(|f| f.name == "--json") { a.push("'--json[errors as JSON on stdout]'".into()); } + a.push("'(- *)'{-h,--help}'[usage and every flag]'".into()); a.push("'*:file:_files'".into()); a.join(" \\\n ") + }; + let mut s = String::from("#compdef probbit\n# zsh completion for probbit (generated by `probbit completions zsh`)\n"); + s += "# install: probbit completions zsh > ~/.zfunc/_probbit, with fpath=(~/.zfunc $fpath) before compinit in ~/.zshrc\n"; + s += "_probbit() {\n local -a cmds subs\n if (( CURRENT == 2 )); then\n cmds=(\n"; + for c in cmds { s += &format!(" '{}:{}'\n", name(c.name), esc(c.summary)); } + s += " )\n _describe -t commands 'probbit command' cmds\n return\n fi\n case $words[2] in\n"; + for c in cmds { + match c.name { + "help" => { s += &format!(" help) _values 'command' {} ;;\n", names(cmds).join(" ")); continue; } + "completions" => { s += &format!(" completions) _values 'shell' {} ;;\n", SHELLS.join(" ")); continue; } + _ => {} + } + if c.subs.is_empty() { s += &format!(" {})\n shift words; (( CURRENT-- ))\n _arguments {} ;;\n", c.name, args(c.flags().collect())); continue; } + s += &format!(" {})\n if (( CURRENT == 3 )); then\n subs=(\n", c.name); + for sb in c.subs { s += &format!(" '{}:{}'\n", name(sb.name), esc(first_sentence(sb.help))); } + s += " )\n _describe -t subcommands 'subcommand' subs\n"; + if c.name == "live" { s += " _files\n"; } + s += " return\n fi\n case $words[3] in\n"; + for sb in c.subs { s += &format!(" {})\n shift 2 words; (( CURRENT -= 2 ))\n _arguments {} ;;\n", sb.name, args(c.sub_flags(sb).collect())); } + s += &format!(" *)\n shift words; (( CURRENT-- ))\n _arguments {} ;;\n esac ;;\n", args(c.flags().collect())); + } + s += " *) _files ;;\n esac\n}\nif [ \"$funcstack[1]\" = \"_probbit\" ]; then _probbit \"$@\"; else compdef _probbit probbit; fi\n"; + s +} +/// fish: one `complete` line per command, subcommand and flag, each with its condition and its help +fn fish(cmds: &[Command]) -> String { + let q = |t: &str| format!("'{}'", t.replace('\\', "\\\\").replace('\'', "\\'")); + let flag = |cond: &str, f: &Flag| format!("complete -c probbit -n {} -l {}{} -d {}\n", q(cond), &f.name[2..], if f.takes_value() { " -r" } else { "" }, q(first_sentence(f.help))); + let mut s = String::from("# fish completion for probbit (generated by `probbit completions fish`)\n"); + s += "# install: probbit completions fish > ~/.config/fish/completions/probbit.fish\n"; + for c in cmds { s += &format!("complete -c probbit -f -n __fish_use_subcommand -a {} -d {}\n", c.name, q(c.summary)); } + for c in cmds { + let on = format!("__fish_seen_subcommand_from {}", c.name); + match c.name { + "help" => s += &format!("complete -c probbit -f -n {} -a {}\n", q(&on), q(&names(cmds).join(" "))), + "completions" => s += &format!("complete -c probbit -f -n {} -a {}\n", q(&on), q(&SHELLS.join(" "))), + _ => {} + } + let subs: Vec<&str> = c.subs.iter().map(|s| s.name).collect(); + for sb in c.subs { + s += &format!("complete -c probbit -n {} -a {} -d {}\n", q(&format!("{on}; and not __fish_seen_subcommand_from {}", subs.join(" "))), sb.name, q(first_sentence(sb.help))); + for f in sb.flags { s += &flag(&format!("{on}; and __fish_seen_subcommand_from {}", sb.name), f); } + } + let own = if c.shared || subs.is_empty() { on.clone() } else { format!("{on}; and not __fish_seen_subcommand_from {}", subs.join(" ")) }; + for f in c.flags() { s += &flag(&own, f); } + s += &format!("complete -c probbit -n {} -l json -d 'errors as JSON on stdout'\n", q(&on)); + } + s += "complete -c probbit -s h -l help -d 'usage and every flag'\n"; + s +} + +/// roff text: backslashes as `\e`, `-` as `\-` (a real hyphen-minus: flags can be copied), any other non-ASCII character as +/// `\[uXXXX]` (the page is ASCII: § · → render under groff and mandoc alike), a line-leading `.` or `'` guarded +fn roff(t: &str) -> String { + let t: String = t.replace('\\', "\\e").replace('-', "\\-").chars().map(|c| if c.is_ascii() { c.to_string() } else { format!("\\[u{:04X}]", c as u32) }).collect(); + t.split('\n').map(|l| if l.starts_with(['.', '\'']) { format!("\\&{l}") } else { l.to_string() }).collect::>().join("\n") +} +fn roff_flag(f: &Flag) -> String { format!(".TP\n\\fB{}\\fR{}\n{}\n", roff(f.name), if f.arg.is_empty() { String::new() } else { format!(" \\fI{}\\fR", roff(f.arg)) }, roff(&f.help.replace('\n', " "))) } +/// A synopsis line in a no-fill block, wrapped for an 80-column terminal (7 columns of man's indent) with a hanging indent +fn man_synopsis(u: &str) -> String { + let w = wrap_synopsis_to("probbit", u, 8, 72); + roff(w.trim_end()).replacen("probbit", "\\fBprobbit\\fR", 1) + "\n" +} +/// `probbit man`: the manual page (roff, man(7) macros) from the table: NAME, SYNOPSIS, DESCRIPTION, COMMANDS, EXIT STATUS, +/// ENVIRONMENT, EXAMPLES, SEE ALSO. docs/probbit.1 is this output, committed (test `committed_man_page_is_generated`). +pub(crate) fn man(cmds: &[Command], version: &str) -> String { + let mut m = format!(".TH PROBBIT 1 \"\" \"probbit {version}\" \"probbit manual\"\n"); + m += ".SH NAME\nprobbit \\- a virtual probabilistic (p\\-bit) processor: joint decisions under hard rules, with gated odds\n"; + m += ".SH SYNOPSIS\n.nf\n"; + for c in cmds { for u in c.usage { m += &man_synopsis(u); } } + m += ".fi\n.SH DESCRIPTION\n"; + m += &roff("probbit reads a JSON problem on stdin and writes a JSON answer on stdout: exact where the structure allows, sampled otherwise, and a sampled answer passes a published diagnostics gate or is refused. The persona, live and monitor commands are the individuality layer: a persona file, an individual's state and a turn's inputs give one stance with exact odds, and a strand logs a life that replays byte for byte."); + m += "\n.PP\n"; + m += &roff("Every command takes --help (-h): its usage, every flag with its default and its exit codes; probbit help COMMAND [SUBCOMMAND] prints the same. An unknown command or subcommand exits 2 and names the closest one."); + m += "\n.PP\n"; + m += &roff("Errors: one human line on stderr, always. A bad input document also gives one {\"error\": {\"code\", \"path\", \"message\"}} object on stdout when stdout is not a terminal, or with --json. A bad command or flag prints nothing on stdout unless --json is given (code \"flag\", or \"command\" with a \"suggestion\"). --json is accepted by every command and changes no answer."); + m += "\n.SH COMMANDS\n"; + for c in cmds { + m += &format!(".SS {}\n{}\n", c.name, roff(c.summary)); + m += ".PP\n.nf\n"; for u in c.usage { m += &man_synopsis(u); } m += ".fi\n"; + for p in c.about.split('\n') { m += &format!(".PP\n{}\n", roff(p)); } + for (t, x) in c.before { m += &format!(".PP\n\\fI{}\\fR: {}\n", roff(t), roff(&x.replace('\n', " "))); } + if c.flags().next().is_some() { m += ".PP\nFlags:\n.RS 4\n"; for f in c.flags() { m += &roff_flag(f); } m += ".RE\n"; } + if !c.subs.is_empty() { + m += ".PP\nSubcommands:\n.RS 4\n"; + for s in c.subs { + m += &format!(".TP\n\\fB{}\\fR {}\n{}\n", roff(s.name), roff(s.args), roff(&s.help.replace('\n', " "))); + if !s.flags.is_empty() { m += ".RS 4\n"; for f in s.flags { m += &roff_flag(f); } m += ".RE\n"; } + } + m += ".RE\n"; + } + for (t, x) in c.after { m += &format!(".PP\n\\fI{}\\fR: {}\n", roff(t), roff(&x.replace('\n', " "))); } + if !c.examples.is_empty() { m += ".PP\nExamples:\n.RS 4\n.nf\n"; for e in c.examples { m += &format!("{}\n", roff(e)); } m += ".fi\n.RE\n"; } + } + m += &format!(".SH EXIT STATUS\n{}\n", roff(EXIT)); + m += ".SH ENVIRONMENT\n"; + m += &roff("PROBBIT_CHAINS, PROBBIT_THREADS, PROBBIT_CPU_LIMIT, PROBBIT_MEM_LIMIT_MB, PROBBIT_PRIORITY: the processor controls (flag > environment > probbit.json, or the file PROBBIT_CONFIG names > default). NO_COLOR=1 or PROBBIT_THEME=plain: no colour on a terminal."); + m += "\n.SH EXAMPLES\n.nf\n"; + for e in TOP_EXAMPLES { m += &format!("{}\n", roff(e)); } + m += ".fi\n.SH SEE ALSO\n"; + m += &roff("README.md, docs/CHEATSHEET.md, docs/persona.md, docs/probbit-ir-json.md and docs/agents.md in the release archive or at https://github.com/BitmapAsset/probbit"); + m += "\n"; + m +} diff --git a/probbit-cli/tests/cli.rs b/probbit-cli/tests/cli.rs index 4f2f3c8..dc8c854 100644 --- a/probbit-cli/tests/cli.rs +++ b/probbit-cli/tests/cli.rs @@ -1275,8 +1275,9 @@ fn json_surrogate_pairs_decode_to_one_character() { /// missing command, which still does). #[test] fn bare_help_exits_0() { - for h in ["--help", "-h"] { let (c, out, err) = probbit(&[h], ""); assert_eq!(c, 0, "{h}: {err}"); assert!(out.starts_with("usage: probbit decide") && err.is_empty(), "{h}: {out}"); } - let (c, out, err) = probbit(&[], ""); assert_eq!(c, 2); assert!(out.is_empty() && err.starts_with("usage: probbit decide"), "{err}"); + let top = concat!("probbit ", env!("CARGO_PKG_VERSION"), " · "); + for h in ["--help", "-h"] { let (c, out, err) = probbit(&[h], ""); assert_eq!(c, 0, "{h}: {err}"); assert!(out.starts_with(top) && out.contains("\nusage: probbit COMMAND") && err.is_empty(), "{h}: {out}"); } + let (c, out, err) = probbit(&[], ""); assert_eq!(c, 2); assert!(out.is_empty() && err.starts_with(top), "{err}"); } /// P2-6: an unknown `probbit.json` key (`{"thread": 3}`) was ignored silently, and nothing in a decision said a config file was @@ -1404,6 +1405,7 @@ fn hero_screen_at_a_terminal() { assert!(plain.contains(&format!("PROCESSOR ONLINE · {} · exact → sample → gate", env!("CARGO_PKG_VERSION"))) && plain.contains("logical cpus") && plain.contains("site updates/s (self-test"), "{plain}"); assert!(plain.contains("probbit demo") && plain.contains("probbit mcp") && plain.contains("usage"), "{plain}"); assert!(plain.trim().lines().count() <= 14, "{} lines: {plain}", plain.trim().lines().count()); assert!(screen.contains("\x1b[38;5;51m"), "the neon palette"); + assert!(plain.lines().all(|l| l.trim_end().chars().count() <= 100), "a hero line over 100 columns: {plain}"); // R94 } let j = json::parse(&std::fs::read_to_string(cache.join("probbit").join("stats.json")).unwrap()).unwrap(); let _ = std::fs::remove_dir_all(&cache); assert!(j.get("site_updates_per_s").and_then(|x| x.as_f64()).is_some_and(|r| r > 0.0), "{j:?}"); diff --git a/probbit-cli/tests/cli_completions.rs b/probbit-cli/tests/cli_completions.rs new file mode 100644 index 0000000..2c00bfd --- /dev/null +++ b/probbit-cli/tests/cli_completions.rs @@ -0,0 +1,71 @@ +//! R94 item 4: `probbit completions bash|zsh|fish` prints a static script generated from the command table. Each script names +//! every command, subcommand and flag the helps list; bash and zsh scripts pass `bash -n` / `zsh -n` (fish: `fish -n` where +//! installed); the bash one completes real words; an unknown shell exits 2 with the list. +use std::process::{Command, Stdio}; + +fn probbit(args: &[&str]) -> (i32, String, String) { + let o = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +fn script(shell: &str) -> String { let (c, out, err) = probbit(&["completions", shell]); assert_eq!(c, 0, "{shell}: {err}"); assert!(err.is_empty()); out } +fn has(cmd: &str) -> bool { Command::new(cmd).arg("-c").arg("exit 0").stdout(Stdio::null()).stderr(Stdio::null()).status().map_or(false, |s| s.success()) } +fn tmp(name: &str, text: &str) -> std::path::PathBuf { let p = std::env::temp_dir().join(format!("probbit-compl-{}-{name}", std::process::id())); std::fs::write(&p, text).unwrap(); p } +/// (command, subcommand) -> the flags its help lists +fn helped() -> Vec<(String, Vec)> { + let flags = |h: &str| -> Vec { h.lines().skip_while(|l| *l != "flags:").skip(1).take_while(|l| l.starts_with(' ')).filter_map(|l| l.strip_prefix(" --")) + .map(|l| format!("--{}", l.split_whitespace().next().unwrap())).collect() }; + let mut v = vec![]; + for c in ["persona", "live", "monitor", "decide", "run", "evaluate", "demo", "ir", "mcp", "stats", "help", "completions", "version"] { + v.push((c.to_string(), flags(&probbit(&[c, "--help"]).1))); + } + for s in ["init", "turn", "replay", "explain", "diff", "lint", "fuzz", "prove", "check", "compile", "describe"] { v.push((format!("persona {s}"), flags(&probbit(&["persona", s, "--help"]).1))); } + for s in ["verify", "control"] { v.push((format!("live {s}"), flags(&probbit(&["live", s, "--help"]).1))); } + v +} + +#[test] +fn every_script_names_every_command_subcommand_and_flag() { + let all = helped(); + for sh in ["bash", "zsh", "fish"] { + let s = script(sh); + for (name, flags) in &all { + for w in name.split(' ') { assert!(s.contains(w), "{sh}: no {w}"); } + for f in flags { let needle = if sh == "fish" { format!("-l {}", &f[2..]) } else { f.clone() }; assert!(s.contains(&needle), "{sh}: {name}: no {f}"); } + } + } +} + +#[test] +fn scripts_parse() { + if !cfg!(unix) { eprintln!("syntax checks run on Unix (a Windows bash takes no Windows paths)"); return; } + let mut checked = vec![]; + for (sh, ext) in [("bash", "bash"), ("zsh", "zsh"), ("fish", "fish")] { + if !has(sh) { eprintln!("no {sh} here: its syntax check skipped"); continue; } + let p = tmp(&format!("syntax.{ext}"), &script(sh)); + let o = Command::new(sh).arg("-n").arg(&p).output().unwrap(); let _ = std::fs::remove_file(&p); + assert!(o.status.success(), "{sh} -n: {}", String::from_utf8_lossy(&o.stderr)); checked.push(sh); + } + assert!(checked.contains(&"bash"), "bash is on every Unix runner"); +} + +/// The bash script completes: commands, subcommands, a subcommand's flags, a command's flags, the shells, help's commands +#[test] +fn bash_completes_real_words() { + if !cfg!(unix) || !has("bash") { eprintln!("no bash: skipped"); return; } + let p = tmp("functional.bash", &script("bash")); + let probe = "t() { COMP_WORDS=(\"$@\"); COMP_CWORD=$(($# - 1)); COMPREPLY=(); _probbit; echo \"${COMPREPLY[*]}\"; }\n\ + t probbit pers; t probbit persona tu; t probbit persona turn x.yaml --st; t probbit live ver; t probbit live verify s --f;\n\ + t probbit decide --bud; t probbit completions z; t probbit help mon; t probbit monitor --de; t probbit persona fuzz x.yaml --fix"; + let o = Command::new("bash").arg("-c").arg(format!("source '{}'\n{probe}", p.display())).output().unwrap(); let _ = std::fs::remove_file(&p); + let out = String::from_utf8_lossy(&o.stdout); + assert_eq!(out.lines().collect::>(), ["persona", "turn", "--state", "verify", "--from-checkpoint", "--budget-ms", "zsh", "monitor", "--demo", "--fixture-src"], + "{}", String::from_utf8_lossy(&o.stderr)); +} + +#[test] +fn unknown_or_missing_shell_exits_2() { + let (c, out, err) = probbit(&["completions", "tcsh"]); assert_eq!(c, 2); assert!(out.is_empty()); + assert_eq!(err, "probbit: completions: unknown shell \"tcsh\"; one of bash, zsh, fish\n"); + let (c, _, err) = probbit(&["completions"]); assert_eq!(c, 2); assert!(err.contains("bash, zsh, fish"), "{err}"); + let (c, _, err) = probbit(&["completions", "zsh", "--extra"]); assert_eq!(c, 2); assert!(err.contains("unknown argument \"--extra\" for `probbit completions`"), "{err}"); +} diff --git a/probbit-cli/tests/cli_errors.rs b/probbit-cli/tests/cli_errors.rs new file mode 100644 index 0000000..47c591e --- /dev/null +++ b/probbit-cli/tests/cli_errors.rs @@ -0,0 +1,77 @@ +//! R94 item 6: one error channel. A human line on stderr, always. A bad input document's `{"error"}` object goes to stdout when +//! stdout is not a terminal (every wrapper reads it there) or with --json; at a terminal without --json only the line: never +//! both. A bad command or flag stays off stdout unless --json is given (then code "command" + "suggestion", or "flag"). +use std::process::{Command, Stdio}; +use std::io::Write; + +fn probbit(args: &[&str], stdin: &str) -> (i32, String, String) { + let mut c = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let _ = c.stdin.take().unwrap().write_all(stdin.as_bytes()); + let o = c.wait_with_output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +/// The command under a pseudo-terminal (`script`): stdout on the terminal, stderr to a file. -> (exit, the terminal, stderr); +/// None where there is no `script` (Windows) +fn at_terminal(args: &[&str], input: &str) -> Option<(i32, String, String)> { + if !cfg!(unix) || Command::new("sh").args(["-c", "command -v script"]).output().map_or(true, |o| !o.status.success()) { return None; } + let tag = format!("{}-{}", std::process::id(), args.join("_").replace(['/', ' ', '"', '\''], "")); + let (inp, errp) = (std::env::temp_dir().join(format!("probbit-err-in-{tag}")), std::env::temp_dir().join(format!("probbit-err-2-{tag}"))); + std::fs::write(&inp, input).unwrap(); + let q = |s: &str| format!("'{}'", s.replace('\'', "'\\''")); + let cmd = format!("{} {} < {} 2> {}", q(env!("CARGO_BIN_EXE_probbit")), args.iter().map(|a| q(a)).collect::>().join(" "), q(&inp.to_string_lossy()), q(&errp.to_string_lossy())); + let mut c = Command::new("script"); + if cfg!(target_os = "macos") { c.args(["-q", "/dev/null", "sh", "-c", &cmd]); } else { c.args(["-q", "-e", "-c", &cmd, "/dev/null"]); } + let o = c.stdin(Stdio::null()).env("TERM", "xterm-256color").output().unwrap(); + let err = std::fs::read_to_string(&errp).unwrap_or_default(); let _ = std::fs::remove_file(&inp); let _ = std::fs::remove_file(&errp); + // macOS script(1) echoes a ^D for the closed stdin + let screen = String::from_utf8_lossy(&o.stdout).replace("^D", "").replace(['\u{8}', '\r'], ""); + Some((o.status.code().unwrap_or(-1), screen, err)) +} +const BAD: &str = "{"; +const WANT: &str = "{\"error\":{\"code\":\"schema\",\"path\":\"\",\"message\":\"bad JSON: expected string at byte 1\"}}\n"; + +#[test] +fn piped_input_error_is_one_object_on_stdout_and_a_line_on_stderr() { + for cmd in ["decide", "run", "evaluate", "ir"] { + let (c, out, err) = probbit(&[cmd], BAD); assert_eq!(c, 2, "{cmd}"); + assert_eq!(out, WANT, "{cmd}"); assert!(err.starts_with("probbit: ") && err.lines().count() == 1 && !err.contains('{'), "{cmd}: {err}"); + } + let (c, out, _) = probbit(&["decide", "--json"], BAD); assert_eq!((c, out.as_str()), (2, WANT), "--json changes nothing piped"); + let (c, out, err) = probbit(&["persona", "check", "no-such-persona.yaml"], ""); assert_eq!(c, 2); + assert!(out.starts_with("{\"error\":{\"code\":\"persona\"") && out.lines().count() == 1 && err.starts_with("probbit: "), "{out} {err}"); +} + +#[test] +fn at_a_terminal_the_line_only_and_with_json_the_object_too() { + let Some((c, screen, err)) = at_terminal(&["decide"], BAD) else { eprintln!("no script(1): terminal test skipped"); return }; + assert_eq!(c, 2); assert!(!screen.contains("{\"error\""), "the object on a terminal: {screen:?}"); assert!(screen.trim().is_empty(), "{screen:?}"); + assert_eq!(err, "probbit: bad JSON: expected string at byte 1\n"); + let (c, screen, err) = at_terminal(&["decide", "--json"], BAD).unwrap(); + assert_eq!(c, 2); assert_eq!(screen.trim(), WANT.trim(), "--json: the object at a terminal"); assert_eq!(err, "probbit: bad JSON: expected string at byte 1\n"); + let (c, screen, err) = at_terminal(&["persona", "check", "no-such-persona.yaml"], "").unwrap(); + assert_eq!(c, 2); assert!(screen.trim().is_empty() && err.starts_with("probbit: persona: "), "{screen:?} {err}"); + // a good answer at a terminal is unchanged + let (c, screen, _) = at_terminal(&["version"], "").unwrap(); assert_eq!((c, screen.trim()), (0, concat!("probbit ", env!("CARGO_PKG_VERSION")))); +} + +#[test] +fn usage_errors_stay_off_stdout_unless_json() { + for args in [vec!["persna"], vec!["decide", "--nope"], vec!["persona", "lnt", "x.yaml"], vec!["completions", "tcsh"]] { + let (c, out, err) = probbit(&args, ""); assert_eq!(c, 2, "{args:?}"); assert!(out.is_empty(), "{args:?}: {out}"); assert!(err.starts_with("probbit: "), "{args:?}"); + } + let (c, out, err) = probbit(&["persna", "init", "x.yaml", "--json"], ""); assert_eq!(c, 2); + assert_eq!(out, "{\"error\":{\"code\":\"command\",\"path\":\"\",\"message\":\"unknown command \\\"persna\\\"; did you mean \\\"persona\\\"? see `probbit --help`\",\"suggestion\":\"persona\"}}\n"); + assert_eq!(err, "probbit: unknown command \"persna\"; did you mean \"persona\"? see `probbit --help`\n"); + let (_, out, _) = probbit(&["frobnicate", "--json"], ""); assert!(out.ends_with(",\"suggestion\":null}}\n"), "{out}"); + let (_, out, _) = probbit(&["persona", "lnt", "x.yaml", "--json"], ""); assert!(out.contains("\"code\":\"command\"") && out.contains("\"suggestion\":\"lint\""), "{out}"); + let (c, out, _) = probbit(&["decide", "--budget-ms", "nan", "--json"], ""); assert_eq!(c, 2); + assert!(out.starts_with("{\"error\":{\"code\":\"flag\",\"path\":\"\",\"message\":\"--budget-ms must be") && !out.contains("suggestion"), "{out}"); +} + +#[test] +fn every_command_accepts_json() { + for args in [vec!["version", "--json"], vec!["stats", "--sweeps", "10", "--json"], vec!["help", "persona"], vec!["completions", "bash", "--json"]] { + let (c, _, err) = probbit(&args, ""); assert_eq!(c, 0, "{args:?}: {err}"); + } + let (c, out, _) = probbit(&["demo", "--json"], ""); assert_eq!(c, 0); assert!(out.starts_with('{')); +} diff --git a/probbit-cli/tests/cli_help.rs b/probbit-cli/tests/cli_help.rs new file mode 100644 index 0000000..cc14646 --- /dev/null +++ b/probbit-cli/tests/cli_help.rs @@ -0,0 +1,38 @@ +//! R94 item 2: `probbit help [COMMAND [SUBCOMMAND]]` prints exactly what `COMMAND [SUBCOMMAND] --help` prints (exit 0), and +//! `probbit help` alone exactly what `probbit --help` prints. +use std::process::Command; + +fn probbit(args: &[&str]) -> (i32, String, String) { + let o = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +const COMMANDS: [&str; 13] = ["persona", "live", "monitor", "decide", "run", "evaluate", "demo", "ir", "mcp", "stats", "help", "version", "--version"]; + +#[test] +fn help_command_is_dash_dash_help() { + let (c, top, err) = probbit(&["help"]); assert_eq!(c, 0, "{err}"); assert!(err.is_empty()); + for h in [vec!["--help"], vec!["-h"], vec!["help", "--plain"]] { assert_eq!(probbit(&h), (0, top.clone(), String::new()), "{h:?}"); } + for cmd in COMMANDS { + let want = probbit(&[cmd, "--help"]); assert_eq!(want.0, 0, "{cmd}"); + assert_eq!(probbit(&["help", cmd]), want, "help {cmd}"); assert_eq!(probbit(&[cmd, "-h"]), want, "{cmd} -h"); + } + for (cmd, subs) in [("persona", &["init", "turn", "replay", "explain", "diff", "lint", "fuzz", "prove", "check", "compile", "describe"][..]), ("live", &["verify", "control"][..])] { + for s in subs { + let want = probbit(&[cmd, s, "--help"]); assert_eq!(want.0, 0, "{cmd} {s}"); assert!(want.1.starts_with(&format!("usage: probbit {cmd} {s} ")), "{}", want.1); + assert_eq!(probbit(&["help", cmd, s]), want, "help {cmd} {s}"); + assert_ne!(want.1, probbit(&[cmd, "--help"]).1, "{cmd} {s}: the subcommand has its own help"); + } + } + // flags after the subcommand do not change it: `persona turn x.yaml --state s --help` is turn's help + assert_eq!(probbit(&["persona", "turn", "x.yaml", "--state", "s", "--help"]).1, probbit(&["help", "persona", "turn"]).1); + // `live PERSONA --help`: the word after `live` is a persona, not a subcommand: live's help + assert_eq!(probbit(&["live", "tutor.yaml", "--help"]).1, probbit(&["help", "live"]).1); +} + +#[test] +fn help_errors_exit_2() { + for args in [vec!["help", "frobnicate"], vec!["help", "persona", "frobnicate"], vec!["help", "persona", "init", "extra"]] { + let (c, out, err) = probbit(&args); assert_eq!(c, 2, "{args:?}"); assert!(out.is_empty(), "{args:?}: {out}"); assert!(err.starts_with("probbit: "), "{args:?}: {err}"); + } + let (_, _, err) = probbit(&["help", "persona", "frobnicate"]); assert!(err.contains("subcommand \"frobnicate\"") && err.contains("probbit persona --help"), "{err}"); +} diff --git a/probbit-cli/tests/cli_layout.rs b/probbit-cli/tests/cli_layout.rs new file mode 100644 index 0000000..c430fa6 --- /dev/null +++ b/probbit-cli/tests/cli_layout.rs @@ -0,0 +1,49 @@ +//! R94 item 5: help layout. Every help screen (top-level, every command, every subcommand, `probbit help ...`) fits 100 columns; +//! the top-level help starts with the version line, groups the commands (persona · live · monitor · decide/run · mcp · other) +//! and ends with 4-6 examples; `monitor --help` is split into usage / modes / flags / demo sections. +use std::process::Command; + +fn probbit(args: &[&str]) -> (i32, String, String) { + let o = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +fn screens() -> Vec<(String, String)> { + let mut v: Vec> = vec![vec!["--help"], vec!["help"]]; + for c in ["persona", "live", "monitor", "decide", "run", "evaluate", "demo", "ir", "mcp", "stats", "help", "completions", "version"] { v.push(vec![c, "--help"]); } + for s in ["init", "turn", "replay", "explain", "diff", "lint", "fuzz", "prove", "check", "compile", "describe"] { v.push(vec!["persona", s, "--help"]); } + for s in ["verify", "control"] { v.push(vec!["live", s, "--help"]); } + v.into_iter().map(|a| { let (c, out, err) = probbit(&a); assert_eq!(c, 0, "{a:?}: {err}"); (a.join(" "), out) }).collect() +} + +#[test] +fn no_help_line_is_wider_than_100_columns() { + for (name, out) in screens() { + for l in out.lines() { assert!(l.chars().count() <= 100, "`probbit {name}`: {} columns: {l}", l.chars().count()); } + assert!(!out.contains('\t') && !out.lines().any(|l| l.ends_with(' ')), "`probbit {name}`: tabs or trailing spaces"); + } +} + +#[test] +fn top_level_help_is_versioned_grouped_and_ends_with_examples() { + let (_, h, _) = probbit(&["--help"]); + assert!(h.starts_with(&format!("probbit {} · ", env!("CARGO_PKG_VERSION"))), "{h}"); + let at = |s: &str| h.find(s).unwrap_or_else(|| panic!("no {s:?} in {h}")); + let groups = ["\npersona\n", "\nlive\n", "\nmonitor\n", "\ndecide/run\n", "\nmcp\n", "\nother\n"]; + assert!(groups.windows(2).all(|w| at(w[0]) < at(w[1])), "groups out of order"); + let ex: Vec<&str> = h[at("\nexamples:\n")..].lines().skip(2).collect(); + assert!((4..=6).contains(&ex.len()) && ex.iter().all(|l| l.starts_with(" ")), "{ex:?}"); + assert!(h.lines().count() <= 60, "{} lines", h.lines().count()); +} + +#[test] +fn monitor_help_has_sections() { + let (_, h, _) = probbit(&["monitor", "--help"]); + let at = |s: &str| h.find(s).unwrap_or_else(|| panic!("no {s:?} in {h}")); + assert!(h.starts_with("usage: probbit monitor STRAND")); + assert!(at("\nmodes: ") < at("\nflags:\n") && at("\nflags:\n") < at("\ndemo: ") && at("\ndemo: ") < at("\nexit: "), "{h}"); + // every flag and fact of the 0.8.1 paragraph is still there + for w in ["--follow", "--once", "--plain", "--fps N", "--serve", "--open", "--port N", "--demo [week|drives]", "127.0.0.1", "no --host", "/events", "/doc/N", + "heartbeat every 15 s", "10000", "xdg-open", "NO_COLOR=1", "TERM=dumb", "1-60, default 10", "default 0: a free one", "--demo drives", "under a minute", "changes no file"] { + assert!(h.contains(w), "monitor help lost {w:?}"); + } +} diff --git a/probbit-cli/tests/cli_man.rs b/probbit-cli/tests/cli_man.rs new file mode 100644 index 0000000..305eb39 --- /dev/null +++ b/probbit-cli/tests/cli_man.rs @@ -0,0 +1,50 @@ +//! R94 item 7: `probbit man` prints the manual page (roff) from the command table; docs/probbit.1 is that output, committed (the +//! release attaches it and install.sh installs it); the page renders under `man -l` (man-db) or mandoc. +use std::process::{Command, Stdio}; + +fn man() -> String { + let o = Command::new(env!("CARGO_BIN_EXE_probbit")).arg("man").output().unwrap(); + assert!(o.status.success() && o.stderr.is_empty(), "{}", String::from_utf8_lossy(&o.stderr)); String::from_utf8(o.stdout).unwrap() +} +const PAGE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../docs/probbit.1"); + +#[test] +fn committed_man_page_is_generated() { + let committed = std::fs::read_to_string(PAGE).unwrap().replace("\r\n", "\n"); + assert!(committed == man(), "docs/probbit.1 is stale: regenerate it with `cargo run -p probbit-cli -- man > docs/probbit.1` (after any help or version change)"); +} + +#[test] +fn man_page_has_every_section_command_and_flag() { + let m = man(); + assert!(m.starts_with(&format!(".TH PROBBIT 1 \"\" \"probbit {}\"", env!("CARGO_PKG_VERSION"))), "{}", &m[..80]); + let at = |s: &str| m.find(s).unwrap_or_else(|| panic!("no {s:?}")); + let sections = [".SH NAME\n", ".SH SYNOPSIS\n", ".SH DESCRIPTION\n", ".SH COMMANDS\n", ".SH EXIT STATUS\n", ".SH EXAMPLES\n", ".SH SEE ALSO\n"]; + assert!(sections.windows(2).all(|w| at(w[0]) < at(w[1])), "sections out of order"); + assert!(m.is_ascii(), "the page is ASCII (non-ASCII as \\[uXXXX])"); + for c in ["persona", "live", "monitor", "decide", "run", "evaluate", "demo", "ir", "mcp", "stats", "help", "completions", "man", "version"] { at(&format!(".SS {c}\n")); } + // every flag of every help screen, as roff writes it + let mut screens: Vec> = ["persona", "live", "monitor", "decide", "run", "evaluate", "demo", "ir", "stats"].iter().map(|c| vec![*c, "--help"]).collect(); + for s in ["init", "turn", "replay", "explain", "diff", "lint", "fuzz", "prove", "compile"] { screens.push(vec!["persona", s, "--help"]); } + for s in ["verify", "control"] { screens.push(vec!["live", s, "--help"]); } + for a in screens { + let h = String::from_utf8(Command::new(env!("CARGO_BIN_EXE_probbit")).args(&a).output().unwrap().stdout).unwrap(); + for f in h.lines().skip_while(|l| *l != "flags:").skip(1).take_while(|l| l.starts_with(' ')).filter_map(|l| l.strip_prefix(" --")) { + let f = f.split_whitespace().next().unwrap(); assert!(m.contains(&format!("\\fB\\-\\-{}\\fR", f.replace('-', "\\-"))), "{a:?}: --{f} not in the man page"); + } + } +} + +#[test] +fn man_page_renders() { + if !cfg!(unix) { eprintln!("no man on Windows: render check skipped"); return; } + // man-db (Linux): `man -l FILE`; elsewhere mandoc (macOS, BSD) + let try_run = |cmd: &str, args: &[&str]| Command::new(cmd).args(args).env("MANPAGER", "cat").env("PAGER", "cat").env("MANWIDTH", "100").stdin(Stdio::null()).output().ok().filter(|o| o.status.success()); + let out = try_run("man", &["-l", PAGE]).or_else(|| try_run("mandoc", &["-T", "utf8", PAGE])); + let Some(o) = out else { eprintln!("neither `man -l` nor mandoc here: render check skipped"); return }; + // overstrike bold and underline (`c BS c`, `_ BS c`): keep the character after each backspace + let raw = String::from_utf8_lossy(&o.stdout); let mut text = String::new(); + for c in raw.chars() { if c == '\u{8}' { text.pop(); } else { text.push(c); } } + for w in ["NAME", "SYNOPSIS", "COMMANDS", "EXIT STATUS", "SEE ALSO"] { assert!(text.contains(w), "rendered page lacks {w}"); } + assert!(text.contains("persona") && text.contains("budget") && text.contains("5.7"), "{}", &text[..text.len().min(400)]); +} diff --git a/probbit-cli/tests/cli_suggest.rs b/probbit-cli/tests/cli_suggest.rs new file mode 100644 index 0000000..ed41750 --- /dev/null +++ b/probbit-cli/tests/cli_suggest.rs @@ -0,0 +1,36 @@ +//! R94 item 3: an unknown command or subcommand exits 2 (as before) and names the closest one (Damerau-Levenshtein distance +//! <= 2, or the one command it is a prefix of); a miss says where the list is. stdout stays empty. +use std::process::Command; + +fn probbit(args: &[&str]) -> (i32, String, String) { + let o = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +fn err2(args: &[&str]) -> String { let (c, out, err) = probbit(args); assert_eq!(c, 2, "{args:?}: {err}"); assert!(out.is_empty(), "{args:?}: {out}"); err } + +#[test] +fn unknown_command_hit() { + assert_eq!(err2(&["persna", "init", "x.yaml"]), "probbit: unknown command \"persna\"; did you mean \"persona\"? see `probbit --help`\n"); + for (typo, want) in [("dcide", "decide"), ("rnu", "run"), ("monitr", "monitor"), ("pers", "persona"), ("verison", "version")] { + assert!(err2(&[typo]).contains(&format!("did you mean \"{want}\"?")), "{typo}"); + } + assert!(err2(&["help", "persna"]).contains("did you mean \"persona\"?")); +} + +#[test] +fn unknown_command_miss() { + assert_eq!(err2(&["frobnicate"]), "probbit: unknown command \"frobnicate\"; see `probbit --help`\n"); + assert!(!err2(&["x"]).contains("did you mean"), "a one-letter word is not anything's typo"); +} + +#[test] +fn unknown_subcommand_typo() { + assert_eq!(err2(&["persona", "lnt", "x.yaml"]), "probbit: unknown persona subcommand \"lnt\"; did you mean \"lint\"? see `probbit persona --help`\n"); + assert!(err2(&["persona", "fuz", "x.yaml"]).contains("did you mean \"fuzz\"?")); + assert_eq!(err2(&["persona", "frobnicate", "x.yaml"]), "probbit: unknown persona subcommand \"frobnicate\"; see `probbit persona --help`\n"); + assert!(err2(&["help", "persona", "descibe"]).contains("did you mean \"describe\"?")); + // live's first word is a persona file; a missing file that is a subcommand's typo is named as one + assert!(err2(&["live", "verfy", "x.strand"]).contains("unknown live subcommand \"verfy\"; did you mean \"verify\"?")); + // no subcommand at all keeps its own message + assert!(err2(&["persona"]).contains("persona: a subcommand: init, turn")); +} diff --git a/probbit-cli/tests/cli_table.rs b/probbit-cli/tests/cli_table.rs new file mode 100644 index 0000000..4ba2828 --- /dev/null +++ b/probbit-cli/tests/cli_table.rs @@ -0,0 +1,59 @@ +//! R94 item 1: the command table is the one source. Every command the top-level help lists runs (`COMMAND --help` exits 0 with +//! its usage), every subcommand a help lists has its own help, and every flag a help lists is accepted by the parser. +use std::process::Command; + +fn probbit(args: &[&str]) -> (i32, String, String) { + let o = Command::new(env!("CARGO_BIN_EXE_probbit")).args(args).env_remove("PROBBIT_CONFIG").output().unwrap(); + (o.status.code().unwrap_or(-1), String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()) +} +/// The flags a help's `flags:` section lists +fn help_flags(h: &str) -> Vec { + h.lines().skip_while(|l| *l != "flags:").skip(1).take_while(|l| l.starts_with(' ')).filter_map(|l| l.strip_prefix(" --")) + .map(|l| format!("--{}", l.split_whitespace().next().unwrap())).collect() +} +/// The commands of the top-level help (piped): the first word of each two-space indented line of the groups +fn listed() -> Vec { + let (c, out, err) = probbit(&["--help"]); assert_eq!(c, 0, "{err}"); + let groups = &out[..out.find("\nevery command\n").expect("the flags every command takes")]; + let mut v: Vec = groups.lines().filter(|l| l.starts_with(" ") && !l.starts_with(" ") && !l.trim_start().starts_with('-')) + .filter_map(|l| l.split_whitespace().next()).filter(|w| w.chars().all(|c| c.is_ascii_lowercase())).map(String::from).collect(); + v.dedup(); v.retain(|w| w != "probbit" && w != "claude"); v +} + +#[test] +fn every_listed_command_runs_and_every_old_command_is_listed() { + let cmds = listed(); + for old in ["persona", "live", "monitor", "decide", "run", "evaluate", "demo", "ir", "mcp", "stats", "version"] { assert!(cmds.iter().any(|c| c == old), "{old} missing from {cmds:?}"); } + for c in &cmds { + let (code, out, err) = probbit(&[c, "--help"]); + assert_eq!(code, 0, "{c}: {err}"); assert!(out.starts_with(&format!("usage: probbit {c}")), "{c}: {out}"); assert!(err.is_empty(), "{c}: {err}"); + } + for alias in ["--version", "-V"] { let (code, out, _) = probbit(&[alias]); assert_eq!((code, out.trim()), (0, concat!("probbit ", env!("CARGO_PKG_VERSION")))); } + let (code, _, err) = probbit(&["frobnicate"]); assert_eq!(code, 2); assert!(err.contains("unknown command \"frobnicate\""), "{err}"); +} + +#[test] +fn every_listed_subcommand_has_help_and_every_listed_flag_parses() { + for (cmd, subs) in [("persona", &["init", "turn", "replay", "explain", "diff", "lint", "fuzz", "prove", "check", "compile", "describe"][..]), ("live", &["verify", "control"][..])] { + let (_, h, _) = probbit(&[cmd, "--help"]); + for s in subs { + assert!(h.contains(&format!("\n {s} ")), "{cmd} help lists no {s}"); + let (code, out, err) = probbit(&[cmd, s, "--help"]); assert_eq!(code, 0, "{cmd} {s}: {err}"); + assert!(out.starts_with(&format!("usage: probbit {cmd} {s}")), "{cmd} {s}: {out}"); + // every flag the subcommand's help lists is accepted: the error, if any, is never "unknown argument" + for f in help_flags(&out) { + let (_, _, err) = probbit(&[cmd, s, "no-such-file.yaml", &f]); + assert!(!err.contains("unknown argument"), "{cmd} {s} {f}: {err}"); + } + } + } + for cmd in ["decide", "run", "evaluate", "demo", "ir", "stats", "monitor"] { + let (_, h, _) = probbit(&[cmd, "--help"]); + let fl = help_flags(&h); assert!(!fl.is_empty(), "{cmd}: no flags listed"); + for f in fl { + // a value flag given no value fails on the value, never as an unknown argument + let (_, _, err) = probbit(&[cmd, &f, "--help-no-such"]); + assert!(!err.contains(&format!("unknown argument \"{f}\"")), "{cmd} {f}: {err}"); + } + } +} diff --git a/probbit-cli/tests/monitor.rs b/probbit-cli/tests/monitor.rs index c0cad02..9cc875f 100644 --- a/probbit-cli/tests/monitor.rs +++ b/probbit-cli/tests/monitor.rs @@ -27,7 +27,7 @@ fn monitor_has_help() { assert!(out.starts_with("usage: probbit monitor STRAND"), "{out}"); for f in ["--follow", "--once", "--plain", "--fps N", "--demo", "exit: 0", "live verify"] { assert!(out.contains(f), "monitor help lacks {f}: {out}"); } } - let (c, out, _) = probbit(&["--help"]); assert_eq!(c, 0); assert!(out.contains("probbit monitor STRAND"), "the usage lists monitor"); + let (c, out, _) = probbit(&["--help"]); assert_eq!(c, 0); assert!(out.contains("\n monitor STRAND "), "the usage lists monitor"); } /// --once --plain: one frame in plain ASCII, byte for byte; the last event of the week and a failure turn (event 18: loss and diff --git a/scripts/bench/test_install_sh.sh b/scripts/bench/test_install_sh.sh index 1f6c10d..a465082 100755 --- a/scripts/bench/test_install_sh.sh +++ b/scripts/bench/test_install_sh.sh @@ -25,7 +25,8 @@ BASE="http://127.0.0.1:$PORT" echo "shell under test: $SH ($(command -v "$SH")); archive: $(ls "$WORK/srv/good/$TAG")" echo "== 1. piped to $SH, PROBBIT_INSTALL_DIR set, version given without the v" -PROBBIT_DOWNLOAD_BASE="$BASE/good" PROBBIT_VERSION="${TAG#v}" PROBBIT_INSTALL_DIR="$WORK/bin" "$SH" < install.sh +PROBBIT_DOWNLOAD_BASE="$BASE/good" PROBBIT_VERSION="${TAG#v}" PROBBIT_INSTALL_DIR="$WORK/bin" PROBBIT_MAN_DIR="$WORK/man/man1" "$SH" < install.sh +cmp "$WORK/man/man1/probbit.1" docs/probbit.1 || { echo "FAIL: the manual page was not installed to PROBBIT_MAN_DIR"; exit 1; } PATH="$WORK/bin:$PATH" echo "\$ command -v probbit: $(command -v probbit)" echo "\$ probbit version: $(probbit version)" @@ -36,7 +37,8 @@ echo "== 2. default directory, scratch HOME" HOME="$WORK/home" PROBBIT_DOWNLOAD_BASE="$BASE/good" PROBBIT_VERSION="$TAG" "$SH" install.sh want="$WORK/home/.local/bin/probbit" [ -x "$want" ] || { echo "FAIL: expected $want"; exit 1; } -echo "ok: installed to $want ($("$want" version))" +[ -f "$WORK/home/.local/share/man/man1/probbit.1" ] || { echo "FAIL: expected the manual page under the scratch HOME"; exit 1; } +echo "ok: installed to $want ($("$want" version))" and its manual page echo "== 3. tampered .sha256 must fail and install nothing" if PROBBIT_DOWNLOAD_BASE="$BASE/bad" PROBBIT_VERSION="$TAG" PROBBIT_INSTALL_DIR="$WORK/badbin" "$SH" install.sh; then @@ -58,6 +60,11 @@ after=$("$PY" -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb" echo "== 6. invalid release path is rejected before download" if PROBBIT_DOWNLOAD_BASE="$BASE/good" PROBBIT_VERSION='../bad' PROBBIT_INSTALL_DIR="$WORK/bin" "$SH" install.sh; then echo "FAIL: invalid version accepted"; exit 1; fi +echo "== 6b. an unwritable man directory is not fatal" +PROBBIT_DOWNLOAD_BASE="$BASE/good" PROBBIT_VERSION="$TAG" PROBBIT_INSTALL_DIR="$WORK/bin6" PROBBIT_MAN_DIR="/dev/null/man1" "$SH" install.sh > "$WORK/out6b" +[ -x "$WORK/bin6/probbit" ] && grep -q "skipped the manual page" "$WORK/out6b" || { echo "FAIL: a man directory failure broke the install"; cat "$WORK/out6b"; exit 1; } +echo "ok: binary installed, manual page skipped" + echo "== 7. checksummed but unrunnable candidate must preserve the working binary" mkdir -p "$WORK/unrunnable" printf '#!/bin/sh\nexit 25\n' > "$WORK/unrunnable/probbit" @@ -71,7 +78,7 @@ echo "== 8. the PATH hint names the rc file of the user's shell" case "$(uname -s)" in Darwin) bashrc='~/.bash_profile' ;; *) bashrc='~/.bashrc' ;; esac for pair in "/bin/zsh ~/.zshrc" "/usr/local/bin/zsh ~/.zshrc" "/bin/bash $bashrc" "/bin/dash ~/.profile" " ~/.profile"; do shell=${pair% *} want=${pair##* } - out=$(SHELL="$shell" PROBBIT_DOWNLOAD_BASE="$BASE/good" PROBBIT_VERSION="$TAG" PROBBIT_INSTALL_DIR="$WORK/hint" "$SH" install.sh 2>&1) + out=$(SHELL="$shell" PROBBIT_DOWNLOAD_BASE="$BASE/good" PROBBIT_VERSION="$TAG" PROBBIT_INSTALL_DIR="$WORK/hint" PROBBIT_MAN_DIR="$WORK/man8" "$SH" install.sh 2>&1) case "$out" in *"$WORK/hint is not on your PATH. Add it in $want:"*) ;; *) echo "FAIL: SHELL=$shell: no hint naming $want"; echo "$out"; exit 1 ;; esac echo "ok: SHELL=${shell:-(empty)} -> $want" done