Human Intent; acceptance criteria in human words, with ids that never move.
Rust · single binary · no network, no model, no API key
See a live page · Docs · Install
Specs drift into coding intent: modules, contracts, API shapes. That is the right thing for a spec to do, and spec-sync already checks it. But by the time something is a spec, what a person actually wanted has usually been translated away.
hi is where the untranslated version lives.
A criterion says what the thing should be, not what it currently does. It can describe something
true today, something a year out, or something that has since been revised. They are directions, and
directions stay correct through a wrong turn: a criterion that is false right now means the code has
not arrived yet, not that the criterion is wrong. Whether the code has arrived is a question for
something that reads the code, which is what hi export feeds.
hi/chat.md:
---
hi: 1
families: [SEND, RECEIPT, SPEND]
owner: leif
---
# Chat
## Intent
I want to talk to people I trust without anyone in the middle being able to read it, and without it feeling like a security product. It should feel like texting.
## Criteria
- **SEND-1** I hit enter and the message shows up right away, marked as sending.
- **SEND-1.a** If I have no connection it queues and tells me, and never silently disappears.
- **SEND-1.b** If the thread was deleted before it sends, it warns me first.
- **SEND-2** It reaches them and the mark changes to sent.
- **RECEIPT-1** I can tell sent from read without thinking about it.
- **SPEND-1** An operator can cap what the service spends in a day.
## Retired
- **SEND-3** My messages auto-delete after 24 hours.
retired: we decided this was a different productThat is the whole format. Five rules:
- A criterion is one markdown list item: a bold id, two spaces, and a sentence. However long the sentence runs it stays on one line, so criteria stay greppable and diffable. And because it is a list item, it renders as its own line everywhere, instead of markdown joining it into a paragraph with its neighbours.
- You write the id yourself, because you are the one who has to say it out loud.
SEND-1is a name, not a position. - Letters are cases, numbers are steps, alternating strictly:
SEND-1.a.1.b. Reading an id tells you what kind of thing it is. - Ids are permanent and append-first. hi never renumbers anything, refuses an id that is already taken, and keeps a retired id reserved, including one written somewhere hi cannot parse. Captures running at the same time all land rather than overwriting each other. It cannot stop you renumbering a file by hand, so permanence is a convention the tool supports rather than one it enforces; what it can do is refuse to be the one that breaks it.
- A paragraph of prose is one line, and a blank line is the only break. In
## Intent, let the line keep going however long it runs, and leave a blank line where you want a break. The newline you type only to stay inside your own margin is invisible in a file on GitHub and a visible break in a GitHub issue, which is rendered with hard line breaks on, so hand-wrapped prose reaches a ticket as a narrow column down a wide pane.hi issueunwraps it on the way into the ticket, and hi never reflows the file it read it from: that one is yours (DECISIONS.md §29).
hi: 1 is the one machine-facing line in the file, and it names the version of the format. This
binary reads HI/1 and nothing else: a file declaring any other version is refused by name, by
every verb, rather than read as though it were this one — because a version nobody checks is a
version that can never be frozen and can never be changed. A file with no hi: line at all is
HI/1, so files written before the line existed keep working.
There is no rule about who the sentence speaks for. Notice that SEND-1 says I and
SPEND-1 says an operator, and that the difference is in the sentence, where anyone can read it.
On a product with a paying side and a using side, say which one you mean the way you would say it
out loud. On a product with one audience, do not: the ceremony costs four words and buys nothing.
hi will never read a subject off the front of your line, and hi check has no opinion on your
English. DECISIONS.md §24 records why an earlier version of this was a rule and why
it is not one now.
The ## Intent block is the part a spec can never carry, and it is the first thing an agent
should read.
Files in hi/ are lowercase, because a family names its own file and hi lowercases it. An
uppercase name in there is hi's own rather than criteria: your first capture leaves a
hi/AGENTS.md, and a hi/CLAUDE.md beside it, describing the habit so an agent working in your
repository finds it without being told. hi writes them once and never again, and they are yours
afterwards. If a criterion ever ends up in one, hi check says so rather than letting it go quiet.
Written once means a newer hi does not update the one you already have on capture, which is
deliberate: it is what makes the file unable to overwrite something you edited. If the file is
still a template hi has shipped, hi seed rewrites it. If you have edited it, hi seed leaves
every byte alone and tells you so. Delete it and run hi seed if the current text is what you
want. Capture never overwrites, even when the file is an old template
(DECISIONS.md §39, HI-1.md).
That file only reaches an agent already looking in hi/, which is no use in a repository that has
never seen hi. The other half is the fledge plugin: fledge plugins install CorvidLabs/hi installs
once for you rather than once per repository, and from then on fledge work start in a repository
with nothing written down says so, as does fledge work push. Both go quiet the moment a hi/
exists, and neither can fail your command.
The hooks need a fledge carrying #520, which is merged but not yet in a release: v1.7.2 and earlier skip them silently, and hi stays quiet rather than guessing at the wrong repository.
cargo install human-intent # the command it installs is `hi`Or take a binary from the latest release for Linux, macOS (Intel or Apple Silicon) or Windows.
If you use fledge, the same CLI is available as a plugin.
It is not bundled with fledge, so install it once, and then every hi command works as fledge hi:
fledge plugins install CorvidLabs/hi # builds from source, so it needs cargo
fledge hi checkThe crate is human-intent because the crate name hi is taken on crates.io by something
unrelated. A crate's name and its binary's name are independent, so cargo install human-intent
puts hi on your path. The command name is shared: several projects install a binary called hi, including
PipeNetwork/hi, which is a coding agent rather than anything
like this. If you already have one, installing ours shadows it, and you pick which wins on your
PATH. DECISIONS.md §13 explains why we kept the name.
hi anchors to a repository: run it anywhere inside one and your criteria land at the top. It
needs no init and no config, and it creates hi/ the first time you capture something.
$ hi CHECKOUT-1 "As a shopper, I can pay without making an account"
INTENT.md created, for the product-level why
hi/checkout.md created
hi/checkout.md +CHECKOUT-1
$ hi CHECKOUT-1.a "As a shopper, if my card is declined it tells me which field to fix"
hi/checkout.md +CHECKOUT-1.aTwo files, not one. hi/checkout.md holds the feature. INTENT.md at the root holds the
product-level why, above any one feature, and it exists from the first capture rather than waiting
for you to discover it, because a product with criteria and no stated why is the common failure.
The prose in it is yours; hi only ever regenerates the feature list between its own markers, and it
does that itself on every capture and every hi retire, so the list is true without you
remembering hi index. If it cannot — you broke a marker, or hi cannot read the file at all — it
says so, leaves every byte where it is, and your capture still succeeds, because the criterion is
already stored. Delete the list and it stays deleted: hi index is how you ask for one back. Until you have written that why, hi check
mentions it, as a note and never as a failure:
$ hi check
2 criteria · 1 family · 1 file
note: INTENT.md has no product-level why yetA new id just works. An id that is already taken refuses, and never overwrites it:
$ hi CHECKOUT-1 "something else"
error: CHECKOUT-1 already exists in hi/checkout.md:14
hint: next free is CHECKOUT-2Reading it back gives you the file as a tree, one sentence per line, exactly as you wrote them.
This is hi/chat.md from the top of this page:
$ hi ls
hi/chat.md
SEND-1 I hit enter and the message shows up right away, marked as sending.
SEND-1.a If I have no connection it queues and tells me, and never silently disappears.
SEND-1.b If the thread was deleted before it sends, it warns me first.
SEND-2 It reaches them and the mark changes to sent.
RECEIPT-1 I can tell sent from read without thinking about it.
SPEND-1 An operator can cap what the service spends in a day.hi export hands an agent the same sentences as JSON with the ## Intent prose attached,
hi issue shapes one into a ticket, and hi view puts the whole set on a page you can search and
filter. Your words are passed through untouched in all of them.
| Command | What it does |
|---|---|
hi <ID> <sentence> |
Capture. The family picks the file; a new family starts one. |
hi check |
Structural problems only. Exits 1 on a broken file, never on unfinished intent. |
hi ls [--family F] [--retired] |
Read what you have agreed to. |
hi retire <ID> [reason] |
Change your mind. Moves a criterion and its cases into ## Retired. |
hi issue <ID> [--create] |
Print a ticket, or open a real GitHub issue with gh. |
hi export [FAMILY | file | ID] |
JSON for an agent, intent prose included. Give one id to get just that criterion, its cases and the criteria above it, so a large hi/ is never read whole. |
hi index |
Rewrite the feature list inside INTENT.md, and nothing else in it, adding the ## Features section if there is none. Capture and hi retire refresh a list that is there; run this by hand after editing a hi/*.md yourself, or to ask for a list back after deleting one. |
hi view [--out FILE] |
One self-contained HTML page: a sticky feature rail, search with match highlighting, sort, keyboard navigation, and a copyable link for every id. Named after your INTENT.md heading, in CorvidLabs brand colors, light and dark. Works offline, and with scripting off it is still readable. |
hi seed |
Write hi/AGENTS.md when it is missing, or replace it when it is still a template hi has shipped. Refuses if you have edited the file. |
Every command takes --root <PATH> to work on a repository other than the one you are standing in.
When you are capturing, put it before the id: everything after the id is your sentence, word for
word, so --root written after the id is just a word you typed.
## Retired is where a criterion goes when you decide against it, and hi retire is what puts it
there, so you never hand-edit the markdown to do it:
$ hi retire SPEND-1 "the operator console is a separate product"
hi/chat.md SPEND-1 retired
$ hi retire SEND-1
hi/chat.md SEND-1 retired, with 2 of its casesThe reason is optional, and worth typing: it is written on its own line under what it explains, and
it is the only record of why the sentence stopped being true. Cases go with their parent, so nothing
is left orphaned behind it. The section is created if the file has none. The id is reserved forever
after this: capture refuses it, hi issue refuses to make work out of it, and hi check reports
any live criterion that tries to reuse it.
hi view writes intent.html into the repository root. It is a generated file, rewritten whole
every run, so put it in your .gitignore rather than committing a fresh copy of the page each time
a sentence changes. --out FILE puts it somewhere else, relative to the root, in a directory that
already exists.
talk → hi (intent + criteria) → tickets (gh) → specs (agent → spec-sync) → code
Intent is written once, by a human. Everything downstream is generated from it:
$ hi issue SEND-1 --create # a ticket, with hi: SEND-1 as the permanent backlink
$ hi export SEND | claude -p "write the spec-sync module spec for this"
$ hi export SEND-1.a # just the piece you are building, and what it sits underReach for hi upstream of whoever already decided the shape. Writing intent for code that already exists means reverse-engineering the want from the implementation, and you will feel the pull the whole way. But a ticket written as a solution does exactly the same thing: someone who tried this before any of the code existed reported the identical pull, sourced from the ticket instead of the file. Most tickets are written as solutions.
Two things get easier when nothing has decided the shape yet. You can write a criterion you have no idea how to implement, which an implementation never suggests. And absence becomes visible: writing from something that already exists hides what is missing from it.
Say in the prose if none of it is built yet. hi records what was wanted, not what exists, so a
reader cannot tell a shipped criterion from a wish. One line at the top of the ## Intent block
fixes that, and unlike a status field it cannot go stale without somebody reading the sentence that
is now wrong:
Written before any of it was built, which is the point. None of this exists yet.
Say who only when who matters. An operator can cap what the service spends in a day and I can tell sent from read are both fine. Naming a person on every line when the product has one audience is filler, and naming nobody on a product with two sides loses the distinction that is usually the whole reason two criteria disagree. Use the subject the sentence actually needs.
Name the person, not the permission. If your codebase says admin, the sentence probably wants
an operator. admin is a permission bit; an operator is someone with a job to do, and the job is
what the criterion is about.
One binary and plain files. The verbs that write hold a lock on hi/ for the whole
read-modify-write, and the two that write criteria read the result back before they save it; the
verbs that read never lock. Nothing leaves the repository except the ticket hi issue --create
hands to gh.
flowchart LR
accTitle: hi at a glance
accDescr: A person or an agent runs hi. Capture, retire, index and seed write the files in hi/ and INTENT.md under a lock. Check, ls, export, issue and view only read them, and hand JSON to an agent, a ticket to gh, and a page to a browser.
who(["person or agent"]) --> write["capture · retire · index · seed<br/>one writer at a time"]
who --> read["check · ls · export · issue · view<br/>read only"]
write --> files[("hi/*.md<br/>INTENT.md")]
files --> read
read -->|"hi export"| json["JSON for an agent<br/>then a spec-sync spec"]
read -->|"hi issue --create"| gh["a GitHub issue, via gh"]
read -->|"hi view"| page["one self-contained page"]
docs/HLD.md is the high-level design: the modules, the capture, retire and export
flows, how HI/1 is parsed, the id rules, how INTENT.md's list is regenerated, the fledge plugin
and its hooks, and what breaks and how. It is also published with its diagrams at
corvidlabs.github.io/hi/architecture.
hi stores no state, tracks no lifecycle, binds no evidence, and never fails a build
because a criterion is unproven. It has no grammar rules on your sentence and no prose linter.
The prose linter is the first thing everyone asks for, and the number is why there is not one.
Requirements-smell detection, the published state of the art at flagging a vague or untestable
sentence, measures about 59% precision. A linter built on it would be wrong two times in five.
Nobody argues with a tool that is right; they argue with the two, and after a week of arguing they
stop reading the output, and a month later somebody deletes it from CI. A checker you have learned
to ignore is worse than no checker, because it still looks like coverage. So the sentence stays
yours, and hi check never has an opinion about it.
There is a test you can run on yourself, and it is free. Try putting As a ___, in front of your sentence. Someone using hi on a real product found that two of their forty criteria would not take it, and that both were defective in a way they had not noticed: they had written a fact about the system rather than anything anybody wants.
That test has no false positives, because nothing is guessing. You either can finish the sentence or you cannot, and being unable to finish it means what you wrote was not a want. It is the check the 59% number says is impossible, and it costs nothing, because it happens in your head while you type. hi does not ask you to leave the words in the file. An earlier version did, for four releases; DECISIONS.md §24 is why it no longer does.
Those are not omissions, they are the design. Every one of them is a thing you would have to maintain, and a tool you maintain is a tool you stop writing in. The reasoning behind each is in DECISIONS.md, and the docs are at corvidlabs.xyz/hi. docs/ac-formats.html surveys the acceptance-criteria formats the design came from: EARS, Volere, Gherkin, OpenFastTrace, Kiro, spec-kit. GitHub shows that file as source, so save it and open it in a browser. Read that page as history rather than documentation. It was written before any code existed and argues for a stricter product than the one that shipped, with evidence bindings and a lifecycle that were later cut; DECISIONS.md records why.
This is the one promise the format rests on, because the whole point of an id is that it can be
quoted somewhere hi will never see. Four ways hi's own verbs could quietly reuse one were found and
closed in 0.4.0, three of which hi check had reported as fine; DECISIONS.md §26
records them and what they say about the design.
hi check fails on seven things, all structural: a duplicate id, a case with no parent, an
id that collides with a retired one, a line shaped like an id that is not a valid one, a family a
file never declared, a criterion stranded outside every section where nothing would read it, and a
family two files both claim. 1.0 freezes those seven and the policy that they are structural only —
not the cardinality of the list, which is why a seventh was still admissible in 0.x and an eighth
is a 2.0. HI-1.md is the contract.
It also prints notes, which are never failures and never move the exit code: that you have not
written the product-level why yet, that a retired criterion never said why it was retired, and that
INTENT.md's feature list is behind what is captured. The last one has a single cause, since the
verbs keep that list current themselves: you typed a criterion straight into a file, which hi has
always let you do. Run hi index and it goes away.
hi check --json carries those notes as a list, each under a code that stays the same when the
wording changes — no-product-why, index-behind, index-markers, unexplained-retirement — so
a script can act on one without matching on English. None of them is a structural problem
and none of them touches the exit code.
hi describes itself. Its own intent lives in hi/, across 10 families in 7 files, and the
feature list in INTENT.md at the root is generated with the count per feature, and
kept current by every capture and every retirement rather than by anyone remembering to run
hi index. hi check prints the total; this README deliberately does not restate it, because the
one number here that was maintained by hand is the one that went stale. The ## Intent blocks in
those files are the honest version of why this exists, and
corvidlabs.github.io/hi is what hi view makes of them.
Dogfooding has a blind spot, and it is worth naming here. hi has one audience and one voice. A product with an operator on one side and a member on the other has voices that actually contradict each other, and that is a gap hi could never have found in its own files. It took someone using it on their own product to find it, and the first fix we shipped for it was the wrong one. See DECISIONS.md §14 for what they found and §24 for the correction.
v0.8.0 on crates.io, with binaries for Linux (x86_64 and arm64), macOS (Intel and Apple silicon) and Windows on the release page. 0.8.0 is the 1.0 release candidate. HI-1.md is the contract 1.0 will freeze, and 1.0 is this build after it has soaked: the same code with the version number changed. A defect in the promise found meanwhile ships as 0.8.x and restarts the clock.
Permanence of an id is a convention over shared history: the merged tree, not an unmerged
branch. Two workstreams can each choose the same hand-chosen id against the tree they captured
on, and git will merge both without a conflict marker. hi's own verbs refuse to be the one that
breaks it; hi check on the merged tree is the thing that proves it
(DECISIONS.md §26, §37, §39).
brew install corvidlabs/tap/hiSee it before installing it. corvidlabs.github.io/hi is this
repository's own hi view output: the real criteria in hi/, rendered by the real binary on
every push. Docs are at corvidlabs.xyz/hi.
CHANGELOG.md has the history.
MIT