fettle 0.1.4

Put Claude Code's file tools in fine fettle. Removes artificial size limits on reads and the read-before-write gate on writes.
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
# Fettle Enhanced Write Protocol -- Architectural Blueprint

*Drafted by Drafty von Blueprint. The napkin is the blueprint.*

---

## 1. System Overview

Fettle currently writes files unconditionally on every Write hook interception.
The enhanced protocol introduces **awareness of what changed** and **protection
against large unreviewed mutations**. Two tiers, one goal: the model always
knows exactly what happened, and large changes get a human-visible diff before
they land.

```
                      Write hook fires
                            |
                    [extract path + content]
                            |
                    [file exists on disk?]
                     /              \
                   NO               YES
                   |                 |
             [new file]        [compute diff]
             write direct       /          \
             return ok      SMALL           LARGE
                            diff            diff
                             |               |
                        [backup]         [backup]
                        [write]          [stage only]
                        [return          [return diff
                         summary]         + session ID
                                          + confirm
                                          instructions]
```

---

## 2. Threshold Design

### The Metric: Changed Lines

Count the number of lines that are insertions or deletions in the diff. A
changed line (modification) counts as 2 (one deletion + one insertion), which
is how unified diffs naturally represent them.

### The Threshold: Adaptive, Not Fixed

A flat "20 lines" threshold is wrong. Rewriting 20 of 25 lines in a file is
a total rewrite. Changing 20 lines in a 2,000-line file is a surgical edit.
Both should be treated differently.

**Rule:**

```
tier = if changed_lines <= ABSOLUTE_FLOOR -> Tier 1 (always small)
       if changed_lines >= ABSOLUTE_CEIL  -> Tier 2 (always large)
       if changed_lines / total_lines > RATIO_THRESHOLD -> Tier 2
       else -> Tier 1
```

**Default values:**

| Parameter | Value | Env Override |
|-----------|-------|-------------|
| `ABSOLUTE_FLOOR` | 10 lines | `FETTLE_WRITE_FLOOR` |
| `ABSOLUTE_CEIL` | 80 lines | `FETTLE_WRITE_CEIL` |
| `RATIO_THRESHOLD` | 0.40 (40%) | `FETTLE_WRITE_RATIO` |

Rationale:
- Under 10 changed lines: always safe to apply directly. This covers the vast
  majority of edits (fix a typo, add an import, tweak a constant).
- Over 80 changed lines: always stage for review regardless of file size.
  This is a substantial rewrite.
- Between 10-80: check the ratio. Changing 30 lines in a 50-line file (60%)
  triggers staging. Changing 30 lines in a 500-line file (6%) does not.
- 40% ratio threshold: if you are rewriting more than 40% of a file, the
  model should see the diff before the write lands.

**New file bypass:** When a file does not exist on disk, there is no diff to
compute and nothing to protect. Write directly. Always Tier 1.

---

## 3. Diff Computation

### Approach: In-Process, No External Dependencies

Use the `similar` crate (MIT licensed, pure Rust, no dependencies worth
worrying about). It provides line-level diffs with unified diff output.
No shelling out to `diff(1)` -- that adds process overhead on every write
hook and introduces a platform dependency.

**Crate:** `similar = "2"` added to `[dependencies]` in Cargo.toml.

### Diff Algorithm

`similar` uses the Myers diff algorithm by default, which is the same
algorithm used by `git diff`. This is correct for our use case.

### Computing the Diff

```rust
// Pseudostructure, not final code
fn compute_diff(old: &str, new: &str) -> DiffResult {
    let diff = similar::TextDiff::from_lines(old, new);
    let stats = DiffStats {
        insertions: /* count of Insert changes */,
        deletions:  /* count of Delete changes */,
        total_old_lines: old.lines().count(),
    };
    let unified = diff.unified_diff()
        .context_radius(3)
        .header(/* old path, new path */)
        .to_string();
    DiffResult { stats, unified }
}
```

### What Gets Shown to Claude

**Tier 1 (small, applied immediately):** A brief summary line, not the full
diff. Token-efficient. The model already knows what it wrote -- it just needs
confirmation that the write landed.

```
fettle: Wrote /path/to/file.rs (2.1KB, 45 lines, +3 -2 ~1 changed)
  backup: ~/.wonka/bench/fettle/backups/file.rs.20260312T143022
```

