weir-harness 1.2.0 → 1.2.2

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.
Files changed (99) hide show
  1. package/cordis.patch.yml +0 -2
  2. package/lib/client.blackboard-model.js +172 -56
  3. package/lib/client.capability-manager.js +0 -1
  4. package/lib/client.capability-model.js +378 -31
  5. package/lib/client.chain-model.js +80 -0
  6. package/lib/client.disabled-categories-editor.js +22 -0
  7. package/lib/client.edit-lock-maintenance.js +239 -246
  8. package/lib/client.edit-lock-panel.js +110 -0
  9. package/lib/client.hash-edit-model.js +48 -0
  10. package/lib/client.js +267 -1549
  11. package/lib/client.lsp-model.js +92 -0
  12. package/lib/client.lsp-panel.js +11 -16
  13. package/lib/client.lsp-toggle.js +18 -0
  14. package/lib/client.notify-permissions.js +112 -20
  15. package/lib/client.notify-web.js +14 -11
  16. package/lib/client.robash-model.js +26 -0
  17. package/lib/client.settings-page.js +287 -17
  18. package/lib/client.worktree-model.js +225 -0
  19. package/package.json +5 -2
  20. package/src/blackboard/durable.js +12 -0
  21. package/src/blackboard/index.js +2 -2
  22. package/src/blackboard/kernel.js +23 -6
  23. package/src/blackboard/notify.js +10 -3
  24. package/src/blackboard/remote.js +31 -13
  25. package/src/blackboard/storage.js +13 -5
  26. package/src/capabilities/apply-engine.js +189 -101
  27. package/src/capabilities/defaults-transaction.js +136 -1
  28. package/src/capabilities/lifecycle-snapshot.js +83 -204
  29. package/src/capabilities/mcp-admission.js +1 -23
  30. package/src/capabilities/mcp-drain.js +30 -19
  31. package/src/capabilities/mcp-facade.js +23 -3
  32. package/src/capabilities/preset-library.js +247 -0
  33. package/src/capabilities/preset-package.js +212 -1
  34. package/src/capabilities/same-name-policy.js +3 -41
  35. package/src/capabilities/selection-notify.js +324 -21
  36. package/src/capabilities/skill-office-adapter.js +7 -1
  37. package/src/capabilities/skill-selection-plugin.js +315 -729
  38. package/src/capabilities/store/checked-contract.d.ts +5 -0
  39. package/src/capabilities/store/fs-adapter.js +52 -0
  40. package/src/capabilities/store/lock.js +86 -0
  41. package/src/capabilities/store/paths.js +5 -0
  42. package/src/capabilities/store/store.js +105 -5
  43. package/src/creative-guide/guide.js +1 -1
  44. package/src/delegate/child-composition.js +258 -0
  45. package/src/delegate/group-coordinator.js +25 -27
  46. package/src/delegate/index.js +21 -2
  47. package/src/delegate/rehydrate.js +24 -4
  48. package/src/delegate/robash-guard-core.js +2 -2
  49. package/src/delegate/spawn-adapter.js +214 -100
  50. package/src/delegate/status-tool.js +9 -6
  51. package/src/delegate/supervision-mount.js +35 -6
  52. package/src/delegate/tool.js +29 -4
  53. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  54. package/src/edit-lock/admin-ledger.js +8 -11
  55. package/src/edit-lock/admin-recovery.js +2 -1
  56. package/src/edit-lock/admission.js +12 -2
  57. package/src/edit-lock/binding-guidance.js +28 -10
  58. package/src/edit-lock/binding-machine.js +312 -0
  59. package/src/edit-lock/derived-facts.js +67 -0
  60. package/src/edit-lock/index.js +44 -244
  61. package/src/edit-lock/inspect.js +113 -1
  62. package/src/edit-lock/lifecycle.js +8 -5
  63. package/src/edit-lock/limits.js +61 -0
  64. package/src/edit-lock/maintenance.js +5 -2
  65. package/src/edit-lock/manager.js +6 -2
  66. package/src/edit-lock/operation-history.js +1 -1
  67. package/src/edit-lock/peer-client.js +9 -2
  68. package/src/edit-lock/peer-transport.js +7 -1
  69. package/src/edit-lock/remote.js +11 -4
  70. package/src/edit-lock/state.js +2 -4
  71. package/src/edit-lock/view.js +10 -11
  72. package/src/lsp/admin.js +2 -24
  73. package/src/lsp/index.js +1 -1
  74. package/src/lsp/manager.js +1 -1
  75. package/src/notify/permissions-admin.js +1 -1
  76. package/src/{lsp/child-process.js → process/index.js} +23 -6
  77. package/src/settings/env-admin.js +1 -1
  78. package/src/settings/index.js +1 -1
  79. package/src/settings/sections.js +0 -54
  80. package/src/shared/audit.js +12 -0
  81. package/src/shared/child-scope.js +3 -1
  82. package/src/shared/http-json.js +28 -0
  83. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  84. package/src/worktree/errors.js +3 -0
  85. package/src/worktree/index.js +1 -2
  86. package/src/worktree/lane-core.js +641 -0
  87. package/src/worktree/lane-views.js +147 -0
  88. package/src/worktree/lane-workflows.js +853 -0
  89. package/src/worktree/lanes.js +24 -1513
  90. package/src/worktree/runner.js +5 -4
  91. package/src/capabilities/content-pin.js +0 -88
  92. package/src/capabilities/content-refresh.js +0 -331
  93. package/src/capabilities/distribution-manifest.js +0 -78
  94. package/src/capabilities/isolated-exec.js +0 -106
  95. package/src/capabilities/publisher.js +0 -129
  96. package/src/capabilities/skill-admission.js +0 -181
  97. package/src/capabilities/update-lifecycle.js +0 -106
  98. package/src/edit-lock/remote-service.js +0 -28
  99. package/src/worktree/contract-guidance.test.js +0 -100
