diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7a547d1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The seed templates are compared byte for byte against files older releases +# wrote with LF, so they must check out as LF on every platform, including +# the Windows runner that builds the release binary (DECISIONS.md §39). +src/seed/*.md text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index b81d32c..60120a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,31 @@ All notable changes to `hi` (Human Intent). Format follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The format itself is versioned separately by the `hi:` key in each file's frontmatter. `HI/1` is the -only version so far, and since the Unreleased entry below a file declaring any other version is +only version so far, and since 0.8.0 a file declaring any other version is refused rather than read as this one. -## Unreleased +## [0.8.0] 2026-09-18 + +**The 1.0 release candidate.** Everything three readiness reviews called blocking is in this +release, and nothing else is planned before 1.0. [HI-1.md](HI-1.md) is the contract it will +freeze. The tag for 1.0 is this build after a soak, with the version number changed and nothing +else; if a defect in the promise is found meanwhile, it ships as 0.8.x and the soak restarts. + +The one change here that can turn an adopter's green build red is `duplicate-family`, the +seventh `hi check` kind: two files both declaring one family. Fix it by leaving the family in one +file's frontmatter. A file declaring a `hi:` version other than `1` is now refused as well. + +### After the 1.0 readiness review of #20 + +- `hi: "1"`, `hi: '1'` and `hi: 1 # comment` are HI/1. They were refused as unknown versions, + which locked the whole repository over YAML punctuation that `owner:` already tolerated. +- `hi seed` rewrites `hi/AGENTS.md` atomically. A truncating write that died halfway would have + left a file that is no longer a template hi shipped, which `hi seed` then refuses forever. +- On Windows, `still_at` now compares the held handle's file identity against the path's, through + `GetFileInformationByHandle`, instead of relying on the delete-pending argument DECISIONS.md §34 + admitted nothing tested. +- HI-1.md's id grammar names the digits a family may contain, the `a-z` letter levels, and the + 32-bit bound on a number. ### Two files claiming one family is a structural problem, not a tie-break diff --git a/CLAUDE.md b/CLAUDE.md index 2a5f19b..22cc20d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ serve (`hi: CAPTURE-3`). If you change behavior, update the spec. `specsync chec `Doc::save`, and do not give it a lock: `capture` and `retire` already hold `lock::acquire` and it is not reentrant (DECISIONS.md §30, `hi: INDEX-4`, `INDEX-4.a`). - **`hi check`'s note about that list is a note, not a problem.** `out::index_note` is - pushed onto `Report::note`, which `Report::ok` never reads, so the exit code cannot move. It is + pushed onto `Report::notes`, which `Report::ok` never reads, so the exit code cannot move. It is the first thing `check` nags about that hi itself maintains, and it is only admissible because capture and retire keep the list current, leaving one cause: a criterion typed in by hand, which `FILE-14` allows (DECISIONS.md §30, `hi: INDEX-4.b`, `CHECK-1`). @@ -208,7 +208,7 @@ gitignored. ## Releasing -v0.7.0 is out: the repo is public, `human-intent` is on crates.io, `corvidlabs/tap/hi` is in the +v0.8.0 is out and is the 1.0 release candidate; HI-1.md is the contract 1.0 freezes, and the 1.0 tag is 0.8.x with only the version changed. The repo is public, `human-intent` is on crates.io, `corvidlabs/tap/hi` is in the Homebrew tap, and every tagged release carries binaries for Linux and macOS (both architectures each) and Windows. The docs are at corvidlabs.xyz/hi, and corvidlabs.github.io/hi publishes this repository's own `hi view` output on every push to `main`. @@ -218,8 +218,8 @@ publish` runs failed on a dirty tree and the failure was not noticed. 0.3.0 clos lesson is the rule below: read what `cargo publish` actually printed, and check the registry. `release.yml` fires on a `v*` tag, so **tagging is the release**. Bump `Cargo.toml`, update -`CHANGELOG.md`, commit, push, tag, then `cargo publish` separately. The format is not frozen; this -is 0.x. +`CHANGELOG.md`, commit, push, tag, then `cargo publish` separately. HI/1 is the format 1.0 freezes; until the +tag it may still change, but only for a defect in what HI-1.md promises. **Publish from a clean tree, and verify the registry afterwards.** `cargo publish` refuses a dirty working tree, and that refusal is easy to miss in a wall of output. Run `cargo publish --dry-run` diff --git a/Cargo.lock b/Cargo.lock index cd38450..24db478 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,7 +112,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "human-intent" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 732ae2d..b5b154b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "human-intent" -version = "0.7.0" +version = "0.8.0" edition = "2024" description = "Human Intent; acceptance criteria in human words, with permanent ids" license = "MIT" diff --git a/DECISIONS.md b/DECISIONS.md index 6af92f2..08fe0dc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1803,10 +1803,11 @@ the bootstrap into a repository with no `hi/` locks like every later capture, a into a fresh repository all land. What is *not* asserted anywhere is the delete-pending argument itself — that no replacement lock file can be created while a handoff is in flight. Nothing in the suite forces that interleaving on Windows the way -`a_lock_granted_on_a_file_that_was_replaced_is_not_the_repository_s_lock` forces it on unix. If -that argument is wrong, Windows has the same hazard `still_at` exists to close, and the fix would -be a `GetFileInformationByHandle` comparison in `still_at`, which is a fourth `extern` and no new -dependency. +`a_lock_granted_on_a_file_that_was_replaced_is_not_the_repository_s_lock` forces it on unix. *Since the +1.0 readiness pass, Windows makes the same comparison unix does: `still_at` compares the volume +serial and file index of the held handle against the path's, through `GetFileInformationByHandle`, +a fourth `extern` and no new dependency. The delete-pending argument may well be right; the promise +no longer rests on it.* ### What is still true, and what is not claimed diff --git a/HI-1.md b/HI-1.md index 405018a..1856882 100644 --- a/HI-1.md +++ b/HI-1.md @@ -79,7 +79,7 @@ Normative: 2. **`## Intent`.** Human prose. A fenced block in it is prose, so an example of the format is not criteria. 3. **`## Criteria`.** Live criteria. One criterion is one markdown list item, one line, however long the sentence runs. The parser also accepts a bare `ID sentence` line with no bullet and no bold, so a hand-typed file is still read. 4. **`## Retired`.** Criteria the author changed their mind about. The id stays spoken for. -5. **Ids.** `FAMILY-1`, then letters for cases and numbers for steps, alternating strictly: `SEND-1.a.1.b`. Families are uppercase `A-Z` and `_`. A leading zero on a multi-digit level is not an id (`SEND-007` is refused, not normalised to `SEND-7`). +5. **Ids.** `FAMILY-1`, then letters for cases and numbers for steps, alternating strictly: `SEND-1.a.1.b`. A family starts with `A-Z` and continues with `A-Z`, `0-9` and `_`; a letter level is one or more of `a-z`; a number level is a decimal integer that fits in 32 bits. A leading zero on a multi-digit level is not an id (`SEND-007` is refused, not normalised to `SEND-7`). 6. **A criteria file is lowercase `*.md` directly inside `hi/`.** An uppercase name in `hi/` is hi's own (`AGENTS.md`, `CLAUDE.md`) and is not read as criteria. hi itself always writes the list form, LF or the file's existing endings, and the frontmatter style the file already had. It never rewrites, reflows, or reformats prose it did not write. diff --git a/README.md b/README.md index 7e9453e..a92f9c5 100644 --- a/README.md +++ b/README.md @@ -358,10 +358,12 @@ on their own product to find it, and the first fix we shipped for it was the wro ## Status -**v0.7.0** on [crates.io](https://crates.io/crates/human-intent), with binaries for Linux (x86_64 +**v0.8.0** on [crates.io](https://crates.io/crates/human-intent), with binaries for Linux (x86_64 and arm64), macOS (Intel and Apple silicon) and Windows on the -[release page](https://github.com/CorvidLabs/hi/releases). The Unreleased work on this branch -writes the contract a 1.0 will freeze — [HI-1.md](HI-1.md) — without tagging it. +[release page](https://github.com/CorvidLabs/hi/releases). **0.8.0 is the 1.0 release candidate.** +[HI-1.md](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 diff --git a/docs/1.0-plan.md b/docs/1.0-plan.md new file mode 100644 index 0000000..75a95d5 --- /dev/null +++ b/docs/1.0-plan.md @@ -0,0 +1,389 @@ +# hi 1.0: readiness and the plan to get there + +Assessed against `main` at 8c0d663 (0.7.0 plus the unreleased fixes from the second re-review). +Read in this order: DECISIONS.md §1 to §37, README, `hi/`, `src/`, CHANGELOG, then the binary was +built and driven against hand-typed files for the questions the code alone did not answer. + +## Status after PR #20 (`prep-0.8.0`, a23fefb) + +Reviewed against this plan. Items 1, 2, 3, 4 and 6 are on the branch, plus two things this plan +missed: `hi index` was a read-modify-write outside the lock, and `"hi": 1` in the export was doing +two jobs, so `"export": 1` now sits beside it. Item 5 (the Windows file-identity check) is not on +the branch and is not argued in §38 or §39; it is still open. + +One recommendation here is withdrawn. Item 3 said a family declared by two files should be +documented as first-wins rather than made a seventh kind. §39's argument is better: first-wins +makes the home of a family a function of the *other* files' names, so a rename moves later +captures, and freezing that freezes a bug. `duplicate-family` as the seventh kind, with capture +refusing a new top-level id and a case still following its parent, is the right call. The +consequence stands as written below: the list is frozen at 1.0 and an eighth is a 2.0, and this +seventh is the one change in 0.8 that can turn an adopter's green build red, so the estate has to +be checked for split families before the release, alongside the `hi:` grep. + +Found on the branch, all small, all before it is the candidate: `hi: "1"` (YAML-quoted) and +`hi: 1 # comment` are refused as unknown versions and lock the whole repository, while `owner:` has +its quotes trimmed; `hi seed`'s rewrite uses `fs::write`, which truncates before it writes, on the +one file `hi seed` exists to rewrite, so it should go through `write_atomically`; HI-1.md says a +family is `A-Z` and `_` and omits the digits `valid_family` accepts; CLAUDE.md still names +`Report::note`. The seed templates were verified byte-identical to what the 0.5.0, 0.6.0 and +0.7.0 binaries actually write, by building all three and running `hi seed` over their output. + +## Verdict + +**Not yet. 1.0 after specific work, then a soak measured in calendar time and in other people's +hands, and the tag goes on the build that soaked, not on a build with new code in it.** + +The code is closer to right than the defect count suggests. The tool has not been used by anyone +who could tell you whether it is right. And two things a 1.0 needs in order to mean anything, a +format version that the reader actually reads and a written statement of what is being frozen, do +not exist yet. Neither is hard. Both have to come before the tag, because after it they cannot be +added without a 2.0. + +## 1. What the defect history says + +The whole repository is two calendar days old. Forty commits on 16 and 17 September carried it +from 0.1.0 to 0.7.0; DECISIONS.md was written the day before. Nine P1 defects in the one promise +across three rounds is therefore nine in forty-eight hours, and "three rounds" means three +reviewers, not three release cycles. Nothing in this history has had time to age. + +Read as a sequence, the fixes converge. Each one was correct at the level the failure was named, +and the next failure was one level out: + +| Round | Fix shipped | Next failure, one level out | +|---|---|---| +| atomic write | a torn file cannot happen | two readers of the same original (§26.4) | +| lock file | two writers cannot overlap | the lock was not a lock at bootstrap (§33) | +| break a stale lock by age, then by heartbeat | a dead holder cannot wedge the repo | a live holder could be broken (§34) | +| read-back on ids | a criterion cannot vanish | a criterion could move, or change words (§35) | +| one reservation lookup | check and capture cannot disagree | they could both be wrong (§36) | + +The last three fixes are the first that close the *class* rather than the instance: the kernel +owns the lock so nothing is inferred; the postcondition compares what a reader sees rather than +what the writer touched; a read error is a `Result` rather than an empty answer. That is the +right shape and it is the reason to expect the rate to fall. It is not evidence that it has. + +What has not changed across the three rounds is who finds the defect. Zero of nine were found by +hi, by its tests, or by its own dogfooding. All nine were found by a person or an agent looking +harder than the last one. Every regression test in the suite is a fixed reproduction of a case +somebody already found. A 1.0 needs the *next* one found before the reviewer does, and the only +mechanism that does that is a test that generates cases nobody wrote (item 2 below). + +So the history argues against 1.0 now and for a defined path to it, and the path is short. The +argument for not waiting indefinitely is also real: about 1,600 ids are already quoted in specs, +tickets and commits inside one organisation. Those ids are permanent whether or not the version +number says so. 0.x is not protecting anyone from anything; it is only deferring the sentence +that says what is promised. + +## 2. The work, in order + +Everything marked **blocking** goes into one release, 0.8.0, which is the release candidate. +The 1.0 tag is 0.8.x with the version number changed and nothing else. Each item names the +criterion to capture first, in hi's own words, because CLAUDE.md says intent comes before code. + +### Blocking + +**1. Make `hi:` mean something.** Today `Front::version` is parsed and read by nothing but three +unit tests. A file declaring `hi: 2`, `hi: banana`, or no `hi:` key at all is loaded as HI/1 and +written into. Verified against the built binary: `hi SEND-2 "..."` appended to a file whose +frontmatter says `hi: 2` and exited 0. A format cannot be frozen at version 1 while the reader +treats every version as 1, because a future HI/2 writer's files would be silently rewritten by +every 1.x binary in the estate. + +Rule to adopt: an absent key means 1, because that is what every hand-typed file in the world +looks like today and refusing them breaks `FILE-14`. `hi: 1` means 1. Anything else is a file hi +does not know how to read: the read verbs report it as an operational failure naming the file and +exit 1 through the same path as an unreadable file (§36), and the write verbs refuse before any +write (`CAPTURE-5`). Not a seventh check kind; hi could not do the check. + +- Buys: HI/1 becomes a thing that can be frozen, and HI/2 becomes a thing that can exist. +- Costs: a day. One new refusal message. Grep the twelve adopter repositories for any `hi:` + value that is not `1` before shipping; there should be none. +- Capture first: `FILE-25 A file written for a newer version of the format is refused, not rewritten as if it were this one.` + +**2. A property test of the one promise.** Generate files hi did not write: bare lines, bullets, +emphasis, continuation lines, fences open and closed, indented headings, a `# ` heading closing a +section, CRLF, a BOM, an example of the format inside `## Intent`, uppercase files holding a +retired id. Generate random sequences of `capture`, `retire`, `retire` with a reason on an +already-retired id, a hand edit, and run them serially and concurrently from a bare repository. +After every step assert: every id any verb reported as saved is readable by `Workspace::load` in +the section the verb named; the shape multiset of every criterion the step did not name is +unchanged; no id is ever assigned twice across the whole run; and `hi check` exiting 0 implies +all three. + +This is the item that changes the pattern in §1. Every one of the nine defects is a counterexample +this test would generate, and the fixture rules CLAUDE.md now carries by hand (start bare, use a +file hi did not write, put an example in the prose) become the generator's job. + +- Buys: the tenth defect is found by `cargo test`. +- Costs: two or three days. `proptest` as a dev-dependency, which reaches no installer, or a + forty-line xorshift generator if the four-dependency rule is read as covering dev-dependencies. +- Capture first: `ID-5 Whatever order I capture, retire and edit in, alone or alongside another hi, an id I have seen is never given to a second sentence.` + +**3. Settle the `hi check` contract before freezing it.** Three decisions, all cheap, all +impossible after the tag: + +- *Notes get codes and the JSON gets an array.* `Report::note` is one `Option` and the + three notes are joined with `"\n "`, so `hi check --json` carries terminal indentation + inside a JSON string. Make it `notes: [{code, message}]` with codes like `product-why`, + `index-behind`, `retired-reason`, and print `note: message` in the text form the way + problems print `line:code`. That is the whole answer to the reviewer who warned that + undifferentiated permanent nags become noise: a coded line can be read, grepped, or ignored by + code. The exit code does not move and no fourth note is added. +- *A family declared by two files.* Two files both listing `families: [SEND]` pass `hi check` + clean, and capture appends a new top-level `SEND` to whichever file sorts first. Verified. It is + exactly the shape §37 describes arriving by a second route: two branches each start a file for + the same family under different names, git merges them without a conflict, and check is silent. + Ids stay unique because `next_free` and `duplicate-id` scan every file, so the promise holds; + what is lost is the resolution the frontmatter exists for (§3). Recommendation: do not make it a + seventh kind. Write down that capture resolves to the first declaring file in path order and + that a case always lands with its parent (`CAPTURE-4.a`), and let `hi ls` show the split. The + decision matters less than making it before the list is frozen. +- *Exit 1 means two things.* §8.2 says `0` clean, `1` structural error, `2` usage. In fact every + operational failure (unreadable file, lock refused, bad frontmatter) also exits 1, and clap's + usage errors exit 2. For CI both 1s should fail, so keep it, but the frozen contract has to say + it: "1 when problems were found or the check could not run". Whether `--json` is emitted on the + second case decides whether a consumer can tell them apart; today it is not, and that is fine + if stated. + +- Buys: a check contract that can be promised. +- Costs: a day, and a breaking change to `--json` for anyone parsing `note` today, which is why + it happens in 0.8 and not in 1.1. + +**4. Write the format down.** §7 says there is no formal grammar because the format is five rules +and a file layout. The parser is considerably more than five rules: any bullet or none, any +emphasis or none, continuation lines, a fence is opaque, a `# ` heading closes a section, an +indented `## Retired` is a heading, `retired:` on the following line, a BOM is tolerated, line +endings are preserved, uppercase files are hi's own, the id's number is a `u32` and a letter level +is `[a-z]+`, `families` is read inline or as a block list, `owner` is parsed and used by nothing. +"HI/1 is frozen" has to point at a text, or it means "whatever `doc.rs` did on the day of the +tag". One page, `FORMAT.md` or a page on the docs site, in prose, covering what a reader must +accept and what hi will write. The export envelope belongs on the same page with its additive-only +rule. Decide `owner` while writing it: either it is a free-form key hi reads for nothing, or it +comes out of the examples. + +- Buys: the promise has a referent. +- Costs: one or two days of writing, most of it already in `specs/doc/`. + +**5. Take Windows off an argument.** §34 says the delete-pending semantics make `still_at` +unnecessary on Windows and admits nothing tests that. hi ships Windows binaries and runs the suite +there. Add the identity comparison (`GetFileInformationByHandle`: volume serial plus file index) +so both platforms verify rather than one arguing, and run the 32-way bootstrap test in a loop of +twenty in the Windows CI job. A fourth `extern`, no dependency. + +- Buys: the one part of the frozen promise that currently rests on reasoning about an OS rests on + a check instead. +- Costs: half a day. + +**6. The release candidate and the promise.** Ship 0.8.0 carrying items 1 to 5. Replace the +README's Status paragraph with the compatibility statement in §4 below, worded as "this is what +1.0 will promise, and 0.8 is the candidate". Start the clock (§8). + +### Not blocking + +**7. `hi/AGENTS.md` already has a migration path, undocumented.** `start_agent_files` writes the +file whenever it is absent, on every capture, not only the first. So a repository stuck with the +hard-wrapped 0.5.0 file fixes it by deleting `hi/AGENTS.md` and `hi/CLAUDE.md` and capturing +anything. Say so in the README and CHANGELOG. If a note is wanted later, the precise form is: hi +keeps the templates it has shipped as `include_str!`, and `hi check` mentions the file only when +it is byte-identical to an older template, which means nobody has edited it. A file a person +touched is never mentioned. This would be a fourth note, so it should wait until item 3's codes +exist and someone asks. + +**8. §37 needs a recipe, not a mechanism.** The remedy §37 names, a check over the merge result, +already exists: it is `hi check` on the merged tree. What is missing is that no adopter runs it. +Publish a six-line CI job (`hi check` on pull requests and on `main`) in the README next to rule +4, and one sentence saying that dense numbering (`next free is SEND-22`) means two parallel +branches will be offered the same id and the merge is where that gets caught. Do not build a +merge driver, a reservation file, or a namespace per branch; all three are the state §5 refused. + +**9. Network filesystems.** hi fails closed there today with a message naming the path. Make the +message name the cause when `flock` returns `ENOTSUP`, `EOPNOTSUPP` or `ENOLCK`, and put "a local +checkout is the supported answer" in the promise's fine print rather than only in §34. + +**10. Measure the habit on a person (§22).** Not a gate on the format, but the thing that decides +whether freezing it matters. Pick one human in the organisation who does not maintain hi, one +repository, thirty days, and count captures typed at a terminal against captures an agent made. +Do it during the soak, so the answer arrives before the tag rather than after. + +## 3. What 1.0 freezes, and what stays free + +| Surface | Freeze? | Versioning after 1.0 | +|---|---|---| +| **Id grammar** (family charset, hyphen, strict alternation, no padding, `u32`, `[a-z]+`) | Yes. This is the promise itself. | Never changes in 1.x. Widening it (a new charset, deeper nesting) is HI/2 and a 2.0. | +| **HI/1 reading rules** (what a reader must accept as a criterion, section, fence, heading, frontmatter key) | Yes, additively. | A 1.x may accept more, never less. Anything a 1.0 reads as a criterion, every 1.x reads as the same criterion. | +| **HI/1 writing rules** (`- **ID** sentence`, two spaces per depth, one line, `retired:` line, frontmatter style preserved) | Yes. | Unchanged in 1.x. A different written form is HI/2. | +| **The `hi:` key** | Yes, once item 1 exists. | Absent or `1` is HI/1. A 1.x binary refuses to read or write any other value. | +| **The one promise**: hi's own verbs never assign an id that is readable, retired, or stranded anywhere in the tree they run against | Yes, in exactly those words. | Any defect against it in 1.x is a patch release, with a CHANGELOG entry that says which verb and which shape. | +| **`hi check` exit codes** and the six problem codes | Yes. | Codes are never renamed or removed. Adding a seventh turns a clean repository red, so adding one is a 2.0. Freeze the *criterion* (structural only, never content, never completeness) as well as the list, so the list has a reason. | +| **`hi check --json` shape** | Yes, after item 3. | Additive only. Consumers ignore unknown keys. | +| **`hi export` envelope** (`hi`, `scope`, `product`, `files[]`, `criteria[]` with `id`, `text`, `depth`, `parent`, `retired`) | Yes. | Additive only, and `"hi": 1` is the envelope version. New keys may appear; existing keys never change meaning or type. | +| **CLI verbs and positional shapes** (`hi ` as default, `check`, `ls`, `retire`, `issue`, `export`, `index`, `view`, `--root` before the id, `--json`, `--family`, `--retired`, `--create`, `--repo`, `--out`) | Yes. | Flags may be added; none removed or repurposed in 1.x. `looks_like_id` routing is part of this. | +| **stdout is the record, stderr is everything else** | Yes. | Unchanged. | +| **`` / `` markers** | Yes; they sit in people's files. | Unchanged. The list *inside* them is regenerated and may change format. | +| Human text of `ls`, `check`, error and hint messages | No. | Changes in any release. Parse `--json`, not the words. | +| `hi issue` ticket wording and layout | No. | Any release. The `hi: ` backlink line stays. | +| `hi view` (HTML, CSS, JS, brand tokens, keyboard) | No. | Any release. `VIEW-2` (self-contained, no network) is a promise; the page is not. | +| `hi/AGENTS.md`, `hi/CLAUDE.md`, starter `INTENT.md` text | No. | Written once; later versions write different text for new adopters. | +| The lock (`.hi.lock`, `flock`, `LockFileEx`, patience, retry) | No. | Mechanism may change; the guarantee (`FILE-19`, `FILE-23`, `FILE-24`) does not. | +| `owner:` | Decide in item 4. | Either documented as free-form and unread, or dropped from examples. | +| The fledge plugin, hooks, `bin/fledge-hi-nudge` | No. | Follows fledge, not hi. | +| The specs under `specs/` | No. | Internal. Already excluded from the crate. | + +## 4. The compatibility promise + +For the README, one paragraph: + +> **What 1.0 promises.** A `hi/*.md` file that hi 1.0 reads as a set of criteria is read as the +> same set, id for id and sentence for sentence, by every 1.x release. The id grammar, the six +> structural problems `hi check` fails on and the meaning of its exit codes, the keys in +> `hi export`, and the verbs and their arguments do not change in 1.x except by addition. And +> hi's own verbs never assign an id that is already readable, retired or stranded anywhere in the +> tree they run against. Everything else, the page, the ticket text, the messages, the lock, and +> the files hi writes once for you, may change in any release. + +And the fine print, one sentence each, next to it: + +- hi cannot see another branch. An id is unique against the tree you captured on, and the merge is + where two branches' ids are reconciled: run `hi check` there. +- hi cannot stop a hand edit. Permanence is a convention the tool supports and refuses to be the + one to break. +- A local checkout is the supported filesystem. On a network filesystem hi refuses rather than + writes. + +## 5. The load-bearing decisions, judged + +**Refusing any sentence grammar (§9, §14, §24).** Right, and stronger than "defensible": §24 is +the experiment that proves it. The one slot ever added to the sentence became a four-word +heuristic over English, was wrong in both directions, and shipped visibly broken for four releases +without anyone noticing, which is the verdict on how load-bearing it was. The reason the refusal +is cheap is worth stating in the 1.0 text: **all of hi's structure lives in the id.** Family, +depth, case or step, parent: the id carries every field a downstream consumer needs, which is why +the sentence can be a bag of words. Freeze the id, free the sentence. The one cost is real and +should be said plainly: because the sentence has no fields, `hi export` is a list of strings with +addresses, and any structure a consumer wants beyond that has to be derived by an agent reading +English. That is the correct trade when the consumer is an agent. It would be the wrong trade if +the consumer were a regex. + +**Hand-chosen ids and §37.** This is the format's real weak point and freezing it freezes the +hazard. Two facts sharpen it. First, dense numbering makes the collision the *likely* outcome of +parallel work, not an unlucky one: two branches both ask "next free?" and both get 22. Second, +the wording of the promise in §26 ("never reused") is absolute, while the mechanism is per-tree +and per-verb; README rule 4 already carries the honest hedge ("refuses to be the one that breaks +it") and the 1.0 text must use that wording, not §26's. With that said, the decision stands. +Every alternative is state: an allocator, a reservation file, a branch namespace, a merge +driver. §5 refused state for a reason that has held up through everything else in this file. The +remedy is item 8: the check that catches it exists, and adopters need to be told to run it where +the collision is visible. + +**Six structural problems, never on unfinished intent.** Right, and the best-tested decision in +the repository: §30's refusal to add a seventh kind for a stale generated list, §36's refusal to +make an unreadable file a finding, and §31's refusal to reject an indented heading are all the +same principle holding under pressure. Two corrections for 1.0. "Exactly six" is a count; the +thing to freeze is the membership test (a file hi cannot read *correctly*, never a file that is +incomplete or unproven), with the six as its current output. And the consequence has to be +accepted out loud: under that freeze, a seventh kind is a 2.0, because it turns somebody's green +build red. That is the right consequence, and it is why item 3's family-in-two-files question has +to be decided now. + +**Intent and identity, nothing else.** Right, and §23 is the best paragraph in the design record: +"is it met" is a question about where the car is, and hi holds where it is going. Two things to +carry into 1.0. The refusal has a boundary that has already been walked up to twice, once with +`INDEX-3.a`'s nag and once with `INDEX-4.b`'s nag about a list hi itself generates; §30 names the +second as admissible only because hi keeps the list current. The 1.0 text should say that a note +is the outer limit and that a note about something hi could have fixed itself is a bug. And +outside the organisation, the pipeline hi is upstream of (spec-sync, fledge, an agent writing +specs from `hi export`) does not exist. There, hi is a markdown convention with an id linter and a +page. That is a fine thing to be, and the README should pitch it as that first and the pipeline +second, because a reader without spec-sync currently meets a diagram of tools they do not have. + +## 6. Adoption reality + +The evidence: + +- Twelve repositories, about 1,600 criteria, all inside CorvidLabs, nearly all captured by agents. +- One field report from a human using hi cold (§14, §15, §17, §21), on `corvid-bot`, which is + also CorvidLabs. Most substantive format changes after 0.1.0 trace to that one report. +- One stranger agent reading `hi/` blind (§20). +- One external reviewer, who found bugs and did not use the tool. +- Zero evidence of a human outside the organisation typing a criterion, and no measurement (§22) + of whether a human inside it reaches for hi without an agent doing the reaching. + +So yes: this is a well-built tool that nobody outside its author's organisation has meaningfully +used, whose one human field test found the format's largest gap, and whose 1,600 criteria are +mostly the output of agents that were told to produce them. That is not an argument against a +1.0 target. The ids are already load-bearing where they exist, and a frozen format is what an +outside adopter would need before trusting it. It is an argument against tagging one this month, +and for being honest in the tag about what it certifies: **1.0 means the file format, the ids and +the check contract are frozen. It does not mean the tool has been proven anywhere but here.** + +## 7. What the two reviews did not find, because they were hunting bugs + +1. **The format version is decorative.** `hi:` is parsed and never read. Item 1. +2. **The check JSON leaks its terminal layout.** Notes are one string joined with six spaces of + indentation. Item 3. +3. **A family can live in two files and nothing says so.** Item 3. +4. **Exit 1 is two different sentences.** "I found problems" and "I could not look" share a code + and a `--json` behaviour that differs. Item 3. +5. **The promise is written two ways.** §26 says "never reused"; README rule 4 says "refuses to be + the one that breaks it". Only the second is true. Item 6 fixes the wording; §26 should get a + one-line pointer. +6. **The discovery process is the defect pattern.** Nine for nine found by somebody looking + harder, none by hi's own suite generating a case nobody wrote. Item 2. +7. **Dense numbering makes §37 the expected case under parallel work**, not an accident. Item 8. +8. **"Exactly six" is a README number, not a design rule.** The rule is "structural only", and it + deserves to be the frozen thing. Item 3 and §5 above. +9. **`owner:` is a key in the canonical example that the tool uses for nothing.** In a frozen + format every key in the example is a promise. Item 4. +10. **The `hi/AGENTS.md` migration exists and nobody has said so.** Item 7. +11. **The pitch is the house pipeline.** Outside CorvidLabs the tool is a convention plus an id + check, and the README's first diagram shows two tools the reader does not have. Not a 1.0 + blocker; a README paragraph. +12. **One lock test needs a non-root user.** + `a_lock_that_cannot_be_taken_is_refused_rather_than_pretended` sets `0o555` on `hi/` and + expects a refusal; as root the directory is still writable and the test fails. CI is not root, + so it passes there. It should skip when `geteuid() == 0` rather than fail, because a + contributor in a container is the next person to hit it. + +## 8. What 1.0 deliberately leaves out, and what it refuses to promise + +Left out, on purpose, all already refused in DECISIONS.md and none of them reopened here: +state, lifecycle, evidence bindings, a built-yet bit, checkboxes, EARS or any sentence grammar, +a prose linter, a `roles:` list, a `speaker:` field, a seventh check kind for wrapping or for a +stale index, a CI gate on unfinished intent, a cross-branch id allocator, a merge driver, a lock +that anyone can break. + +Also left out, newly: a bulk-capture verb (§30 names the trigger, thousands not hundreds, and it +has not fired); a network-filesystem lock location (§34 names the trigger and it has not fired); +a fourth `hi check` note for `AGENTS.md` (wait for item 3's codes and a request). + +Refused as promises, in the fine print: + +- That an id is unique across branches, forks, or repositories. It is unique in the tree you ran + against. +- That a hand edit cannot break permanence. It can, and `hi check` reports what it sees afterwards. +- That the text of any message, list, ticket or page is stable. Parse `--json` and `export`. +- That the lock works on a network filesystem, or that `.hi.lock` is anything other than an + implementation detail. +- That `hi check` passing means your ids are unique in your organisation. It means they are unique + in this checkout. +- That the fledge integration is covered by hi's versioning. +- That performance at any particular scale is stable. The per-capture index rewrite and the + per-write reparse are both O(file) and both are allowed to change. + +## 9. When to tag + +Tag 1.0 when all of the following are true, and on the first day they are: + +1. Items 1 to 6 are shipped in 0.8.0. +2. Eight weeks have passed with no change to `doc.rs`, `lock.rs`, `workspace.rs` or the write + path in `capture.rs` other than a fix for something item 2's generator found, and no P1 against + the promise found by anyone else. +3. One more external review has run *against the candidate*, after the soak started, rather than + before it. The previous two ran against builds that changed the next day. +4. At least one of: a repository outside CorvidLabs has adopted it through a release cycle, or the + item 10 measurement shows a human inside it capturing at a terminal without being told. If + neither happens in eight weeks, tag anyway, because the internal estate needs the freeze, and + say in the release notes that the outside evidence is zero. + +The 1.0 commit changes `Cargo.toml`, `CHANGELOG.md` and the README's Status. If it changes +anything else, it is not 1.0 yet. diff --git a/src/capture.rs b/src/capture.rs index 7be84be..b312205 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -498,8 +498,12 @@ mod tests { Seeded::Updated(file) => assert_eq!(file, "hi/AGENTS.md"), other => panic!("expected Updated, got {other:?}"), } + // The endings the template on disk had are kept, so compare folded: + // a checkout with autocrlf gives include_str! CRLF bytes. assert_eq!( - fs::read_to_string(root.join("hi/AGENTS.md")).unwrap(), + fs::read_to_string(root.join("hi/AGENTS.md")) + .unwrap() + .replace("\r\n", "\n"), crate::out::agent_instructions() ); } @@ -548,7 +552,9 @@ mod tests { #[test] fn seed_recognises_a_prior_template_through_a_bom_and_crlf() { let root = temp_dir("seed-folded"); - let prior = include_str!("seed/agents_0_6.md").replace('\n', "\r\n"); + let prior = include_str!("seed/agents_0_6.md") + .replace("\r\n", "\n") + .replace('\n', "\r\n"); fs::write(root.join("hi/AGENTS.md"), format!("\u{feff}{prior}")).unwrap(); let workspace = Workspace::load(&root).unwrap(); assert!(matches!( diff --git a/src/doc.rs b/src/doc.rs index e44d8a7..8d3c0ce 100644 --- a/src/doc.rs +++ b/src/doc.rs @@ -276,6 +276,17 @@ impl Doc { // is what the file says it is; `version_text` is what the // file actually wrote, which is the only thing that can // tell an unreadable version from an absent one. + // Quotes and a trailing `# comment` are YAML, not a version: + // `hi: "1"` and `hi: 1 # format` both mean 1, and `owner` + // already sheds its quotes. Refusing them would lock a whole + // repository over punctuation (hi: FILE-25). + let value = value + .split_once(" #") + .map(|(v, _)| v) + .unwrap_or(value) + .trim() + .trim_matches(['"', '\'']) + .trim(); self.front.version = value.parse::().ok(); self.front.version_text = Some(value.to_string()); } @@ -1409,6 +1420,17 @@ mod tests { let one = doc("---\nhi: 1\nfamilies: [SEND]\n---\n\n## Criteria\n"); assert_eq!(one.front.unreadable_version(), None); + // YAML punctuation is not a version. Refusing these locked a whole + // repository over quotes (hi: FILE-25). + for spelled in ["\"1\"", "'1'", "1 # the format version", "\"1\" # quoted"] { + let raw = format!("---\nhi: {spelled}\nfamilies: [SEND]\n---\n\n## Criteria\n"); + assert_eq!( + doc(&raw).front.unreadable_version(), + None, + "`hi: {spelled}` is HI/1" + ); + } + for declared in ["2", "0", "10", "1.1", "one", "HI/1"] { let raw = format!("---\nhi: {declared}\nfamilies: [SEND]\n---\n\n## Criteria\n"); assert_eq!( diff --git a/src/lock.rs b/src/lock.rs index ef94623..b4d2e92 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -391,16 +391,68 @@ mod os { Err(err) } - /// Windows does not need the unix check, and has no stable way to make it. + #[repr(C)] + struct FileTime { + low: u32, + high: u32, + } + + /// `BY_HANDLE_FILE_INFORMATION`. Volume serial plus the two index words is + /// the file's identity, the same fact `dev`/`ino` states on unix. + #[repr(C)] + struct ByHandleFileInformation { + attributes: u32, + creation: FileTime, + last_access: FileTime, + last_write: FileTime, + volume_serial: u32, + size_high: u32, + size_low: u32, + links: u32, + index_high: u32, + index_low: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle(file: Handle, info: *mut ByHandleFileInformation) -> i32; + } + + fn identity(file: &File) -> io::Result<(u32, u32, u32)> { + let mut info = ByHandleFileInformation { + attributes: 0, + creation: FileTime { low: 0, high: 0 }, + last_access: FileTime { low: 0, high: 0 }, + last_write: FileTime { low: 0, high: 0 }, + volume_serial: 0, + size_high: 0, + size_low: 0, + links: 0, + index_high: 0, + index_low: 0, + }; + // SAFETY: the handle belongs to `file`, which outlives this call, and + // `info` is a live, correctly laid out BY_HANDLE_FILE_INFORMATION. + let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle() as Handle, &mut info) }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok((info.volume_serial, info.index_high, info.index_low)) + } + + /// Whether the locked file is still the file `path` names. /// - /// A delete there marks the file and leaves it in place until the last - /// handle closes, and while it is marked every `CreateFile` on that name is - /// refused. So nobody can create a replacement while the outgoing holder's - /// or the incoming one's handle is open, which is the exact window the unix - /// check exists to cover. Waiters see access denied during a handoff, which - /// `acquire` already sits out (DECISIONS.md §34). - pub fn still_at(_file: &File, _path: &Path) -> io::Result { - Ok(true) + /// The delete-pending argument says this cannot fail on Windows: a removed + /// file stays in place until its last handle closes and no `CreateFile` on + /// that name succeeds meanwhile. That argument was never asserted by a + /// test, and a frozen promise should not rest on it, so the same identity + /// comparison unix makes is made here: the volume and file index of the + /// handle hi holds against those of whatever the path names now. A path + /// that cannot be opened (gone, or delete-pending) is not this file, and + /// `acquire` drops the lock and starts over (DECISIONS.md §34). + pub fn still_at(file: &File, path: &Path) -> io::Result { + let named = File::open(path)?; + Ok(identity(file)? == identity(&named)?) } } diff --git a/src/out.rs b/src/out.rs index 93371c3..fc53e4b 100644 --- a/src/out.rs +++ b/src/out.rs @@ -587,7 +587,10 @@ pub fn write_with_endings(path: &std::path::Path, text: &str, like: &str) -> std } else { text.to_string() }; - std::fs::write(path, body) + // Atomic, because this is the one file `hi seed` exists to rewrite: a + // truncating write that dies halfway leaves a file that is no longer a + // template hi shipped, which `hi seed` would then refuse forever (hi: FILE-8). + crate::doc::write_atomically(path, &body) } /// Byte range of the generated block, matched on whole lines only. @@ -1231,9 +1234,11 @@ mod tests { ); // A BOM and CRLF are storage, not words. Folding them is how we tell // hi's unmodified template from a file somebody touched (hi: HABIT-6.a). + // Folded first: a checkout with autocrlf hands include_str! CRLF bytes, + // and doubling the carriage return would test a file nobody wrote. let wrapped = format!( "\u{feff}{}", - include_str!("seed/agents_0_5.md").replace('\n', "\r\n") + fold_agent_text(include_str!("seed/agents_0_5.md")).replace('\n', "\r\n") ); assert_eq!(classify_agent_file(&wrapped), AgentTemplate::Prior); assert_eq!(classify_agent_file("mine now\n"), AgentTemplate::Other);