Format: `+N` insertions, `-N` deletions, `~N` is a convenience count of
modified lines (min of insertions, deletions -- lines that were changed
rather than purely added/removed). This is compact and informative.

**Tier 2 (large, staged for confirmation):** The full unified diff, plus
instructions. The diff IS the value here -- the model needs to see what
will change without re-reading the entire file.

```
fettle: Staged write for /path/to/file.rs (session: a1b2c3)
  +47 -32 lines changed (62% of file)
  backup: ~/.wonka/bench/fettle/backups/file.rs.20260312T143022

--- /path/to/file.rs (current)
+++ /path/to/file.rs (proposed)
@@ -10,7 +10,9 @@
 unchanged context
-old line
+new line
+another new line
 more context
...

To apply: run `fettle confirm a1b2c3` via Bash
To discard: run `fettle discard a1b2c3` via Bash
```

### Diff Context Radius

3 lines of context around each hunk. This is the git default and provides
enough orientation without bloating the output. The `similar` crate supports
configurable context radius natively.

### Binary Files

If the file exists and its content is not valid UTF-8, skip diffing entirely.
Write directly (Tier 1 behavior) with a note: `fettle: Wrote binary file
/path/to/file (N bytes)`. Binary diffs are meaningless to the model. No
backup for binary files -- they are typically generated artifacts.

Actually, reconsider: binary files written by Claude Code are rare and
usually intentional (e.g., writing a small binary test fixture). Backing
them up is cheap. Do back them up if the file previously existed.

---

## 4. Backup System

### Location

```
~/.wonka/bench/fettle/backups/
```

This follows the factory's bench tier (session scratch, cleared between
sessions). Backups are ephemeral protection, not version control.

### Naming Scheme

```
{filename}.{timestamp}
```

Where timestamp is `YYYYMMDD_HHMMSS_NNN` (NNN = milliseconds for uniqueness
within the same second). The filename is the basename only -- the full
original path is stored in a sidecar metadata file.

Example:
```
~/.wonka/bench/fettle/backups/
  main.rs.20260312_143022_471
  main.rs.20260312_143022_471.meta    <- JSON: {"original": "/home/user/project/src/main.rs"}
```

The `.meta` sidecar is tiny (one JSON object) and enables rollback without
guessing which `/src/main.rs` this backup belongs to.

### Retention Policy

- **Max age:** 24 hours. Backups older than 24h are deleted on next write.
- **Max count:** 100 backups total. If exceeded, oldest are purged first.
- **Purge trigger:** Every write operation. Before creating a new backup,
  scan the backup directory and remove stale entries. This is a fast `readdir`
  + timestamp parse -- no meaningful overhead.
- **No background daemon.** Purging is opportunistic, happens inline during
  write operations. If fettle is never called again, old backups sit harmlessly
  until manually cleaned.

### Backup Flow

1. Check if file exists on disk.
2. If yes: read current content, write to backup path, write `.meta` sidecar.
3. If backup write fails: log warning but proceed with the write. Backup
   failure should never block a write. The backup is a safety net, not a gate.

---

## 5. Staging System (Tier 2)

### Location

```
/tmp/fettle-stage/
```

Using `/tmp` because staged content is truly ephemeral -- it matters for
minutes, not hours. System temp cleanup handles abandoned sessions. This also
avoids cluttering user home directories with transient data.

### Session Structure

```
/tmp/fettle-stage/
  {session-id}/
    content          <- the proposed file content
    metadata.json    <- session metadata
```

### Session ID

8-character lowercase hex string derived from the current timestamp and file
path. Not cryptographically random -- just unique enough to avoid collisions
within a single user's concurrent sessions.

Generation: hash (e.g., FNV or simple truncated hash) of
`"{timestamp_nanos}:{file_path}"`, take first 8 hex chars. No external crate
needed -- Rust's standard library can do this with basic byte manipulation,
or use the `fastrand` crate already implied by the dependency tree.

Actually, looking at Cargo.toml, there are no random crates in deps. Use
timestamp nanos + path bytes, XOR-fold to 32 bits, format as hex. Simple,
deterministic-enough, no new dependencies.

### Metadata

```json
{
  "session_id": "a1b2c3d4",
  "target_path": "/home/user/project/src/main.rs",
  "backup_path": "~/.wonka/bench/fettle/backups/main.rs.20260312_143022_471",
  "created_at": "2026-03-12T14:30:22.471Z",
  "diff_summary": "+47 -32",
  "status": "pending"
}
```

