aboutsummaryrefslogtreecommitdiff
path: root/docs/design.md
blob: 437b602c16e664da0eeeb40125fff8723ab24a02 (plain) (blame)
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
# 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).

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
```

`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))
```

### 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

(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` | |
| `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).

## 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) to ASCII.

### 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 false and the plan
   shows a warning naming the rule that wanted it.

### 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** 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, or detected from the first 8 KiB: valid UTF-8, or UTF-16 with a BOM, and no NUL bytes |
| 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.

## 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.

### 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. The
claim set spans the whole run, so two configured directories cannot plan the
same final name. 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/`.
  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 as busy: files newer than `min-age`, or with a `busy` sibling.

### 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 |
  | `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  [d] done, apply chosen so far  [q] quit, apply nothing
```

Approval is per file: the whole chain or none of it.

### 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; if that run is itself an undo run, it is refused. An older run
  is undone by naming it: `krino undo RUN`. Undo runs cannot themselves be
  undone.
- Undo builds a plan like any other, shown and approved the same way
  (`-y` and `-n` apply).

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, 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.

## 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 nothing
-v            also list unmatched, ignored and busy files; full match reasons
--json        with -n: the plan as JSON (format unstable before 1.0)
-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
-h, --help    help
--version     print "krino 0.0.1"
```

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 during apply finishes the
current step, logs it, and stops. 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.

## 14. Build, dependencies, release

- Go 1.24 or newer. `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.

## 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.