filekeeper-cli 0.1.4__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/PKG-INFO +53 -7
  2. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/README.md +52 -6
  3. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/pyproject.toml +1 -1
  4. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/pyproject.toml.orig +1 -1
  5. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/__init__.py +5 -1
  6. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/output.py +13 -1
  7. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/__init__.py +3 -1
  8. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/errors.py +65 -2
  9. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/journal.py +4 -0
  10. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/undo.py +95 -6
  11. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/models.py +89 -3
  12. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/directory.py +42 -9
  13. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/edit.py +47 -19
  14. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/write.py +87 -14
  15. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/service.py +15 -3
  16. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/LICENSE +0 -0
  17. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/__init__.py +0 -0
  18. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/app.py +0 -0
  19. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/exit_codes.py +0 -0
  20. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/config.py +0 -0
  21. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/database.py +0 -0
  22. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/diffing.py +0 -0
  23. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/__init__.py +0 -0
  24. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/blobs.py +0 -0
  25. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/repository.py +0 -0
  26. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/__init__.py +0 -0
  27. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/read.py +0 -0
  28. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/search.py +0 -0
  29. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/paths.py +0 -0
  30. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/__init__.py +0 -0
  31. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/atomic.py +0 -0
  32. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/hashing.py +0 -0
  33. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/sensitive.py +0 -0
  34. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/textdata.py +0 -0
  35. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/vcs.py +0 -0
  36. {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filekeeper-cli
3
- Version: 0.1.4
3
+ Version: 0.1.7
4
4
  Summary: Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement.
5
5
  Keywords: filesystem,atomic-write,cli,agents,mcp
6
6
  Author: sangingeum
@@ -244,6 +244,13 @@ later file fails after earlier ones were written, the written files are rolled
244
244
  back to their pre-patch bytes and the failure names what was restored. A crash is
245
245
  resolved from the pending journal record, never from an all-or-nothing commit.
246
246
 
247
+ When a hunk's header and its body disagree — the hunk is cut short or a line
248
+ prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
249
+ header declares and the counts the body actually supplied, in the message and in
250
+ `details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
251
+ `actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
252
+ text is never echoed into the error.
253
+
247
254
  ## Commands
248
255
 
249
256
  ```
@@ -304,10 +311,11 @@ affected path with the state the operation recorded:
304
311
 
305
312
  - if the path still matches the recorded **after** state, undo restores the
306
313
  recorded **before** bytes;
307
- - if it already matches the **before** state (a partially applied multi-file
308
- operation), undo leaves it alone;
309
- - if it matches neither, someone changed it afterwards: the undo is refused with
310
- `UNDO_CONFLICT` (exit code 12) and **nothing is modified**.
314
+ - if it already matches the **before** state of an operation that never committed
315
+ (a partially applied multi-file operation), undo leaves it alone;
316
+ - if a committed operation's path no longer matches the **after** state — edited,
317
+ deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
318
+ 12) and **nothing is modified**.
311
319
 
312
320
  Undoing a create removes the file only while it still matches what was created.
313
321
  Undoing a delete restores the file only while the destination is still absent.
@@ -318,6 +326,18 @@ The undo is itself recorded as an operation, so `history list` remains a
318
326
  truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
319
327
  undo is the supported way to go forward again.
320
328
 
329
+ A copy or a move records the path it read from in the operation's `metadata`
330
+ under `source_path`, and its summary names both ends. `target_path` keeps its
331
+ meaning — the path the operation targets — so existing readers are unaffected.
332
+
333
+ A refused undo is recorded too: the ledger gains an `undo_refused` row with the
334
+ target path, the conflict code, and the paths that blocked the undo. It changed
335
+ nothing, so it carries no items, is never offered as undoable, and `undo-last`
336
+ skips it. A refusal of a refusal is the one exception: undoing that
337
+ `undo_refused` row is refused with the same coded error, but writes no second row,
338
+ because the existing row already states the conflict. Every recorded path is
339
+ workspace-relative, never a resolved host path.
340
+
321
341
  `keeper file read` writes file content to standard output unchanged (line endings
322
342
  included). Metadata goes to stderr, or into the JSON object with `--json`.
323
343
  `--input FILE` reads replacement content from a file, avoiding shell argument
@@ -380,6 +400,23 @@ re-reads the file and writes against its new hash.
380
400
  so its previous bytes were deliberately not stored; `history_skipped` then names
381
401
  the paths.
382
402
 
403
+ **A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
404
+ `false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
405
+ and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
406
+ a real run would produce appears as `predicted_after_hash` — a prediction, never an
407
+ observation — and `would_change` says whether a real run would alter anything.
408
+ `before_hash` and `preview_diff` are kept, because they describe the state the
409
+ preview was computed against. The same shape applies to every mutating command and
410
+ to the MCP tools that mirror them. A committed operation still reports
411
+ `"changed": true` with its observed `after_hash`.
412
+
413
+ `changed` and `would_change` answer different questions: `would_change` asks
414
+ whether the bytes would differ, while `changed` records whether an operation
415
+ executed and was journalled — so a real write of identical content reports
416
+ `"changed": true` and adds a history row, which callers may rely on as proof a
417
+ write completed. In a preview the two cannot disagree: when `would_change` is
418
+ false, every item reports `"unchanged"`.
419
+
383
420
  Editing results add `match_stage` and `match_count` (text replacement),
384
421
  `preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
385
422
  A failure that was rolled back reports the restored paths in the error details.
@@ -421,6 +458,13 @@ a shell predicate.
421
458
 
422
459
  Codes are stable; messages are not a contract.
423
460
 
461
+ Messages name the interface the caller is using. A refusal that offers an
462
+ override states that interface's own argument: Core's keyword, the CLI's flag, or
463
+ the MCP tool argument. The same request therefore reads correctly whether it came
464
+ from a library, a shell, or an agent over MCP, and an adapter never prints another
465
+ surface's argument. Codes, `retryable` and `details` are identical across all
466
+ three surfaces.
467
+
424
468
  ## Safety rules
425
469
 
426
470
  - **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
@@ -435,8 +479,10 @@ Codes are stable; messages are not a contract.
435
479
  `*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
436
480
  `.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
437
481
  `SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
438
- `--i-accept-secret-write-risk` (Core: `allow_sensitive_write=True`). The flag is
439
- long because the mistake is expensive.
482
+ `--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
483
+ or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
484
+ mistake is expensive. The error message names only the argument belonging to the
485
+ interface that received the request.
440
486
  - **Version-control-ignored targets warn.** Writing a path that git ignores still
441
487
  succeeds, but the result carries a warning that the change will not be committed.
442
488
  - **No shell.** Filekeeper never interpolates paths into a shell command.
@@ -223,6 +223,13 @@ later file fails after earlier ones were written, the written files are rolled
223
223
  back to their pre-patch bytes and the failure names what was restored. A crash is
224
224
  resolved from the pending journal record, never from an all-or-nothing commit.
225
225
 
226
+ When a hunk's header and its body disagree — the hunk is cut short or a line
227
+ prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
228
+ header declares and the counts the body actually supplied, in the message and in
229
+ `details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
230
+ `actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
231
+ text is never echoed into the error.
232
+
226
233
  ## Commands
227
234
 
228
235
  ```
@@ -283,10 +290,11 @@ affected path with the state the operation recorded:
283
290
 
284
291
  - if the path still matches the recorded **after** state, undo restores the
285
292
  recorded **before** bytes;
286
- - if it already matches the **before** state (a partially applied multi-file
287
- operation), undo leaves it alone;
288
- - if it matches neither, someone changed it afterwards: the undo is refused with
289
- `UNDO_CONFLICT` (exit code 12) and **nothing is modified**.
293
+ - if it already matches the **before** state of an operation that never committed
294
+ (a partially applied multi-file operation), undo leaves it alone;
295
+ - if a committed operation's path no longer matches the **after** state — edited,
296
+ deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
297
+ 12) and **nothing is modified**.
290
298
 
