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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
|
# krino design
Status: describes krino 0.0.1, 2026-09-13; amended 2026-09-14 for 0.0.2:
duplicates are never deleted (§5.5), and coloured output with `--no-color`
(§8.2, §11); amended again 2026-09-14 for 0.0.3: the plan
shown as one block per file, wrapped to the terminal, and `-P` (§8.2, §8.3, §11);
amended again 2026-09-14 for 0.0.4: `max-size` (§4.4), `(exclude ...)`
(§4.2, §4.3, §4.6) and `--min-age` (§11); amended again 2026-09-14 for
0.0.5: the keyword cache (§3, §6.1, §13), and `t`, `d` and `w` in review
(§8.3, §10); amended again 2026-09-14 for 0.0.6: `w` applies and quits, and
each choice is echoed in red (§8.2, §8.3); amended again 2026-09-14 for
0.0.7: the threat model and its tests (§15.1).
krino (from Greek κρίνω, "to separate, to judge, to decide") sorts files in
chosen directories by rules. A rule tests a file's type, name, path, size,
age, text content or duplicate status, combined with `and` / `or` / `not`,
and applies actions: copy, move, rename, delete. krino shows what it will do,
lets you approve all of it or file by file, logs every step, and can undo a
run.
It grew out of an earlier Python prototype; the lessons that shaped it are
in §16.
---
## 1. Scope
**0.0.1 is:** the engine and a command-line interface.
- per-directory rule files, a main file listing which directories run, a
template for new ones
- conditions with `and` / `or` / `not` over type, name, path, content, size,
age, duplicates
- actions copy, move, rename, delete (to Trash by default), chained
- plan, review, approve all or per file, `-y` to skip review
- a log of every step, and `krino undo`
- placeholders in destinations and new names
- builds for Linux, FreeBSD and OpenBSD
**Not in 0.0.1:** the GUI (a separate project after 0.0.1, see §12), watch
mode, OCR, EXIF dates, macOS and Windows, a content cache, a shell-command
action, looking inside archives.
## 2. Terms
| Term | Meaning |
|---|---|
| directory | a configured directory krino sorts, e.g. `~/downloads`; one config file each |
| root | that directory's path |
| rule | a named condition plus actions |
| test | one leaf of a condition, e.g. `(type pdf)` |
| chain | the ordered actions krino will perform on one file |
| plan | every chain for one directory, computed before anything changes |
| run | one invocation of krino that applied at least one step, across all the directories it processed; has an id; the unit of undo |
## 3. Files and locations
```
$XDG_CONFIG_HOME/krino/ default ~/.config/krino
krino.conf main file: which directories run, defaults, log path
template.conf copied by `krino new`
dirs/<name>.conf one per directory
$XDG_STATE_HOME/krino/ default ~/.local/state/krino
krino.log the log (§9)
<name>.lock per-directory lock while a run is active
$XDG_CACHE_HOME/krino/ default ~/.cache/krino
<name>.cache keyword cache (§6.1), 0600, safe to delete
```
`krino init` creates the config directory with a commented `krino.conf` and
`template.conf` (both embedded in the binary). It refuses if krino.conf
exists and keeps an existing template.conf.
`krino new NAME PATH` copies `template.conf` to `dirs/NAME.conf`, fills in
the path, and appends NAME to `include` in `krino.conf`, keeping its comments.
Generated files begin with `;; -*- mode: lisp -*-` and `;; vim: set ft=lisp :`
so editors apply Lisp highlighting and parenthesis matching.
## 4. Config language
### 4.1 Syntax
The files are s-expressions (see `docs/sexp-primer.md` for a tutorial).
- **List:** `(` items separated by whitespace `)`
- **String:** `"..."`. A backslash escapes only `"` and `\`; any other
backslash is kept literally, so `"\bacme\b"` is the regex `\bacme\b`.
Strings may span lines.
- **Symbol:** any other run of characters except whitespace, `(`, `)`, `"`, `;`.
- **Comment:** `;` to end of line.
- Encoding UTF-8. No other syntax: no quote characters, no dotted pairs,
no block comments.
The reader records the byte offset, line and column of every list and atom.
krino edits a file by splicing text at those offsets (`krino new` inserts a
name into `include`), so everything else, comments and layout included,
stays byte for byte. A printer for generated forms arrives with the GUI.
Errors report `file:line:col`. An unclosed list is reported where it opened,
described by its first atoms: `"(" never closed: (rule "acme" ...)`.
Paths, keywords, regexes and rule names must be strings. Settings values,
type names, operators, sizes and durations are symbols.
### 4.2 `krino.conf`
```lisp
(include "downloads" "documents") ; dirs/<name>.conf, run in this order
(log "~/.local/state/krino/krino.log") ; optional
(defaults ; optional; any setting from 4.4
(min-age 5m))
(exclude (type iso)) ; optional, may repeat; every directory (4.6)
```
### 4.3 `dirs/<name>.conf`
```lisp
(path "~/downloads") ; required
(recursive no) ; any setting from 4.4
(ignore "*.part" "*.aria2" ".*") ; may repeat; patterns accumulate in order
(exclude (name "^keep-")) ; may repeat; this directory only (4.6)
(rule "acme"
(when (type document)
(or (content "acme ltd" "0000000000")
(name "\bacme\b"))
(not (name "^draft")))
(move "Work/Acme/{mtime:%Y}")
(stop))
```
Top-level forms may appear in any order, except that rules are evaluated in
the order written.
### 4.4 Settings
Each setting can appear in `(defaults ...)`, at the top of a directory file,
or (where marked) inside a rule. The most specific wins: rule, then
directory, then defaults, then built-in.
| Setting | Values | Built-in | In a rule |
|---|---|---|---|
| `case` | `ignore` \| `strict` | `ignore` | yes |
| `fold` | `yes` \| `no`. Strip diacritics before comparing: ą→a, ł→l, é→e, ü→u, and so on for all Latin letters | `yes` | yes |
| `recursive` | `yes` \| `no` | `no` | |
| `max-depth` | integer, 1 = root only | unlimited | |
| `min-age` | duration. Skip files modified more recently | `2m` | |
| `max-read` | size. No content extraction above this file size | `50M` | |
| `max-size` | size. Skip files larger than this, as "too big"; `0` means unlimited | unlimited | |
| `busy` | suffixes. Skip `f` when `f<suffix>` exists beside it | `".part" ".aria2" ".crdownload"` | |
| `on-conflict` | `suffix` \| `skip` \| `overwrite` (§7.4) | `suffix` | yes |
Durations: integer plus `s m h d w`. Sizes: integer plus optional `K M G T`
(powers of 1024).
### 4.5 Rules
```lisp
(rule NAME ITEM...)
```
Items, in any order except that actions run in the order written:
| Item | Meaning |
|---|---|
| `(when COND...)` | the condition. Several conditions means all must hold. A rule without `when` matches every file. An empty `(when)` is an error. |
| `(case ...)` `(fold ...)` `(on-conflict ...)` | rule-level settings |
| `(copy DEST)` | copy the file into directory DEST |
| `(move DEST)` | move the file into directory DEST |
| `(rename NAME)` | rename in place; NAME must not contain `/` |
| `(delete)` | move to Trash |
| `(delete permanent)` | unlink; cannot be undone |
| `(stop)` | once this rule matches, evaluate no further rules for this file |
A rule with only `(stop)` is an exclusion: files it matches receive no
actions from later rules.
A rule whose condition contains `(duplicate)` anywhere, including inside
`and`, `or` or `not`, cannot contain `(delete)` or `(delete permanent)`:
`krino check` and every run refuse it (§5.5).
`DEST` is a directory: relative paths are relative to the root, `~` expands,
absolute paths are allowed. It is created if missing. `DEST` and `NAME` take
placeholders (§7.3).
### 4.6 Exclusions
```lisp
(exclude (type iso img)) ; by extension
(exclude (name "^keep-")) ; by name
(exclude (type pdf) (content "confidential")) ; by content
```
`(exclude COND...)` sets files aside before any rule sees them. Its
conditions are the tests of §5.2, and all of them must hold, as in `when`;
a file matching any `exclude` form is excluded. Forms in `krino.conf` apply
to every directory and are tested first, then the directory's own. They are
compiled with the directory's `case` and `fold`. An excluded file is counted
as "excluded" in the plan, listed with the form that matched under `-v`,
and traced by `explain`; `check` lists every directory's exclusions. Since
no rule has run yet, `(matched)` is never true inside an exclude.
An exclude fails closed: when its value depends on a content test that
cannot read the file (over `max-read`, a tool missing, failing or timing
out, or a document read only in part), the exclude holds, and the file is
set aside as "(content unreadable)" with the warning (§5.4 rule 4). One that
is false whatever the text holds, such as `(exclude (content "x") (type
txt))` on a pdf, does not. A file whose format has no text at all (an
image, an archive) is not unreadable: it contains no keyword, so its
content tests are simply false and raise no warning. So is a file of no
known extension that starts as text and holds binary data further on, such
as a self-extracting installer. An exclude protects files, so a file krino
could not check is left alone. Mistakes in krino.conf's excludes are
reported even while no directory is included.
Unlike `ignore`, which never looks inside a file and never descends into an
ignored directory, an exclude can test content and size, and is evaluated
per file after the walk.
## 5. Conditions
### 5.1 Operators
`(and C...)`, `(or C...)`, `(not C)`. `and` and `or` take one or more
arguments; `not` exactly one.
### 5.2 Tests
| Test | True when |
|---|---|
| `(type T...)` | the name ends in `.T` for any T, case-insensitively. T may be a group (§A) or a multi-part suffix like `tar.gz`. |
| `(name "RE"...)` | the file name matches any regex |
| `(path "RE"...)` | the path relative to the root matches any regex |
| `(content "KW"...)` | the extracted text contains any keyword (§6) |
| `(size OP SIZE)` | OP is `> >= < <= =` |
| `(age OP DURATION)` | age by modification time |
| `(duplicate)` | another scanned file has identical content, and this one is not the original (§5.5) |
| `(duplicate "DIR"...)` | the same, also comparing against files under those directories |
| `(matched)` | an earlier rule matched this file |
Regexes use Go's RE2 syntax: POSIX ERE plus `\d \w \s \b`, non-greedy
quantifiers, and inline flags `(?i)` `(?-i)`; no backreferences or lookaround.
### 5.3 Case and diacritics
`case` and `fold` apply to `name`, `path` and `content`, in both the file's
data and the pattern. `type` is always case-insensitive. A regex can
override `case` locally with `(?i)` or `(?-i)`. Folding decomposes letters
(Unicode NFD), drops combining marks, and maps the letters that do not
decompose (ł ø đ ħ ß æ œ and their capitals, ẞ included) to ASCII. Invalid
UTF-8 is replaced by U+FFFD before folding.
### 5.4 Evaluation
1. Every test sees **the original file**: name, path, size, mtime and
content as they were when scanning. Actions from earlier rules do not
change what later rules see.
2. Rules are evaluated in order. A matching rule adds its actions to the
file's chain. `(stop)` on a matching rule ends evaluation for that file.
3. Tests have no side effects, so `and` / `or` evaluate their arguments
cheapest first: `type`, `size`, `age` and `matched`, then `name` and
`path`, then `duplicate`, then `content`. Content is extracted at most once per file,
and only if evaluation reaches a `content` test.
4. If a file's text cannot be extracted, `content` is **unknown** and the
plan shows a warning naming the rule that wanted it. A document read only
in part (an archive entry that would not open) answers a keyword found in
what was read; one not found is unknown. `and`, `or` and `not` combine
unknowns by three-valued (Kleene) logic: `and` is false if any argument is
false, `or` true if any is true, `not` of unknown is unknown, so an
unknown only reaches the top where the text could change the answer. A
rule matches only when its condition is true; an unknown exclude holds
(§4.6). `explain` shows an unknown test as `?`. A format with no text (§6,
"anything else") is not a failure: `content` is false, silently.
### 5.5 Duplicates
Candidates are the scanned files plus, for `(duplicate "DIR"...)`, every
regular file under those directories (symlinks skipped). Files are grouped
by size. Only groups of two or more are hashed: first the first and last
64 KiB, then SHA-256 of the whole file. Empty files are never duplicates.
Two names for the same file — the same device and inode, as a hardlink
creates — are never duplicates of each other; they may still both be
duplicates of a separate identical file. The original is elected once for
all identical content, so a scanned file whose hardlink under a DIR is
elected is that original, and not a duplicate, even when the DIR also
holds a separate copy.
The **original** in each group is, in order of preference: a file under one
of the given DIRs, then the oldest by mtime, then the shortest name, then
the name that sorts first. `(duplicate)` is true for every other scanned
file in the group.
**Duplicates are found, never deleted.** Deciding which copy of identical
content to remove is left to the user, or to a tool built for it such as
jdupes. Two rules enforce this:
1. A rule whose condition contains `(duplicate)` cannot contain a `delete`
action (§4.5).
2. In a directory whose rules use `(duplicate)`, a file that is a duplicate
under any of the duplicate scopes those rules use — each distinct set of
DIRs, and the plain `(duplicate)`, looked up for the file whether or not
evaluation reached that test — gets no delete step from any rule. The
plan shows the step as "skipped: a duplicate is never deleted" and the
rest of the chain continues from the file's current path. This covers what
rule 1 cannot see: a later rule deleting through `(matched)` or through a
condition of its own. If that lookup fails, the delete is skipped too, as
"duplicate check failed, so not deleted: REASON": krino cannot show the
file is not a duplicate, so it keeps it.
Together they mean no rule can delete every copy of content that a duplicate
test in that directory can see. Under each scope every scanned file in a
group except that scope's original is a duplicate, so a scanned file that is
a duplicate under no scope must be the original of every scope, and there is
at most one such file (its hard-linked names count as that one file). Every
other copy is a duplicate somewhere, and a duplicate is never deleted. A file
displaced by `(on-conflict overwrite)` is not covered; it goes to the Trash,
and `krino undo` restores it (§7.4).
The way to deal with duplicates is to move them aside and decide later:
```lisp
(rule "dupes"
(when (duplicate "~/docs/Archive"))
(move "~/.dupes/")
(stop))
```
Every move is logged (§9), so `krino undo` puts them back.
Duplicate conditions with different scopes do not share an original: each
elects from its own candidates, so two such rules can each select a
different copy of the same content, and between them move every copy aside.
Nothing is deleted, and undo restores them.
## 6. Content extraction
| Format | Method |
|---|---|
| text | known text extensions; any other file whose first 8 KiB start with a UTF-16 BOM, or are valid UTF-8 with no NUL byte and so is the rest of it (one that turns binary further on, like a self-extracting installer, has no content) |
| pdf | `pdftotext -q -enc UTF-8 FILE -`, 30 s timeout |
| docx xlsx pptx odt ods odp epub | zip plus streaming XML, Go standard library |
| html xml | tags removed, entities decoded |
| doc | `antiword`, else `catdoc` |
| xls / ppt | `xls2csv` / `catppt` (both from catdoc) |
| anything else | no content |
External tools are optional. Each is looked up once per run; `krino check`
lists which were found. Files above `max-read` are not extracted. Tools run
without a shell, get absolute paths only (so a name starting with `-` is
never read as an option), and are killed at the timeout.
Before matching, text and keywords are normalised the same way: case (if
`ignore`), fold (if `yes`), and runs of whitespace collapsed to one space,
so a keyword split across lines in a PDF still matches. Matching is
substring: `"acme"` matches `"acmeco"`. Words hyphenated across lines in a
PDF are not rejoined.
### 6.1 Keyword cache
Extraction is nearly all of a run's time, so krino remembers what it found.
For each file it extracted, `<name>.cache` records which of the
directory's content keywords the text contains, against the full list of
keywords it was checked against. It stores no text and no file names; a
file is known by device, inode, size, modification time and extension
(the extension picks the extractor), which a move within one filesystem
keeps. The keywords themselves are stored, normalised the way the test
compares them (case, folding, whitespace), and only those the directory
still uses.
- When a file is extracted, every content keyword of its directory is
answered at once, so one extraction serves every rule and exclude.
- A content test is answered from the cache when the file's entry covers
all of the test's keywords. Otherwise the file is extracted and its entry
replaced: a changed file, or a new keyword, costs one extraction.
- Files above `max-read` are refused before the cache is consulted.
Failures (unreadable, tool missing, timeout) are never cached.
- The cache is discarded whole when its fingerprint changes: a tool
installed, removed or replaced; krino's extraction code (`extract.Version`)
or normalisation (`norm.Version`) changing; the Go release or the Unicode
tables changing; or the directory's `max-read` changing.
- Planning a run (`-n` included) reads the cache and writes it back holding
only files still in the directory, under the directory's lock, via a
temporary file renamed into place; the directory is made 0700, the file
0600. `explain` reads it and never writes. A directory with no content
tests has no cache, and an old one is removed.
- Known gaps: a file edited in place with its size and modification time
deliberately preserved keeps its old answers, and so does a new file that
reuses a deleted file's inode with the same size, extension and preserved
modification time. A `.doc` that `antiword` failed on (a timeout, say) and
`catdoc` then read is cached with `catdoc`'s answers. Deleting
`~/.cache/krino` resets everything.
## 7. Actions
### 7.1 Chains
A file's chain is the actions of every matching rule, in rule order, and
within a rule in the order written. The executor tracks the file's current
path:
- `copy` leaves the file where it is;
- `rename` changes its name, `move` its directory; later steps use the new path;
- `delete` ends the chain. Steps after it appear in the plan as
"skipped: deleted by rule X". A `delete` of a duplicate is itself skipped
(§5.5) and does not end the chain.
The plan warns when a chain moves a file more than once; that is usually a
missing `(stop)`.
### 7.2 How each action is carried out
- **move:** `rename(2)` when source and destination are on the same
filesystem. Otherwise: copy to a temporary file in the destination
directory, fsync, rename into place, then remove the source. If any step
fails the source is untouched and the temporary file removed.
- **copy:** to a temporary file in the destination, then rename into place.
Mode and mtime are preserved.
- **rename:** `rename(2)` within the same directory.
- **delete:** into the freedesktop.org Trash in `$XDG_DATA_HOME/Trash`
(`files/` plus a `.trashinfo` in `info/`). A file on a different
filesystem from the Trash is not trashed: the step fails with a message
suggesting `(delete permanent)` or a move. The Trash layout is the same on
Linux, FreeBSD and OpenBSD.
- **delete permanent:** `unlink(2)`.
Before each step the executor checks that the source still exists with the
size and mtime recorded in the plan. If not, the step fails as "changed
since plan" and the rest of that file's chain is skipped.
### 7.3 Placeholders
| Placeholder | Value |
|---|---|
| `{name}` | the current file name |
| `{stem}` | the name without its last extension |
| `{ext}` | the last extension with its dot, e.g. `.pdf`; empty if none |
| `{1}` … `{9}` | capture groups of the first `name` test that matched in this rule |
| `{mtime:FMT}` | the file's modification time |
| `{now:FMT}` | the start of the run |
| `{{` `}}` | literal braces |
FMT is a strftime subset: `%Y %m %d %H %M %S %j %%`.
Captures come from the first `name` test, not inside a `not`, that was true
while evaluating this rule. Tests of equal cost keep their written order, so
"first" is well defined. `krino check` rejects a rule that uses `{N}` unless
every such `name` test in it has at least N groups, and a rule that uses
`{N}` with no `name` test at all.
With `fold` on, a `name` test matches the folded name, but its captures are
the original name's characters: `(name "^(.+)-faktura")` on
`Łódź-faktura.pdf` makes `{1}` `Łódź`, not `Lodz`. A capture that ends inside
a letter folding to two (`ß` to `ss`) takes the whole letter.
### 7.4 Conflicts
When the target already exists:
- `suffix` (default): use `stem_1.ext`, `stem_2.ext`, ...
- `skip`: skip this step; the chain continues from the file's current path
- `overwrite`: move the existing target to Trash first (logged, so undo
restores it), then proceed
A `copy` whose target has identical content is skipped as "already there",
whatever the policy, so a backup rule can run every time.
A step whose target is the file itself — a rule whose `DEST` resolves to the
directory the file already sits in, or a `rename` to the name it already has —
is skipped as "already there" as well, whatever the policy. Without that the
file's own existence would read as a conflict with itself and the plan would
rename it to `stem_1` on every run.
Conflicts between files in the same plan are resolved when planning, so the
plan shows final names. An in-plan claim is never displaced: when `overwrite`
finds a target another step of the same plan has already claimed, it takes the
next free name instead, because that path will hold the other step's output by
the time either runs, and displacing it would destroy that step's result. A
real run applies each directory before planning the next, so a later
directory sees the earlier one's result on disk and its claims end with it.
A dry run (`-n`) applies nothing: its claim set spans every directory, so two
configured directories cannot show the same final name, but each directory is
otherwise planned as if no earlier one had been applied (a file the first
moves into the second is not in the second's plan). The executor re-checks
at execution time; if the name has to change, the log records the actual
name.
## 8. Plan, review, approval
### 8.1 What is scanned
- The root, recursively if `recursive yes`, to `max-depth`.
- Symlinks are never followed; symlinked files are skipped.
- `ignore` patterns use gitignore semantics: `*`, `**`, `?`, `[...]`; a
leading `/` anchors to the root; a trailing `/` matches directories only;
`!` re-includes; a pattern without `/` matches at any depth; the last
matching pattern wins. Ignored directories are not descended into.
- Always ignored: every rule `DEST` that lies inside the root, the Trash, and
the config directory. For a `DEST` with placeholders, the part before the
first placeholder is ignored: `Work/Acme/{mtime:%Y}` ignores `Work/Acme/`
(`{{` and `}}` are literal text there). `-v` lists each such destination
that exists, under "not scanned".
Known limitation: a `DEST` whose *first* path component is itself a
placeholder (`{ext}`, `{mtime:%Y}`) has no static prefix, so nothing can be
excluded before the walk, and krino would re-examine its own output; plan 3
closes this by treating a file already at its computed destination as a
no-op.
- Skipped: files newer than `min-age` ("too new"), larger than `max-size`
("too big"), or with a `busy` sibling ("busy").
### 8.2 Display
One directory at a time, one block per file that has steps: its number and
name, then each step under its kind word, then after each rule's steps that
rule's name and the reason it matched (left out for a rule with no
condition):
```
krino: downloads ~/downloads
266 scanned · 41 to act on · 2 warnings · 13.67s
1 scan001.pdf
move → Work/Acme/2026/
rule acme
because content "acme ltd"
2 fv_123.pdf
copy → ~/backup/invoices/2026/
rule backup
because content "invoice"
move → Work/Acme/2026/
rule acme
because name \bacme\b
3 IMG_2031.JPG
rename → 2026-09-01_IMG_2031.JPG
move → Photos/2026/
rule photos
because type image
4 setup-1.2.deb
DELETE permanently
rule old-pkgs
because age 94d
5 report (1).pdf
move → ~/.dupes/
rule dupes
because duplicate of ~/docs/work/report.pdf
warnings
brochure.doc content unreadable: antiword/catdoc not installed (rule acme)
big.pdf content unreadable: 120M > max-read 50M
not acted on: 3 busy · 12 ignored · 210 unmatched (-v lists them)
[a] apply all [c] choose per file [s] skip this directory [q] quit
```
- On a terminal, every line is wrapped to its width: a long name, path or
reason continues on lines indented under its own first column, breaking
after a space, `/`, `_` or `-` where it can. Output that is not a
terminal is never wrapped, so a plan redirected to a file keeps each
field on one line.
- Paths are shown relative to the directory when inside it, and with `~`
for the home directory otherwise.
- Colours use only the terminal's 16-colour ANSI palette plus bold and
faint, never 256-colour or RGB values, so the terminal's theme decides what
they look like. They appear only on a terminal, and never when `NO_COLOR`
is set or `--no-color` is given. What is styled:
| Element | Style |
|---|---|
| directory header `krino: NAME PATH`, and `krino: undo RUN` | bold |
| `copy`, `move`, `rename` | green |
| `trash` | yellow |
| `DELETE permanently`, and undo's `refused:` | bold red |
| a skipped step, and a match reason | faint |
| rule name | blue |
| the `warnings` heading and its lines | yellow |
| outcome line | the applied count green when above 0, the failed count red when above 0 |
| prompt keys `[a]` `[y]` … | bold |
| the choice echoed in review, `→ yes` … | red |
| `krino log`'s `(undone)` | faint |
Permanent deletes are always marked in capitals, so they stand out without
colour too.
- A plan taller than the terminal is shown through `$PAGER` (default
`less -FRX`) and the prompt follows when the pager exits. `-P`
(`--no-pager`) prints it straight to the terminal instead.
- Keys are read one at a time without Enter.
- `[s]` applies nothing in this directory and moves on to the next one.
`[q]` stops krino; directories already applied in this run stay applied
and can be undone with `krino undo`.
### 8.3 Choosing per file
Each file shows the same block the plan shows, under its position in the
list:
```
[2/41] fv_123.pdf
copy → ~/backup/invoices/2026/
rule backup
because content "invoice"
move → Work/Acme/2026/
rule acme
because name \bacme\b
[y] yes [n] no [a] yes to this and all remaining [t] trash [d] delete permanently [w] write, apply chosen so far [q] quit, apply nothing
```
Approval is per file: the whole chain or none of it. `t` and `d` approve
the file with its chain replaced by a single step on the file itself: to the
Trash, or deleted permanently. They are the user's own decision, logged
under the rule name `(review)`; `(review)` steps are not subject to the
duplicate protection of §5.5, which governs rules. `d` asks
`delete NAME permanently? [y/N]`, and any key but `y` deletes nothing and
asks about the file again. Each choice is confirmed on its own red line
under the file (`→ yes`, `→ no`, `→ trash`, `→ DELETE permanently`,
`→ yes, and all remaining`). Enter is ignored.
`w` applies what was decided so far and stops krino, without planning or
asking about any later directory, so a long review can be done a session at
a time. Files answered `n` are logged as declined; files never reached are
not logged and are counted separately: `20 applied · 0 failed · 3 declined ·
139 not reviewed`. Nothing about a declined file is remembered: the next run
asks about it again (an `exclude` stops that). `q` applies nothing in this
directory, choices included, and stops krino.
### 8.4 Modes
- default: plan, review, approve.
- `-y`: print the plan and apply everything.
- `-n`: print the plan and exit. `-n --json` prints it as JSON.
- If stdin is not a terminal and neither `-y` nor `-n` is given, krino
refuses rather than guess.
## 9. Log
Append-only, `$XDG_STATE_HOME/krino/krino.log`, one line per event,
tab-separated:
```
time run dir file step action status rule src dst size mtime detail
```
- `time`: RFC 3339 with offset. `run`: e.g. `20260911T100203-4f2a`.
- `action`: `run-start`, `mkdir`, `copy`, `move`, `rename`, `trash`,
`delete`, `displace` (a target trashed by `overwrite`), `run-end`, and the
`undo-` forms of each.
- `status`: `ok`, `failed`, `skipped`, `declined`.
- `size` and `mtime` describe the file at `dst` after the step, so undo can
tell whether it has been touched since.
- Fields are escaped: `\t`, `\n`, `\\`, and `\xNN` for control bytes and
bytes that are not valid UTF-8. Every file name round-trips exactly, and
the file stays readable with `grep` and `awk`.
Dry runs are not logged. Files never acted on (unmatched, ignored, busy) are
not logged; declined files are.
## 10. Undo
- `krino log` lists recent runs: id, time, directories, counts.
- `krino undo` reverses the most recent run, in every directory it
touched. An older run is undone by naming it: `krino undo RUN`. Undo runs
cannot themselves be undone: naming one is refused.
- A run is undone as far as it can be. Reversals an earlier undo of the
same run completed are not offered again, so an undo that stopped part
way (a refused or failed step, an interrupt) is finished by undoing the
run once more; when the most recent run is itself an undo, plain
`krino undo` does exactly that for the run it undid.
- A damaged log line (a crash or a full disk cutting it) refuses only the
file it names, when that file can still be read from the line; the rest
of the run is reversed. A line of another run is ignored. Only a line
whose file cannot be read refuses the whole run.
- A file is identified by its directory and its path within it, so two
directories' files of the same name are reversed apart. A file that one
directory's rules move into another included directory, which sorts it
again in the same run, is two files to undo, reversed in the order the
run touched them; the first may then be refused as missing (known
limitation).
- Undo builds a plan like any other, shown and approved the same way
(`-y` and `-n` apply). Its per-file prompt has no `t` or `d`.
Reversals, last step first within each file:
| Step | Reversal | Refused when |
|---|---|---|
| move, rename | move `dst` back to `src` | `dst` missing or changed (size/mtime), or `src` now exists |
| copy | move `dst` to Trash | `dst` missing or changed |
| trash | restore from Trash, remove the `.trashinfo` | the Trash entry is gone, is not the file this run trashed (size/mtime), or its `.trashinfo` now records another original path; or `src` now exists |
| displace | restore the displaced target from Trash | as above |
| mkdir | remove the directory if empty | not empty |
| delete permanent | none; reported as not undoable | always |
If a reversal is refused, the earlier steps of that file's chain are not
reversed either, so no file is left half undone. Removing a directory that is
not empty is the exception: it means another file still lives there, not that
the world changed under us, so that refusal is recorded and the rest of the
file's reversal proceeds. Undo runs are logged like any other run.
Known limitation: a reversal recreates the directories it needs to move a
file back and removes them again only when the file's reversal completes.
An undo interrupted or failed after recreating one, and never finished for
that file, leaves it behind, empty.
## 11. Command line
```
krino [-y | -n] [-v] [--json] [-c FILE] [NAME...]
plan and apply every included directory, or only the named ones
krino init create ~/.config/krino with krino.conf and template.conf
krino new NAME PATH create dirs/NAME.conf from the template and include it
krino check [NAME...] validate config, list rules and available extractors
krino explain FILE evaluate every rule against FILE, show each test's result
krino log [-n N] list recent runs
krino undo [RUN] reverse a run (default: the last one)
-y apply without asking
-n dry run: show the plan, change no file (krino's empty state directory
may be created, the keyword cache refreshed)
-v also list unmatched, ignored and busy files, and destinations not scanned; full match reasons
--json with -n: the plan as JSON (format unstable before 1.0); invalid
UTF-8 in a name becomes U+FFFD
-c FILE use FILE instead of ~/.config/krino/krino.conf
--no-color never colour the output, as when NO_COLOR is set
-P, --no-pager print the plan straight out, never through the pager
--min-age D for this run, every directory's min-age is D (0, 30s, 1d);
sorting and explain only; a future modification time counts as age 0
-h, --help help
--version print "krino VERSION"
```
Exit status: 0 success, including nothing to do and everything declined;
1 one or more steps failed; 2 usage or config error; 130 interrupted.
A config error anywhere stops the whole run before any scanning: krino never
acts on a config it only partly understood. Ctrl-C (SIGTERM, or SIGHUP unless
started under `nohup`) during apply finishes the current step, logs it, skips
the rest of that file's chain, and stops; during an undo it finishes reversing
the file it is on, then stops. A second interrupt exits at once:
the step in flight is not logged, and a copy in progress can leave a
temporary `.krino-*` file and an empty directory behind. A second krino on the same directory waits
for the lock, or fails immediately with `-y` (so a cron job never piles up).
## 12. Architecture
One binary, frontends kept thin. Everything the GUI will need lives in the
engine and returns data.
```
cmd/krino/ flags, subcommands, exit codes
internal/sexp/ reader: syntax tree with positions and byte offsets
internal/config/ syntax tree → typed config; validation; embedded template and defaults
internal/cond/ condition tree: compile, cost ordering, evaluation, explain trace
internal/scan/ walking, busy detection
internal/ignore/ gitignore-compatible path matcher
internal/extract/ text extraction
internal/norm/ text normalisation: case, diacritics, white space
internal/dup/ duplicate index
internal/plan/ files × rules → chains; placeholders; conflicts; JSON
internal/apply/ executing approved steps; cross-filesystem moves
internal/trash/ freedesktop.org Trash
internal/journal/ log writer and reader, run ids, undo planning
internal/engine/ the facade: Load, Check, Plan, Apply, Explain, Runs, PlanUndo, NewDir, Init
internal/tui/ terminal rendering, pager, key prompts
```
Rules for the GUI to come:
1. Every operation is an `engine` call that returns data. The CLI only
renders it.
2. `Apply` takes a plan plus the set of approved files. The CLI's `a` and `c`
and a GUI's checkboxes are the same call.
3. The reader records byte offsets, so a GUI can replace one form's text and
leave the rest of the file, comments included, untouched.
4. `Explain` returns the condition tree with each test's value.
5. Log and undo are in the engine.
## 13. Performance
- The walk uses `os.ReadDir` and prunes ignored directories.
- Files are evaluated by a pool of `GOMAXPROCS` workers. `pdftotext` and the
other tools run in parallel within that bound.
- Cheap tests first; content only when reached; content at most once.
- Keywords are normalised once at load time; matching is `strings.Contains`
per keyword on normalised text. Aho-Corasick only if a benchmark shows
keyword matching matters next to extraction.
- Duplicates: stat-only grouping, partial hash, then full hash.
- Measured before release on a real downloads directory of a few hundred
files, about half of them PDFs, and reported in the release notes.
**No performance target for 0.0.1.** Measured throughput is dominated by the
external extractors, not by krino: on a real folder of about 265 files, 164
of them needing `pdftotext` at roughly 80 ms each, full plans measured
between about 13 and 20 s across runs, while a synthetic tree of 4405 files
needing no extraction takes 1.09 s. A single threshold would therefore
describe poppler's speed and the shape of one folder rather than anything
about krino, and would fail or pass for reasons outside its control.
Report the measurement; promise nothing. `make bench` runs the Go
benchmarks on generated trees.
- Keyword cache (§6.1, 0.0.5): measured on the same kind of folder, 170
files, a dry run took 12.78 s with an empty cache and 0.13 s with a warm
one, printing the identical plan. Without the cache, 59 `pdftotext` runs
took 21.5 s of wall time between them, one PDF 14.7 s alone; with
extraction answered instantly, the run took 0.25 s.
## 14. Build, dependencies, release
- Go 1.25 or newer (`golang.org/x/text` v0.39 fixed an infinite loop on
invalid input, GO-2026-5970, and needs it). `go.mod` pins the toolchain,
`go1.26.8`, so builds carry the standard library's security fixes: an
older `go` with `GOTOOLCHAIN=auto`, the default, downloads it itself.
`CGO_ENABLED=0`: static binaries on Linux and FreeBSD;
on OpenBSD Go links against the system libc, as that platform requires.
- Go dependencies: `golang.org/x/term` (key-at-a-time input) and
`golang.org/x/text` (Unicode normalisation for `fold`). Everything else is
the standard library, including the s-expression reader and the gitignore
matcher.
- `make` builds; Go downloads the module dependencies itself. It then checks
for the optional extractors and prints what is missing.
- `make deps` installs the extractors with the system package manager,
asking for privileges: `apt-get install poppler-utils catdoc antiword`
(Debian, Devuan), `pkg install poppler-utils antiword` (FreeBSD; catdoc is
left out because it pulls in tcl/tk), `pkg_add poppler-utils catdoc antiword`
(OpenBSD). Plain `make` never asks for privileges.
- Targets: `help build install uninstall test vet fmt lint ci bench cross
release deps install-hooks`. `install` puts the binary in
`$PREFIX/bin` (default `~/.local`), man pages in `$PREFIX/share/man`,
examples in `$PREFIX/share/doc/krino/examples`.
- `make cross`: linux/amd64, linux/arm64, freebsd/amd64, openbsd/amd64.
- `make release VERSION=0.0.1`: runs `ci` and `cross`, writes tarballs and
`SHA256SUMS` into `dist/`, and creates the annotated tag **`v0.0.1`** (the
`v` is required for `go install ...@v0.0.1`). It never pushes.
- Version stamped with `-ldflags -X main.version=...`.
- `scripts/leak-check` keeps personal data out of the repository. `make ci`
runs it, and so does the pre-commit hook that `make install-hooks`
installs. It checks the staged files against built-in patterns (the
current user's home directory, email addresses) and against a private
pattern list whose path is set with `git config krino.leakpatterns FILE`.
The list lives outside the repository, so the secrets it names are never
published either. Without it, only the built-in patterns run.
- `/local/` is ignored by git: a place for personal configs and test data
next to the code.
Repository contents for 0.0.1: `README.md` (60-second quickstart),
`LICENSE`, `CHANGELOG.md`, `Makefile`, `go.mod`, `cmd/`, `internal/`, `scripts/`,
`man/krino.1`, `man/krino.conf.5`, `docs/design.md`,
`docs/sexp-primer.md`, `examples/` (by type, invoices by tax number with
placeholder numbers, screenshots by date, cleaning up old installers).
## 15. Testing
- **Unit tests** per package, table-driven.
- **sexp:** positions and offsets (every list's offsets point at its own
parentheses), error positions, and a fuzz target for the reader.
- **gitignore:** an oracle test that builds trees and compares every
path against `git check-ignore --no-index` (skipped if git is absent).
- **Conditions:** truth tables; a property test that random condition trees
give the same result with and without cost reordering.
- **Extraction:** the tests generate their fixtures in temporary
directories and use fake tools for PDF and the legacy formats (doc, xls,
ppt); the real `pdftotext` test skips when the tool is missing. Real
office-suite files and real legacy-tool runs are not tested in 0.0.1.
- **Plan, apply, undo:** in temporary directories: build a tree, plan,
apply, check; undo; check the tree is identical to the start (paths,
contents, modes, mtimes). A cross-filesystem move test runs when a tmpfs is
available on a different device, and skips otherwise.
- **Trash:** with `XDG_DATA_HOME` pointed at a temporary directory.
- **Duplicates never deleted:** `krino check` refuses `(duplicate)` with a
`delete` action, including inside `or` and `not`; and no duplicate is
deleted through `(matched)`, through another rule's own condition, through
a duplicate test that evaluation skipped, or with two scopes whose
originals differ. Each case plans and applies in a temporary directory and
checks that every copy is still on disk.
- **CLI:** golden files for plan rendering without colour; exit codes.
With colour on, each styled element of §8.2 carries its escape and the
columns still line up; `--no-color`, before or after a subcommand, and
`NO_COLOR` each give output with no escape byte.
- **Parity check before release:** run `krino -n` beside the prototype's
dry run over the same real directory, and explain every difference. The
rules and the results stay outside the repository.
`make ci` is the gate: gofmt, vet, tests, the leak check, man page lint.
### 15.1 Hostile input
krino runs on folders the internet writes into, so it treats as hostile
every file name, every file's contents, and the output of the tools it runs
on them. The configuration is trusted: it is the user's own. The state
files (the log, the keyword cache, the Trash) are the user's own too: krino
survives their corruption — a truncated line, a damaged cache, a
hand-edited trashinfo — but does not defend against another local process
with write access to them, which could already do anything krino can.
What that means, and the tests that hold it (plans 8 and 9):
- A name never controls the terminal. Everything printed from a name, path,
reason, warning, error, explain trace, JSON plan or log field is escaped:
C0 controls, DEL, C1 controls, every Unicode bidirectional control
(embeddings, overrides, isolates and the marks), line and paragraph
separators, and invalid UTF-8 (`\x1b`, `\u202e`). All of stderr goes
through the same escaping. Invisible format characters that are not
controls are printed as they are (known limitation).
- A name never redirects a step. A destination with a placeholder must
resolve at or under the directory its text names before the first
placeholder, so a capture of "..", "~" or nothing cannot move it
elsewhere; a rename whose placeholders produce "", "." or ".." is skipped
with a reason.
- A file is acted on only while it is still the file that was planned:
same size and modification time, still a regular file (not a symlink put
in its place), and, at its planned path, the same inode. A step that had
to land at a free name stops the rest of its chain. Overwrite trashes only
a regular file, never a directory or another file of the same plan.
- Every step is logged as soon as it has run, so a run killed mid-chain can
be undone as far as it got.
- Undo restores a trash entry only while it is still the file the run put
there (size, mtime, recorded path), re-checks every file at execution
time, and can finish an undo that stopped part way.
- An exclude whose value depends on a content test that cannot read the
file holds (fails closed); a rule whose value does is not matched, so
`(not (content "x"))` never acts on a file krino could not read.
- A file reaches an external tool only as an absolute path, so a name
starting with `-` is never read as an option.
- A trash entry name from the log must be a plain name inside the Trash,
and a trashinfo `Path` must be absolute.
- Every decoder of outside data is fuzzed (`make fuzz`): the config reader,
sizes and durations, placeholders, ignore patterns, the log's escaping,
trashinfo paths, the keyword cache, markup and zip extraction, and text
normalisation.
- For generated trees and rules, apply then undo restores every file, and
nothing is lost in between (property test, `KRINO_PROPERTY_RUNS`).
- For generated trees and rules, apply then undo also restores every
directory, and the test fails if too few cases apply anything.
- Every value of the enums planning, the log and the summary depend on
(`plan.Kind`, `config.ActionKind`, `config.Conflict`, `scan.Reason`) is
handled: tests read the constants from source.
- `make vulncheck` checks the standard library and dependencies against
the Go vulnerability database; `make race` runs the tests under the race
detector.
## 16. Lessons from the prototype
krino replaces a Python prototype that sorted a downloads folder by
keyword. Its defects shaped these decisions:
| Prototype | krino |
|---|---|
| filename keywords were substrings, so a short keyword matched inside unrelated words (`"art"` matched `start.pdf`) | name tests are regexes, e.g. `\bart\b` |
| `.doc` and `.xls` content was never read (the libraries do not support those formats) | antiword or catdoc, or a warning |
| code files were listed as supported, but their content was never read | any text file is read |
| the rules lived in the repository beside the code | rules live in `~/.config/krino`, never in the repository (§14 leak check) |
| it installed Python packages with `pip --break-system-packages` | a static binary; system packages via `make deps` |
## 17. Decisions and why
| Decision | Why |
|---|---|
| Config in one central place | target directories stay clean; one place to back up; the tool never sorts its own config |
| Every matching rule runs, `(stop)` to end | chains like copy-then-move need it; `stop` gives first-match behaviour where wanted |
| Tests see the original file | the plan is fully known before approval, and files can be evaluated in parallel |
| s-expressions, not TOML, YAML or INI | conditions are the core of the config; in sexp they are structure rather than a string in a second language. No nested quoting (TOML's `'''` around single-quoted regexes), no precedence, actions in written order. YAML turns `*` and `!` in ignore patterns into syntax; INI has no standard escaping |
| `pdftotext` rather than a Go PDF library | best text quality of the open tools, fast, packaged on all three systems |
| Delete to Trash by default | recoverable, and undo can restore it |
| Duplicates are found, never deleted | duplicate conditions with different scopes elect different originals, so deleting duplicates could remove every copy; moving them aside is always recoverable, and choosing which copy to delete belongs to the user or a tool built for it (jdupes). Decided 2026-09-14 after 0.0.1 shipped with the hazard documented |
| A new repository rather than the prototype's | the prototype's history holds personal data; the prototype keeps working until krino reaches parity |
| Personal configuration never enters the repository | rules hold private data; the leak check enforces it on every commit and in `make ci` |
## 18. Settled before implementation
1. **Hosting: local only for now.** The module path is `krino`. When a
remote is chosen, `go mod edit -module <path>` and one `sed` over the
`"krino/internal/...` imports rename it; nothing else depends on it.
`go install ...@v0.0.1` works only after that.
2. **License: GPL-3.0-or-later.** Full text in `LICENSE`; every Go file,
shell script and man page carries an
`SPDX-License-Identifier: GPL-3.0-or-later` line.
## Appendix A: type groups
| Group | Extensions |
|---|---|
| `image` | jpg jpeg png gif webp bmp tif tiff heic heif avif svg ico raw cr2 nef arw dng |
| `video` | mp4 mkv webm mov avi m4v mpg mpeg wmv flv 3gp |
| `audio` | mp3 flac ogg opus m4a aac wav wma aiff |
| `archive` | zip tar gz tgz bz2 tbz2 xz txz zst 7z rar lz lzma cpio |
| `document` | pdf doc docx odt rtf txt md tex |
| `spreadsheet` | xls xlsx ods csv tsv |
| `presentation` | ppt pptx odp |
| `ebook` | epub mobi azw azw3 fb2 djvu |
| `code` | go c h cpp hpp py sh js ts rs java rb pl lua html css json yaml yml toml xml sql |
| `text` | txt md log csv tsv json yaml yml toml xml ini conf |
| `package` | deb rpm apk appimage exe msi flatpak snap |
| `font` | ttf otf woff woff2 |
Groups overlap; a file can belong to several.
|