Status values: `pending`, `applied`, `discarded`, `expired`.

### Expiry

- **TTL:** 10 minutes. If not confirmed within 10 minutes, the session is
  considered abandoned.
- **Expiry check:** On `fettle confirm` or `fettle discard`, check timestamp.
  If expired, return an error: `"Session a1b2c3d4 expired (created 12 minutes
  ago). Re-run the Write to generate a new session."`.
- **Cleanup:** On every staging operation, scan `/tmp/fettle-stage/` and
  remove directories older than 10 minutes. Same opportunistic purge strategy
  as backups.

### Concurrency

What if two writes target the same file simultaneously? This is unlikely in
practice (Claude Code is single-threaded in its tool dispatch) but must be
handled:

- Each session has a unique ID tied to its timestamp. Two writes to the same
  file produce two sessions. The backup taken by the second write captures
  whatever the first write left behind. This is correct -- each session's
  backup reflects the state at the moment that session was created.
- `fettle confirm` reads the staged content and applies it atomically
  (write to temp file in same directory, then rename). If the file changed
  between staging and confirmation, that is the human/model's problem to
  detect -- fettle is not a merge tool.

---

## 6. CLI Subcommands

### Existing Commands (unchanged)

```
fettle read <file> [--offset N] [--limit N]
fettle write <file>            # reads content from stdin
fettle install
fettle info
```

### New Commands

#### `fettle confirm <session-id>`

Apply a staged write.

```
$ fettle confirm a1b2c3d4
fettle: Applied staged write to /home/user/project/src/main.rs
  +47 -32 lines changed
  session a1b2c3d4 complete
```

Error cases:
- Unknown session: `"fettle: No session 'xyz'. It may have expired or been discarded."`
- Expired session: `"fettle: Session a1b2c3d4 expired 8 minutes ago. Re-run the Write to try again."`
- Already applied: `"fettle: Session a1b2c3d4 was already applied."`
- Write failure: `"fettle: Failed to apply session a1b2c3d4: permission denied"` (the staged content is preserved so the user can retry or recover).

#### `fettle discard <session-id>`

Discard a staged write without applying.

```
$ fettle discard a1b2c3d4
fettle: Discarded staged write for /home/user/project/src/main.rs
  session a1b2c3d4 removed
```

This removes the staged content. The backup remains (backups have their own
retention policy independent of sessions).

#### `fettle rollback <backup-path-or-pattern>`

Restore a file from backup. This is intentionally explicit -- you provide the
backup file path (shown in write confirmation messages) or a glob pattern.

```
$ fettle rollback ~/.wonka/bench/fettle/backups/main.rs.20260312_143022_471
fettle: Restored /home/user/project/src/main.rs from backup
  backup: main.rs.20260312_143022_471
```

Also accept a shorthand: just the backup filename without the full path.

```
$ fettle rollback main.rs.20260312_143022_471
fettle: Restored /home/user/project/src/main.rs from backup
```

This reads the `.meta` sidecar to find the original path. If the sidecar is
missing, error: `"fettle: Cannot rollback -- no metadata for this backup.
Specify the target path: fettle rollback <backup> --to <path>"`.

Add `--to <path>` flag to override the restore target (useful if the original
path no longer makes sense).

#### `fettle status`

Show pending staged sessions and recent backups.

```
$ fettle status
Pending staged writes:
  a1b2c3d4  /home/user/src/main.rs     +47 -32   2 min ago
  e5f6a7b8  /home/user/src/lib.rs      +12 -3    45 sec ago

Recent backups (last 24h):
  main.rs.20260312_143022_471      /home/user/src/main.rs     2 min ago
  lib.rs.20260312_142955_102       /home/user/src/lib.rs      3 min ago
```

This is a diagnostic command. Useful when Claude or the user wants to see
what is pending or what can be rolled back.

---

## 7. Data Structures

### New Structs