@@ -6,9 +6,48 @@
6
6
  // a synchronous followup, and the advisory text promises neither history
7
7
  // erasure nor call retraction. A failed injection is audit/warn only and
8
8
  // never affects the accepted commit.
9
+ //
10
+ // durable-pending-notice (D1/D2/D4/D5/D6): the queued obligation is a durable
11
+ // capability-store unit ({kind:'notice', sessionId} → sessions/<id>/notice.json),
12
+ // not process memory — an Apply that was accepted is owed a notice even if the
13
+ // host exits before the session's next request. The queue write happens inside
14
+ // the engine's one non-yielding publication segment (commitSync), coalescing
15
+ // with whatever is already pending through the unchanged mergeNetChanges; the
16
+ // obligation is cleared only AFTER the text has actually been injected, by
17
+ // subtracting exactly the delivered names. Failing to read, write or clear
18
+ // never fabricates a notice, never deletes a damaged unit and never touches the
19
+ // accepted selection record — it is reported on the cold-safe audit channel
20
+ // (AUDIT_TYPES.capabilityNotice; counts, never names).
21
+ import { AUDIT_TYPES } from '../shared/audit.js'
22
+ import { userTextMessage } from '../shared/user-message.js'
9
23
 
10
24
  export const NOTIFY_SOURCE = 'weir-selection-notify'
11
25
 
