# Changelog ## Unreleased ## 0.0.12 — 2026-09-17 Published, and reviewed. The repository is at `https://git.labunix.xyz/krino.git` and the module path is `git.labunix.xyz/krino`, so `go install git.labunix.xyz/krino/cmd/krino@latest` works from this release on. Then five reviews — security, correctness, determinism, performance and the window — found four high-severity defects and a number of smaller ones; what follows is what they found and what was done about it, each with the measurement or the test that settles it. **Files that could have been lost** - A symlink planted in the directory being sorted, named after a rule's destination, redirected a move or a copy out of the tree — and under `(on-conflict overwrite)` trashed a file *outside* it — while the plan the user approved showed only the in-tree text. A step whose destination passes through a symlink at or below the sorted directory now fails. A destination the configuration itself names outside that directory, `~/docs` on another disk say, is the user's own arrangement and is followed as before. - A file trashed to make room was logged only when the step that needed its name had finished — for a copy, the whole data transfer later. Killed in that window, krino reported "nothing applied" while the user's file sat in the Trash. It is now logged the moment it is trashed, and a displace that cannot be logged fails the step. - A chain ending in `(delete permanent)` never gave back the file it had displaced: the walk stopped at the delete, so the user's only copy stayed in the Trash and `krino log` called the run undone. The displaced file is offered as its own entry in the undo plan. - A `(duplicate)` test written in an `(exclude ...)` bought no protection at all: a later rule permanently deleted every copy. Duplicate scopes are now collected from excludes as well as rules. - The window could release the directory lock and close the log under a running Apply — by saving rules, saving settings, adding a directory or closing the window — so a file moved that no `krino undo` could see. The tabs refuse to close while their apply is in flight. - Deleting one of two forms written on the same line deleted both, and clearing a setting took whatever else shared its line — including the closing paren of `(defaults ...)`, which broke the file the Settings window had just written. Both take a whole line only when the line holds nothing else. **The directory lock is now the kernel's.** `flock(2)` on the lock file replaces "write my pid, and judge whether the pid in the file is still alive". A crashed run's lock is released by the kernel, so there is no stale lock to detect and no takeover to race over: two runs could previously both decide a lock was stale and both take it. A pid reused after a crash no longer wedges a directory for ever, and a run that waits for a lock says so before it waits. **Faster, measured on invented corpora** - A content class is worked out once instead of once per copy: 2000 identical files, 1.89s → 0.58s, with byte-identical plans. - The search for a free `stem_N` continues instead of starting again at `_1`: 1500 files renamed onto one name, 2.63s → 0.05s. - A file's name is folded once rather than once per name test: 4000 names with diacritics over twelve rules, 0.33s → 0.13s. - `max-read` now bounds what a file *becomes*, not only what is read, and large extractions are rationed: twelve 40 MB files, peak memory 3294 MB → 728 MB. Small files are untouched. **Other** - `{now:FMT}` is the start of the run, as the spec always said, rather than the moment each directory happened to be planned — so a long review cannot split one run's output across two folders, or two dates. - The Settings window's sort order, column toggles and preview height were read but never connected to anything; they work now, and changing a setting no longer discards the divider positions. - "Keep this copy, replace the other" refuses a second file aimed at the same place, which the engine has always refused to plan. - `About`, at the foot of Settings: the version, the licence, the author and the repository. - The Plan tab's toolbar reads in the order the work happens, and `Scan` carries the theme's accent. - Comments that cited the development process rather than the code are rewritten; the README is a third shorter, with a screenshot. ## 0.0.11 — 2026-09-17 The window, worked over from a fortnight's worth of using it: the plan reads like a table, a filter and a sort over it, choices that act on everything checked, the file itself shown beside its explanation, and rules edited as a tree. The command line is unchanged. - The plan is a table with headings: file, size, age, action, where it would go, rule, and an outcome column once it has been applied. The action is in capitals and carries the running GTK theme's own colours - accent for a move, the selection colour for a rename, success, warning and error for the rest - and stays readable on a selected row. Size, age and rule can each be turned off. - A filter over the plan: the letters of a name or a rule, in order, capitals and accents ignored as `(fold yes)` does. `Select all` then takes what is shown, and the window says how many checked files the filter is hiding. - A sort picker: as scanned, by name, size, age, action or rule. - `With checked` sets every checked file to the Trash, or - after a confirmation naming the count - to a permanent delete. A row's own menu still does one file at a time. - A file krino found another copy of names that copy in full, and offers to keep this one instead: it takes the other's place and the other goes to the Trash, which `krino undo` reverses. - The file behind the selected row is shown: a picture for an image, the first page for a PDF, the first lines of anything that is text. The divider above it sets how big it is, and the page is rendered to suit. - The explanation is laid out rather than printed: the name, the size and age, the copy it duplicates, then a line per step with its action coloured. - Rules: the conditions of a rule are a tree - `and`, `or` and `not` holding the conditions under them - the text has line numbers and colours, a `Check` button, and `Test rule` lists the files a rule would take. `Add directory` writes `dirs/NAME.conf` and adds it to the include, as `krino new` does. - `Settings`, at the top right: krino's own defaults, written to `krino.conf` with the same care a rules file is saved with, and how the window behaves - the layout (the file list beside the explanation or above it), which columns are shown, the sort, the preview, and whether a scan starts with every file checked. Every setting says what it does. The window keeps where each divider was left. - Nothing on disk changes until Apply, Undo or Save: a review choice says so, and closing with an unapplied plan asks first. - `FileMatch.DuplicateOf`: the file a `(duplicate)` test matched, absolute, so a front end can say where the other copy is and act on it. The reason text is unchanged. - Fixed: selecting the exclude row in Forms took the window down with it (0.0.10). An exclude has conditions but no name, action or `(stop)`, and the form now opens with only what an exclude has. ## 0.0.10 — 2026-09-16 A window, `krino-gui`, over the same engine: review and apply a plan, look back over runs and undo one, and edit a directory's rules with the configuration checked as you type. The command line is unchanged. - `krino-gui`, an optional GTK 4 program built and installed separately (`make gui`, `make install-gui`; it needs GTK 4 development files and cgo, which `krino` itself does not). Three tabs: **Plan** (scan a directory, a checkbox and an explanation per file, Trash or delete-permanently instead from a row's menu, Apply), **History and undo** (runs newest first, a run's reversal file by file, refused files with the reason, Undo), and **Rules** (the directory's excludes and rules as forms, or the whole file as text; a check after every edit; Test on file; Save that refuses a file which would not load, keeps the previous text as `NAME.conf.bak`, and asks before overwriting an edit made elsewhere). Described in krino-gui(1). - A plan the window holds open keeps its directory's lock, so nothing moves underneath it, and applying or closing frees it. Each plan and each undo is a run of its own in the log, exactly as a command-line invocation is, and `krino log` and `krino undo` treat them no differently. - Engine, for both front ends: a `Session` that owns a run's lock, log, run id and claims (the command line now goes through it, with no change to what it does); loading a configuration with some files' text supplied in memory, for the editor's live check; `Explain` that also reports a rule's captures and the chain a file would get; a printer and splice for one configuration form; and one atomic way to write a configuration file. - An undo result now says which file each outcome belongs to, so a front end can show it per file. Nothing on the command line changes. - A directory that applies nothing no longer frees what an earlier directory of the same run put somewhere: every applied destination stays protected from a later `(on-conflict overwrite)` for the whole run. ## 0.0.9 — 2026-09-15 Rules that cannot be decided fail closed in two more places, `krino check` catches bad placeholders, and krino.conf(5) explains how rules are written and used, checked against the code. - A `(duplicate)` test whose lookup fails (an unreadable file) is unknown, like unreadable content: an exclude holds, marked "(duplicate check failed)", and no rule acts on it, `(not (duplicate))` included. - A `(stop)` rule that cannot be decided ends the search for that file: a "keep" rule like `(rule "keep" (when (content "confidential")) (stop))` protects an unreadable file from the rules below it. `explain` shows the rule as "undecided" and the later rules as stopped by it. - `krino check`, and every run, refuses a placeholder that could never expand (`{foo}`, `{mtime}` or `{mtime:}` without a format, `%B`, `{0}`, `{10}`, an unclosed `{`) at the action's file:line:col, instead of skipping the step at plan time. - krino.conf(5) gains WRITING RULES: what happens to a file from scan to execution, how to make and test a configuration, a worked example with real output, and the mistakes `krino check` reports. Its reference sections were checked statement by statement against the code and corrected where they were wrong or left things to guess (claims across directories, `busy` values, `ignore` anchoring and case, capture order, `{now}`, max-read, text detection, overwrite and execution-time conflicts, unscanned destinations, and more). - `make ci` passes on OpenBSD 7.9 (OpenBSD make) and FreeBSD 15.0 (bmake) with Go 1.26.8; the design notes that OpenBSD's `go` package needs `GOTOOLCHAIN=auto` to use it. ## 0.0.8 — 2026-09-15 The rest of the open work after 0.0.7, and the checks on real FreeBSD and OpenBSD hosts: every test passes there, and the lock's stale-holder check works with real processes. - A file of no known extension that starts as text and turns binary further on (a self-extracting installer) has no content, like an image: it no longer matches a content test, is not set aside by a content exclude, and raises no warning. - A content test that cannot read its file is unknown rather than false, and `and`/`or`/`not` combine unknowns by three-valued logic. A rule never matches on an unknown, so `(not (content "x"))` no longer acts on a file krino could not read; an exclude holds on an unknown only when the text could change its answer; `explain` shows `?`. `(matched)` is unknown while no earlier rule matched but one could not be decided, so a `(not (matched))` catch-all leaves such a file alone. A document read only in part answers the keywords found in what was read, and leaves the others unknown; its answers are never cached, and keyword caches written by 0.0.7 are discarded. - `krino log` marks a run "(partly undone)" while some of its reversible steps are not reversed, and "(undone)" only once every one is. - Tests read real documents made by LibreOffice and pandoc (docx, odt, ods, xlsx, pptx, epub, pdf, doc, xls), the tool formats when a tool is installed; antiword's refusal of a very short `.doc` is documented. - Tests: a genuine apply error exits 1, and `w` in review stops krino even when applying fails, both through the real command; the undo projection's "about to be occupied" half. - Build: `make build` and `make install` refuse a `VERSION` that is not a plain version string, as `cross` and `release` did; `make cross` names its directories without the tag's `v`; the dependency gate covers test imports and FreeBSD and OpenBSD builds; `scripts/deps --print` prints only the command on stdout; the README says how to install from a release tarball; tests check that the examples load and that the Makefile's extractor list matches the code. - krino.conf's excludes are checked with the defaults' `case` and `fold` even while no directory is included; Latin-1 text is decoded in a third of the memory. - `-v` lists the rule destinations inside a directory that were not scanned, so files there are no longer invisible. - Plan and table widths count terminal columns: a name in a wide script (CJK) no longer runs past the terminal or misaligns its column. A long name's continuation lines - in the plan, and in the per-file headings of review and undo - start past the label column, so a name cannot pass for a step line. - Duplicate warnings, for the directory and on a rule, name the path shortened to `~` and do not repeat it inside the cause (`duplicate: ~/dl/a.txt: open: permission denied`). - `--json` says which exclude set a file aside, carries the warnings raised while matching, and lists unmatched files that raised one. - A literal `{{` or `}}` in a destination is text, not a placeholder: the directory a placeholder may not leave, and the directory left out of the walk, now include it. - In a real run, a later directory is no longer blocked by paths an earlier one moved files away from, or planned and did not apply ("target exists"); where an earlier directory's files ended up stays protected from a later `overwrite`. - `krino explain` says when a duplicate's delete would be skipped, no longer removes a directory's unused keyword cache (it holds no lock), and walks the directory only when a duplicate test needs it. - `{1}` … `{9}` keep the name's diacritics: a folded `name` test still matches `Lodz`, but `Łódź-faktura.pdf` now files under `Łódź`, not `Lodz`, including when the name is stored decomposed. - A tool's error message is shown even when it writes more than the capture limit at once. - A damaged lock file naming a pid beyond 32 bits no longer counts as held by whatever process the kernel reads it as. - Tests pass on OpenBSD 7.9 and FreeBSD 15.0 (test binaries run on the hosts; the fake extraction tools no longer rely on GNU `head -c` or a `printf` builtin). ## 0.0.7 — 2026-09-14 Security and correctness hardening, from a threat model (spec §15.1), an independent review, and the tests that hold both. Hostile names and contents - File names, paths, tool messages, errors, explain traces, JSON plans and log fields can no longer control or reorder the terminal: control characters, every bidirectional control, line and paragraph separators and invalid UTF-8 are printed as escapes (`\x1b`, `\u202e`). - A placeholder can no longer take a destination outside the directory the rule names: a capture of "..", "~" or nothing is refused with a reason, and so is a rename to "", "." or "..". - A file swapped between planning and applying - for a symlink, or for another file with the same size and modification time - is not acted on; a step that had to take a free name stops the rest of its chain. - `on-conflict overwrite` never trashes a directory, or another file of the same plan; it skips or takes a free name instead. - A content exclude holds when its content test cannot read the file (fails closed), so a protected file krino could not read is left alone. A file whose format has no text (an image, an archive) simply does not match a content test, so it is not set aside; one that starts as text and turns binary further on is unreadable, and is. - Extraction tools are only found through absolute PATH entries. Apply and undo - Every step is logged as soon as it has run; a step that ran but could not be logged is reported with where its file is now. SIGHUP is handled like SIGTERM, unless krino runs under `nohup`. An interrupt finishes the current step and skips the rest of that file's chain (an undo finishes the file it is reversing). A run killed mid-chain can be undone as far as it got. - Undo reverses a file its run renamed and then moved, moved twice, or moved and then trashed (it refused them before). - Undo restores a trash entry only while it is still the file the run put there, re-checks every file just before acting, keeps same-named files of two directories apart, and leaves no directory behind once it completes. Finishing an undo that stopped part way refuses a file changed since. - An undo that stopped part way can be finished by undoing the run again; plain `krino undo` after an undo does that. - The log restores a missing final newline, so a crash mid-line no longer breaks the next run's undo, and a damaged line refuses only the file it names. - Trash entries skip names left orphaned in the Trash, and long names are shortened to fit. Matching and the keyword cache - Folding maps the capital sharp s (ẞ) to "SS", and replaces invalid UTF-8 first so a stray byte no longer stops the next letter losing its accent. - The cache keys on the extension too (it picks the extractor), and is discarded when max-read, the Go release or the Unicode tables change; removed keywords leave it, and an unused cache is removed. Command line - Undo's per-file review behaves like review's (`w` leaves the rest unreviewed, choices echoed). - `w` stops krino even when applying fails. - `--min-age` is refused by commands it does not apply to, and an empty value is an error; a file dated in the future counts as brand new. - `krino new` refuses a path that is not valid UTF-8 or holds control or bidirectional characters, with that reason. - `-n`, `-y`, `--json` and `-v` are refused by commands that do not use them, instead of ignored: `krino -n new` no longer writes config. - A rule name cannot start with "(". - Builds need Go 1.25 or newer and use the Go 1.26.8 toolchain; `golang.org/x/text` is v0.41.0 (GO-2026-5970, and through the toolchain GO-2026-6088 and the `os/exec` and `os` fixes). Tests - Fuzz targets for every decoder of outside data (`make fuzz`), a generated apply-and-undo round trip comparing files and directories (`KRINO_PROPERTY_RUNS`), enum-completeness tests, `make race`, `make vulncheck`, and a dependency gate that checks modules. ## 0.0.6 — 2026-09-14 - Choosing per file: `w` now applies what was decided and quits krino, instead of going on to the next directory, so a long review can be done a session at a time. Files never reached are not logged as declined; the outcome line counts them as "not reviewed". - Each choice is echoed on its own red line under the file (`→ yes`, `→ trash`, ...). - Enter is ignored at the review prompts instead of being reported as a wrong key. ## 0.0.5 — 2026-09-14 - Keyword cache: for each file whose text it extracts, krino records which content keywords the text contains, in `~/.cache/krino/NAME.cache`, and answers content tests from it while the file is unchanged. A rerun over the same files extracts nothing: on a real downloads folder a dry run went from 12.8 s to 0.13 s. No text and no file names are stored. A changed file, a new keyword, or a changed extraction tool means reading again; failures are never cached. `krino check` shows where the cache is. - Choosing per file: `t` sends the file to the Trash and `d` deletes it permanently, after a `y` to confirm, instead of what its rules planned; both are logged under the rule `(review)`. "Done, apply chosen so far" moves from `d` to `w`, in undo's review too. ## 0.0.4 — 2026-09-14 - `max-size` setting: files larger than it are skipped as "too big", in `(defaults ...)` or a directory file. - `(exclude COND...)` sets files aside before any rule sees them, by type or extension, name regex, content, or any other condition; all conditions in one form must hold. In `krino.conf` it applies to every directory, in a directory file to that directory. Excluded files are counted in the plan and listed with the form that matched under `-v`; `explain` traces every exclusion and `check` lists them. - `--min-age DURATION` overrides every directory's `min-age` for one run, e.g. `krino -n --min-age 0` to include files written a moment ago. - `krino init` and `krino new` write commented examples of all three. ## 0.0.3 — 2026-09-14 - The plan is shown as one block per file instead of a table: the file's name, each step, then the rule and the reason it matched. On a terminal every line wraps to its width, with continuation lines indented under their own column; piped output is never wrapped. - Choosing per file (`c`) shows that same full block for each file, not only its steps. - `-P` / `--no-pager` prints the plan straight out instead of through the pager. - A duplicate's original is shown with `~` for the home directory. ## 0.0.2 — 2026-09-14 - Duplicates are found, never deleted. A rule combining `(duplicate)` with `(delete)` or `(delete permanent)` is refused by `krino check` and every run, and a file that is a duplicate under any duplicate scope its directory's rules use gets no delete step from any rule. This replaces 0.0.1's known limitation: duplicate conditions with different scopes can no longer delete every copy of a group, only move copies aside. - Coloured output on a terminal, in the terminal's own 16-colour palette: actions by kind, rule names, skipped steps and reasons, warnings, the outcome counts and the prompt keys. `--no-color`, before or after any subcommand, turns it off, as `NO_COLOR` does. ## 0.0.1 — 2026-09-13 - Per-directory rule files, a main file listing which directories run, and a template for new ones. - Conditions with `and`, `or` and `not` over type, name, path, content, size, age and duplicates. - Actions copy, move, rename and delete (to Trash by default), chained. - Plan, review and approve — all files or per file — with `-y` to skip review. - A log of every step, and `krino undo`. - Placeholders in destinations and new names. - Builds for Linux, FreeBSD and OpenBSD. Performance: no threshold or target for 0.0.1. These were measured during development and are reported rather than promised — throughput is dominated by external content extractors, not by krino itself: - Full plans over a real folder of about 265 files, 164 of them needing text extraction through `pdftotext` at roughly 80 ms each, measured between about 13 and 20 s across runs. - A synthetic benchmark during development planned a generated tree of 4,405 files needing no extraction in 1.09 s. That benchmark is not `make bench`, whose generated tree is smaller. Files that share a byte size but differ in content — six templated invoices, for instance — are still told apart, because the duplicate check hashes the first and last 64 KiB and then the whole file rather than trusting size. Known limitation: `(duplicate)` conditions with different scopes can elect different originals for the same content, and within one directory's rules this can select every copy for deletion — see `krino.conf(5)`, DUPLICATES.