dsh-rewind-plugin 0.11.0 → 0.12.0

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.
package/CONTRIBUTING.md CHANGED
@@ -58,9 +58,10 @@ npm run verify:host # end-to-end host verification (full check suite)
58
58
  ## Testing expectations
59
59
 
60
60
  - Pure planning (`rewind.ts`, `hidden.ts`) → unit tests in `tests/`.
61
- - Store behavior (`snapshot.ts`) → `tests/snapshot.test.ts`, plus crash-safety
62
- scenarios in `tests/crash-safety.test.ts` via the test-only `crash` seam
63
- (`RestoreRunOptions.crash`).
61
+ - Store behavior (`snapshot.ts`) → `tests/snapshot.test.ts` plus the focused
62
+ suites (`snapshot-bytes`, `snapshot-mode`, `downgrade-safety`,
63
+ `parent-guard`), and crash-safety scenarios in `tests/crash-safety.test.ts`
64
+ via the test-only `crash` seam (`RestoreRunOptions.crash`).
64
65
  - Harness interaction → the compatibility suites
65
66
  (`compat-invariants` / `compat-interop` / `compat-gaps`) and
66
67
  `scripts/verify-host.mjs`.
package/README.en.md CHANGED
@@ -207,7 +207,7 @@ Contributing guide: [CONTRIBUTING.md](CONTRIBUTING.md).
207
207
  Releases go out through GitHub Actions Trusted Publishing (OIDC, no stored `NPM_TOKEN`): push a `v<version>` tag and CI publishes with Sigstore provenance.
208
208
 
209
209
  ```sh
210
- npm version patch && git push origin main --tags
210
+ npm version patch && git push origin <branch> --tags
211
211
  ```
212
212
 
213
213
  One-time npm-side setup and the full workflow details: [docs/release/release.md](docs/release/release.md).
package/README.md CHANGED
@@ -168,7 +168,7 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
168
168
 
169
169
  ## 安全
170
170
 
171
- 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份存储于 `~/.dsh/rewind-snapshots/`;还原以备份为唯一来源。不触碰你的 git 仓库,无网络请求,不访问任何凭据。对**长期不活跃**的会话,另有默认关闭的全局自动清理可整目录移除其快照,不影响活动会话与对话日志。完整安全模型:[SECURITY.md](SECURITY.md)。
171
+ 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份存储于 `<dsh home>/rewind-snapshots/`;还原以备份为唯一来源。不触碰你的 git 仓库,无网络请求,不访问任何凭据。对**长期不活跃**的会话,另有默认关闭的全局自动清理可整目录移除其快照,不影响活动会话与对话日志。完整安全模型:[SECURITY.md](SECURITY.md)。
172
172
 
173
173
  ## 开发
174
174
 
@@ -192,7 +192,7 @@ node scripts/verify-host.mjs # 端到端验证构建产物
192
192
  通过 GitHub Actions Trusted Publishing(OIDC,无存储 `NPM_TOKEN`)发布:推送 `v<版本>` tag,CI 即带 Sigstore provenance 发布。
193
193
 
194
194
  ```sh
195
- npm version patch && git push origin main --tags
195
+ npm version patch && git push origin <branch> --tags
196
196
  ```
197
197
 
198
198
  一次性 npm 侧配置与完整流程:见 [docs/release/release.zh.md](docs/release/release.zh.md)。
package/SECURITY.md CHANGED
@@ -12,9 +12,8 @@ The plugin runs in the DSH host process and therefore holds the host user's
12
12
  filesystem authority — it reads and writes files with plain `node:fs`. The
13
13
  following are treated as **untrusted inputs**:
14
14
 
15
- - **Model arguments** — the `file_path` / `path` / `command` fields of write,
16
- `edit`, and `str_replace_editor` tool calls (they name the paths the
17
- checkpoint store records).
15
+ - **Model arguments** — the `file_path` field of `write` / `edit` tool calls
16
+ (it names the paths the checkpoint store records).
18
17
  - **Session log contents** — events are parsed structurally; a hostile or
19
18
  malformed id must never escape the store root.
20
19
  - **Current worktree state** — restore planning reconciles against the live
@@ -57,7 +56,13 @@ to exactly the apply-start state, and per-file failures are reported rather
57
56
  than hidden.
58
57
 
59
58
  A failed gate fails closed: an invalid target, a missing store, an absent
60
- backup, or a cancelled invocation aborts the rewind with an error.
59
+ backup, or a cancelled invocation aborts the rewind with an error. A store
60
+ written by a **newer** build is one of those gates: it is refused as a whole —
61
+ no partial restore and no new entry written into it, and the automatic
62
+ format-change clear below is skipped — while the conversation rewind itself
63
+ still works. An explicit clear (`run --current --apply`) or the age-based sweep,
64
+ being a user-directed deletion of whole session directories, is deliberately not
65
+ version-gated.
61
66
 