26
+ /**
27
+ * The accepted-set delta for the model-facing notice (apply-delta-durable-basis
28
+ * D1): two `{ skills: identities[], mcpServers: string[] }` set objects in —
29
+ * the durable pre-write accepted record and the just-committed payload — one
30
+ * sorted `{ added, removed }` name delta out. Skill identities map to display
31
+ * names (`identity?.name`), MCP server names pass through unchanged; nameless
32
+ * entries never leak. Pure: the same inputs always yield the same delta, so
33
+ * the committed branch and the recovery-'published' branch share it.
34
+ * @param {{ skills?: unknown[], mcpServers?: string[] } | null | undefined} previousSets
35
+ * @param {{ skills?: unknown[], mcpServers?: string[] } | null | undefined} acceptedSets
36
+ * @returns {{ added: string[], removed: string[] }}
37
+ */
38
+ export function selectionDelta(previousSets, acceptedSets) {
39
+ const names = identities => new Set((Array.isArray(identities) ? identities : []).map(identity => String(identity?.name ?? '')).filter(Boolean))
40
+ const servers = value => new Set(Array.isArray(value) ? value : [])
41
+ const before = names(previousSets?.skills)
42
+ const after = names(acceptedSets?.skills)
43
+ const beforeMcp = servers(previousSets?.mcpServers)
44
+ const afterMcp = servers(acceptedSets?.mcpServers)
45
+ return {
46
+ added: [...after].filter(name => !before.has(name)).concat([...afterMcp].filter(name => !beforeMcp.has(name))).sort(),
47
+ removed: [...before].filter(name => !after.has(name)).concat([...beforeMcp].filter(name => !afterMcp.has(name))).sort(),
48
+ }
49
+ }
50
+
12
51
  /**
13
52
  * The merged net change set for one session (12.4): a name queued as added
14
53
  * then removed nets out of BOTH sets (and vice versa).
@@ -48,28 +87,292 @@ export function notificationText(net) {
48
87
  ].join('\n')
49
88
  }
50
89
 
90
+ /** The empty net: nothing is owed to the model. */
91
+ const NO_CHANGES = Object.freeze({ added: [], removed: [] })
92
+
51
93
  /**
52
- * The per-session pending queue + injection seam (12.4).
94
+ * Normalize a stored notice payload into a net change set. Only non-empty
95
+ * strings are names, both lists dedupe and sort, and a payload that is not an
96
+ * object reads as an empty net: a damaged payload is NEVER turned into a
97
+ * fabricated notice (the notice is advisory — nothing is owed when nothing
98
+ * readable names a change).
99
+ * @param {unknown} payload
100
+ * @returns {{ added: string[], removed: string[] }}
53
101
  */
