dsh-rewind-plugin 0.4.2 → 0.5.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.
@@ -0,0 +1,128 @@
1
+ # Architecture
2
+
3
+ How dsh-rewind is built: module layering, the rewind pipeline, the checkpoint
4
+ pipeline, the compatibility strategy, and the roadmap. The module map below is
5
+ the same one in `AGENTS.md`; this document adds the wiring between the
6
+ modules. The durable on-disk format is specified separately in
7
+ `docs/format.md`, the security model in `SECURITY.md`.
8
+
9
+ ## Purpose
10
+
11
+ One thing: rewind a conversation **in place** to any earlier user message —
12
+ never forking a session or switching windows — with an optional Claude-Code-
13
+ style workspace file restore (`both` mode). No network, no git operations, no
14
+ credentials access.
15
+
16
+ ## Module layering
17
+
18
+ ```
19
+ src/
20
+ ├── index.ts host plugin: /rewind command + checkpoint pipeline
21
+ ├── rewind.ts pure planning: target parsing, surface-range plan,
22
+ │ candidate listing, marker turn/step math
23
+ ├── snapshot.ts checkpoint store: disk before-backups, journaled restore,
24
+ │ reconcile / continue / rollback, bounded prune
25
+ ├── session-cwd.ts session working-directory resolution (fs-tools rule)
26
+ └── client/ browser half: per-message ↶ button (portal bridge),
27
+ mode popover, hidden-span computation, candidate parsing,
28
+ locales, styles
29
+ ```
30
+
31
+ Two dependency rules keep the design testable:
32
+
33
+ 1. **`rewind.ts` is pure** — no I/O, no `Session` dependency; everything
34
+ derives from the event log + ordered surface, so the whole planning layer
35
+ is unit-testable without a host.
36
+ 2. **`snapshot.ts` is host-independent** — it talks to the disk through plain
37
+ `node:fs` plus injected seams (`DiskProbe`, `DeleteFile`, a test-only
38
+ `crash` hook), so the store is testable without the harness.
39
+
40
+ The client never reads the DOM to derive rewind state; it consumes the host's
41
+ machine channels (see [Compatibility strategy](#compatibility-strategy)).
42
+
43
+ ## Rewind pipeline
44
+
45
+ ```
46
+ ↶ button / /rewind @<seq> both
47
+ → handleRewind: parseRewindTarget + planRewind (target must be a
48
+ user/message currently on the surface)
49
+ → agent.cancel({ keepInbox: true }) if running; waitForAgentIdle
50
+ → dropPendingSteering (next-step inbox only; queued messages untouched)
51
+ → append ghost step frame: step/start (turn = last started turn,
52
+ step = that turn's next unused number) … marker … step/end
53
+ → marker = empty assistant/message with
54
+ surfaceOp { op: 'replace', start, end } over every surface node
55
+ after the target (+ sourceEventSeqs = shadowed seqs)
56
+ → if mode 'both': store.restoreAfter(targetSeq) + syncRestoreObservations
57
+ → result text carries machine tokens (impact=<n>, restore:/delete: lines)
58
+ → client: hides withdrawn rows (data-dsh-rewind-hidden), refills composer
59
+ with the target message's text
60
+ ```
61
+
62
+ Key invariants:
63
+
64
+ - **The log is append-only.** The marker is the *only* mutation: it cuts the
65
+ model-visible surface, never the raw history (search/export still see it).
66
+ - **The marker is empty** (`content: []`, derives to `null` in the model
67
+ context) so it renders nothing and enters no model context.
68
+ - **The ghost step frame exists for the token-meter**: replay requires every
69
+ `assistant/message` inside an open step of the same `(turn, step)`; a bare
70
+ marker appended while idle would throw on the next `measure()` and silently
71
+ break `/compact`. The turn/step numbers are chosen so they can never collide
72
+ with a future real turn (`markerTurnOf` / `markerStepOf` — see
73
+ `src/rewind.ts`).
74
+ - **Restore is reconciled against the live disk** (`planRestore`), so repeated
75
+ rewinds are idempotent and a rewind whose target state already matches is a
76
+ no-op.
77
+
78
+ ## Checkpoint pipeline (Claude Code before-backup model)
79
+
80
+ ```
81
+ tools/execute captureBefore: for write / edit / str_replace_editor
82
+ (mutating commands only), read the file's BEFORE state;
83
+ subagent edits are NOT tracked (Claude Code alignment).
84
+ tools/post-execute commitEntry: anchor = latest user/message seq; skip
85
+ failed calls; write the before-backup to the store.
86
+ session/event user/message boundary: reconcileTracked re-reads every
87
+ (user/message) tracked file and records a new before-backup for any
88
+ whose disk state changed since last seen — external
89
+ edits/deletions enter the record this way.
90
+ prune keeps the newest 100 anchor groups per session, storing
91
+ identical before-content as in-place links that are
92
+ materialized before their group is dropped, and recycles
93
+ terminal restore journals.
94
+ pruneStale cross-session auto-cleanup (default off): whole
95
+ long-inactive session dirs past the cutoff are removed;
96
+ the active session is never targeted.
97
+ ```
98
+
99
+ ## Compatibility strategy
100
+
101
+ - **Peer ranges as OR-union of DSH rc tuples** (`^0.1.0-rc.6 || ^0.1.1-rc.2`):
102
+ npm's prerelease rules require the peer range to share the host's
103
+ `[major, minor, patch]` tuple; `scripts/check-dsh-version.mjs` detects when
104
+ a new tuple requires appending. See `docs/release/release.md`.
105
+ - **Test-driven investigation**: `tests/compat-invariants.ts` /
106
+ `compat-interop` / `compat-gaps` probe harness behavior and pin findings in
107
+ `docs/compat/audit.md`; `scripts/verify-host.mjs` runs a real end-to-end
108
+ rewind + `/compact` chain (the full check suite).
109
+ - **Stable machine channels for third parties**: `dsh-rewind-plugin/client`
110
+ exports the pure hidden-span computation; withdrawn rows carry
111
+ `data-dsh-rewind-hidden`; both are semver-protected
112
+ (`docs/contract/client-contract.md`). DOM coupling is minimized to a small
113
+ set of marked rows plus a structurally-typed slot registration.
114
+
115
+ ## Roadmap
116
+
117
+ Ideas under consideration, not commitments:
118
+
119
+ - **Multi-process identity/lock**: the current in-flight guard is
120
+ per-process; a cross-process exclusive lock (like the change-ledger
121
+ competitors) would cover multiple host processes on one worktree.
122
+ - **Lazy-commit UX**: an explicit confirm-then-apply step (a pending state)
123
+ to reduce mis-touch risk on in-window rewinds, which are inherently less
124
+ reversible than forked branches.
125
+ - **Locale expansion**: client/host copy is zh/en today; the copy layer
126
+ (`src/locales.ts`, `src/client/locales.ts`) is already keyed for more.
127
+ - **Composer re-send polish**: the target text is already refilled after a
128
+ rewind; a first-class "edit and re-send" affordance is a small extension.
@@ -0,0 +1,103 @@
1
+ # File-rewind tracking boundary
2
+
3
+ > Behavioral note — this is **not** a bug. The plugin's file rewind deliberately
4
+ > tracks only the model's dedicated editing tools, and applies an additional
5
+ > boundary (message-boundary re-check) to registered files. Written for issue
6
+ > [#5](https://github.com/SiriLee/dsh-rewind/issues/5); aligned with Claude Code's
7
+ > checkpoint semantics. [中文](tracking-boundary.zh.md)
8
+
9
+ ## One-line summary
10
+
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
17
+ [Rewind precision](#rewind-precision-for-a-registered-file-changed-outside-editing-tools)).
18
+
19
+ ## How file changes are classified
20
+
21
+ **Editing tools** (create/overwrite, edit lines, insert): each call tells the
22
+ plugin *which file* it will change, so the plugin snapshots the file's current
23
+ content **before** the edit and can restore it on rewind. This path is
24
+ deterministic — using them always yields a restorable rewind.
25
+
26
+ **Command-line or manual edits**: PowerShell (`pwsh`) writes on Windows, `sed -i`
27
+ on Linux, or saving a file by hand. These do **not** tell the plugin which file
28
+ is being touched, so there is nothing to snapshot beforehand.
29
+
30
+ Command/manual edits are nevertheless covered in one case: if the file was
31
+ **previously registered** by an editing tool in this conversation, the plugin
32
+ re-checks registered files at each user-message boundary and records a change it
33
+ sees, so those files restore on rewind too.
34
+
35
+ ## When a rewind is not available
36
+
37
+ These are cases where there is **no recorded pre-change state at all** (as
38
+ opposed to the precision gap in the next section, where a state exists but an
39
+ intermediate message's exact value is lost):
40
+
41
+ 1. **The model edits the file with a command on its first touch, and the file
42
+ was never registered.** No snapshot, no registration — nothing was recorded
43
+ around the change, so there is nothing to restore from. Every rewind target
44
+ reports no restorable change for this file.
45
+ 2. **The file was manually edited before the model's first editing-tool change.**
46
+ The plugin's first snapshot is taken at the editing-tool call, so rewind can
47
+ return the file only to its state *after* the manual edit, not before it.
48
+
49
+ Both stem from a deliberate **lightweight** trade-off. Supporting command edits
50
+ in every case would require scanning the whole workspace and classifying what
51
+ arbitrary commands changed — costly, error-prone, hard to maintain. Instead the
52
+ plugin registers only the editing tools that hand it an exact path, and relies
53
+ on the message-boundary re-check as its lightweight safety net. The cost is that
54
+ unregistered files' command edits are not recorded; the benefit is a simple,
55
+ reliable, maintainable plugin — the same behavior Claude Code exhibits.
56
+
57
+ ## Rewind precision for a registered file changed outside editing tools
58
+
59
+ A registered file's snapshots come only from (a) an editing tool's before-capture
60
+ and (b) the boundary's change-detection. So a message is a *recording point* for
61
+ a file only when the file's state differs from its most recent recorded state;
62
+ rewinding to message *M* restores each file to the earliest entry at/after *M*.
63
+
64
+ If the file was **unchanged** at *M* (relative to its last record), then *M* is
65
+ **not** a recording point. If the file is later changed by a command/manual
66
+ edit, the earliest entry at/after *M* is the one that recorded the **post-change**
67
+ state — so rewinding to *M* restores that post-change state, not the state the
68
+ file actually held at *M*:
69
+
70
+ ```
71
+ message 1 write sets f = "X" records before=null (file created)
72
+ message 2 boundary records f = "X" (null → X)
73
+ a command changes f "X" → "Y"
74
+ message 3 boundary records f = "Y" (X → Y)
75
+ rewind to message 2 → restores "X" (correct: f was "X" at message 2)
76
+ ```
77
+
78
+ Now insert one *unchanged* message, and the precision gap appears:
79
+
80
+ ```
81
+ message 1 write sets f = "X" records before=null (file created)
82
+ message 2 boundary records f = "X" (null → X)
83
+ message 3 reply, f still "X" NOT a recording point (unchanged)
84
+ a command changes f "X" → "Y"
85
+ message 4 boundary records f = "Y" (X → Y)
86
+ rewind to message 3 → restores "Y" (wrong: f was "X" at message 3)
87
+ ```
88
+
89
+ Message 2 is a recording point, so rewinding to it recovers the pre-change state
90
+ `X`; message 3 is not, so its exact state is lost once the command changes the
91
+ file. Only recording every registered file at every message — even when nothing
92
+ changed — would close this, at the cost of a per-message file per registered
93
+ file. The plugin avoids that to stay lightweight (see
94
+ [Position](#position)). Claude Code's checkpoints behave the same way.
95
+
96
+ ## Position
97
+
98
+ Making command edits restorable in the *unregistered-first-touch* case would
99
+ mean repeatedly backing up the entire workspace, or depending on a Git working
100
+ tree — both heavy, and at odds with the plugin's lightweight-snapshot design.
101
+ Git is already the right tool for workspace-change management. The plugin's
102
+ position is to favor **fast, conversation-scoped rewinds**, so this capability
103
+ is deliberately not adopted.
@@ -0,0 +1,55 @@
1
+ # 文件回退的追踪边界
2
+
3
+ > 行为说明——这**不是 bug**。插件的文件回退**有意只追踪模型的专用编辑工具**,并对**已登记文件**额外施加一层边界(消息边界复查)。为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写;与 Claude Code 的 checkpoint 语义保持一致。 [English](tracking-boundary.md)
4
+
5
+ ## 一句话概括
6
+
7
+ 回退**始终能还原**用专用编辑工具(`write` / `edit` / `str_replace_editor`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
8
+
9
+ ## 文件变更的分类
10
+
11
+ **编辑工具**(创建/覆盖、按行编辑、插入):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
12
+
13
+ **命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
14
+
15
+ 命令/手动编辑仍有一种情况会被覆盖:若该文件在本会话中**先前被某个编辑工具登记过**,插件会在每个用户消息边界复查已登记文件并记录它看到的变更,因此这类文件也能在回退时还原。
16
+
17
+ ## 何时无法回退
18
+
19
+ 这类是**完全没有记录到"变更前"状态**(有别于下一节的"精度缺口"——那是有记录、但某个中间消息的精确值丢失):
20
+
21
+ 1. **模型第一次触达时用命令编辑,且该文件从未被登记。** 无快照、无登记——变更前后都没有记录,无从还原;回退到任何消息对该文件都报告"无变更可恢复"。
22
+ 2. **文件在模型第一次编辑工具改动前就被手动编辑过。** 插件的首个快照是在编辑工具调用时拍的,因此回退只能把文件还原到手动编辑**之后**的状态,而非**之前**。
23
+
24
+ 两者都来自**刻意求轻**的取舍。要覆盖各种命令编辑,就得扫描整个工作区、并分类任意命令改了什么——代价高、易错、难维护。插件只登记会交出手确切路径的编辑工具,并以消息边界复查作为轻量安全网。代价是未登记文件的命令编辑不被记录;收益是简单、可靠、易维护——与 Claude Code 相同。
25
+
26
+ ## 已登记文件被编辑工具之外的方式改动时的回退精度
27
+
28
+ 已登记文件的快照只来自:(a) 编辑工具的 before 捕获,(b) 边界复查的变更检测。所以**只有"文件状态相对上次记录发生变化"的那条消息才是记录点**;回退到消息 *M* 会把各文件还原到 *M* 之后**最早**的那条记录。
29
+
30
+ 若文件在 *M* 处**未变**(相对上次记录),则 *M* **不是记录点**。若随后文件被命令/手动编辑改掉,*M* 之后最早的那条记录是**变更后**状态——于是回退到 *M* 会还原成**变更后**状态,而非 *M* 时刻文件**真实**的状态:
31
+
32
+ ```
33
+ 消息 1 write 设 f="X" 记录 before=null(文件新建)
34
+ 消息 2 边界记录 f="X" (null→X)
35
+ 命令把 f "X"→"Y"
36
+ 消息 3 边界记录 f="Y" (X→Y)
37
+ 回退到消息 2 → 还原成 "X" (正确:消息 2 时 f 是 "X")
38
+ ```
39
+
40
+ 插入一条**未变**消息,精度缺口才显现:
41
+
42
+ ```
43
+ 消息 1 write 设 f="X" 记录 before=null(文件新建)
44
+ 消息 2 边界记录 f="X" (null→X)
45
+ 消息 3 回复,f 仍="X" 不是记录点(未变)
46
+ 命令把 f "X"→"Y"
47
+ 消息 4 边界记录 f="Y" (X→Y)
48
+ 回退到消息 3 → 还原成 "Y" (错:消息 3 时 f 实际是 "X")
49
+ ```
50
+
51
+ 消息 2 是记录点,所以回退它能恢复**变更前**状态 `X`;消息 3 不是记录点,一旦文件被命令改掉,它那刻的精确状态就丢了。要关闭这个缺口,就得让每条边界都是快照——每条消息都记录每个已登记文件(哪怕没变)——代价是**每条消息×每个文件都写一个文件**。插件**刻意不做**,以保持轻量(见[定位](#定位))。Claude Code 的 checkpoint 同样如此。
52
+
53
+ ## 定位
54
+
55
+ 要把"未登记首触即命令编辑"的情形也做成可还原,意味着对整个工作区反复备份,或依赖 Git 工作树——都笨重,且与插件"轻量快照"的设计相悖。Git 才是管理工作区变更的正确工具。插件的定位是**快速、会话内回退**,因此**刻意不采纳**该能力。
@@ -0,0 +1,41 @@
1
+ # Troubleshooting
2
+
3
+ [简体中文](troubleshooting.zh.md)
4
+
5
+ ## History load failure: `…turn-tail… received an update before its start Match`
6
+
7
+ Rewinds from `≤ 0.2.4` collided with the next real turn's `turn/start`, so reopening the session showed
8
+
9
+ ```
10
+ Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)
11
+ ```
12
+
13
+ and the history vanished. Rewinds from `0.2.5` on no longer collide; already-corrupted sessions need an offline repair (the log is append-only). The repair tool (`dsh-rewind-repair`) is no longer shipped from v0.4.0 — install a pre-v0.4.0 release to get it (fully quit dsh web / host first, then):
14
+
15
+ ```sh
16
+ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
17
+ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # preview only
18
+ ```
19
+
20
+ It only rewrites the marker events' `data.turn` (seqs, order, and zstd frame structure intact) and backs up the original file before writing — safe to run repeatedly. From a source checkout of a pre-v0.4.0 tag: `node scripts/repair-markers.mjs` (identical flags).
21
+
22
+ ## Known compatibility boundaries
23
+
24
+ Behavioral notes — none of these is a crash — verified by the compatibility
25
+ probe suites; the audit [audit.md](audit.md) is the source of truth and lists
26
+ the probes that pin each one:
27
+
28
+ - Session stats / telemetry do **not** roll back with a rewind.
29
+ - Withdrawn messages stay **searchable and exported** (`/export` and full-text
30
+ search read the raw log).
31
+ - Session titles may **regenerate** (title derives from the current surface).
32
+ - Files written by a **cancelled tool call** (write happened, no snapshot
33
+ commit) cannot be restored by "conversation and code".
34
+
35
+ **R-OPENSTEP** (harness-side, plugin does not guard): a session log carrying an
36
+ *unclosed* `step/start` (a crash before the agent loop's `finally` closed the
37
+ step) makes later step activity break token-meter replay, so `/compact` can
38
+ fail after a rewind. Harness `0.1.1-rc.2` fixes the crash path on load
39
+ (`interruptedTurnClosers`); a plugin-side up-front rejection was tried and
40
+ **reverted** (`177ec14`, false positives on real logs). Deep analysis:
41
+ [audit.md](audit.md) → R-OPENSTEP.
@@ -12,3 +12,18 @@ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-r
12
12
  ```
13
13
 
14
14
  工具只改写标记事件的 `data.turn`(保持 seq / 顺序 / zstd 帧结构不变),改前自动备份原文件——可安全重复运行。源码方式:v0.4.0 之前的 tag 下 `node scripts/repair-markers.mjs`(参数相同)。
15
+
16
+ ## 已知兼容边界
17
+
18
+ 以下均为**预期行为而非崩溃**(探针钉住,完整依据见 [audit.md](audit.md) 事实源):
19
+
20
+ - 会话统计 / 遥测**不回退**(折叠的是完整日志);
21
+ - 被撤回的消息**仍可搜索、可导出**(`/export` 与全文搜索基于原始日志);
22
+ - 会话标题可能**重新生成**(标题由当前 surface 派生);
23
+ - 被**取消的工具调用**写入的文件(无快照提交)无法由「回退对话和代码」恢复。
24
+
25
+ **R-OPENSTEP**(harness 侧,插件不设守卫):日志中存在*未闭合* `step/start`
26
+ (agent 循环的 `finally` 闭合 step 前崩溃)时,后续 step 活动会破坏 token-meter
27
+ 重放,回退后 `/compact` 可能报错。harness `0.1.1-rc.2` 已在加载时自动闭合
28
+ (`interruptedTurnClosers`);插件曾实现前置拒绝但已回退(`177ec14`,真实日志
29
+ 误判)。深入分析:[audit.md](audit.md) → R-OPENSTEP。
package/docs/format.md ADDED
@@ -0,0 +1,150 @@
1
+ # Durable format
2
+
3
+ The on-disk format of the dsh-rewind checkpoint store, pinned as a spec. The
4
+ implementation is `src/snapshot.ts`; this document is the reference for what
5
+ readers may rely on and what a future incompatible change must do. If the code
6
+ and this spec disagree, the code wins and this spec is a bug.
7
+
8
+ ## State root
9
+
10
+ The store root defaults to `~/.dsh/rewind-snapshots/` (the dsh data
11
+ directory), overridable via the `DSH_REWIND_SNAPSHOT_DIR` environment
12
+ variable. It is a sibling of the workspace, never a subtree of it. Deleting
13
+ the root only removes file backups; the store rebuilds from scratch.
14
+
15
+ ```
16
+ <root>/
17
+ └── <sessionId>/ # safeSessionId(sessionId)
18
+ ├── <anchorSeq>/ # decimal seq of the anchoring user/message
19
+ │ └── <callId>.json # one committed before-backup
20
+ └── restore-journal-<opId>.json # one restore-op journal
21
+ ```
22
+
23
+ - `sessionId` is sanitized to `[a-zA-Z0-9._-]`; the bare values `.` and `..`
24
+ are replaced (`safeSessionId`) so a hostile id cannot traverse out of the
25
+ root.
26
+ - `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`).
27
+ - `<anchorSeq>` is a decimal integer; directories with non-integer names are
28
+ ignored by readers.
29
+ - Journal files are recognized by the `restore-journal-` prefix; everything
30
+ else ending in `.json` under the session dir is treated as a checkpoint
31
+ entry.
32
+
33
+ ## Checkpoint entry
34
+
35
+ One JSON file per before-backup, named `<callId>.json`:
36
+
37
+ ```ts
38
+ interface CheckpointEntry {
39
+ callId: string // the tool call that mutated the file
40
+ anchorSeq: number // seq of the user message anchoring the turn of the change
41
+ path: string // resolved display path (absolute)
42
+ before: string | null // full content before the change; null = file was created
43
+ time: number // epoch ms, strictly increasing within a store instance
44
+ }
45
+ ```
46
+
47
+ Semantics:
48
+
49
+ - **`before` is the pre-edit state**: `null` means the call created the file.
50
+ - **`anchorSeq` ties the backup to a user message**: rewinding to message N
51
+ applies every entry anchored at or after N (the boundary is inclusive).
52
+ - **`time` is the ordering key within an anchor group**: it is monotonic per
53
+ store instance (bumped past the previous commit), so same-millisecond
54
+ commits stay capture-ordered and a re-read always picks the same "earliest"
55
+ entry per path.
56
+ - Synthetic re-check entries (external edits/deletions seen at a user-message
57
+ boundary) use `callId = recheck-<anchorSeq>-<sha256(path) first 8 hex>`.
58
+
59
+ ### Dedup link entry
60
+
61
+ A tracked file that records the same `before` content as its immediately-prior
62
+ entry for that path is stored as a **link** instead of a full copy: the entry
63
+ carries a `ref` (the `<anchorSeq>/<callId>.json` of that prior entry) and omits
64
+ `before`, so identical content is never duplicated across entries. A reader
65
+ resolves the `ref` back to the terminal real snapshot; `before: null` still
66
+ means "the file was created". A `ref` is validated as a single-level,
67
+ `<digits>/<callId>.json` relative reference (no traversal) so a corrupt or
68
+ hostile ref cannot escape the store root when followed. Because links reference
69
+ prior entries, `prune` materializes a surviving link whose `ref` lands on a
70
+ group it is about to drop before deleting that group, so no kept link is left
71
+ dangling.
72
+
73
+ Real entries (with `before`) are unchanged and read identically before and
74
+ after this addition; a link entry is a distinct kind that lacks `before`.
75
+
76
+ ## Restore journal
77
+
78
+ One JSON file per restore operation, written **before any mutation** and
79
+ updated as the pass applies:
80
+
81
+ ```ts
82
+ interface RestoreJournal {
83
+ version: 1
84
+ id: string // `op-<base36 ms>-<random>`; file name suffix
85
+ sessionId: string
86
+ targetSeq: number // rewind target the restore belongs to
87
+ startedAt: number // epoch ms
88
+ finishedAt?: number // set on a terminal state
89
+ state: 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'recovery-required'
90
+ actions: RestoreJournalAction[]
91
+ rollbackError?: string // set when a rollback pass failed partway
92
+ }
93
+
94
+ interface RestoreJournalAction {
95
+ path: string
96
+ action: 'restore' | 'delete' // restore = write `before` back; delete = unlink
97
+ before: string | null // target content for restore; null for delete
98
+ rescue: string | null // pre-restore disk state; null = file was absent
99
+ rescueError?: string // set when the rescue capture failed (rollback skips it)
100
+ done: boolean // true once the action's fs op completed and was marked
101
+ failed?: string // per-action failure message (the pass never aborts)
102
+ }
103
+ ```
104
+
105
+ States: `running` and `rollback-running` are non-terminal; a host restart turns
106
+ them into `interrupted` (or `recovery-required` when the journal is corrupt or
107
+ a rollback could not complete). `completed` / `rolled-back` are terminal.
108
+
109
+ ## Write guarantees
110
+
111
+ - **Atomicity**: every JSON write serializes to a sibling `<target>.tmp` and
112
+ renames over the target. A crash between the two steps leaves only the temp
113
+ file — never a readable half-written target — and readers ignore temp files
114
+ (they do not end in `.json`). The next write of the same target overwrites
115
+ a leftover temp.
116
+ - **Journal before mutation**: the rescue state of every planned path is
117
+ captured and the intent journal persisted atomically BEFORE the first fs
118
+ mutation; each action is marked `done` as it is applied.
119
+ - **Disk is truth**: after a restart, reconciliation compares the real disk
120
+ against each action's goal (the restore target for `running` journals, the
121
+ rescue state for `rollback-running` / `recovery-required` ones). A path
122
+ whose disk already matches is marked done without being touched.
123
+ - **Bounded storage**: `prune` keeps the newest 100 anchor groups per session
124
+ (`MAX_ANCHOR_GROUPS`), materializing any surviving dedup link that references
125
+ a group being dropped before deleting whole anchor directories; it also
126
+ recycles terminal journals (`completed` / `rolled-back`). Non-terminal and
127
+ corrupt journals are always kept. Across sessions, `pruneStale` removes whole
128
+ long-inactive session directories whose newest member stamp is older than a
129
+ configurable idle cutoff (default off), so the store root does not grow
130
+ without bound either.
131
+
132
+ ## Validation and failure policy
133
+
134
+ - **Entries**: a missing or malformed entry is read as `undefined` (silently
135
+ skipped) — losing one backup, never the recovery path.
136
+ - **Journals**: a corrupt or schema-invalid journal **fails loud** —
137
+ `reconcileRestores` reports it as `recovery-required` and never drops it,
138
+ because dropping it would silently erase the interrupted restore's recovery
139
+ record.
140
+ - **Journal IO**: best-effort by design — if a journal cannot be written, the
141
+ restore proceeds with pre-journal semantics (crash safety degrades,
142
+ behavior does not).
143
+
144
+ ## Versioning policy
145
+
146
+ The journal schema is `version: 1`. Checkpoint entries currently carry no
147
+ version field. A future incompatible format must either bump the journal
148
+ `version` (readers reject unknown values — there is no best-effort fallback or
149
+ legacy coercion) or move the state root (e.g. `rewind-snapshots/v2`) and ship
150
+ an explicit migration tool. Old-format data is never silently re-interpreted.
@@ -21,7 +21,7 @@
21
21
 
22
22
  Compatibility probes against these subsystems (test-driven investigation, the
23
23
  `compat-invariants` / `compat-interop` vitest suites + the `verify-host` real
24
- `/compact` chain): [compat-audit.md](compat-audit.md).
24
+ `/compact` chain): [compat/audit.md](compat/audit.md).
25
25
 
26
26
  Also under `docs/` at the repo root: `persistence-catalog.md` (full
27
27
  `SessionEventMap`), `tool-catalog.md` (tool inventory), `config-catalog.md`
@@ -45,6 +45,10 @@ Also under `docs/` at the repo root: `persistence-catalog.md` (full
45
45
 
46
46
  ## Plugin source layout
47
47
 
48
+ The module map below duplicates `AGENTS.md` (`Layout at a glance`), which is
49
+ the canonical source; this block only adds the finer-grained client-side files
50
+ and packaging entries.
51
+
48
52
  ```
49
53
  src/index.ts host plugin: /rewind command + checkpoint pipeline (tools/execute|post-execute)
50
54
  src/rewind.ts pure planning: target resolution, surface range, candidate listing
@@ -56,9 +60,9 @@ src/client/hidden.ts withdrawn-span computation (hiddenSeqsOf), pure
56
60
  src/client/locales.ts zh / en copy (LocaleNamespaceMap)
57
61
  src/client/styles.ts injected styles (dsh design tokens)
58
62
  scripts/build.mjs esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
59
- scripts/verify-host.mjs end-to-end host verification (18 checks)
63
+ scripts/verify-host.mjs end-to-end host verification (full check suite)
60
64
  tests/ vitest suites (rewind / snapshot / hidden / session-cwd / integration)
61
- docs/ maintainer docs: harness reference, troubleshooting, release steps
65
+ docs/ maintainer docs: contract/, compat/, release/ subdirectories
62
66
  assets/screenshots/ UI screenshots
63
67
  cordis.patch.yml bundle patch (mounts the dual-face plugin row)
64
68
  package.json dsh.bundle + dsh.client manifests, optional peerDependencies
@@ -0,0 +1,69 @@
1
+ # Release
2
+
3
+ [简体中文](release.zh.md)
4
+
5
+ ## First release (manual, one-time)
6
+
7
+ Trusted Publisher can only be configured once the package exists, so the first
8
+ version is published locally:
9
+
10
+ ```sh
11
+ npm login
12
+ npm publish --access public
13
+ ```
14
+
15
+ - If prompted for `EOTP`: complete the browser auth link the CLI prints, or
16
+ retry with a 6-digit code — `npm publish --otp=<code>`.
17
+ - The first version carries no provenance (local path) — acceptable; every CI
18
+ release after that publishes with Sigstore/SLSA provenance automatically.
19
+
20
+ ## Configure Trusted Publisher (npmjs.com, one-time)
21
+
22
+ Open `https://www.npmjs.com/package/dsh-rewind-plugin` → package **settings** →
23
+ **Trusted Publisher**:
24
+
25
+ | Field | Value |
26
+ | --- | --- |
27
+ | Provider | GitHub Actions |
28
+ | Organization or user | `SiriLee` |
29
+ | Repository | `dsh-rewind` (the GitHub repo, not the npm name) |
30
+ | Workflow filename | `publish.yml` |
31
+ | Environment | empty |
32
+ | Allowed actions | `npm publish` |
33
+
34
+ ## Subsequent releases (CI, automatic)
35
+
36
+ ```sh
37
+ npm version patch
38
+ git push origin main --tags # triggers .github/workflows/publish.yml
39
+ ```
40
+
41
+ - The workflow verifies the tag matches `package.json`, runs typecheck + tests +
42
+ a full build + artifact verification, publishes with `--provenance`
43
+ (Sigstore), and creates a GitHub Release. It is **idempotent** — an already
44
+ published version is skipped.
45
+ - CI (`.github/workflows/ci.yml`) runs `npm run check` — typecheck + tests +
46
+ build + artifact verification + a `npm pack --dry-run` — on every push / PR
47
+ across both Node engines boundary versions; the tarball layout is guarded by
48
+ `tests/package-layout.test.ts`.
49
+
50
+ ## DSH version alignment (peer range maintenance)
51
+
52
+ DSH is still in rc; npm's prerelease matching rules require a peer range to
53
+ share the host version's `[major, minor, patch]` tuple. So `peerDependencies`
54
+ uses an OR-union covering every published rc tuple series
55
+ (e.g. `^0.1.0-rc.6 || ^0.1.1-rc.2`), extended as DSH releases new tuples.
56
+
57
+ - **When to update**: only when DSH releases a new tuple
58
+ (`0.1.1 → 0.1.2 → 0.2.x`); rc rolling within a tuple (`0.1.1-rc.2 → rc.3`)
59
+ needs nothing. All `@deepseek-ai/*` packages release together;
60
+ `npm view @deepseek-ai/dsh version` is the authoritative signal.
61
+ - **Automatic detection**: `node scripts/check-dsh-version.mjs` compares the
62
+ latest npm version against the tuples the peers cover and reports whether an
63
+ extension is needed (exit 0 = nothing to do, exit 1 = update).
64
+ - **Update steps**: append `|| ^<new-tuple>-rc.<n>` to every
65
+ `@deepseek-ai/dsh-*` peer → bump devDependencies to the latest → `npm
66
+ install` → `npm run check` → release.
67
+ - **After DSH goes final**: final releases are not bound by the prerelease
68
+ tuple rule, so the peers can converge to a single stable range (e.g.
69
+ `^0.1.x`); this section can then be deleted.