62
67
  **Automatic store deletion**: two things delete session snapshots. The opt-in
63
68
  `snapshot-auto-cleanup` sweep (default off) removes the whole directories of
@@ -68,7 +73,9 @@ positions that no longer line up. Both stay confined to the store root, use
68
73
  `lstat` (so they never follow a symlink out of the root), the sweep never targets
69
74
  the active session, and neither touches the conversation log. When auto-cleanup
70
75
  is disabled (the default), only the format-change clear runs, and only for an
71
- affected session.
76
+ affected session. The store-format guard runs before that clear, so a session
77
+ whose store is **newer** than this build understands is not cleared by it (see
78
+ `docs/format.md`).
72
79
 
73
80
  ## Conversation integrity
74
81
 
@@ -91,29 +98,49 @@ transcript.
91
98
  `[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
92
99
  root; `.` and `..` bare values are replaced — hostile ids cannot traverse
93
100
  out of the root.
94
- - **Never written through links**: symlinked and hard-linked paths
95
- (`lstat().nlink > 1`) are skipped and reported, never restored a symlink
96
- would redirect the write outside the checkpoint, and a hard link would
97
- clobber every other name of the same inode (e.g. pnpm-installed files).
101
+ - **Never written through a link or a moved directory**: two checks guard a
102
+ tracked path before any write or unlink. (1) A path whose last component is a
103
+ symlink or a hard link (`lstat().nlink > 1`) is skipped and reported, never
104
+ restored a symlink would redirect the write outside the checkpoint, and a
105
+ hard link would clobber every other name of the same inode (e.g.
106
+ pnpm-installed files). (2) Every record this build writes — entry, link and
107
+ journal action — carries the `realpath` of the file's directory at commit time
108
+ (the location pin; best-effort, and absent on released-v1 data or a commit
109
+ whose parent could not be resolved), and a restore re-checks it before
110
+ touching the path, including a post-restart continue or rollback, which reads
111
+ the pin from the journal: a directory that no longer resolves there (a
112
+ repointed or moved ancestor) is refused and reported instead of writing
113
+ outside the recorded location, while data with no pin falls back to check (1)
114
+ alone. A parent chain that
115
+ is gone is still recreated (files whose directory was deleted are restorable),
116
+ but only while its nearest surviving ancestor resolves inside the pin. A
117
+ stable symlinked ancestor resolves identically on both sides, so a symlinked
118
+ workspace or temp root is never a false skip. This matches Claude Code's
119
+ checkpoint behavior since v2.1.216.
98
120
  - **Restores name only recorded paths**: the store contains resolved display
99
121
  paths of the session's own write-class tool calls (plus boundary re-checks
100
122
  over that same tracked set) — a restore can never write an arbitrary path.
101
123
  - **Path resolution rule**: relative paths resolve against the session
102
124
  workspace cwd, mirroring the fs tools' own rule (`src/session-cwd.ts`).
103
125
  - **Bounded backups**: `prune` keeps the newest 100 anchor groups per session
104
- (`MAX_ANCHOR_GROUPS`), so backup accumulation cannot grow the store without
105
- bound (the exact cap is pinned in `docs/format.md`). Across sessions, the
106
- opt-in `pruneStale` sweep removes whole **long-inactive** session directories
107
- (measured by the newest member being idle past `maxAgeDays`); it uses `lstat`
108
- (no symlink following), skips dot-prefixed temp files, and never targets the
109
- active session (`keepActiveId`).
126
+ (`MAX_ANCHOR_GROUPS`), except groups a non-terminal restore journal still
127
+ references (pinned so the op can be finished), so backup accumulation stays
128
+ bounded by the cap plus the number of unresolved journals (the exact cap is
129
+ pinned in `docs/format.md`). Across sessions, the opt-in `pruneStale` sweep
130
+ removes whole **long-inactive** session directories (measured by the newest
131
+ member being idle past `maxAgeDays`); it uses `lstat` (no symlink following),
132
+ skips dot-prefixed temp files, and never targets the active session
133
+ (`keepActiveId`).
110
134
 
111
135
  ## Crash safety
112
136
 
113
- - **Atomic commits**: every JSON write (checkpoint entries, restore journals)
114
- goes to a sibling temp file and is renamed over the target. A host crash
115
- mid-write can leave only an inert `<target>.tmp` never a readable
116
- half-written file and readers never pick up temp files.
137
+ - **Ordered commits**: a checkpoint commit places the before-sidecar (a raw
138
+ byte copy) before publishing the entry JSON, so a crash can leave an
139
+ unreferenced sidecar but never an entry whose bytes are missing. Every JSON
140
+ write (checkpoint entries, restore journals) goes to a sibling temp file and
141
+ is renamed over the target: a host crash mid-write can leave only an inert
142
+ `<target>.tmp` — never a readable half-written file — and readers never pick
143
+ up temp files.
117
144
  - **Journaled restores**: before mutating anything, the restore captures each
118
145
  planned path's pre-restore ("rescue") state and persists an intent journal,
119
146
  then marks each action done as it is applied. A crash at any point leaves
@@ -126,7 +153,9 @@ transcript.
126
153
  - **Fail-loud vs fail-soft**: a corrupt **journal** is reported
127
154
  `recovery-required` — never silently dropped (dropping it would erase the
128
155
  interrupted restore's recovery record). Corrupt **checkpoint entries** are
129
- silently ignored (they only lose one backup, not the recovery path).
156
+ silently ignored (they only lose one backup, not the recovery path), and a
157
+ record whose sidecar is missing or truncated fails only that file — never a
158
+ delete of the live file.
130
159
  - **Journal IO never fails the restore**: if the journal cannot be written the
131
160
  restore proceeds with pre-journal semantics (crash safety degrades,
132
161
  behavior does not).
@@ -136,9 +165,11 @@ transcript.
136
165
  - This plugin does **not** sandbox other processes or stop them from changing
137
166
  files concurrently.
138
167
  - It does **not** provide confidentiality or tamper resistance against the
139
- same operating-system user: state files are created with the process
140
- default permissions (no special modes are set — a standard umask applies),
141
- and the host user remains trusted.
168
+ same operating-system user: **store** files are created with the process
169
+ default permissions (a standard umask applies), and the host user remains
170
+ trusted. Restored **workspace** files do carry their recorded permission
171
+ bits: a content restore reapplies them (`chmod`, best-effort), while a mode
172
+ difference on its own never triggers a restore.
142
173
  - It does **not** exclude paths from the store, and it never oversteps: the
143
174
  plugin only adds backup/restore on top of permissions DSH already holds. Two
144
175
  distinct cases follow:
@@ -81,7 +81,8 @@ Key invariants:
81
81
  on it, so the marker is appended while idle, outside any turn, as one event.
82
82
  - **Restore is reconciled against the live disk** (`planRestore`), so repeated
83
83
  rewinds are idempotent and a rewind whose target state already matches is a
84
- no-op.
84
+ no-op. A path whose directory no longer resolves to its commit-time location
85
+ is skipped and reported, never written through (see `SECURITY.md`).
85
86
 
86
87
  ### Marker format history
87
88
 
@@ -103,19 +104,24 @@ the `0.9.x` line) and is **removed in the `0.10.x` line**.
103
104
  ## Checkpoint pipeline (Claude Code before-backup model)
104
105
 
105
106
  ```