```rust
/// Result of computing a diff between old and new file content.
pub struct DiffResult {
    /// Number of inserted lines.
    pub insertions: usize,
    /// Number of deleted lines.
    pub deletions: usize,
    /// Total lines in the original file.
    pub old_line_count: usize,
    /// The unified diff string (with context).
    pub unified: String,
}

impl DiffResult {
    /// Total changed lines (insertions + deletions).
    pub fn changed_lines(&self) -> usize {
        self.insertions + self.deletions
    }

    /// Changed lines as a fraction of the original file size.
    /// Returns 0.0 for empty files (prevents division by zero).
    pub fn change_ratio(&self) -> f64 {
        if self.old_line_count == 0 {
            0.0
        } else {
            self.changed_lines() as f64 / self.old_line_count as f64
        }
    }

    /// Compact summary string: "+N -M"
    pub fn summary(&self) -> String {
        format!("+{} -{}", self.insertions, self.deletions)
    }
}

/// Classification of a write operation.
pub enum WriteTier {
    /// New file, no existing content.
    NewFile,
    /// Small diff -- apply immediately.
    DirectWrite { diff: DiffResult },
    /// Large diff -- stage for confirmation.
    StagedWrite { diff: DiffResult },
}

/// A staged write session, persisted as metadata.json.
#[derive(Serialize, Deserialize)]
pub struct StagedSession {
    pub session_id: String,
    pub target_path: String,
    pub backup_path: Option<String>,
    pub created_at: String,  // ISO 8601
    pub diff_summary: String,
    pub status: SessionStatus,
}

#[derive(Serialize, Deserialize, PartialEq)]
pub enum SessionStatus {
    Pending,
    Applied,
    Discarded,
    Expired,
}

/// Backup metadata sidecar.
#[derive(Serialize, Deserialize)]
pub struct BackupMeta {
    pub original_path: String,
    pub created_at: String,  // ISO 8601
    pub size_bytes: u64,
}
```

### Threshold Configuration

```rust
/// Write threshold configuration, loaded from env or defaults.
pub struct WriteThresholds {
    pub absolute_floor: usize,   // default 10
    pub absolute_ceil: usize,    // default 80
    pub ratio_threshold: f64,    // default 0.40
}

impl WriteThresholds {
    pub fn from_env() -> Self { /* read FETTLE_WRITE_* env vars */ }

    pub fn classify(&self, diff: &DiffResult) -> WriteTier {
        let changed = diff.changed_lines();
        if changed <= self.absolute_floor {
            WriteTier::DirectWrite { /* ... */ }
        } else if changed >= self.absolute_ceil {
            WriteTier::StagedWrite { /* ... */ }
        } else if diff.change_ratio() > self.ratio_threshold {
            WriteTier::StagedWrite { /* ... */ }
        } else {
            WriteTier::DirectWrite { /* ... */ }
        }
    }
}
```

---

## 8. Module Layout

### Current Structure

```
src/
  main.rs       -- entry point, hook mode vs CLI mode dispatch
  cli.rs        -- clap CLI definition
  hook.rs       -- hook input parsing, decision tree
  read.rs       -- file reading with line numbers
  write.rs      -- file writing (simple)
  filetype.rs   -- file category detection
  info.rs       -- info display
  install.rs    -- hook installation
```

### Proposed Structure

```
src/
  main.rs       -- entry point (add confirm/discard/rollback/status dispatch)
  cli.rs        -- clap CLI definition (add new subcommands)
  hook.rs       -- hook input parsing, decision tree (modify process_write)
  read.rs       -- file reading (unchanged)
  write.rs      -- file writing, ENHANCED: diff, backup, staging logic
  filetype.rs   -- file category detection (unchanged)
  info.rs       -- info display (update decision tree description)
  install.rs    -- hook installation (unchanged)
  diff.rs       -- NEW: diff computation, DiffResult, threshold logic
  backup.rs     -- NEW: backup creation, purging, rollback
  stage.rs      -- NEW: session staging, confirmation, discard, expiry
```

Three new modules. Each has a single, clear responsibility. No existing module
changes its primary purpose -- they gain new call sites but not new identities.

### Dependency Flow

```
main.rs
  |-> cli.rs (parse args)
  |-> hook.rs (hook mode)
        |-> read.rs (Read interception, unchanged)
        |-> write.rs (Write interception, enhanced)
              |-> diff.rs (compute diff, classify tier)
              |-> backup.rs (create backup)
              |-> stage.rs (stage content for Tier 2)
  |-> stage.rs (confirm/discard commands)
  |-> backup.rs (rollback command, status command)
```

---

## 9. Hook Flow -- Detailed Decision Tree

### `process_write` in hook.rs (enhanced)

