dsh-diff-approval 0.13.0 → 0.14.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.
@@ -44,8 +44,12 @@ export declare function referenceOf(path: string, workspacePath: string | undefi
44
44
  /**
45
45
  * Map one referenced line range from `oldContent` coordinates to `newContent`
46
46
  * coordinates. Lines that survive (unchanged context) map to their new line
47
- * numbers, and the surviving lines' min/max span is returned. When every line
48
- * in the range was removed, returns `undefined` (the reference is expired).
47
+ * numbers, and a line whose content changed in place (a delete replaced by an
48
+ * add at the same logical position) maps to that position's new line — its text
49
+ * changed, but the line is not gone. Only a line that is genuinely removed
50
+ * (a delete with no replacement) is omitted. The surviving lines' min/max span
51
+ * is returned; when every line in the range is gone, returns `undefined` (the
52
+ * reference is expired).
49
53
  * @param oldContent - the file content the reference was made against.
50
54
  * @param newContent - the file content now.
51
55
  * @param start - first referenced line (1-based, inclusive).
@@ -59,8 +63,9 @@ export declare function remapReferenceRange(oldContent: string, newContent: stri
59
63
  /**
60
64
  * Rewrite every `(referencePath:line)` / `(referencePath:start-end)` occurrence
61
65
  * in `text`, remapping each range from `oldContent` to `newContent`. A range
62
- * whose lines all survived becomes the new range; one whose lines were all
63
- * removed becomes `(referencePath:LINE_MISSING)`.
66
+ * whose lines all survived (unchanged context or an in-place edit) becomes the
67
+ * new range; one whose lines were all genuinely removed becomes
68
+ * `(referencePath:LINE_MISSING)`.
64
69
  * @param text - the free text (composer draft, queued message) to rewrite.
65
70
  * @param referencePath - the file's reference path (workspace-relative or absolute).
66
71
  * @param oldContent - the file content the references were made against.
@@ -37,4 +37,13 @@ export interface WholeFileDiff {
37
37
  * @param newText - the file content after the pending change.
38
38
  * @returns the complete row list with totals.
39
39
  */
40
+ /**
41
+ * Normalize content for equality: line endings → `\n`, and a single trailing
42
+ * newline is a terminator rather than a line. The result is the line bodies
43
+ * joined by `\n` with no trailing newline, so representation-only differences
44
+ * (EOL style, trailing-newline presence) compare equal.
45
+ * @param text - the content to normalize.
46
+ * @returns the normalized line bodies.
47
+ */
48
+ export declare function contentKey(text: string): string;
40
49
  export declare function computeWholeFileDiff(oldText: string, newText: string): WholeFileDiff;
@@ -1,10 +1,11 @@
1
1
  /**
2
- * In-memory pending-diff store: one entry per (session, path), holding the
3
- * file's full set of unhandled changes as one cumulative span. Every later
4
- * operation to a tracked path folds into its entry `oldText` stays the
5
- * earliest basis, `newText` takes the latest contenteven when the chain
6
- * breaks (an outside writer changed the file between operations): the list
7
- * still shows one element per file. Pure state and transitions; the plugin
2
+ * In-memory pending-diff store: one entry per file path, globally, holding the
3
+ * file's full set of unhandled changes as one cumulative span (earliest basis →
4
+ * latest content) across every session and workspace that touched it. Every
5
+ * later operation to a tracked path folds into its entry `oldText` stays the
6
+ * earliest basis, `newText` takes the latest content, and the touching sessions
7
+ * accumulate in `sessionIds` even when the chain breaks (an outside writer
8
+ * changed the file between operations). Pure state and transitions; the plugin
8
9
  * body owns the `tools/result` observation, the RPC surface, and the
9
10
  * filesystem I/O.
10
11
  * @module dsh-diff-approval/src/pending
@@ -12,75 +13,74 @@
12
13
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
13
14
  import type { PendingEntry } from './types.ts';
14
15
  /**
15
- * The pending-diff store. Keep/Revert decides one whole file at a time.
16
+ * The pending-diff store: one entry per file path across all sessions. Keep /
17
+ * Revert decides one whole file at a time.
16
18
  */
17
19
  export declare class PendingDiffStore {
18
20
  private readonly entries;
19
- private readonly pathIndex;
20
- /** Merge one operation into the store, keyed by (session, path). */
21
- private merge;
22
21
  /**
23
- * Fold one captured operation into its file's entry. A no-op (equal before
22
+ * Merge one captured operation into its file's entry. A no-op (equal before
24
23
  * and after) folds nothing.
25
24
  * @param entry - the captured operation (id assigned by the caller).
26
25
  * @returns whether the stored entry changed.
27
26
  */
28
27
  fold(entry: PendingEntry): boolean;
28
+ /** Fold a captured entry into the path's single global entry. */
29
+ private merge;
29
30
  /**
30
- * Merge persisted entries into the store, one per path after folding. An
31
- * already-keyed live entry wins over a persisted one: the live entry is
32
- * newer by construction (it was captured after the persisted state was
33
- * written), so the path's whole persisted run is skipped.
34
- * @param sessionId - the session the entries belong to.
35
- * @param entries - persisted entries, oldest capture first, to fold in.
31
+ * Combine a stored entry and a new capture. `oldText` comes from the earlier
32
+ * capture (the file's original basis), `newText` from the later (latest
33
+ * content), and the session set is the union — so the entry always spans the
34
+ * whole pending change regardless of which session contributed which part.
36
35
  */
37
- hydrate(sessionId: SessionId, entries: readonly PendingEntry[]): void;
36
+ private mergeEntry;
38
37
  /**
39
- * Copy the session's entries, oldest capture first.
40
- * @param sessionId - the session whose entries to list.
38
+ * Copy every entry touching a session (the session's view), oldest capture
39
+ * first. The file list is session-scoped: an entry appears for each session
40
+ * that touched it, so a session only sees files it worked on.
41
+ * @param sessionId - the viewing session.
41
42
  * @returns detached entries in capture order.
42
43
  */
43
44
  list(sessionId: SessionId): PendingEntry[];
45
+ /** Every entry (all paths, all sessions), oldest capture first. */
46
+ all(): PendingEntry[];
44
47
  /**
45
- * Read one entry without removing it.
46
- * @param sessionId - the owning session.
47
- * @param id - the entry id.
48
- * @returns the entry, or `undefined` when the pair has none.
48
+ * Read one path's entry without removing it.
49
+ * @param path - the file path (the global entry key).
50
+ * @returns the entry, or `undefined` when none is tracked.
49
51
  */
50
- get(sessionId: SessionId, id: string): PendingEntry | undefined;
52
+ get(path: string): PendingEntry | undefined;
51
53
  /**
52
- * Remove one entry.
53
- * @param sessionId - the owning session.
54
- * @param id - the entry id.
54
+ * Remove one path's entry.
55
+ * @param path - the file path.
55
56
  * @returns whether an entry was removed.
56
57
  */
57
- remove(sessionId: SessionId, id: string): boolean;
58
+ remove(path: string): boolean;
58
59
  /**
59
60
  * Advance one entry's tracked content after a block-level keep/revert. The
60
- * entry keeps its id and path; only the given side's text and capture time
61
- * move. When the caller decides the sides now match, it removes the entry
62
- * instead of updating it.
63
- * @param sessionId - the owning session.
64
- * @param id - the entry id.
61
+ * entry keeps its path; only the given side's text and capture time move.
62
+ * @param path - the file path.
65
63
  * @param patch - the side to advance (`oldText` for keep, `newText` for revert).
66
64
  * @returns whether the entry changed.
67
65
  */
68
- update(sessionId: SessionId, id: string, patch: {
66
+ update(path: string, patch: {
69
67
  oldText?: string;
70
68
  newText?: string;
71
69
  }): boolean;
72
70
  /**
73
71
  * Restore one entry exactly as given (an undo/redo replays a snapshot). The
74
- * entry is inserted or replaced by id, and the path index points at it so a
75
- * later capture folds into the restored entry. The store keeps one entry per
76
- * path: restoring an entry for a path that a DIFFERENT entry currently owns
77
- * drops that occupant first, so an undo can never leave two list items for
78
- * the same file (e.g. an imported entry and a replayed keep of the same path).
79
- * @param sessionId - the owning session.
72
+ * entry is inserted or replaced by path.
80
73
  * @param entry - the entry state to restore.
81
74
  * @returns whether the store changed.
82
75
  */
83
- restore(sessionId: SessionId, entry: PendingEntry): boolean;
84
- /** Total entry count across all sessions. */
76
+ restore(entry: PendingEntry): boolean;
77
+ /**
78
+ * Merge persisted entries into the store, one per path after folding. A live
79
+ * entry wins over a persisted one only when its time is newer (folders are
80
+ * applied in capture order, so a later persisted capture is strictly newer).
81
+ * @param entries - persisted entries, oldest capture first, to fold in.
82
+ */
83
+ hydrate(entries: readonly PendingEntry[]): void;
84
+ /** Total entry count (one per tracked file path). */
85
85
  get size(): number;
86
86
  }
@@ -1,59 +1,52 @@
1
1
  /**
2
- * Durable pending-entry persistence under the harness home. One JSON file per
3
- * workspace holds every session's entries, at
4
- * `<storageDir>/<workspaceId>.json`; the default root is
5
- * `<dshHome>/diff-approval/workspaces` and the plugin's `storageDir` config
6
- * relocates it. `save` rewrites the whole workspace file so sibling sessions
7
- * survive; a session with no entries leaves the file, and a workspace with no
8
- * sessions loses its file. Writes are serialized per file, staged as a
9
- * sibling temp file, and atomically renamed, so a crash leaves either the old
10
- * or the new file. Missing files read as empty; corrupt content and unknown
11
- * versions throw so the caller can fail loud instead of silently dropping
12
- * persisted data.
2
+ * Durable pending-entry persistence under the harness home: one global JSON
3
+ * file holding one entry per file path (across all sessions and workspaces), at
4
+ * `<storageDir>/pending.json`; the default root is `<dshHome>/diff-approval/workspaces`
5
+ * and the plugin's `storageDir` config relocates it. Saves rewrite the whole
6
+ * file (the entry set is small), staged as a sibling temp file and atomically
7
+ * renamed, so a crash leaves either the old or the new file. A missing file
8
+ * reads as empty; corrupt content and unknown versions throw.
9
+ *
10
+ * Legacy layout: the pre-global schema stored one JSON file per workspace
11
+ * (`<storageDir>/<workspaceId>.json`, `{ version: 2, sessions: { [sessionId]:
12
+ * PendingEntry[] } }`). On first load without a global file, `loadAll` scans for
13
+ * those files, flattens every session's entries, and reports them so the caller
14
+ * can fold them into the global store once; `save` then writes the global file
15
+ * and deletes the legacy files.
13
16
  * @module dsh-diff-approval/src/persist
14
17
  */
15
18
  import type { PendingEntry } from './types.ts';
16
19
  /** Default persistence root: this plugin's own directory under the harness home. */
17
20
  export declare function defaultStorageDir(): string;
18
21
  /**
19
- * File-backed pending entries keyed by (workspace, session).
22
+ * File-backed pending entries, one global file keyed by path.
20
23
  */
21
24
  export declare class PendingPersistence {
22
25
  private readonly root;
23
26
  private readonly tails;
27
+ private readonly globalFile;
24
28
  /**
25
- * @param root - directory holding one JSON file per workspace.
29
+ * @param root - directory holding the global `pending.json` (and, pre-migration,
30
+ * one JSON file per workspace).
26
31
  */
27
32
  constructor(root: string);
28
- private fileOf;
29
- /** Read one workspace file; a missing file reads as empty. */
30
- private readWorkspace;
31
- /** Stage the envelope as a sibling temp file and atomically rename it into place. */
32
- private writeWorkspace;
33
33
  /**
34
- * Load one session's entries from its workspace file, oldest capture first.
35
- * @param workspaceId - the owning workspace's stable id.
36
- * @param sessionId - the session whose entries to load.
37
- * @returns the persisted entries; empty when none were saved.
34
+ * Load every persisted entry. Returns the global file's entries, or when the
35
+ * global file is absent — flattens the legacy per-workspace files so the
36
+ * caller can fold them once and then save (which clears the legacy files).
37
+ * @returns the entries plus whether they came from the legacy layout (awaiting
38
+ * a save to finalize the migration).
38
39
  */
39
- load(workspaceId: string, sessionId: string): Promise<PendingEntry[]>;
40
+ loadAll(): Promise<{
41
+ entries: PendingEntry[];
42
+ migratedLegacy: boolean;
43
+ }>;
40
44
  /**
41
- * Load every session's entries for one workspace, oldest capture first.
42
- * The list shows a workspace's pending changes across sessions a session
43
- * that restarted carries a fresh id while its earlier entries sit under the
44
- * original session ids in the same workspace file so hydration reads the
45
- * whole workspace, not one session.
46
- * @param workspaceId - the owning workspace's stable id.
47
- * @returns the persisted entries across all sessions; empty when none were saved.
48
- */
49
- loadWorkspace(workspaceId: string): Promise<PendingEntry[]>;
50
- /**
51
- * Replace one session's entries durably. Saves to one file are serialized;
52
- * a previous save's failure does not block the next one.
53
- * @param workspaceId - the owning workspace's stable id.
54
- * @param sessionId - the session whose entries to replace.
55
- * @param entries - the session's complete entry list, possibly empty.
45
+ * Replace the persisted entry set durably. Saves to the one file are
46
+ * serialized; a previous save's failure does not block the next one. On a
47
+ * legacy migration this also removes the per-workspace files.
48
+ * @param entries - the complete entry list, possibly empty.
56
49
  * @returns resolution after the file is durable.
57
50
  */
58
- save(workspaceId: string, sessionId: string, entries: readonly PendingEntry[]): Promise<void>;
51
+ save(entries: readonly PendingEntry[]): Promise<void>;
59
52
  }
@@ -5,26 +5,29 @@
5
5
  */
6
6
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
7
7
  /**
8
- * One file's pending entry in one session: the complete set of unhandled
9
- * changes folded into a single cumulative span. `oldText` is the earliest
10
- * captured basis and `newText` the latest captured content, so Keep/Revert
11
- * decides the whole file at once.
8
+ * One file's pending entry, global and unique per `path`: the complete set of
9
+ * unhandled changes folded into a single cumulative span across every session
10
+ * and workspace that touched the file. `oldText` is the earliest captured basis
11
+ * and `newText` the latest captured content, so Keep/Revert decides the whole
12
+ * file at once.
12
13
  */
13
14
  export interface PendingEntry {
14
- /** Stable per-entry id (generated at capture; persisted with the entry). */
15
+ /** Stable per-entry id, equal to the path (the global key). */
15
16
  id: string;
16
- /** The session whose agent ran the operation. */
17
- sessionId: SessionId;
18
17
  /** Backend-resolved display path (the tool's output `path`). */
19
18
  path: string;
20
19
  /** What the operation did: an in-place change or a file creation. */
21
20
  kind: PendingEntryKind;
22
- /** File content before the operation (empty for a creation). */
21
+ /** File content before the first captured operation (empty for a creation). */
23
22
  oldText: string;
24
- /** File content after the operation. */
23
+ /** File content after the latest captured operation. */
25
24
  newText: string;
26
- /** Epoch milliseconds of the capture. */
25
+ /** Epoch milliseconds of the latest capture. */
27
26
  updatedAt: number;
27
+ /** The most recent session whose agent touched the file (back-compat). */
28
+ sessionId: SessionId;
29
+ /** Every session that touched the file (drives the per-session list filter). */
30
+ sessionIds: SessionId[];
28
31
  }
29
32
  /** What one captured operation did to the file. */
30
33
  export type PendingEntryKind = 'edit' | 'create';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-diff-approval",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "DeepSeek Harness plugin: pending-edit review with whole-file diff and Keep/Revert",
5
5
  "packageManager": "pnpm@11.21.0",
6
6
  "author": "Wu Zhiwei",