291
299
  Undoing a create removes the file only while it still matches what was created.
292
300
  Undoing a delete restores the file only while the destination is still absent.
@@ -297,6 +305,18 @@ The undo is itself recorded as an operation, so `history list` remains a
297
305
  truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
298
306
  undo is the supported way to go forward again.
299
307
 
308
+ A copy or a move records the path it read from in the operation's `metadata`
309
+ under `source_path`, and its summary names both ends. `target_path` keeps its
310
+ meaning — the path the operation targets — so existing readers are unaffected.
311
+
312
+ A refused undo is recorded too: the ledger gains an `undo_refused` row with the
313
+ target path, the conflict code, and the paths that blocked the undo. It changed
314
+ nothing, so it carries no items, is never offered as undoable, and `undo-last`
315
+ skips it. A refusal of a refusal is the one exception: undoing that
316
+ `undo_refused` row is refused with the same coded error, but writes no second row,
317
+ because the existing row already states the conflict. Every recorded path is
318
+ workspace-relative, never a resolved host path.
319
+
300
320
  `keeper file read` writes file content to standard output unchanged (line endings
301
321
  included). Metadata goes to stderr, or into the JSON object with `--json`.
302
322
  `--input FILE` reads replacement content from a file, avoiding shell argument
@@ -359,6 +379,23 @@ re-reads the file and writes against its new hash.
359
379
  so its previous bytes were deliberately not stored; `history_skipped` then names