106
- tools/execute captureBefore: for write / edit
107
- (mutating commands only), read the file's BEFORE state;
107
+ tools/execute captureBefore: for write / edit, stage a raw byte copy of
108
+ the file's BEFORE state into the store's .pending/
109
+ (node:fs copyFile, never through a string);
108
110
  subagent edits are NOT tracked (Claude Code alignment).
109
111
  tools/post-execute commitEntry: anchor = latest user/message seq; skip
110
- failed calls; write the before-backup to the store.
112
+ failed calls; publish the staged bytes as the entry's
113
+ sidecar and write the metadata beside them — including
114
+ where the directory resolved (`realpath`), the location
115
+ pin a restore re-checks.
111
116
  session/event user/message boundary: reconcileTracked re-reads every
112
117
  (user/message) tracked file and records a new before-backup for any
113
118
  whose disk state changed since last seen — external
114
119
  edits/deletions enter the record this way.
115
120
  prune keeps the newest 100 anchor groups per session, storing
116
121
  identical before-content as in-place links that are
117
- materialized before their group is dropped, and recycles
118
- terminal restore journals.
122
+ materialized before their group is dropped, never dropping
123
+ a group a non-terminal restore journal still references,
124
+ and recycles terminal restore journals.
119
125
  pruneStale cross-session auto-cleanup (default off): whole
120
126
  long-inactive session dirs past the cutoff are removed;
121
127
  the active session is never targeted.
@@ -45,7 +45,7 @@ version's shape only (no `Session.events` / `[data-time-hover-root]` /
45
45
  | I3 step/turn structure | Client turn-tail ordering, unique `step/start` (real turns), no ghost turns; the `user/message` rewind marker adds no step frame | `compat-invariants` I3, `helpers.assertTurnTailOrdering` |
46
46
  | I4 fold-service safety | stats / title / goal / projection fold a marker-bearing log without throwing, with predictable values | `compat-invariants` I4 |
47
47
  | I5 compact interop | A tool-call orphaned by a cancelled turn is pair-balanced once shadowed by a rewind; a rewind across a compaction checkpoint is explicitly refused; a rewind-then-compact transaction stays legal | `compat-interop` I5, `verify-host` 12/14 |