```
1. Extract file_path from tool_input.
   - Missing? Return deny with error message. (unchanged)

2. Extract content from tool_input.
   - Missing? Return deny with error message. (unchanged)

3. Check if file exists on disk.
   |
   +-- NO (new file):
   |     a. Write content to file (create parent dirs as needed).
   |     b. Return deny: "fettle: Wrote {path} ({size}, {lines} lines) [new file]"
   |
   +-- YES (existing file):
         a. Read current content from disk.
         |
         +-- Read fails (permissions, etc):
         |     Write anyway (current behavior). No backup (can't read = can't backup).
         |     Return deny: "fettle: Wrote {path} ({size}, {lines} lines) [no backup: read failed]"
         |
         +-- Current content == proposed content:
         |     No-op. Don't write. Don't backup.
         |     Return deny: "fettle: No changes to {path} (content identical)"
         |
         +-- Content differs:
               b. Compute diff (diff.rs).
               c. Run opportunistic backup purge (backup.rs).
               d. Create backup of current file (backup.rs).
               |
               +-- Backup fails:
               |     Log warning, proceed anyway. Backup failure is not fatal.
               |
               e. Classify diff tier (diff.rs).
               |
               +-- Tier 1 (DirectWrite):
               |     f. Write new content to file.
               |     g. Return deny:
               |        "fettle: Wrote {path} ({size}, {lines} lines, {+N -M} changed)
               |         backup: {backup_filename}"
               |
               +-- Tier 2 (StagedWrite):
                     f. Run opportunistic stage purge (stage.rs).
                     g. Stage content to /tmp/fettle-stage/{session-id}/ (stage.rs).
                     h. Return deny:
                        "fettle: Staged write for {path} (session: {id})
                         {+N -M} lines changed ({pct}% of file)
                         backup: {backup_filename}

                        {unified diff output}

                        To apply: run `fettle confirm {id}` via Bash
                        To discard: run `fettle discard {id}` via Bash"
```

### The permission decision is "deny"

The code uses `permissionDecision: "deny"`. This is deliberate and correct.
When fettle handles a Write, it has already either written the file (Tier 1)
or staged it (Tier 2). Returning "deny" tells Claude Code not to also run
its builtin Write tool, which would cause a double-write.

Claude sees the deny reason as an "error" message, but the message itself
confirms the operation succeeded (e.g., `fettle: Wrote /path (+3 -1)`).
The "error" IS the success confirmation. This is how the hook protocol is
designed to work -- the deny reason is the communication channel.

For Tier 2 (staged writes), the deny reason contains the diff and
instructions to run `fettle confirm` or `fettle discard`. The file is NOT
written until confirmed. The deny prevents the builtin from writing it
prematurely.

---

## 10. The `fettle confirm` Flow

```
1. Parse session ID from CLI args.

2. Load /tmp/fettle-stage/{session-id}/metadata.json.
   - Directory doesn't exist? "No session '{id}'."
   - metadata.json missing? "Corrupted session '{id}'."

3. Check status field.
   - "applied"? "Session {id} was already applied."
   - "discarded"? "Session {id} was already discarded."

4. Check expiry.
   - Parse created_at, compare to now.
   - Older than 10 minutes? "Session {id} expired N minutes ago."
     Update status to "expired" in metadata. Return error.

5. Read staged content from /tmp/fettle-stage/{session-id}/content.

6. Write content to target_path.
   - Create parent dirs if needed (same logic as write.rs).
   - On failure: return error, keep staged content for retry.

7. Update metadata status to "applied".

8. Print confirmation: "fettle: Applied staged write to {path}"
```

---

## 11. The `fettle rollback` Flow

```
1. Resolve backup path:
   - Full path provided? Use it directly.
   - Filename only? Look in ~/.wonka/bench/fettle/backups/.
   - Not found? Error.

2. Read .meta sidecar.
   - Missing? Error unless --to flag provided.
   - Present? Extract original_path.

3. Determine restore target:
   - --to flag overrides original_path.
   - No --to and no .meta? Error: "Specify --to <path>".

4. Read backup content.

5. Write to restore target.
   - Note: does NOT create a counter-backup. Rollback is a deliberate
     reversal. If you want to undo the rollback, the staged content
     (if applicable) or git history is there.

6. Print confirmation: "fettle: Restored {path} from backup"
```

---

## 12. Integration with main.rs

### Updated CLI enum

