dsh-diff-approval 0.30.1 → 0.31.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.
@@ -51,10 +51,11 @@ export declare class PendingDiffStore {
51
51
  /** Fold a captured entry into the path's single global entry. */
52
52
  private merge;
53
53
  /**
54
- * Combine a stored entry and a new capture. `oldText` comes from the earlier
55
- * capture (the file's original basis), `newText` from the later (latest
56
- * content), and the session set is the union — so the entry always spans the
57
- * whole pending change regardless of which session contributed which part.
54
+ * Combine a stored entry and a new capture. `oldText` comes from the earlier capture (the file's
55
+ * original basis) — except where that capture was the file's creation and this one edits it, when
56
+ * the creation's own content becomes the basis — and `newText` from the later (latest content).
57
+ * The session set is the union, so the entry always spans the whole pending change regardless of
58
+ * which session contributed which part.
58
59
  */
59
60
  private mergeEntry;
60
61
  /**
@@ -82,6 +83,15 @@ export declare class PendingDiffStore {
82
83
  /**
83
84
  * Advance one entry's tracked content after a block-level keep/revert. The
84
85
  * entry keeps its path; only the given side's text and capture time move.
86
+ *
87
+ * A keep that advances `oldText` also ends the entry's life as a creation. `earlierVersion` is the fact
88
+ * "this file did not exist before", and once part of the file has been accepted there IS an earlier
89
+ * version to restore — what the reader kept. The whole-file action must therefore become 回退, not 删除:
90
+ * deleting would throw away the part they just accepted. (Leaving the field alone kept a partly-kept new
91
+ * file offering to delete itself, which is what sent the reader here.)
92
+ *
93
+ * Every caller of this is a reader action — a block keep or revert, a refresh from version control — so it
94
+ * also takes the unseen dot down: the reader has just handled this file.
85
95
  * @param path - the file path.
86
96
  * @param patch - the side to advance (`oldText` for keep, `newText` for revert).
87
97
  * @returns whether the entry changed.
@@ -90,6 +100,22 @@ export declare class PendingDiffStore {
90
100
  oldText?: string;
91
101
  newText?: string;
92
102
  }): boolean;
103
+ /**
104
+ * Mark paths the reader has now seen — the file was opened, or its row was acted on. The dot is about
105
+ * attention rather than content, so this and the entry leaving the list are the only ways it goes out, and
106
+ * nothing brings it back except a new capture.
107
+ * @param paths - one path, or several.
108
+ * @returns whether anything changed.
109
+ */
110
+ markSeen(paths: string | readonly string[]): boolean;
111
+ /**
112
+ * Raise the dot for paths whose content moved WITHOUT the reader asking — a writer outside the panel, a
113
+ * second process. The capture sites set the field on the entry they fold; this is for the changes the
114
+ * plugin notices by itself while refreshing, which do not come through a capture.
115
+ * @param paths - one path, or several.
116
+ * @returns whether anything changed.
117
+ */
118
+ markUnseen(paths: string | readonly string[]): boolean;
93
119
  /**
94
120
  * Restore one entry exactly as given (an undo/redo replays a snapshot). The
95
121
  * entry is inserted or replaced by path.
@@ -5,6 +5,26 @@
5
5
  */
6
6
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
7
7
  import type { CommentLineRange } from './comment-lines.ts';
8
+ /**
9
+ * Where a session sits in the lineage a merged view groups by.
10
+ *
11
+ * Read off the session's own header when an entry is CAPTURED (`SessionHeader.parentSession`, `.origin`,
12
+ * `.delegationDepth`, `.cwd`) and carried on the entry, so a client can group without a second wire
13
+ * field. Every part is optional and absent when the header — or that one field of it — was not
14
+ * available: an ordinary root session has no parent by design, and a host older than these header
15
+ * fields reports none of them. An unknown lineage is recorded as unknown; guessing a root would file a
16
+ * session under the wrong one. Nothing reads this yet: the record is written for the merged view to use.
17
+ */
18
+ export interface SessionLineage {
19
+ /** The session this one was forked from, or absent for an ordinary root. */
20
+ parentSessionId?: SessionId | undefined;
21
+ /** The header's coarse product classification — `'subagent'` for a subagent child. */
22
+ origin?: string | undefined;
23
+ /** How deep below its root the session was delegated, when the shell reports it. */
24
+ delegationDepth?: number | undefined;
25
+ /** The session's own working directory, when its header names one. */
26
+ cwd?: string | undefined;
27
+ }
8
28
  /**
9
29
  * One file's pending entry, global and unique per `path`: the complete set of
10
30
  * unhandled changes folded into a single cumulative span across every session
@@ -17,8 +37,12 @@ export interface PendingEntry {
17
37
  id: string;
18
38
  /** Backend-resolved display path (the tool's output `path`). */
19
39
  path: string;
20
- /** What the operation did: an in-place change or a file creation. */
21
- kind: PendingEntryKind;
40
+ /**
41
+ * Whether there is an earlier version of the file to restore: `'file'` when
42
+ * the whole-file action writes the earlier text back, `'none'` when the file
43
+ * did not exist, so the whole-file action deletes it.
44
+ */
45
+ earlierVersion: PendingEntryKind;
22
46
  /** File content before the first captured operation (empty for a creation). */
23
47
  oldText: string;
24
48
  /** File content after the latest captured operation. */
@@ -29,14 +53,70 @@ export interface PendingEntry {
29
53
  sessionId: SessionId;
30
54
  /** Every session that touched the file (drives the per-session list filter). */
31
55
  sessionIds: SessionId[];
56
+ /**
57
+ * Where this entry's session sits in its lineage, as the shell reported it when the entry was
58
+ * CAPTURED — display-only data for a merged view. The entry keeps its real `sessionId`/`sessionIds`
59
+ * either way: grouping by lineage never rewrites who did the work. Absent on a record written before
60
+ * this field existed, on a session whose header carried none of it, and on an ordinary root (which
61
+ * has no parent by design) — so a reader must treat absent as "not known", never as "a root".
62
+ */
63
+ lineage?: SessionLineage | undefined;
64
+ /**
65
+ * Whether a change the reader has not looked at yet arrived on this path — the dot beside its row. Set
66
+ * when an agent operation (or an agent-side admission) changes the entry, cleared when the reader opens
67
+ * the file or acts on the row, and deliberately NOT part of an undo snapshot: a snapshot restores
68
+ * CONTENT, and having seen something is not content.
69
+ */
70
+ unseen?: boolean | undefined;
32
71
  }
33
- /** What one captured operation did to the file. */
34
- export type PendingEntryKind = 'edit' | 'create';
72
+ /** Whether an entry has an earlier version of its file to restore. */
73
+ export type PendingEntryKind = 'file' | 'none';
74
+ /**
75
+ * Which way a row's other contributor sits relative to the session LISTING it, walked from the recorded
76
+ * subagent parent links (see `lineageView` on the host).
77
+ *
78
+ * `child` — the other session descends from the lister (the lister is on the other's upward chain).
79
+ * `parent` — the other session is ABOVE the lister (the lister descends from it). It means an ANCESTOR,
80
+ * possibly more than one hop up, not only the direct parent: the Chinese 上级 says exactly that without
81
+ * ambiguity, so the English copy and this name keep that meaning.
82
+ * `sibling` — same lineage root, neither is on the other's chain (two children of one parent, say).
83
+ * `mixed` — non-requester owners of DIFFERENT directions: never one of them, and never a guess at one.
84
+ */
85
+ export type LineageDirection = 'child' | 'parent' | 'sibling' | 'mixed';
35
86
  /**
36
87
  * One listed pending entry: the stored operation plus the live file state the
37
88
  * list endpoint computes by reading the file's current content.
38
89
  */
39
90
  export interface PendingFileDiff extends PendingEntry {
91
+ /**
92
+ * Whether this row reached the list through the LINEAGE MERGE rather than because the LISTING session
93
+ * itself touched it. The HOST computes it per read (`listWithState`), because only the host can walk a
94
+ * lineage — the client cannot, and must not try: it is what lets the panel show a child session's row
95
+ * without re-deriving the merge in the browser. Absent on a host older than the merge and absent on a
96
+ * row this session touched, so a client that does not know the field behaves exactly as it did before.
97
+ */
98
+ viaLineage?: boolean | undefined;
99
+ /**
100
+ * Whether some session OTHER than the listing one — and inside the listing session's lineage — also
101
+ * contributed to this row, so a row the session touched itself still says a child had a hand in it. Kept
102
+ * apart from `viaLineage` on purpose: that one answers "why is this row here" (visibility), this one
103
+ * answers "whose change does it carry" (the mark). Only a session in the same lineage counts: entries are
104
+ * keyed by path globally, so an unrelated session's touch of the same file must never be called a child's
105
+ * work. Host-computed per read (`listWithState`), absent on a host older than the mark.
106
+ */
107
+ hasChildContribution?: boolean | undefined;
108
+ /**
109
+ * WHICH WAY the row's other contributor stands, so the marker's sentence tells the truth from the seat it
110
+ * is drawn in: the same row is a child's change in its parent's panel and a parent's change in its
111
+ * child's. Host-computed per read from the SAME recorded parent links the merge walks, and carried to the
112
+ * browser like `viaLineage` — the client never infers lineage.
113
+ *
114
+ * Absent whenever no mark applies (the lister is the only contributor), and absent on a host older than
115
+ * this field — in which case the client says the neutral sentence rather than picking a direction.
116
+ * `mixed` is likewise the honest answer when the row carries non-requester owners of different
117
+ * directions; it is never resolved to one of them.
118
+ */
119
+ lineageDirection?: LineageDirection | undefined;
40
120
  /**
41
121
  * Whether the file no longer exists (or cannot be resolved). Reverting a
42
122
  * missing entry restores its old content, which recreates the file.
@@ -110,6 +190,47 @@ export interface DiffApprovalListValue {
110
190
  */
111
191
  commentAnswers: Record<string, string>;
112
192
  }
193
+ /**
194
+ * Value returned by the channel's `list-count` endpoint: how many rows a full `list` read for the same
195
+ * session would carry.
196
+ *
197
+ * The badge needs a number once a second, and a full read is enormously more than a number: it ships
198
+ * every visible entry's whole `oldText`+`newText` (measured at 6.77 MB across 302 stored entries, with
199
+ * single rows at 1.2-1.6 MB). So this is the LIGHT read — the same visibility rule (`lineageView().sees`
200
+ * for the requesting session) and the same `ensureLoaded` discipline as `list`, and nothing else.
201
+ *
202
+ * WHAT IT DELIBERATELY DOES NOT DO: it reads no file content (`ctx.fs.readText` is never called), so it
203
+ * computes no diff, adopts no external change, creates no undo checkpoint, sweeps no comments and does not
204
+ * persist anything. It sends no texts, no comment records, no `commentLines`, no revision and no errors —
205
+ * `count` is the whole answer.
206
+ *
207
+ * WHAT THAT COSTS THE CALLER: a count is not a list. It asks `stat` about each visible row (the cheap
208
+ * liveness signal) and drops a row whose file is GONE, which is the one state `list` also drops without
209
+ * reading — but a file that is present and merely unreadable cannot be told from a readable one without
210
+ * reading it, so it is counted here and dropped by the next full read. The number can therefore be one
211
+ * poll stale, and the next full read is what corrects it; nothing else in this value is authoritative.
212
+ */
213
+ export interface DiffApprovalListCountValue {
214
+ /** How many rows this session's next full `list` read would carry. */
215
+ count: number;
216
+ }
217
+ /**
218
+ * Value returned by the channel's `update-check` endpoint: whether a newer release of
219
+ * this package is published, and what changed since the installed one.
220
+ *
221
+ * The comparison itself is the HOST's (`src/version.ts`): it is the side that knows the
222
+ * installed version, and the only side that can reach the registry (the browser cannot —
223
+ * `registry.npmjs.org` sends no CORS header). `newer` is therefore an ANSWER, not a hint:
224
+ * the client never re-derives it, so exactly one rule decides "is this newer".
225
+ */
226
+ export interface DiffApprovalUpdateValue {
227
+ /** The installed version as this host read it; empty when it could not be read at all. */
228
+ current: string;
229
+ /** The published version; absent when the registry was unreachable or answered nonsense. */
230
+ latest?: string | undefined;
231
+ /** Whether `latest` is STRICTLY newer than `current`; always false when either is unparsable. */
232
+ newer: boolean;
233
+ }
113
234
  /** What the open endpoint asks the OS to do with a file. */
114
235
  export type DiffApprovalOpenAction = 'open' | 'reveal';
115
236
  /** Value returned by the channel's open endpoint. */
@@ -287,6 +408,20 @@ export interface CommentAsk {
287
408
  * has admitted anything.
288
409
  */
289
410
  requestId: string;
411
+ /**
412
+ * The SESSION THIS QUESTION WAS SUBMITTED INTO — the transcript that will carry its answer.
413
+ *
414
+ * It is not always the comment's own `sessionId`: once a lineage reads every seat's threads (`list`
415
+ * fans the read out across the lineage root), any seat may ask a question on a thread another seat
416
+ * wrote, and the prompt goes into the seat the human is using. The answer fold then has to know which
417
+ * transcript to read for which question, and turn numbers are only unique WITHIN one session — so this
418
+ * is what `markTurnEnded` matches on as well.
419
+ *
420
+ * Absent on a question asked before this field existed, when the only session a question could have
421
+ * gone to was the comment's own: the fold reads the comment's `sessionId` for those (see
422
+ * `askTranscript` in the comment store), which is exactly where their answers already are.
423
+ */
424
+ sessionId?: SessionId | undefined;
290
425
  /**
291
426
  * The question's own words, as the reader typed them — without the marker, the
292
427
  * `(path:lines)` reference and the answer rules that wrap them in the submitted
@@ -352,12 +487,53 @@ export interface CommentRecord {
352
487
  * something the reader said would put words in their mouth. Absent means the reader.
353
488
  */
354
489
  author?: 'agent' | undefined;
490
+ /**
491
+ * Which CLASS of annotation this one belongs to, as the calling agent named it (see
492
+ * `annotate-tool.ts`'s `category` argument).
493
+ *
494
+ * The panel draws it as a coloured round dot on the comment list's row, so an agent that annotates
495
+ * several passes or topics can be told apart at a glance. OPAQUE to every reader of this record: the
496
+ * panel hashes the string to pick a colour and shows the string itself in the mark's own label, and
497
+ * nothing parses it, orders it or validates its shape. Absent means "no class, no dot" — which is
498
+ * every comment the reader writes themselves, and every annotation written before this field existed.
499
+ * Short by construction (the tool trims it and caps it at `MAX_ANNOTATION_CATEGORY_CHARS`).
500
+ */
501
+ category?: string | undefined;
355
502
  /** Epoch milliseconds the comment was written. */
356
503
  createdAt: number;
357
504
  /** Epoch milliseconds of the last change to this record. */
358
505
  updatedAt: number;
359
506
  /** The questions asked in this thread, oldest first; empty until one is asked. */
360
507
  asks?: CommentAsk[] | undefined;
508
+ /**
509
+ * Whether something the reader has not looked at yet arrived in this thread — the dot beside its
510
+ * card. Set when an agent-authored annotation is written or when an answer lands in the thread,
511
+ * cleared when the card comes into view, and deliberately NOT part of an undo snapshot: a snapshot
512
+ * restores CONTENT, and having seen something is not content.
513
+ */
514
+ unseen?: boolean | undefined;
515
+ /**
516
+ * The answer text the reader has already been told about, keyed by question id.
517
+ *
518
+ * The answer's text is deliberately absent from this record (see the doc above) — the transcript is
519
+ * the one source
520
+ * of truth for what the agent said — but "is this the answer I have already seen?" cannot be
521
+ * answered from the transcript alone, because a read has nothing to compare against. This is the
522
+ * baseline the comparison takes: the host writes the current answer here when the card goes out of
523
+ * the reader's way, and an answer whose text differs from this is news. Persisted rather than held
524
+ * in memory, so a restart does not light a dot the reader had already cleared.
525
+ */
526
+ answerSeen?: Record<string, string> | undefined;
527
+ /**
528
+ * The latest answer text the host has observed for each question, keyed by question id.
529
+ *
530
+ * The other half of the comparison above, and kept apart from it on purpose: this follows what the
531
+ * agent said, while `answerSeen` follows what the reader has looked at. An answer whose text differs
532
+ * from its stored `answerSeen` entry is what lights the dot — and an answer that disappears leaves
533
+ * this empty rather than looking like a change, so a transcript that cannot be read never re-lights
534
+ * a thread the reader already read.
535
+ */
536
+ answerNow?: Record<string, string> | undefined;
361
537
  }
362
538
  /** Value returned by the channel's comment-add endpoint. */
363
539
  export interface DiffApprovalCommentAddValue {
@@ -34,7 +34,8 @@ export interface VcsRoot {
34
34
  export interface VcsChange {
35
35
  /** Absolute path of the changed file (inside the workspace). */
36
36
  path: string;
37
- kind: 'edit' | 'create';
37
+ /** Whether an earlier version of the file exists: `'file'` restores it, `'none'` deletes the file. */
38
+ earlierVersion: 'file' | 'none';
38
39
  /** Baseline (pre-change) content; empty for a brand-new file. */
39
40
  oldText: string;
40
41
  /** Working content; empty for a deleted file. */
@@ -0,0 +1,162 @@
1
+ /**
2
+ * "Is a newer release published?" — the host's half of the panel's update notice.
3
+ *
4
+ * The check lives on the host because the browser cannot make it: `registry.npmjs.org`
5
+ * sends no CORS header, and only the host knows which version of this package is
6
+ * actually installed (it reads its own `package.json`). The client asks once through
7
+ * this plugin's existing channel and renders whatever comes back.
8
+ *
9
+ * Everything here FAILS SOFT: a timeout, a refused connection, a registry answer that
10
+ * is not JSON, a package with no `version` — all of them end as `undefined`, never as a
11
+ * throw. A reader on a plane must not see a network error in their review panel, and the
12
+ * panel must never wait on this to draw.
13
+ *
14
+ * The dialog shows NOTHING but the versions and a button, so this module asks for nothing
15
+ * but the version: there is no changelog to slice and no URL to hand over.
16
+ *
17
+ * @module dsh-diff-approval/version
18
+ */
19
+ import type { DiffApprovalUpdateValue } from './types.ts';
20
+ /** The published name this check asks the registry about. */
21
+ export declare const UPDATE_PACKAGE = "dsh-diff-approval";
22
+ /** The registry's "latest" document for this package: `{ version, … }`. */
23
+ export declare const UPDATE_REGISTRY = "https://registry.npmjs.org/dsh-diff-approval/latest";
24
+ /**
25
+ * How long the request may take. Short on purpose: this is a nicety in a status bar,
26
+ * and the client-side request is issued in the background — but the host's own worker
27
+ * should not be held by a hanging socket either.
28
+ */
29
+ export declare const UPDATE_TIMEOUT_MS = 3000;
30
+ /**
31
+ * How long a registry answer may be reused before asking again: TEN MINUTES.
32
+ *
33
+ * The cache exists for the network cost of the REMOTE answer only (see {@link createUpdateCheck}), so this is
34
+ * the one staleness a reader can be shown. Ten minutes because a release cadence is measured in days: the
35
+ * figure is far shorter than any release gap, so a genuinely new release reaches an already-running host
36
+ * without a restart, and far longer than a page load, so reloading a page — or opening a second seat — does
37
+ * not hammer a public registry. It is deliberately NOT permanent: the process-lifetime memo this replaced
38
+ * kept serving a verdict computed before an upgrade, which is exactly how a reader saw "a new version" for a
39
+ * version they had already installed.
40
+ */
41
+ export declare const UPDATE_CACHE_TTL_MS: number;
42
+ /** A parsed `major.minor.patch`. */
43
+ export type Version = readonly [number, number, number];
44
+ /** One HTTP answer, as much of it as this module reads. */
45
+ export interface FetchResponseFace {
46
+ readonly ok: boolean;
47
+ readonly status: number;
48
+ text(): Promise<string>;
49
+ }
50
+ /** The one call this module makes on the network, injected so tests need no socket. */
51
+ export type FetchFace = (url: string, init: {
52
+ signal: AbortSignal;
53
+ headers: Record<string, string>;
54
+ }) => Promise<FetchResponseFace>;
55
+ /** Where to ask, and how. Every field has a production default. */
56
+ export interface UpdateProbe {
57
+ /** Registry `latest` document. */
58
+ registryUrl?: string;
59
+ /** Per-request budget, in ms. */
60
+ timeoutMs?: number;
61
+ /** The transport; defaults to the runtime's global `fetch`. */
62
+ fetchImpl?: FetchFace;
63
+ }
64
+ /** How {@link createUpdateCheck} reads and asks; every field has a production default. */
65
+ export interface UpdateCheckOptions {
66
+ /** The manifest the INSTALLED version is read from; the package's own `package.json` by default. */
67
+ manifestUrl?: URL;
68
+ /** Registry `latest` document. */
69
+ registryUrl?: string;
70
+ /** Per-request budget, in ms. */
71
+ timeoutMs?: number;
72
+ /** The transport; defaults to the runtime's global `fetch`. */
73
+ fetchImpl?: FetchFace;
74
+ /** How long a fetched registry answer may be reused, in ms; {@link UPDATE_CACHE_TTL_MS} by default. */
75
+ ttlMs?: number;
76
+ /**
77
+ * The clock, in ms since the epoch; `Date.now` by default. Injected so a test can age the cached REMOTE
78
+ * answer without waiting ten real minutes — the TTL is the one thing here a test cannot otherwise reach.
79
+ */
80
+ now?: () => number;
81
+ }
82
+ /**
83
+ * Parse `major.minor.patch`, and NOTHING else.
84
+ *
85
+ * Deliberately strict, because the alternative is a bogus "newer": a pre-release
86
+ * (`0.31.0-rc.1`), a range (`^0.31.0`), a dist-tag (`latest`) or a stray suffix all
87
+ * answer `undefined`, and an unparsable side of a comparison is never allowed to look
88
+ * newer. A leading `v` is tolerated because tags carry one.
89
+ *
90
+ * @param text - the candidate, from a registry document or a `package.json`.
91
+ * @returns the three numbers, or `undefined` when the text is not exactly that shape.
92
+ */
93
+ export declare function parseVersion(text: unknown): Version | undefined;
94
+ /**
95
+ * Is `latest` strictly newer than `current`?
96
+ *
97
+ * Strictly: equal versions are NOT newer, and neither side parsing means NOT newer —
98
+ * the rule the code above exists to make safe. Comparison is component-wise on the
99
+ * three numbers, so `0.9.0` precedes `0.10.0` (string comparison would get that wrong).
100
+ *
101
+ * @param latest - the published version.
102
+ * @param current - the installed version.
103
+ * @returns whether a notice is warranted.
104
+ */
105
+ export declare function isNewerVersion(latest: unknown, current: unknown): boolean;
106
+ /**
107
+ * Compare two already-parsed versions.
108
+ *
109
+ * Component-wise, because a string comparison gets `0.9.0` vs `0.10.0` wrong — the reason both
110
+ * callers go through here rather than comparing something textual.
111
+ *
112
+ * @param left - one version.
113
+ * @param right - the other.
114
+ * @returns negative, zero or positive, as a comparator should.
115
+ */
116
+ export declare function compareVersions(left: Version, right: Version): number;
117
+ /**
118
+ * The version installed in this package, read from the `package.json` beside the built
119
+ * `lib/` — the only authority on what the reader is running, and the value the registry
120
+ * answer is compared against.
121
+ *
122
+ * Resolved relative to this module, so it holds for the bundled `lib/index.js`
123
+ * (`../package.json` is the package root) and for the source under test (`../package.json`
124
+ * is the repository root) alike.
125
+ *
126
+ * @param manifestUrl - the manifest to read; the package's own `package.json` unless a caller
127
+ * says otherwise. It is a parameter because a test runner can serve modules over a non-file
128
+ * URL, where the default cannot be resolved — the production path is the default, and the
129
+ * end-to-end case is what proves the default itself.
130
+ * @returns the version, or `undefined` when the file is missing or has none.
131
+ */
132
+ export declare function installedVersion(manifestUrl?: URL): Promise<string | undefined>;
133
+ /**
134
+ * Ask the registry for the published version.
135
+ *
136
+ * @param probe - where to ask and how; every field defaults (see {@link UpdateProbe}).
137
+ * @returns the version, or `undefined` on any failure — including an answer that is not
138
+ * a version, which must never become a notice.
139
+ */
140
+ export declare function fetchLatestVersion(probe?: UpdateProbe): Promise<string | undefined>;
141
+ /**
142
+ * Build the answer behind the `update-check` endpoint: one function a host calls per request.
143
+ *
144
+ * WHAT IS CACHED, AND FOR HOW LONG — the whole point of this factory:
145
+ *
146
+ * - The REMOTE answer (the registry's latest version) is cached, because that is the network cost. It is held
147
+ * for {@link UPDATE_CACHE_TTL_MS} and then fetched again, so a release published while the host is running
148
+ * still reaches the reader without a restart. A FAILED fetch is not cached at all: remembering a failure
149
+ * would hide the notice for the whole TTL after one offline moment, and there is no network cost to save.
150
+ * - The INSTALLED version is read from the manifest on EVERY call. It is a file read, not a request, and it is
151
+ * the side that changes when the reader upgrades — so a verdict computed before that upgrade must never be
152
+ * replayed. Caching it was a real defect: a host that answered while an older version was installed kept
153
+ * announcing "a newer release" to every page until it was restarted.
154
+ *
155
+ * `newer` is therefore decided afresh on every call, from today's manifest against the cached registry answer.
156
+ * NEVER THROWS: every failure — no manifest, an unreachable registry, a nonsense answer — ends as "nothing to
157
+ * say" (`newer: false`), so a network problem can never become an error in the reader's status bar.
158
+ *
159
+ * @param options - where to read and how; every field defaults (see {@link UpdateCheckOptions}).
160
+ * @returns the check itself; call it as often as you like.
161
+ */
162
+ export declare function createUpdateCheck(options?: UpdateCheckOptions): () => Promise<DiffApprovalUpdateValue>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-diff-approval",
3
- "version": "0.30.1",
3
+ "version": "0.31.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",
@@ -60,10 +60,12 @@
60
60
  "test": "vitest run",
61
61
  "e2e": "playwright test",
62
62
  "compat": "node scripts/dsh-compat.mjs",
63
+ "compat:newer": "node scripts/dsh-compat.mjs --newer",
64
+ "verify:release": "node scripts/verify-release.mjs",
63
65
  "fonts:check": "python -m scripts.fonts.build --check",
64
66
  "fonts:plan": "python -m scripts.fonts.build --plan",
65
67
  "prepare": "tsc -b tsconfig.json && tsdown --env.DSH_BUILD_FACE client",
66
- "release": "release-it"
68
+ "release": "release-it && node scripts/verify-release.mjs"
67
69
  },
68
70
  "license": "MIT",
69
71
  "dependencies": {