48
- | I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 4–8); cancellation timing never hangs | `verify-host` 4–8, 15 |
48
+ | I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 4–8) for the tracked tools `write` / `edit` — `str_replace_editor` is an optional DSH package that stopped being a default tool in DSH 0.1.3, so it is not tracked; cancellation timing never hangs | `verify-host` 4–8, 15 |
49
49
  | I7 client ordering | A log carrying tool turns and rewind markers (a single `user/message` replace) satisfies the client builder ordering | `compat-interop` I7 |
50
50
  | I8 runtime safety | `rewind`/`compact` combinations never leave a dangling step/turn frame | `verify-host` 15 |
51
51
 
@@ -9,16 +9,16 @@
9
9
  ## One-line summary
10
10
 
11
11
  Rewind **always** restores files edited with the dedicated editing tools
12
- (`write` / `edit` / `str_replace_editor`) because the plugin snapshots them
13
- before the edit. Command-line (or hand) edits are covered **only when the file
14
- was previously registered** by an editing tool in the same conversation — and,
15
- in that case, the change itself is recorded, but rewinding to a boundary inside
16
- an unchanged interval is only as precise as the next recorded state (see
12
+ (`write` / `edit`) because the plugin snapshots them before the edit.
13
+ Command-line (or hand) edits are covered **only when the file was previously
14
+ registered** by an editing tool in the same conversation — and, in that case,
15
+ the change itself is recorded, but rewinding to a boundary inside an unchanged
16
+ interval is only as precise as the next recorded state (see
17
17
  [Rewind precision](#rewind-precision-for-a-registered-file-changed-outside-editing-tools)).
18
18
 
19
19
  ## How file changes are classified
20
20
 
21
- **Editing tools** (create/overwrite, edit lines, insert): each call tells the
21
+ **Editing tools** (create/overwrite, line edits): each call tells the
22
22
  plugin *which file* it will change, so the plugin snapshots the file's current
23
23
  content **before** the edit and can restore it on rewind. This path is
24
24
  deterministic — using them always yields a restorable rewind.
@@ -4,11 +4,11 @@
4
4
 
5
5
  ## 一句话概括
6
6
 
7
- 回退**始终能还原**用专用编辑工具(`write` / `edit` / `str_replace_editor`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
7
+ 回退**始终能还原**用专用编辑工具(`write` / `edit`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
8
8
 
9
9
  ## 文件变更的分类
10
10
 
11
- **编辑工具**(创建/覆盖、按行编辑、插入):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
11
+ **编辑工具**(创建/覆盖、按行编辑):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
12
12
 
13
13
  **命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
14
14
 
package/docs/format.md CHANGED
@@ -16,63 +16,105 @@ Deleting the root only removes file backups; the store rebuilds from scratch.
16
16
  ```
17
17
  <root>/
18
18
  └── <sessionId>/ # safeSessionId(sessionId)
19
+ ├── store # store-format marker ("2")
20
+ ├── format # DSH session-format marker (session.header.version)
19
21
  ├── <anchorSeq>/ # decimal seq of the anchoring user/message
20
- └── <callId>.json # one committed before-backup
21
- └── restore-journal-<opId>.json # one restore-op journal
22
+ ├── <base>.json # one committed before-backup (metadata)
23
+ └── <base>.before # its raw byte sidecar (the before content)
24
+ ├── .pending/ # captures staged but not yet committed
25
+ │ └── <base>.before
26
+ ├── rescue/<opId>/<n>.before # one pre-restore ("rescue") copy per op
27
+ └── journal-<opId>.json # one restore-op journal
22
28
  ```
23
29
 
24
30
  - `sessionId` is sanitized to `[a-zA-Z0-9._-]`; the bare values `.` and `..`
25
31
  are replaced (`safeSessionId`) so a hostile id cannot traverse out of the
26
32
  root.
27
- - `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`).
33
+ - `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`) and extended with an
34
+ 8-hex digest of the unsanitized id — `<base>` is
35
+ `<safeFileId(callId)>-<sha256(callId)[0..8]>`, so two call ids that sanitize
36
+ to the same name (e.g. `a:b` / `a_b`) cannot collide. Readers never infer a
37
+ name: every reference names its file, so pre-digest (released v1) names keep
38
+ resolving.
28
39
  - `<anchorSeq>` is a decimal integer; directories with non-integer names are
29
40
  ignored by readers.
30
- - Journal files are recognized by the `restore-journal-` prefix; everything
31
- else ending in `.json` under the session dir is treated as a checkpoint
32
- entry.
41
+ - Checkpoint entries are read from those numeric directories only
42
+ (`<anchorSeq>/<base>.json`). Neither marker is `.json`, and a stray `.json` in
43
+ the session root is ignored.
44
+ - Journal files are recognized by the `journal-` prefix (current format) or the
45
+ released `restore-journal-` prefix (read-only compatibility).
46
+ - `store` is the session's store-format marker (a decimal version, written
47
+ atomically); a missing marker means the released v1 string format. `format` is
48
+ the DSH **session**-format marker the snapshots were anchored under (see
49
+ `docs/snapshot-auto-cleanup.md`) — the two are independent.
33
50
 
34
51
  ## Checkpoint entry
35
52
 
36
- One JSON file per before-backup, named `<callId>.json`:
53
+ One JSON file per before-backup, named `<base>.json` next to its `<base>.before`
54
+ sidecar:
37
55
 
38
56
  ```ts
39
- interface CheckpointEntry {
40
- callId: string // the tool call that mutated the file
41
- anchorSeq: number // seq of the user message anchoring the turn of the change
42
- path: string // resolved display path (absolute)
43
- before: string | null // full content before the change; null = file was created
44
- time: number // epoch ms, strictly increasing within a store instance
57
+ interface CheckpointEntryJson { // one `<base>.json`
58
+ store: 2 // on-disk store format (absent/1 = the released v1 string format)
59
+ callId: string // the tool call that mutated the file
60
+ file: string // resolved display path (absolute)
61
+ parent?: string // realpath of the file's directory at commit time (the location pin)
62
+ blob: string | null // sibling sidecar file name; null = the file was created
63
+ size: number // byte length of the sidecar (0 when `blob` is null)
64
+ mode?: number // permission bits, applied when content is restored
65
+ lossy?: true // recorded from v1 content that had already lost bytes
66
+ time: number // epoch ms, strictly increasing within a store instance
45
67
  }
46
68
  ```
47
69
 
70
+ A dedup link (see below) carries `callId`, `file`, `ref`, `time` and an
71
+ optional `parent` instead of `blob` and `size`.
72
+
48
73
  Semantics:
49
74
 
50
- - **`before` is the pre-edit state**: `null` means the call created the file.
51
- - **`anchorSeq` ties the backup to a user message**: rewinding to message N
52
- applies every entry anchored at or after N (the boundary is inclusive).
75
+ - **`blob` is the pre-edit state**: a string names the sidecar holding the exact
76
+ before bytes (copied raw, never through a JS string, so binary and non-UTF-8
77
+ files round-trip byte-exactly); `null` means the call created the file valid
78
+ only with `size: 0` and never with `lossy`. The name is an invariant: it must
79
+ equal the entry's own file name minus `.json` plus `.before`.
80
+ - **`anchorSeq` is the parent directory**, deliberately not a field of the
81
+ entry: rewinding to message N applies every entry anchored at or after N (the
82
+ boundary is inclusive).
83
+ - **`parent` is the location pin**: the `realpath` of `dirname(file)` at commit
84
+ time. A restore refuses the path when its directory no longer resolves there.
85
+ Only the path's FINAL component is checked for links (`lstat().nlink > 1` or a
86
+ symlink; see `SECURITY.md`), so a repointed ancestor directory would otherwise
87
+ redirect the write — or the unlink of a recorded creation — outside the
88
+ recorded location. Absent means "no pin" and falls back to the final-component
89
+ check alone.
53
90
  - **`time` is the ordering key within an anchor group**: it is monotonic per
54
91
  store instance (bumped past the previous commit), so same-millisecond
55
92
  commits stay capture-ordered and a re-read always picks the same "earliest"
56
93
  entry per path.
94
+ - **`mode` never decides an action** (a mode-only difference is a no-op); it is
95
+ applied, best-effort, only when content is written back.
96
+ - **`lossy` marks a record that had already lost bytes** (v1 content that was
97
+ decoded lossily, or a materialized link to one): it is comparable but never
98
+ written back, so a lossy record can never overwrite a live file.
57
99
  - Synthetic re-check entries (external edits/deletions seen at a user-message
58
100
  boundary) use `callId = recheck-<anchorSeq>-<sha256(path) first 8 hex>`.
59
101
 
60
102
  ### Dedup link entry
61
103
 
62
104
  A tracked file that records the same `before` content as its immediately-prior
63
- entry for that path is stored as a **link** instead of a full copy: the entry
64
- carries a `ref` (the `<anchorSeq>/<callId>.json` of that prior entry) and omits
65
- `before`, so identical content is never duplicated across entries. A reader
66
- resolves the `ref` back to the terminal real snapshot; `before: null` still
67
- means "the file was created". A `ref` is validated as a single-level,
68
- `<digits>/<callId>.json` relative reference (no traversal) so a corrupt or
69
- hostile ref cannot escape the store root when followed. Because links reference
70
- prior entries, `prune` materializes a surviving link whose `ref` lands on a
71
- group it is about to drop before deleting that group, so no kept link is left
72
- dangling.
73
-
74
- Real entries (with `before`) are unchanged and read identically before and
75
- after this addition; a link entry is a distinct kind that lacks `before`.
105
+ entry for that path is stored as a **link** instead of a second byte copy: the
106
+ entry carries a `ref` (the `<anchorSeq>/<base>.json` of that prior entry) and
107
+ omits `blob`/`size`, so identical content is never duplicated across entries. A
108
+ reader resolves the `ref` back to the terminal real snapshot; `blob: null` (i.e.
109
+ a recorded creation) still means "the file was created". A `ref` is validated as
110
+ a single-level, `<digits>/<file>.json` relative reference (no traversal) so a
111
+ corrupt or hostile ref cannot escape the store root when followed; because refs
112
+ name the actual file, a link may point at a released-v1 entry. Because links
113
+ reference prior entries, `prune` materializes a surviving link whose `ref` lands
114
+ on a group it is about to drop before deleting that group, so no kept link is
115
+ left dangling; the materialized entry keeps the bytes and the link's own
116
+ location pin, but not the referent's `mode` (a link records no permissions of
117
+ its own).
76
118
 