360
380
  the paths.
361
381
 
382
+ **A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
383
+ `false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
384
+ and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
385
+ a real run would produce appears as `predicted_after_hash` — a prediction, never an
386
+ observation — and `would_change` says whether a real run would alter anything.
387
+ `before_hash` and `preview_diff` are kept, because they describe the state the
388
+ preview was computed against. The same shape applies to every mutating command and
389
+ to the MCP tools that mirror them. A committed operation still reports
390
+ `"changed": true` with its observed `after_hash`.
391
+
392
+ `changed` and `would_change` answer different questions: `would_change` asks
393
+ whether the bytes would differ, while `changed` records whether an operation
394
+ executed and was journalled — so a real write of identical content reports
395
+ `"changed": true` and adds a history row, which callers may rely on as proof a
396
+ write completed. In a preview the two cannot disagree: when `would_change` is
397
+ false, every item reports `"unchanged"`.
398
+
362
399
  Editing results add `match_stage` and `match_count` (text replacement),
363
400
  `preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
364
401
  A failure that was rolled back reports the restored paths in the error details.
@@ -400,6 +437,13 @@ a shell predicate.
400
437
 
401
438
  Codes are stable; messages are not a contract.
402
439
 
440
+ Messages name the interface the caller is using. A refusal that offers an
441
+ override states that interface's own argument: Core's keyword, the CLI's flag, or
442
+ the MCP tool argument. The same request therefore reads correctly whether it came
443
+ from a library, a shell, or an agent over MCP, and an adapter never prints another
444
+ surface's argument. Codes, `retryable` and `details` are identical across all
445
+ three surfaces.
446
+
403
447
  ## Safety rules
404
448
 
405
449
  - **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
@@ -414,8 +458,10 @@ Codes are stable; messages are not a contract.
414
458
  `*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
415
459
  `.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
416
460
  `SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
417
- `--i-accept-secret-write-risk` (Core: `allow_sensitive_write=True`). The flag is
418
- long because the mistake is expensive.
461
+ `--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
462
+ or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
463
+ mistake is expensive. The error message names only the argument belonging to the
464
+ interface that received the request.
419
465
  - **Version-control-ignored targets warn.** Writing a path that git ignores still
420
466
  succeeds, but the result carries a warning that the change will not be committed.
421
467
  - **No shell.** Filekeeper never interpolates paths into a shell command.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.4"
3
+ version = "0.1.7"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.4"
3
+ version = "0.1.7"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -12,24 +12,28 @@ work; see ``CHANGELOG.md`` and ``README.md``.
12
12
  from __future__ import annotations
13
13
 
14
14
  from .core import (
15
+ CallerSurface,
15
16
  Config,
16
17
  ErrorCode,
17
18
  Filekeeper,
18
19
  FilekeeperError,
19
20
  FilekeeperService,
20
21
  MutationResult,
22
+ OverrideHint,
21
23
  Workspace,
22
24
  )
23
25
 
24
- __version__ = "0.1.4"
26
+ __version__ = "0.1.7"
25
27
 
26
28
  __all__ = [
29
+ "CallerSurface",
27
30
  "Config",
28
31
  "ErrorCode",
29
32
  "Filekeeper",
30
33
  "FilekeeperError",
31
34
  "FilekeeperService",
32
35
  "MutationResult",
36
+ "OverrideHint",
33
37
  "Workspace",
34
38
  "__version__",
35
39
  ]
@@ -14,7 +14,7 @@ from enum import Enum
14
14
  from pathlib import Path
15
15
  from typing import Any
16
16
 