```rust
pub enum Command {
    Read { file, offset, limit },      // existing
    Write { file },                     // existing
    Install,                            // existing
    Info,                               // existing
    Confirm { session_id: String },     // NEW
    Discard { session_id: String },     // NEW
    Rollback {                          // NEW
        backup: String,
        to: Option<PathBuf>,
    },
    Status,                             // NEW
}
```

### Updated main.rs dispatch

The `run_hook_mode` function changes only in that `process_write` in hook.rs
now calls into the new diff/backup/stage modules. The hook mode entry point
itself is unchanged.

The `run_cli_mode` function gains four new match arms for confirm, discard,
rollback, and status. These are straightforward dispatches to stage.rs and
backup.rs functions.

The bare `fettle hook` entry (line 25-27) is unchanged. The detection logic
(stdin vs args) is unchanged.

---

## 13. Edge Cases

### File Deleted Between Hook Fire and Write

The hook fires, fettle reads the file for diffing, but by the time it writes,
the file is gone. This is astronomically unlikely in normal use (Claude Code
dispatches hooks synchronously) but possible if an external process deletes
the file.

**Handling:** The write call itself will create the file (fs::write creates if
absent). This is correct -- the model asked to write the file, so write it.
The diff was computed against the now-deleted content, which means the backup
captured the content that was deleted. Everything is consistent.

### Symlinks

Follow symlinks for reading (to compute diff against real content) and for
writing (write to the real target, not the link). Backups store the real path
in the `.meta` sidecar. `std::fs::read` and `std::fs::write` follow symlinks
by default in Rust, so this requires no special handling.

### Permission Issues

- Cannot read existing file for diff: Write anyway without diff/backup.
  Return a note in the confirmation message.
- Cannot create backup directory: Log warning, proceed without backup.
- Cannot write the file itself: Return error (this is fatal, same as current
  behavior).
- Cannot create stage directory: Fall back to Tier 1 (direct write). Stage
  failure should not block a write -- degrade gracefully.

### Concurrent Writes to Same File

As discussed in section 5: each write gets its own session and backup. No
locking. No merge. This is correct for the single-agent use case and
fail-safe for multi-agent edge cases.

### Very Large Files

Files over, say, 10MB: the diff computation itself could be slow. In practice,
Claude Code is unlikely to write 10MB files, but if it does, `similar`'s
Myers algorithm is O(ND) where N is file length and D is edit distance. For
large files with small diffs, this is fast. For large files with large diffs
(essentially a full rewrite), it could be slow.

**Mitigation:** If the file is over 5MB, skip diffing entirely. Write
directly, backup the original, note in the message that diff was skipped
due to size. This is a pragmatic escape hatch.

### Empty Files

An existing empty file being written to: the diff is all insertions. Follow
the normal threshold logic -- if the new content is over 80 lines, stage it.
This is probably overcautious for writing to an empty file, but consistency
matters more than cleverness here.

### Non-UTF-8 Content

If the proposed content (from tool_input) or the existing file content is not
valid UTF-8, skip diffing. Write directly with backup. Note in the message
that diff was skipped (binary content). `similar` operates on `&str`, so we
must validate UTF-8 before passing content to it. The tool_input content is
already a JSON string (so it is UTF-8), but the existing file on disk might
not be.

---

## 14. New Dependencies

### Required

- `similar = "2"` -- line diff computation. Pure Rust, no transitive deps
  worth worrying about. Well-maintained (by mitsuhiko).

### Not Required

- No random crate needed. Session IDs use timestamp-based hashing.
- No chrono/time crate needed. Use `std::time::SystemTime` for timestamps,
  format manually or use basic `UNIX_EPOCH` offset arithmetic for ISO 8601.
  The formatting is simple enough to not warrant a dependency.
- No fs-extra or similar. `std::fs` covers everything needed.

---

## 15. Token Efficiency Considerations

The whole point of fettle intercepting writes is to save tokens and remove
friction. The enhanced protocol must not introduce token bloat.

### Tier 1 Messages

Two lines max. The model already knows what it wrote. It just needs
confirmation plus backup location.

```
fettle: Wrote src/main.rs (2.1KB, 45 lines, +3 -2 changed)
  backup: main.rs.20260312_143022_471
```

### Tier 2 Messages

The diff is the unavoidable cost. But it replaces what would otherwise be
a full file re-read (the model would need to read the file to see if its
write worked). The diff is strictly smaller than the full file content for
any non-trivial file. Context radius of 3 keeps the diff focused.

The instructions ("To apply: run ...") are 2 lines. The session ID is 8
characters. Minimal overhead.