77
119
  ## Restore journal
78
120
 
@@ -80,8 +122,10 @@ One JSON file per restore operation, written **before any mutation** and
80
122
  updated as the pass applies:
81
123
 
82
124
  ```ts
125
+ type ByteRef = { blob: string } | { text: string } // session-relative byte file, or inline text
126
+
83
127
  interface RestoreJournal {
84
- version: 1
128
+ version: 2
85
129
  id: string // `op-<base36 ms>-<random>`; file name suffix
86
130
  sessionId: string
87
131
  targetSeq: number // rewind target the restore belongs to
@@ -94,20 +138,32 @@ interface RestoreJournal {
94
138
 
95
139
  interface RestoreJournalAction {
96
140
  path: string
97
- action: 'restore' | 'delete' // restore = write `before` back; delete = unlink
98
- before: string | null // target content for restore; null for delete
99
- rescue: string | null // pre-restore disk state; null = file was absent
141
+ action: 'restore' | 'delete' // restore = write the content back; delete = unlink
142
+ before: ByteRef | null // target content for restore; null for delete
143
+ rescue: ByteRef | null // pre-restore disk state; null = file was absent
144
+ mode?: number // recorded target permissions
145
+ rescueMode?: number // recorded pre-restore permissions
146
+ parent?: string // checkpoint-time location pin (see the entry)
100
147
  rescueError?: string // set when the rescue capture failed (rollback skips it)
101
148
  done: boolean // true once the action's fs op completed and was marked
102
149
  failed?: string // per-action failure message (the pass never aborts)
103
150
  }
104
151
  ```
