Skip to content
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export default defineConfig({
{ text: 'Settings of its own', link: '/guide/flow-settings' },
{ text: 'Many turns at once', link: '/guide/async-flows' },
{ text: 'A flow that calls a flow', link: '/guide/calling-flows' },
{ text: 'Checking a flow', link: '/guide/checking-flows' },
{ text: 'Testing a flow', link: '/guide/testing-flows' },
{ text: 'Flowverses', link: '/guide/flowverses' },
],
Expand Down
81 changes: 81 additions & 0 deletions docs/guide/checking-flows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Checking a flow

Check a flow before anything runs it: a static reading that executes nothing, then the flow
driven by stubs against a clock. Together they catch the mistakes that otherwise surface
hours into a run — the loop nothing can end, the field read off an answer that failed, the
name the interface does not answer to.

## One line

```sh
hmz check official/rlar
```

```
…/rlar/__init__.py:125: warning: unbounded-loop: every way out of this loop waits for an
agent to say so, and an agent may never say it -- give the loop a bound of its own: a budget
read off spent(), a cap on the rounds, a range
hmz check: 0 errors, 1 warning
```

A flow is named the way `-f` names one — `chat`, `official/rlar`, a path of your own — and
everything wrong is said at once, one finding a line. The full table of codes is in the
[reference](/reference/flows#checking-a-flow).

## What an error is, and what a warning is

An **error** is a flow no run survives: it cannot run, cannot be answered, or cannot end.
`hmz check` exits `1` on any of those. A **warning** is a flow that runs, and a run of it
that may be regretted — rlar's warning above is real and documented: its loop is ended by its
reviewer alone, which is the flow's own shape. Warnings print and pass; `--strict` holds a
flow to the whole bar, which is the right setting for a flowverse's CI:

```sh
hmz check --strict local/mine
```

## The reading that runs nothing

The static reading is pure `ast` over every file the flow holds. Nothing is imported and
nothing is executed, so it is safe to point at a flow nobody has read — one an agent just
wrote, one fetched off the internet, one about to be forked:

```sh
hmz check --static somebody-elses/flow
```

## The reading that runs it against the worst day

Without `--static`, the flow is also loaded — in a subprocess held to a clock, never in your
process — and its live config model is read. The same machinery is a library, and the
scenarios are the questions worth asking of a loop:

```python
from hmz.flows import NEVER_DONE, SILENT, proved

proof = proved(".humanize/flows/mine", scenarios=(NEVER_DONE, SILENT))
assert all(one.finished for one in proof.outcomes), proof.outcomes
```

`NEVER_DONE` is the reviewer that never says the work is done. The stubs answer every turn at
once — every boolean verdict `False`, every turn adding 100k output tokens to `spent()` — so
a loop held to a budget walks to the end of it in milliseconds, and one whose only exit is
the verdict is caught by the turn cap. That is the executable proof that a run of your flow
can end. `SILENT` answers every turn with nothing, which is what a failed turn answers: a
flow that reads a field off an unguarded answer falls over here rather than at hour three.

## In a script

`--json` says the same findings one JSON object a line, and the exit status is the answer:
`0` with nothing blocking, `1` with something, `2` for a line to correct.

```sh
hmz check --json local/mine | jq -r .code
```

## See also

- [`hmz check`](/reference/cli#hmz-check) — the command and its flags
- [Checking a flow](/reference/flows#checking-a-flow) — the library API and the rule table
- [Testing a flow](/guide/testing-flows) — driving a flow with stand-ins of your own
- [Writing a flow](/guide/writing-a-flow)
1 change: 1 addition & 0 deletions docs/guide/testing-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ uv run pytest --run-agents # also drives the real coding agent CLIs

## See also

- [Checking a flow](/guide/checking-flows)
- [Answers in a shape](/guide/shapes)
- [config model](/guide/flow-settings)
- [hook](/guide/hooks)
Expand Down
10 changes: 9 additions & 1 deletion docs/guide/writing-a-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,15 @@ by taking its name. A file whose name starts with `_` is not a flow.

## Check your work

Check which agents the flow declares.
Check the flow itself, before anything runs it: a static reading that executes nothing, then
the flow driven by stubs against a clock — including the world where the reviewer never says
the work is done. See [Checking a flow](/guide/checking-flows).

```sh
hmz check local/twice
```

And check which agents the flow declares.

```python
from hmz.flows import drives
Expand Down
35 changes: 34 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,39 @@ to a shell on that machine — read [Security](/guide/security).
hmz anchor serve --listen 0.0.0.0:7777 --export /srv/project --token "$SECRET"
```

## `hmz check`

```
hmz check [--static] [--strict] [--json] FLOW [FLOW...]
```

Reads a flow for what will not run — before anything runs it. Two readings, in their order: a
static one over every file the flow holds, which executes nothing and is safe to point at a
flow nobody has read, and then the flow loaded — in a subprocess held to a clock — so its live
config model is read too. A flow is named the way `-f` names one: `chat`, `official/rlar`, a
path of your own.

| Argument | |
| --- | --- |
| `--static` | Only the reading that executes nothing: do not load the flow at all. |
| `--strict` | Exit non-zero on warnings too. |
| `--json` | One JSON object per finding, one a line, for a script to read. |

Each finding prints as `file:line: severity: code: what is wrong`, with a count under them.
An error is a flow that cannot run, cannot be answered or cannot end — a loop nothing inside
can end, a name the interfaces do not answer to, an import of humanize's own internals — and
a warning is a flow that runs and may be regretted: a loop whose only way out is an agent's
verdict, a shaped answer read without a guard, a config that takes anything.

It exits `0` for flows with nothing blocking (warnings print and pass), `1` where any error
was found — or any warning, under `--strict` — and `2` for a line to correct or a name no
flow answers to.

```sh
hmz check official/rlar # one warning: a loop only its reviewer ends
hmz check --strict local/mine # hold a flow of your own to the whole bar
```

## `hmz flowverses`

Where flows come from: a git repository with a `flows/` directory apiece, cloned under
Expand Down Expand Up @@ -579,7 +612,7 @@ into them.
| | |
| --- | --- |
| `0` | It did what it was asked. |
| `1` | It could not: the target could not be reached, the listener could not be started, there is no such provider, a turn could not be supervised. |
| `1` | It could not: the target could not be reached, the listener could not be started, there is no such provider, a turn could not be supervised, `hmz check` found something blocking. |
| `2` | The command line was wrong — argparse's own rejections, a flow that is not there or takes other agents, a malformed listen address, a non-loopback listener with no token. |
| `130` | Interrupted. |
| *the agent's own* | `hmz anchor` exits with the status of the program it ran, and `hmz providers add` with that of the login it ran. |
Expand Down
83 changes: 83 additions & 0 deletions docs/reference/flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,89 @@ finished.
What the turn was doing is left where it got to. A stop that waited for a turn would not read
as a stop — a model can think for minutes.

## Checking a flow

Two readings, before anything runs it — what [`hmz check`](/reference/cli#hmz-check) runs
from a command line, reachable as a library for a test or a loop that writes flows.

`checked` is the static one: pure `ast` over every file the flow's directory holds (what is
under its `skills/` excepted), executing nothing, so it is safe to point at a flow nobody has
read. It answers with findings rather than raising, one per thing found:

```python
from hmz.flows import checked

for one in checked(".humanize/flows/mine"):
print(f"{one.where}:{one.line}: {one.severity}: {one.code}: {one.said}")
```

A `Finding` carries `code`, `severity`, `where`, `line` and `said`. An **error** is a flow
that cannot run, cannot be answered, or cannot end — something no run of it survives. A
**warning** is a flow that runs, and a run of it that may be regretted.

| Code | Severity | What it found |
| --- | --- | --- |
| `unread` | error | A file that will not parse. |
| `not-a-flow` | error | No `__init__.py`, or nothing marked `@flow()`. |
| `unsized-agents` | error | An `agents` annotation that does not state a fixed count. |
| `unread-annotation` | error | The annotation's names live under `TYPE_CHECKING`, where a run cannot read them. |
| `foreign-import` | error | An import of humanize's own modules other than `hmz.flows`. |
| `unknown-name` | error | `from hmz.flows import` a name it does not offer. |
| `unknown-ask` | error | An attribute asked of an agent, session or person that is not on the interfaces. |
| `dead-loop` | error | A constant-true loop with no `break`, `return` or `raise` inside it. |
| `sleeping-loop` | error | A constant-true loop that only sleeps: alive from outside, doing nothing. |
| `stateless-resume` | error | `@flow(resumable=True)` with nowhere to be handed its state. |
| `unbounded-loop` | warning | Every way out of a loop waits on an agent's verdict, and the function holds no bound of its own. |
| `unguarded-answer` | warning | A field read off a suppressed, shaped answer nothing tested against `None`. |
| `unknown-verdict` | warning | An answer's field compared against a value its shape does not offer, so the comparison can never be what it reads as. |
| `unsaid-moment` | warning | A hook hung on a moment only some backends run that no place declares. |
| `loose-config` | warning | A config whose `model_config` neither forbids extras nor freezes. |
| `unsaid-field` | warning | A config field without a `Field(description=...)`. |
| `unsaid-flow` | warning | An entry file without a docstring, so lists of flows show nothing for it. |
| `state-kept` | warning | Kept state something writes and nothing ever clears. |
| `twice-named` | warning | Two flows in one file under one name; the first wins. |

Every rule is the proof of an absence, worked out one function at a time — no exit in this
loop, no bound in this function, no guard on this name. Nothing claims an exit reachable or
follows a value through a call: a flow that keeps its loop in one function and its bound in
another is a flow the reading trusts.

`proved` is the second reading: the flow loaded and driven for real, in a subprocess per
scenario, by stubs that claim every capability over the real driver base classes — so the
hooks it hangs fire as they would, every turn lands at once, and each adds what the scenario
says to `spent()`. The parent holds the clock, sleeps are free, and the flow works in a
scratch directory taken away with the process.

```python
from hmz.flows import ALWAYS_DONE, NEVER_DONE, SILENT, proved

proof = proved(".humanize/flows/mine", scenarios=(NEVER_DONE, ALWAYS_DONE, SILENT))
assert proof.findings == ()
assert all(one.finished for one in proof.outcomes), proof.outcomes
```

A `Scenario` says how the world answers: every boolean field of a shaped answer says its
`verdict`, every string field its `answer`, each turn climbs `climb` output tokens, and the
proof ends at `turns` turns or `seconds` seconds, whichever the flow forces. Three are named:
`NEVER_DONE` is the reviewer that never says the work is done — a loop with a bound of its
own still ends here, which is the executable proof that a run can end; `ALWAYS_DONE` is the
shortest road through; `SILENT` answers every turn with nothing, which is what a failed turn
answers, so it is every guard tried at once. A flow the loading refuses comes back as a
`refused-load` finding, and the flow's live config model is read against the config rules
whether or not the static reading could see it.

And the catalogue, for whoever — or whatever — is writing a flow against this installation:

```python
from hmz.flows import briefed, catalogue

catalogue() # one Capability per thing a flow may build on, with the backends that serve it
briefed() # the same, rendered as one page to steer by
```

Read off the live interface at call time — the moments off the enum, the backend sets off
the driver classes' own declarations — so what it promises is what this installation serves.

## Testing a flow

A flow is a function, so drive it with something that is not a coding agent:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ run.wait(timeout=60)
| `find(named)` | The file one flow is written in. Raises `NotAFlow` for a name nothing answers to. |
| `about(named)` | The line a flow says about itself. |
| `places(named)` | Every agent it needs chosen for it, in the order it takes them. |
| `check(named, static=False)` | Reads it for what will not run, before anything runs it: one finding per thing found, errors and warnings. `static=True` keeps to the reading that executes nothing. |
| `configures(named)` | What it can be [set up with](/reference/flows#settings-of-the-flow-s-own), or `None`. |
| `resumes(named)` | Whether it says it can be [picked up](/guide/resuming). |
| `fork(named, into=None)` | Copies it into this project's own flows, whole. |
Expand Down
22 changes: 22 additions & 0 deletions src/hmz/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,28 @@ that can happen to a flowverse -- added, fetched again, taken away.
that will not go -- MUST say so where it can be read and exit non-zero, and MUST leave the
list as it was. None of those MUST reach whoever typed the line as a traceback.

## `hmz check`

```shell
hmz check [--static] [--strict] [--json] <flow> [<flow>...]
```

Reads a flow for what will not run, before anything runs it.

- The two readings MUST run in their order: the static one over every file the flow holds,
which MUST NOT import or execute anything of it -- the flow most worth checking is one
nobody has read -- and then the flow loaded and its live config model read. The second MUST
run only in a subprocess with a clock held over it, MUST NOT run where the first found an
error, and `--static` MUST leave it out altogether: a flow that cannot run is not one to
run to find out more about.
- Every finding MUST print one a line -- the file, the line, the severity, the code and what
is wrong -- with a count under them, and `--json` MUST say the same as one JSON object a
line for a script to read. Everything wrong MUST be said at once rather than first-failure
first: a checker is asked so that one reading answers for the whole flow.
- It MUST exit 0 for flows with nothing blocking -- warnings print and pass -- 1 where any
error was found, or any warning under `--strict`, and 2 for a line to correct or a name no
flow answers to, refused as argparse refuses one.

## `hmz agents`

```shell
Expand Down
16 changes: 16 additions & 0 deletions src/hmz/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ def _flowverses(argv: list[str]) -> int:
return flowverses(argv)


def _check(argv: list[str]) -> int:
"""Reads a flow for what will not run, before anything runs it.

Args:
argv: What followed the command name.

Returns:
Zero for a flow with nothing blocking, one for one with something, or two for a
line to correct.
"""
from .check import check

return check(argv)


def _cred(argv: list[str]) -> int:
"""Runs a program whose credentials are kept somewhere other than where it looks.

Expand Down Expand Up @@ -552,6 +567,7 @@ def _daemon(argv: list[str]) -> int:
),
"anchor": (_anchor, "run an agent here that acts on another machine"),
"flowverses": (_flowverses, "the places flows come from"),
"check": (_check, "check a flow before anything runs it"),
"agents": (_agents, "the agents written down under a name"),
"providers": (_providers, "the accounts an agent may be run as"),
"fallback": (
Expand Down
Loading