17
- from ..core.errors import FilekeeperError
17
+ from ..core.errors import CallerSurface, FilekeeperError
18
18
  from ..core.models import (
19
19
  ContentSearchResult,
20
20
  DirectoryListing,
@@ -53,6 +53,9 @@ def print_json(payload: dict[str, Any]) -> None:
53
53
 
54
54
 
55
55
  def print_error(error: FilekeeperError, *, as_json: bool) -> None:
56
+ """Render an error for this surface: its override hint names this CLI's flag."""
57
+
58
+ error = error.for_surface(CallerSurface.CLI)
56
59
  if as_json:
57
60
  print_json({"ok": False, "error": error.to_dict()})
58
61
  return
@@ -210,6 +213,15 @@ def history_show(record: OperationRecord) -> str:
210
213
  f" {item.item_type:<9} {item.relative_path} exists {exists} "
211
214
  f"before {item.before_blob_hash or '-'} after {item.after_blob_hash or '-'}"
212
215
  )
216
+ if record.metadata:
217
+ lines.append("metadata:")
218
+ for key in sorted(record.metadata):
219
+ value = record.metadata[key]
220
+ if isinstance(value, list):
221
+ rendered = ", ".join(str(entry) for entry in value)
222
+ else:
223
+ rendered = str(value)
224
+ lines.append(f" {key}: {rendered}")
213
225
  if record.history_skipped:
214
226
  lines.append("history_skipped: " + ", ".join(record.history_skipped))
215
227
  return "\n".join(lines)
@@ -5,7 +5,7 @@ the CLI, of the MCP server, or of any filesystem protocol.
5
5
  """
6
6
 
7
7
  from .config import Config
8
- from .errors import ErrorCode, FilekeeperError
8
+ from .errors import CallerSurface, ErrorCode, FilekeeperError, OverrideHint
9
9
  from .history.blobs import BlobStore
10
10
  from .models import (
11
11
  ContentMatch,
@@ -27,6 +27,7 @@ from .workspace import WorkspaceHandle, WorkspaceRegistry
27
27
 
28
28
  __all__ = [
29
29
  "BlobStore",
30
+ "CallerSurface",
30
31
  "Config",
31
32
  "ContentMatch",
32
33
  "ContentSearchResult",
@@ -41,6 +42,7 @@ __all__ = [
41
42
  "FilekeeperService",
42
43
  "MutationResult",
43
44
  "OperationItemResult",
45
+ "OverrideHint",
44
46
  "PathInfo",
45
47
  "PathSearchResult",
46
48
  "ReadManyEntry",
@@ -7,6 +7,9 @@ error payloads, and callers may branch on the code without parsing prose.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import copy
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
10
13
  from enum import StrEnum
11
14
  from typing import Any
12
15
 
@@ -46,6 +49,38 @@ class ErrorCode(StrEnum):
46
49
  INTERNAL_ERROR = "INTERNAL_ERROR"
47
50
 
48
51
 
52
+ class CallerSurface(StrEnum):
53
+ """The interface a caller reached Core through.
54
+
55
+ A refusal that offers an override must name the argument the caller actually
56
+ has. Core renders the sentence for ``CORE``; each adapter re-renders the same
57
+ error for its own surface before returning it, so no message ever names
58
+ another interface's argument.
59
+ """
60
+
61
+ CORE = "core"
62
+ CLI = "cli"
63
+ MCP = "mcp"
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class OverrideHint:
68
+ """One "pass X to override" instruction, worded for each caller surface."""
69
+
70
+ sentences: Mapping[CallerSurface, str]
71
+
72
+ def sentence(self, surface: CallerSurface) -> str:
73
+ return self.sentences[surface]
74
+
75
+
76
+ def override_hint(*, core: str, cli: str, mcp: str) -> OverrideHint:
77
+ """Build a hint whose sentence names each surface's own argument."""
78
+
79
+ return OverrideHint(
80
+ {CallerSurface.CORE: core, CallerSurface.CLI: cli, CallerSurface.MCP: mcp}
81
+ )
82
+
83
+
49
84
  class FilekeeperError(Exception):
50
85
  """Base error carrying a stable code, a human message, and optional details."""
51
86
 
@@ -59,6 +94,7 @@ class FilekeeperError(Exception):
59
94
  code: ErrorCode | None = None,
60
95
  retryable: bool | None = None,
61
96
  details: dict[str, Any] | None = None,
97
+ override_hint: OverrideHint | None = None,
62
98
  ) -> None:
63
99
  super().__init__(message)
64
100
  self.message = message
@@ -67,6 +103,27 @@ class FilekeeperError(Exception):
67
103
  if retryable is not None:
68
104
  self.retryable = retryable
69
105
  self.details: dict[str, Any] = details or {}