105
152
 
153
+ A `{blob}` reference is safe and session-relative: `<anchorSeq>/<base>.before`
154
+ (an entry sidecar) or `rescue/<opId>/<n>.before` (a rescue copy), validated on
155
+ both write and read so a corrupt or hostile journal can never point a restore or
156
+ a rollback outside the store. `{text}` refs carry content that has no sidecar:
157
+ released-v1 entry content, or a legacy journal's inline strings.
158
+
106
159
  States: `running` and `rollback-running` are non-terminal; `completed` /
107
- `rolled-back` are terminal. A restart never rewrites the journal — a
108
- reconciliation *reports* a still non-terminal op as `interrupted` (or
109
- `recovery-required` when the journal is corrupt or a rollback could not
110
- complete), while the journal itself stays `running` / `rollback-running`.
160
+ `rolled-back` are terminal. Reconciliation *reports* a still non-terminal op as
161
+ `interrupted` (or `recovery-required` when the journal is corrupt or a rollback
162
+ could not complete), while the journal itself stays `running` /
163
+ `rollback-running`. A journal read back from a legacy file (`restore-journal-`
164
+ prefix, `version: 1`, inline strings) is rewritten IN PLACE as `version: 2` once
165
+ a redo or rollback pass completes, so the same op never leaves two divergent
166
+ versions on disk.
111
167
 
112
168
  ## Write guarantees
113
169
 
@@ -116,9 +172,29 @@ complete), while the journal itself stays `running` / `rollback-running`.
116
172
  file — never a readable half-written target — and readers ignore temp files
117
173
  (they do not end in `.json`). The next write of the same target overwrites
118
174
  a leftover temp.
