@twinsearth/w2m-dsh-plugin 0.23.0 → 0.24.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/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.24.0] — 2026-10-15
8
+
9
+ **The escalation reaches the approval index — and the bridge between them is where the defect would have been.**
10
+
11
+ Two shapes that look alike and are not:
12
+
13
+ | `approvalRequestFor()` produces | `index.register()` wants |
14
+ |---|---|
15
+ | `subject_level` (flat) | `subject: { id, level }` (nested) |
16
+ | `capability` | `action` |
17
+ | `task_id`, `requested_by` | `sessionKey`, `subject.id` |
18
+
19
+ A caller hand-mapping these at the call site would get it right once and wrong later, and **the failure would
20
+ be a request registered against the wrong subject — the one field the whole decision rests on.** So
21
+ `raiseApproval()` owns the mapping and lives beside the thing it maps from.
22
+
23
+ ### It does not smooth anything over
24
+
25
+ `register()` refuses a subject level that is not an integer 0..100, and the adapter **passes the index's own
26
+ `reason_code` and `detail` through unchanged**. A bridge that substituted a default level to make registration
27
+ succeed would be **inventing the single fact the approver's decision rests on, and would look like success
28
+ while doing it.** Tested directly: a request whose level is `'captain'` is refused, and the queue stays empty.
29
+
30
+ The `approvalId` is **required from the caller, not generated here**, because an id invented at this layer
31
+ could not be correlated with whoever is waiting on the decision.
32
+
33
+ ### The end-to-end property is asserted, not described
34
+
35
+ A real `createApprovalIndex()` is constructed in the suite and an escalation is raised into it: the request
36
+ appears in `index.all()`, `subjectLevel` survives the mapping as `49`, and after an approver at level 50
37
+ settles it, **the subject level is still 49** — settling must not rewrite it.
38
+
39
+ ### Two of my own errors, both caught by the suite rather than by reading
40
+
41
+ * I asserted `INDEX_NO_SUBJECT_LEVEL`. The value is `APPROVAL_NO_SUBJECT_LEVEL` — **every code in
42
+ `INDEX_REFUSAL` carries the `APPROVAL_` prefix.** The implementation was right and had passed the index's
43
+ own code through faithfully, which is exactly what the test was there to check. The assertion now reads the
44
+ value **out of the table** so it cannot drift again.
45
+ * The generator script's escape handling produced a stray `$` inside a template literal in a *failure message*,
46
+ which threw at runtime rather than at parse time. Caught by running the suite, not by `node --check`.
47
+
48
+ ### Still not wired to a live queue
49
+
50
+ `createApprovalIndex` has **no caller in the plugin** — it was built in 0.10.0 and is exported from
51
+ `approval-index.mjs` alone. This release makes the gate and the index meet **on a real object**, with an
52
+ end-to-end test, but no dispatch path yet constructs a queue. So `W2M_PERMISSION_NEEDS_APPROVAL` still
53
+ refuses rather than queuing, and it still says so.
54
+
55
+ **6 new tests, 0 failures.**
56
+
7
57
  ## [0.23.0] — 2026-10-15
8
58
 
9
59
  **The gate is on the dispatch path. "分权限" now executes, and a machine that has not opted in behaves exactly as before.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -255,6 +255,82 @@ export function approvalRequestFor(decision, context = {}) {
255
255
  };
256
256
  }
257
257
 
258
+ /**
259
+ * Turn an approval request into the entry the approval index accepts, and register it.
260
+ *
261
+ * ## Why this adapter exists rather than a direct call
262
+ *
263
+ * The two shapes are close and **not identical**, which is precisely where a defect would live:
264
+ *
265
+ * | `approvalRequestFor` produces | `index.register` wants |
266
+ * |---|---|
267
+ * | `subject_level` (flat) | `subject: { id, level }` (nested) |
268
+ * | `capability` | `action` |
269
+ * | `task_id` / `requested_by` | `sessionKey` / `subject.id` |
270
+ *
271
+ * A caller hand-mapping these at the call site would get it right once and wrong later, and the failure would
272
+ * be a request registered against the wrong subject — the one field the whole decision rests on. So the
273
+ * mapping lives here, beside the thing it maps from.
274
+ *
275
+ * ## What it refuses, and why it does not smooth anything over
276
+ *
277
+ * `register` refuses a subject level that is not an integer 0..100, and this adapter **passes the index's own
278
+ * `reason_code` and `detail` through unchanged**. A bridge that substituted a default level to make
279
+ * registration succeed would be inventing the single fact the approver's decision rests on, and would look
280
+ * like success while doing it.
281
+ *
282
+ * @param {{register: (entry: object) => {ok: boolean, reason_code?: string, detail?: string}}} index
283
+ * @param {object} request from {@link approvalRequestFor}
284
+ * @param {{approvalId: string, sessionKey?: string|null, toolName?: string|null,
285
+ * deadlineAt?: number|null, now?: number}} context
286
+ * @returns {{ok: boolean, approval_id: string|null, reason_code: string|null, detail: string}}
287
+ */
288
+ export function raiseApproval(index, request, context = {}) {
289
+ if (index === null || typeof index !== 'object' || typeof index.register !== 'function') {
290
+ return { ok: false, approval_id: null, reason_code: 'APPROVAL_NO_INDEX', detail: 'no approval index was supplied, so nothing was registered' };
291
+ }
292
+ if (request === null || typeof request !== 'object') {
293
+ return { ok: false, approval_id: null, reason_code: 'APPROVAL_NO_REQUEST', detail: 'no approval request was supplied, so nothing was registered' };
294
+ }
295
+ const approvalId = context.approvalId;
296
+ if (typeof approvalId !== 'string' || approvalId === '') {
297
+ // Required from the caller rather than generated here: an id invented at this layer could not be
298
+ // correlated with whoever is waiting for the decision.
299
+ return {
300
+ ok: false,
301
+ approval_id: null,
302
+ reason_code: 'APPROVAL_NO_ID',
303
+ detail: 'raiseApproval needs a caller-supplied approvalId, so the request can be correlated with whoever waits on it',
304
+ };
305
+ }
306
+
307
+ const result = index.register({
308
+ approvalId,
309
+ sessionKey: context.sessionKey ?? null,
310
+ subject: { id: request.requested_by ?? null, level: request.subject_level },
311
+ action: request.capability,
312
+ toolName: context.toolName ?? null,
313
+ reason: request.detail ?? null,
314
+ ...(Number.isFinite(context.now) ? { openedAt: context.now } : {}),
315
+ deadlineAt: Number.isFinite(context.deadlineAt) ? context.deadlineAt : null,
316
+ });
317
+
318
+ if (result?.ok !== true) {
319
+ return {
320
+ ok: false,
321
+ approval_id: null,
322
+ reason_code: result?.reason_code ?? 'APPROVAL_REFUSED',
323
+ detail: result?.detail ?? 'the approval index refused the request without saying why',
324
+ };
325
+ }
326
+ return {
327
+ ok: true,
328
+ approval_id: approvalId,
329
+ reason_code: null,
330
+ detail: `registered ${approvalId} for ${request.capability} at subject level ${request.subject_level}`,
331
+ };
332
+ }
333
+
258
334
  /**
259
335
  * Whether a decision may be settled automatically.
260
336
  *
@@ -190,7 +190,7 @@ const MAX_STAGES = 16;
190
190
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
191
191
  * overrides it at runtime for tests and packagers.
192
192
  */
193
- const PLUGIN_VERSION = '0.23.0';
193
+ const PLUGIN_VERSION = '0.24.0';
194
194
 
195
195
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
196
196
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;