106
+ # Not serialised: the hint only lets an adapter re-word the message.
107
+ self.override_hint = override_hint
108
+
109
+ def for_surface(self, surface: CallerSurface) -> FilekeeperError:
110
+ """Return this error worded for the interface that received it.
111
+
112
+ ``code``, ``retryable`` and ``details`` are carried over untouched; only
113
+ the message's override sentence changes. An error with no hint, or a
114
+ request for the surface Core already rendered, is returned as is.
115
+ """
116
+
117
+ hint = self.override_hint
118
+ if hint is None or surface is CallerSurface.CORE:
119
+ return self
120
+ core_sentence = hint.sentence(CallerSurface.CORE)
121
+ if core_sentence not in self.message:
122
+ return self
123
+ adapted = copy.copy(self)
124
+ adapted.message = self.message.replace(core_sentence, hint.sentence(surface))
125
+ adapted.args = (adapted.message,)
126
+ return adapted
70
127
 
71
128
  def to_dict(self) -> dict[str, Any]:
72
129
  payload: dict[str, Any] = {
@@ -135,8 +192,14 @@ class SensitivePathRefusedError(FilekeeperError):
135
192
 
136
193
  code = ErrorCode.SENSITIVE_PATH_REFUSED
137
194
 
138
- def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
139
- super().__init__(message, details=details)
195
+ def __init__(
196
+ self,
197
+ message: str,
198
+ *,
199
+ details: dict[str, Any] | None = None,
200
+ override_hint: OverrideHint | None = None,
201
+ ) -> None:
202
+ super().__init__(message, details=details, override_hint=override_hint)
140
203
 
141
204
 
142
205
  class AlreadyExistsError(FilekeeperError):
@@ -38,6 +38,9 @@ COMMITTED = "committed"
38
38
  UNDONE = "undone"
39
39
  FAILED = "failed"
40
40
  PARTIALLY_UNDONE = "partially_undone"
41
+ # A refused undo is recorded so the ledger shows the attempt, but it is not a
42
+ # mutation and is never offered as undoable.
43
+ UNDO_REFUSED = "undo_refused"
41
44
 
42
45
 
43
46
  @dataclass(frozen=True, slots=True)
@@ -424,6 +427,7 @@ __all__ = [
424
427
  "PARTIALLY_UNDONE",
425
428
  "PENDING",
426
429
  "UNDONE",
430
+ "UNDO_REFUSED",
427
431
  "BeginResult",
428
432
  "OperationJournal",
429
433
  "PendingItem",
@@ -19,6 +19,7 @@ from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
 
21
21
  from ..errors import (
22
+ FilekeeperError,
22
23
  HistoryUnavailableError,
23
24
  StorageError,
24
25
  UndoConflictError,
@@ -27,7 +28,14 @@ from ..errors import (
27
28
  from ..models import MutationResult, OperationItemRecord, OperationItemResult, OperationRecord
28
29
  from ..safety.atomic import atomic_write_bytes, fsync_directory
29
30
  from ..safety.hashing import hash_file
30
- from .journal import COMMITTED, FAILED, UNDONE, OperationJournal, PendingItem
31
+ from .journal import (
32
+ COMMITTED,
33
+ FAILED,
34
+ UNDO_REFUSED,
35
+ UNDONE,
36
+ OperationJournal,
37
+ PendingItem,
38
+ )
31
39
 
32
40
 
33
41
  @dataclass(frozen=True, slots=True)
@@ -104,6 +112,13 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
104
112
  conflicts.append(item.relative_path)
105
113
  continue
106
114
  if matches_before:
115
+ # A committed operation left every item in its post-operation state.
116
+ # Matching only the pre-operation state now means the path was
117
+ # changed or removed by someone else, not that this undo already
118
+ # ran. Only a failed operation can legitimately be part-way back.
119
+ if record.status == COMMITTED and not matches_after:
120
+ conflicts.append(item.relative_path)
121
+ continue
107
122
  already_before += 1
108
123
  continue
109
124
 
@@ -140,11 +155,13 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
140
155
  conflicts.append(step.item.relative_path)
141
156
 
142
157
  if conflicts:
158
+ named = ", ".join(sorted(conflicts))
143
159
  raise UndoConflictError(
144
- "cannot undo: these paths changed after the operation, so nothing was modified",
160
+ f"cannot undo: these paths changed after the operation ({named}), "
161
+ "so nothing was modified",
145
162
  details={
146
163
  "operation_id": record.operation_id,
147
- "conflicting_paths": ", ".join(sorted(conflicts)),
164
+ "conflicting_paths": named,
148
165
  },
149
166
  )
150
167
  if record.status == FAILED and already_before == len(record.items):
@@ -161,6 +178,16 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
161
178
  "operation is not committed and cannot be undone",
162
179
  details={"operation_id": record.operation_id, "status": record.status},
163
180
  )
181
+ if not steps:
182
+ # Every item already holds its pre-operation state, which for a committed
183
+ # operation means the write left the content unchanged. There is nothing
184
+ # to restore, and reporting success would claim a change that never
185
+ # happened.
186
+ raise UndoConflictError(
187
+ "cannot undo: the operation left its target in the pre-operation state, "
188
+ "so undoing it would change nothing",
189
+ details={"operation_id": record.operation_id},
190
+ )
164
191
  return steps
165
192
 
166
193
 
@@ -272,6 +299,53 @@ def _result(
272
299
  )
273
300
 
274
301
 
302
+ def _conflicting_paths(error: UndoConflictError) -> list[str]:
303
+ """The refusal's paths as a list, still caller-relative as Core recorded them."""
304
+
305
+ named = error.details.get("conflicting_paths")
306
+ if not isinstance(named, str):
307
+ return []
308
+ return sorted({part.strip() for part in named.split(",") if part.strip()})
309
+
310
+
311
+ def _record_refusal(
312
+ journal: OperationJournal,
313
+ record: OperationRecord,
314
+ error: UndoConflictError,
315
+ *,
316
+ actor: str | None,
317
+ task_id: str | None,
318
+ ) -> str | None:
319
+ """Record a refused undo attempt so the ledger shows it was tried.
320
+
321
+ A refusal changes nothing, so the row carries no items and no before/after
322
+ hashes. Its status is ``failed`` — the attempt did not take effect — and its
323
+ type keeps it out of the undo stack (:func:`latest_undoable`). The conflict
324
+ code and the paths that blocked the undo travel in metadata. Recording is
325
+ best-effort: a storage failure here must never replace the refusal the
326
+ caller is owed.
327
+ """
328
+
329
+ try:
330
+ begun = journal.begin(
331
+ operation_type=UNDO_REFUSED,
332
+ target_path=record.target_path,
333
+ summary=f"Refused to undo operation {record.operation_id}: {error.message}",
334
+ items=[],
335
+ actor=actor,
336
+ task_id=task_id,
337
+ metadata={
338
+ "undone_operation": record.operation_id,
339
+ "conflict_code": str(error.code),
340
+ "conflicting_paths": _conflicting_paths(error),
341
+ },
342
+ )
343
+ journal.abort(begun.operation_id)
344
+ except FilekeeperError:
345
+ return None
346
+ return begun.operation_id
347
+
348
+
275
349
  def undo_operation(
276
350
  journal: OperationJournal,
277
351
  operation_id: str,
@@ -289,7 +363,16 @@ def undo_operation(
289
363
  raise OperationNotFoundError(
290
364
  "no operation with that id in this workspace", details={"operation_id": operation_id}
291
365
  )
292
- steps = _plan(journal, record)
366
+ try:
367
+ steps = _plan(journal, record)
368
+ except UndoConflictError as exc:
369
+ # A refusal of another refusal adds nothing: the first row already
370
+ # records that this conflict was seen and nothing changed. Journalling
371
+ # the repeat would be noise, not new evidence, so only a refusal of a
372
+ # real operation is recorded.
373
+ if record.operation_type != UNDO_REFUSED:
374
+ _record_refusal(journal, record, exc, actor=actor, task_id=task_id)
375
+ raise
293
376
  undo_items = _undo_items(record)
294
377
 
295
378
  begun = journal.begin(
@@ -316,8 +399,14 @@ def undo_operation(
316
399
 
317
400
 
318
401
  def latest_undoable(journal: OperationJournal, *, actor: str | None = None) -> OperationRecord:
319
- clauses = ["status IN (?, ?)"]
320
- parameters: list[object] = [COMMITTED, FAILED]
402
+ """The most recent operation this workspace may undo.
403
+
404
+ A recorded refusal changed nothing; it is not a mutation and is never
405
+ offered as the next undo target, so ``undo-last`` skips those rows.
406
+ """
407
+
408
+ clauses = ["status IN (?, ?)", "operation_type != ?"]
409
+ parameters: list[object] = [COMMITTED, FAILED, UNDO_REFUSED]
321
410
  if actor is not None:
322
411
  clauses.append("actor = ?")
323
412
  parameters.append(actor)