175
+ - **Bytes before metadata**: a checkpoint commit places the sidecar first
176
+ (a staged capture is `rename`d out of `.pending/`; other sources are written
177
+ temp-then-rename) and only then publishes the entry JSON, so a crash can
178
+ leave an unreferenced sidecar but never an entry whose bytes are missing.
179
+ A sidecar that is missing or shorter than `size` is a per-file failure, never
180
+ a silent "the file was created".
181
+ - **Pinned location**: every record this build writes — entry, link and journal
182
+ action — carries where the tracked file's directory resolved at commit time
183
+ (the `parent` pin, best-effort), and a restore re-checks it before touching the
184
+ path — the initial pass, a post-restart `continueRestore` and a
185
+ `rollbackRestore` alike (the journal carries the pin). A directory that
186
+ resolves elsewhere is refused and reported, so a restore can never write or
187
+ unlink outside the recorded location; a record with no pin (released-v1 data,
188
+ or a commit whose parent could not be resolved) falls back to the
189
+ final-component link check alone. A parent chain that is GONE is still
190
+ recreated — the plugin restores files whose directory was deleted — but only
191
+ while its nearest surviving ancestor resolves inside the pin. A stable
192
+ symlinked ancestor is never refused: both sides of the comparison are
193
+ `realpath`s.
119
194
  - **Journal before mutation**: the rescue state of every planned path is
120
- captured and the intent journal persisted atomically BEFORE the first fs
121
- mutation; each action is marked `done` as it is applied.
195
+ captured as a raw byte copy and the intent journal references only
196
+ persisted atomically BEFORE the first fs mutation; each action is marked
197
+ `done` as it is applied.
122
198
  - **Disk is truth**: after a restart, reconciliation compares the real disk
123
199
  against each action's goal (the restore target for `running` journals, the
124
200
  rescue state for `rollback-running` / `recovery-required` ones). A path
@@ -126,32 +202,62 @@ complete), while the journal itself stays `running` / `rollback-running`.
126
202
  - **Bounded storage**: `prune` keeps the newest 100 anchor groups per session
127
203
  (`MAX_ANCHOR_GROUPS`), materializing any surviving dedup link that references
128
204
  a group being dropped before deleting whole anchor directories; it also
129
- recycles terminal journals (`completed` / `rolled-back`). Non-terminal and
130
- corrupt journals are always kept. Across sessions, `pruneStale` removes whole
131
- long-inactive session directories whose newest member stamp is older than a
132
- configurable idle cutoff (default off), so the store root does not grow
133
- without bound either.
205
+ recycles terminal journals (`completed` / `rolled-back`) together with their
206
+ `rescue/<opId>/` bytes, and collects `.pending/` captures older than 24 h that
207
+ no commit consumed. A group a non-terminal journal still references is pinned
208
+ evicting it would make "continue finishes the interrupted op" impossible
209
+ so the effective window may exceed `keep` until that op is resolved.
210
+ Non-terminal and corrupt journals are always kept. Across sessions,
211
+ `pruneStale` removes whole long-inactive session directories whose newest
212
+ member stamp is older than a configurable idle cutoff (default off), so the
213
+ store root does not grow without bound either.
134
214
 
135
215
  ## Validation and failure policy
136
216
 
137
- - **Entries**: a missing or malformed entry is read as `undefined` (silently
138
- skipped) — losing one backup, never the recovery path.
217
+ - **Entries**: a missing, malformed, or self-contradictory entry is read as
218
+ `undefined` (silently skipped) — losing one backup, never the recovery path.
219
+ Contradictions are never guessed at: a `blob: null` with `size !== 0` or with
220
+ `lossy`, a `blob` that is not this entry's sidecar name, and a non-string
221
+ `before` in a v1 record are all corruption, and guessing an entry's kind is
222
+ how a restore turns into a delete. An absent or malformed `parent` is not
223
+ corruption: it means "no pin", so the entry falls back to the released
224
+ final-component rule. Two cases are *not* silent skips: an entry
225
+ whose `store` is newer than this build fails the whole operation closed (see
226
+ Versioning), and a record whose sidecar is missing or too short is a per-file
227
+ failure that the restore reports — never a delete of the live file.
139
228
  - **Journals**: a corrupt or schema-invalid journal **fails loud** —
140
229
  `reconcileRestores` reports it as `recovery-required` and never drops it,
141
230
  because dropping it would silently erase the interrupted restore's recovery
142
- record.
231
+ record. A `ref` with a traversal, absolute, or unknown-root segment counts as
232
+ corrupt.
143
233
  - **Journal IO**: best-effort by design — if a journal cannot be written, the
144
234
  restore proceeds with pre-journal semantics (crash safety degrades,
145
235
  behavior does not).
146
236
 
147
237
  ## Versioning policy
148
238
 