### No-Change Detection

If proposed content equals existing content, return a one-line "no changes"
message. This is a token savings for the common case where the model
re-writes a file it already wrote. No backup created, no diff computed.

---

## 16. Info Display Update

The `info.rs` decision tree display should be updated to reflect the new
write behavior:

```
Decision tree:
  Read + image/PDF/notebook  -> allow builtin (multimodal)
  Read + SVG                 -> fettle handles (text, not multimodal)
  Read + text < threshold    -> allow builtin (works fine)
  Read + text >= threshold   -> fettle reads (no size limit)
  Write + new file           -> fettle writes directly
  Write + small diff         -> fettle writes, backs up original
  Write + large diff         -> fettle stages, shows diff, waits for confirm
  Other tools                -> allow (pass through)
```

---

## 17. Testing Strategy

### Unit Tests (in each new module)

**diff.rs:**
- Identical content returns zero changes.
- Single line change counts correctly.
- Pure insertions counted correctly.
- Pure deletions counted correctly.
- Threshold classification: floor, ceiling, ratio boundaries.
- Empty old file (all insertions).
- Empty new file (all deletions).

**backup.rs:**
- Backup creates file and sidecar.
- Backup naming includes timestamp.
- Purge removes files older than 24h.
- Purge respects max count.
- Rollback restores from backup using sidecar.
- Rollback with --to flag.
- Missing sidecar error.

**stage.rs:**
- Session creation writes content and metadata.
- Confirm applies content correctly.
- Confirm on expired session returns error.
- Confirm on already-applied session returns error.
- Discard removes session.
- Purge removes expired sessions.
- Session ID generation produces 8-char hex.

### Integration Tests (in hook.rs or tests/)

- Write to new file: no diff, no backup, immediate write.
- Write with small diff: direct write, backup created.
- Write with large diff: staged, not written, session created.
- Confirm after staging: file written, session marked applied.
- Write identical content: no-op message, no backup.

---

## 18. Migration / Backward Compatibility

The enhanced write protocol is backward compatible in the following sense:

- The hook returns `permissionDecision: "deny"` with a reason string that
  serves as the success confirmation (see section 9 for rationale).
- The reason string format changes (adds diff info, backup paths) but the
  model already parses these as informational messages.
- New CLI subcommands (confirm, discard, rollback, status) don't conflict
  with existing ones.
- The `fettle write` CLI command (standalone, not hook mode) should also
  gain backup/diff behavior for consistency, but this is a lower priority.
  The standalone CLI is used less often than the hook path.

### Environment Variable Namespace

All new env vars use the `FETTLE_WRITE_` prefix:
- `FETTLE_WRITE_FLOOR` -- absolute floor (default 10)
- `FETTLE_WRITE_CEIL` -- absolute ceiling (default 80)
- `FETTLE_WRITE_RATIO` -- ratio threshold (default 0.40)
- `FETTLE_WRITE_STAGE_TTL` -- stage TTL in seconds (default 600)
- `FETTLE_BACKUP_DIR` -- backup directory override
- `FETTLE_STAGE_DIR` -- stage directory override

Existing `FETTLE_READ_THRESHOLD` is unchanged.

---

## 19. Open Questions (for the implementer)

1. **Should `fettle write` (CLI mode) also do backups?** Probably yes for
   consistency, but it adds complexity to a path that is rarely used. Could
   be deferred.

2. **Should the no-change detection do a byte-level comparison or line-level?**
   Byte-level is faster and catches whitespace-only differences. Recommend
   byte-level for the no-change check, line-level for the actual diff.

3. **Should Tier 2 show the FULL unified diff or cap it?** If a diff is 500
   lines long, that is a lot of tokens. Consider capping the displayed diff
   at, say, 200 lines and noting "... N more lines of diff truncated."
   The staged content is always available in full via the session.

4. **What about `Edit` tool calls?** Claude Code also has an Edit tool. Fettle
   currently does not intercept it. The Edit tool sends a diff-like input
   (old_string/new_string), not full file content. This is a separate concern
   and should not be mixed into this design.

---

*squints at the napkin one more time*

The abstraction holds. Three new modules, three new subcommands, one new
dependency. The existing architecture bends to accommodate this without
breaking. The threshold logic is adaptive rather than fixed, which is the
right amount of clever without being too clever.

The napkin is ready. Hand it to the foundry.