1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
|
# Changelog
## Unreleased
## 0.0.8 — unreleased
- 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 `?`. A document read only in part
answers the keywords found in what was read, and leaves the others
unknown.
- 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").
- `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`.
- 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.
|