149
- The journal schema is `version: 1` (currently descriptive `isRestoreJournal`
150
- validates shape, not version). Checkpoint entries carry no version field. A
151
- future incompatible format should either make readers reject an unknown
152
- `version` (there is no best-effort fallback or legacy coercion) or move the
153
- state root (e.g. `rewind-snapshots/v2`) and ship an explicit migration tool.
154
- Old-format data is never silently re-interpreted.
239
+ The format is versioned twice: a session-level `store` marker (the value is the
240
+ current `CURRENT_STORE_VERSION`, 2) and a self-describing field on every record
241
+ (`store` on entries, `version` on journals). A missing `store` marker (or `1`)
242
+ means the released v1 string format, which is still read but never migrated. A
243
+ `store` **above** the current version fails the whole operation closed — no file
244
+ restore and no new entry written into a store a newer build owns (an explicit
245
+ clear or the age-based sweep is not version-gated) — while the conversation
246
+ rewind itself keeps working (it does not depend on snapshots). A journal whose
247
+ `version` is present but neither 1 nor 2 is corrupt and is reported
248
+ `recovery-required` (see Validation).
249
+
250
+ Because compatibility is not safe in both directions, the byte format reuses
251
+ none of v1's path/state keys (`path`, `anchorSeq`, `before`) and does not carry
252
+ `anchorSeq` (it equals the parent directory). A released v1 build therefore
253
+ rejects each such entry instead of reading it as "the file was created", so a
254
+ downgrade cannot delete workspace files; that field-name contract is pinned by
255
+ `tests/downgrade-safety.test.ts`. Compatibility means reading old data, not
256
+ repairing it: bytes the v1 build had already lost cannot be recovered.
257
+
258
+ A future incompatible format should raise the marker/version (readers already
259
+ fail closed above their own version) or move the state root and ship an explicit
260
+ migration tool. Old-format data is never silently re-interpreted.
155
261
 
156
262
  ## Cleanup policy persistence
157
263
 
@@ -50,19 +50,17 @@ The auto-cleanup switch and the idle-day cutoff live in the **dsh-settings confi
50
50
 
51
51
  ## When automatic cleanup runs
52
52
 
53
- Automatic cleanup checks at most **once per run** (a restart lets it check again),
54
- on the first session activity (a message you send or a tool call that finishes),
55
- and only when it's enabled and at least 24 hours have passed since the last
56
- check. The 24-hour clock is saved to `<dsh home>/snapshot-cleanup-last-sweep.json`,
57
- so restarting doesn't reset it. It runs in the background and never blocks
58
- what you're doing.
59
-
60
- If you want a change to take effect right away, use `run`; automatic cleanup
61
- picks up a fresh setting on the next run.
53
+ When it's enabled, automatic cleanup runs in the background at most **once a
54
+ day** and only after 24 hours have passed since the last cleanup. The clock is
55
+ saved to `<dsh home>/snapshot-cleanup-last-sweep.json`, so restarting the host
56
+ can't make it run early. To clean up right now instead of waiting, use
57
+ `/snapshot-auto-cleanup run`.
62
58
 
63
59
  ## Safety
64
60
 
65
- - Only rewind **snapshots** (the file backups) are ever removed. Your
61
+ - Only rewind **snapshots** are ever removed: a session's snapshot directory
62
+ holds the before-write file backups plus their bookkeeping (staged captures,
63
+ rescue copies, restore journals), and that is all the cleanup touches. Your
66
64
  conversation is never touched, and the plugin never rewrites or deletes your
67
65
  session history.
68
66
  - Automatic cleanup never removes your **active** session's snapshots — only
@@ -38,13 +38,11 @@
38
38
 
39
39
  ## 自动清理何时运行
40
40
 
41
- 自动清理每次运行(宿主启动)**最多检查一次**,发生在首次活动时(你发出一条消息或某次工具调用完成),且仅在**已开启**且距上次检查**已满 24 小时**时才执行。这个 24 小时时钟会写入 `<dsh home>/snapshot-cleanup-last-sweep.json`,重启不会重置。它后台运行,从不阻塞你在做的事。
42
-
43
- 想让改动立即生效,请用 `run`;自动清理要到下次运行才会采用新设置。
41
+ 开启后,自动清理在后台运行,**一天最多一次**——且距上次清理已满 24 小时才会执行。这个时钟保存在 `<dsh home>/snapshot-cleanup-last-sweep.json`,所以重启宿主也不会让它提前运行。想立刻清理而不等待,用 `/snapshot-auto-cleanup run`。
44
42
 
45
43
  ## 安全
46
44
 
47
- - 只会移除回退用的**快照**(那份文件备份)。你的对话绝不会被动到,插件也绝不改写或删除你的会话历史。
45
+ - 只会移除回退用的**快照**:一个会话的快照目录里只有写前文件备份及其附属记录(暂存副本、救援副本、还原日志),清理也只动这些。你的对话绝不会被动到,插件也绝不改写或删除你的会话历史。
48
46
  - 自动清理**永不**移除你**当前活跃**会话的快照——只清理已失活超过阈值的会话。
49
47
  - `run --current` 会清空当前会话的快照。这对该会话的文件回退历史是**不可逆**的:你无法回退到清空之前的代码状态,但你的对话保持完整,会话会从"现在"开始重新记录快照。
50
48