54
- export function createSelectionNotifier() {
55
- const pending = new Map()
56
- return {
57
- /** Queue a net delta for a session (merged with anything pending). */
58
- queue(sessionId, delta) {
59
- const current = pending.get(sessionId) ?? { added: [], removed: [] }
60
- pending.set(sessionId, mergeNetChanges(current, delta))
61
- },
62
- /**
63
- * Consume the merged notification for the session's next safe request,
64
- * or null when nothing is pending.
65
- */
66
- consume(sessionId) {
67
- const net = pending.get(sessionId)
68
- pending.delete(sessionId)
69
- if (!net) return null
70
- const text = notificationText(net)
71
- return text === null ? null : { text, net }
72
- },
73
- has(sessionId) { return pending.has(sessionId) },
102
+ export function noticeNet(payload) {
103
+ const value = /** @type {Record<string, unknown>} */ (payload !== null && typeof payload === 'object' ? payload : {})
104
+ const names = list => Array.isArray(list)
105
+ ? [...new Set(list.filter(name => typeof name === 'string' && name.length > 0))].sort()
106
+ : []
107
+ return { added: names(value.added), removed: names(value.removed) }
108
+ }
109
+
110
+ /** @param {{ added: string[], removed: string[] }} net */
111
+ const isEmptyNet = net => net.added.length === 0 && net.removed.length === 0
112
+
113
+ /** @param {{ added: string[], removed: string[] }} a @param {{ added: string[], removed: string[] }} b */
114
+ const sameNet = (a, b) => a.added.length === b.added.length && a.removed.length === b.removed.length
115
+ && a.added.every((name, index) => name === b.added[index])
116
+ && a.removed.every((name, index) => name === b.removed[index])
117
+
118
+ /**
119
+ * Subtract exactly the delivered names (D4): an added name leaves the added
120
+ * set, a removed name leaves the removed set. A concurrent Apply's delta merged
121
+ * under the CAS therefore survives a clear.
122
+ * @param {{ added: string[], removed: string[] }} current
123
+ * @param {{ added: string[], removed: string[] }} delivered
124
+ */
125
+ function subtractDelivered(current, delivered) {
126
+ const added = new Set(current.added)
127
+ const removed = new Set(current.removed)
128
+ for (const name of delivered.added) added.delete(name)
129
+ for (const name of delivered.removed) removed.delete(name)
130
+ return { added: [...added].sort(), removed: [...removed].sort() }
131
+ }
132
+
133
+ /**
134
+ * @typedef {{ added: string[], removed: string[] }} NetChange
135
+ * @typedef {{ added: string[], removed: string[], revision: number }} PendingNotice
136
+ * @typedef {{ read(unit: unknown): Promise<any>, readSync(unit: unknown): any,
137
+ * commit(unit: unknown, expectedRevision: number, mutate: (payload: unknown, info: { revision: number }) => unknown): Promise<any>,
138
+ * commitSync(unit: unknown, expectedRevision: number, mutate: (payload: unknown, info: { revision: number }) => unknown): any }} NoticeStore
139
+ */
140
+
141
+ /**
142
+ * The durable pending-notice queue + injection seam (12.4, made durable by
143
+ * durable-pending-notice). One instance per host process:
144
+ * - `queueSync` runs inside the Apply publication segment (no await allowed) and
145
+ * THROWS on any non-committed outcome, so the engine's existing catch degrades
146
+ * the response instead of silently dropping the obligation;
147
+ * - `pending`/`deliver`/`clearDelivered` are the request-boundary side: read,
148
+ * inject, then clear;
149
+ * - `has` is the synchronous diagnostic (`hasPendingNotification`).
150
+ * A per-session mirror (D6) keeps the steady state free of extra disk reads: a
151
+ * session is read on its first boundary of this process and after any local
152
+ * write. A notice written by ANOTHER process is therefore not observed until the
153
+ * next cold load — a stated limit, strictly better than the process Map it
154
+ * replaces.
155
+ * @param {{ store?: NoticeStore, audit?: (session: { id?: string } | null, type: string, data?: unknown) => void }} [options]
156
+ */
157
+ export function createSelectionNotifier({ store, audit: emitAudit = () => {} } = {}) {
158
+ /** @type {Map<string, { net: NetChange, revision: number, present: boolean }>} */
159
+ const mirror = new Map()
160
+
161
+ /** @param {string} sessionId */
162
+ const unitOf = sessionId => ({ kind: 'notice', sessionId })
163
+
164
+ /**
165
+ * One audit record on the cold-safe channel (D7): counts, never names.
166
+ * @param {string} sessionId @param {string} outcome @param {Record<string, unknown>} [data]
167
+ */
168
+ function note(sessionId, outcome, data = {}) {
169
+ try { emitAudit({ id: sessionId }, AUDIT_TYPES.capabilityNotice, { outcome, ...data }) } catch { /* audit never breaks a turn */ }
170
+ }
171
+
172
+ /** @param {string} sessionId @param {{ net: NetChange, revision: number, present: boolean }} entry */
173
+ const remember = (sessionId, entry) => { mirror.set(sessionId, entry) }
174
+
175
+ /**
176
+ * Queue a net delta for a session, merged with whatever is already durable.
177
+ * Runs in the publication segment: no await, and a failed write THROWS so the
178
+ * engine can degrade the response (D2). Merging the same delta twice is a
179
+ * no-op — the merge absorbs it, so a double settle can never double-announce.
180
+ * @param {string} sessionId
181
+ * @param {{ added?: string[], removed?: string[] }} delta
182
+ * @param {{ revision?: number }} [info] the accepted revision (diagnosis/audit)
183
+ * @returns {NetChange}
184
+ */
185
+ function queueSync(sessionId, delta, info = {}) {
186
+ const acceptedRevision = typeof info.revision === 'number' ? info.revision : undefined
187
+ if (!store) {
188
+ note(sessionId, 'queue-failed', { status: 'unavailable', revision: acceptedRevision })
189
+ throw new Error('capability notice queue failed: unavailable')
190
+ }
191
+ // One retry on a lost CAS race (a foreign writer landed between read and
192
+ // write); every other outcome fails loudly on the first attempt.
193
+ for (let attempt = 0; attempt < 2; attempt += 1) {
194
+ let record
195
+ try {
196
+ record = store.readSync(unitOf(sessionId))
197
+ } catch (error) {
198
+ note(sessionId, 'queue-failed', { status: 'read-failed', revision: acceptedRevision })
199
+ throw new Error(`capability notice queue failed: read-failed: ${error instanceof Error ? error.message : String(error)}`)
200
+ }
201
+ if (record.kind !== 'ok' && record.kind !== 'absent') {
202
+ // Never repaired by a rewrite: a damaged unit is reported and left alone.
203
+ note(sessionId, 'queue-failed', { status: record.kind, revision: acceptedRevision })
204
+ throw new Error(`capability notice queue failed: ${record.kind}`)
205
+ }
206
+ const previous = record.kind === 'ok' ? noticeNet(record.payload) : NO_CHANGES
207
+ const merged = mergeNetChanges(previous, delta)
208
+ if (sameNet(merged, previous)) {
209
+ // Nothing changed (an absorbed re-queue): no write, no revision bump.
210
+ remember(sessionId, { net: merged, revision: record.kind === 'ok' ? record.revision : 0, present: record.kind === 'ok' })
211
+ note(sessionId, 'queued', { added: merged.added.length, removed: merged.removed.length, revision: acceptedRevision })
212
+ return merged
213
+ }
214
+ const result = store.commitSync(unitOf(sessionId), record.kind === 'ok' ? record.revision : 0, () => merged)
215
+ if (result.status === 'committed') {
216
+ remember(sessionId, { net: merged, revision: result.revision, present: true })
217
+ note(sessionId, 'queued', { added: merged.added.length, removed: merged.removed.length, revision: acceptedRevision })
218
+ return merged
219
+ }
220
+ if (result.status === 'revision-conflict' && attempt === 0) continue
221
+ note(sessionId, 'queue-failed', { added: merged.added.length, removed: merged.removed.length, status: result.status, revision: acceptedRevision })
222
+ throw new Error(`capability notice queue failed: ${result.status}`)
223
+ }
224
+ /* c8 ignore next */
225
+ throw new Error('capability notice queue failed: unsettled')
226
+ }
227
+
228
+ /**
229
+ * Load what the durable unit says about this session, distinguishing the three
230
+ * outcomes the boundary must not conflate (D5): nothing owed, an obligation,
231
+ * or a unit that cannot be read. Never throws — a read failure is reported and
232
+ * delivers nothing, because the notice is advisory and must not block a
233
+ * request. One cold read per session per process, then the mirror.
234
+ * @param {string} sessionId
235
+ * @returns {Promise<{ kind: 'owed', net: NetChange, revision: number } | { kind: 'none' } | { kind: 'unreadable', status: string }>}
236
+ */
237
+ async function loadPending(sessionId) {
238
+ const cached = mirror.get(sessionId)
239
+ if (cached) return cached.present && !isEmptyNet(cached.net)
240
+ ? { kind: 'owed', net: cached.net, revision: cached.revision }
241
+ : { kind: 'none' }
242
+ if (!store) return { kind: 'none' }
243
+ let record
244
+ try {
245
+ record = await store.read(unitOf(sessionId))
246
+ } catch {
247
+ note(sessionId, 'unreadable', { status: 'read-failed' })
248
+ return { kind: 'unreadable', status: 'read-failed' }
249
+ }
250
+ // The platform gate is not an error: no durable queue exists, and no
251
+ // accepted selection can owe a notice here (the same store backs Apply).
252
+ if (record.kind === 'unsupported') return { kind: 'none' }
253
+ if (record.kind !== 'ok' && record.kind !== 'absent') {
254
+ // Delivered nothing, deleted nothing: reported, never repaired.
255
+ note(sessionId, 'unreadable', { status: record.kind })
256
+ return { kind: 'unreadable', status: record.kind }
257
+ }
258
+ const present = record.kind === 'ok'
259
+ const net = present ? noticeNet(record.payload) : NO_CHANGES
260
+ remember(sessionId, { net, revision: present ? record.revision : 0, present })
261
+ return present && !isEmptyNet(net) ? { kind: 'owed', net, revision: record.revision } : { kind: 'none' }
74
262
  }
263
+
264
+ /**
265
+ * The obligation owed to this session right now, or null when nothing is owed
266
+ * (absent unit, an empty unit, an unsupported store). A unit that cannot be
267
+ * read also answers null — `deliver` reports that case separately.
268
+ * @param {string} sessionId
269
+ * @returns {Promise<PendingNotice | null>}
270
+ */
271
+ async function pending(sessionId) {
272
+ const state = await loadPending(sessionId)
273
+ return state.kind === 'owed' ? { ...state.net, revision: state.revision } : null
274
+ }
275
+
276
+ /**
277
+ * Subtract exactly the delivered names (D4). A lost CAS race is retried once
278
+ * against a fresh read — that retry is what preserves a concurrent Apply's
279
+ * delta; a second conflict (or any other failure) leaves the pending intact,
280
+ * which repeats a notice rather than losing one.
281
+ * @param {string} sessionId
282
+ * @param {{ added?: string[], removed?: string[] }} delivered
283
+ * @param {{ revision?: number }} [info] the revision observed when delivering
284
+ * @returns {Promise<{ status: string, revision?: number, kind?: string }>}
285
+ */
286
+ async function clearDelivered(sessionId, delivered, info = {}) {
287
+ if (!store) return { status: 'unsupported' }
288
+ const owed = noticeNet(delivered)
289
+ /** @type {number | null} */
290
+ let expected = typeof info.revision === 'number' ? info.revision : null
291
+ for (let attempt = 0; attempt < 2; attempt += 1) {
292
+ if (expected === null) {
293
+ let record
294
+ try {
295
+ record = await store.read(unitOf(sessionId))
296
+ } catch {
297
+ note(sessionId, 'clear-failed', { status: 'read-failed' })
298
+ return { status: 'read-failed' }
299
+ }
300
+ if (record.kind === 'unsupported') return { status: 'unsupported' }
301
+ if (record.kind === 'absent') {
302
+ remember(sessionId, { net: NO_CHANGES, revision: 0, present: false })
303
+ return { status: 'absent' }
304
+ }
305
+ if (record.kind !== 'ok') {
306
+ note(sessionId, 'clear-failed', { status: record.kind })
307
+ return { status: 'unreadable', kind: record.kind }
308
+ }
309
+ // An empty obligation needs no write at all.
310
+ if (isEmptyNet(noticeNet(record.payload))) {
311
+ remember(sessionId, { net: NO_CHANGES, revision: record.revision, present: true })
312
+ return { status: 'empty', revision: record.revision }
313
+ }
314
+ expected = record.revision
315
+ }
316
+ const result = await store.commit(unitOf(sessionId), expected, previous => subtractDelivered(noticeNet(previous), owed))
317
+ if (result.status === 'committed') {
318
+ remember(sessionId, { net: noticeNet(result.payload), revision: result.revision, present: true })
319
+ note(sessionId, 'cleared', { added: owed.added.length, removed: owed.removed.length, revision: result.revision })
320
+ return { status: 'cleared', revision: result.revision }
321
+ }
322
+ if (result.status === 'revision-conflict' && attempt === 0) {
323
+ expected = null
324
+ continue
325
+ }
326
+ note(sessionId, 'clear-failed', { added: owed.added.length, removed: owed.removed.length, status: result.status })
327
+ return { status: result.status }
328
+ }
329
+ /* c8 ignore next */
330
+ return { status: 'unsettled' }
331
+ }
332
+
333
+ /**
334
+ * Deliver the pending notice into the session's next request boundary:
335
+ * read → build → inject → clear (D4). The obligation is cleared only after the
336
+ * message landed, so a failed injection leaves it for the next boundary — the
337
+ * old consume-before-push lost it silently.
338
+ * @param {string} sessionId @param {unknown[] | unknown} messages
339
+ * @returns {Promise<'absent'|'delivered'|'injection-failed'|'clear-failed'|'unreadable'>}
340
+ */
341
+ async function deliver(sessionId, messages) {
342
+ const state = await loadPending(sessionId)
343
+ // A unit that cannot be read delivers nothing and deletes nothing (D5).
344
+ if (state.kind === 'unreadable') return 'unreadable'
345
+ // Nothing owed — including an empty unit, which holds no names to clear.
346
+ if (state.kind !== 'owed') return 'absent'
347
+ const owed = state.net
348
+ const text = notificationText(owed)
349
+ /* c8 ignore next */
350
+ if (text === null) return 'absent'
351
+ try {
352
+ if (!Array.isArray(messages)) throw new TypeError('messages is not an array')
353
+ messages.push(userTextMessage(text, NOTIFY_SOURCE))
354
+ } catch (error) {
355
+ note(sessionId, 'injection-failed', { added: owed.added.length, removed: owed.removed.length, status: error instanceof Error ? error.message : String(error) })
356
+ return 'injection-failed'
357
+ }
358
+ note(sessionId, 'delivered', { added: owed.added.length, removed: owed.removed.length })
359
+ const cleared = await clearDelivered(sessionId, owed, { revision: owed.revision })
360
+ return cleared.status === 'cleared' || cleared.status === 'absent' || cleared.status === 'empty' ? 'delivered' : 'clear-failed'
361
+ }
362
+
363
+ /**
364
+ * Synchronous diagnostic: is a notice queued for this session? Answers from
365
+ * the mirror, else from one synchronous read — never a stale cache guess.
366
+ * @param {string} sessionId
367
+ */
368
+ function has(sessionId) {
369
+ const cached = mirror.get(sessionId)
370
+ if (cached) return cached.present && !isEmptyNet(cached.net)
371
+ if (!store) return false
372
+ let record
373
+ try { record = store.readSync(unitOf(sessionId)) } catch { return false }
374
+ return record.kind === 'ok' && !isEmptyNet(noticeNet(record.payload))
375
+ }
376
+
377
+ return { queueSync, pending, clearDelivered, deliver, has }
75
378
  }
@@ -30,7 +30,13 @@ export function createOfficeAdapter(skills, machineId) {
30
30
  const observation = Array.isArray(output) ? { candidates: output, complete: true } : output
31
31
  const candidates = []
32
32
  for (const raw of observation.candidates) {
33
- if (!OFFICE_NAMES.includes(raw.name) || raw.provider !== 'dsh-office' || raw.source !== 'bundled' || typeof raw.locator !== 'string') {
33
+ // DSH 0.2.1's office provider lists MORE bundled siblings than the three
34
+ // Weir composes (`office`, `office-render`, `office-recalculate`,
35
+ // `office-export-pdf`). This adapter only owns OFFICE_NAMES: a foreign
36
+ // sibling is skipped, never an error — while a candidate that CLAIMS one
37
+ // of our names with an unexpected shape still fails closed below.
38
+ if (!OFFICE_NAMES.includes(raw.name)) continue
39
+ if (raw.provider !== 'dsh-office' || raw.source !== 'bundled' || typeof raw.locator !== 'string') {
34
40
  throw new Error('Unsupported Office provider candidate')
35
41
  }
36
42
  const path = await realpath(raw.locator)