weir-harness 1.2.0 → 1.2.1

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 (92) hide show
  1. package/lib/client.blackboard-model.js +172 -56
  2. package/lib/client.capability-manager.js +0 -1
  3. package/lib/client.capability-model.js +378 -31
  4. package/lib/client.chain-model.js +80 -0
  5. package/lib/client.disabled-categories-editor.js +22 -0
  6. package/lib/client.edit-lock-maintenance.js +239 -246
  7. package/lib/client.edit-lock-panel.js +110 -0
  8. package/lib/client.hash-edit-model.js +48 -0
  9. package/lib/client.js +267 -1549
  10. package/lib/client.lsp-model.js +92 -0
  11. package/lib/client.lsp-panel.js +11 -16
  12. package/lib/client.lsp-toggle.js +18 -0
  13. package/lib/client.notify-permissions.js +112 -20
  14. package/lib/client.notify-web.js +14 -11
  15. package/lib/client.robash-model.js +26 -0
  16. package/lib/client.settings-page.js +287 -17
  17. package/lib/client.worktree-model.js +225 -0
  18. package/package.json +1 -1
  19. package/src/blackboard/durable.js +12 -0
  20. package/src/blackboard/index.js +2 -2
  21. package/src/blackboard/kernel.js +23 -6
  22. package/src/blackboard/notify.js +10 -3
  23. package/src/blackboard/remote.js +31 -13
  24. package/src/blackboard/storage.js +13 -5
  25. package/src/capabilities/apply-engine.js +189 -101
  26. package/src/capabilities/defaults-transaction.js +136 -1
  27. package/src/capabilities/lifecycle-snapshot.js +83 -204
  28. package/src/capabilities/mcp-admission.js +1 -23
  29. package/src/capabilities/mcp-drain.js +30 -19
  30. package/src/capabilities/mcp-facade.js +23 -3
  31. package/src/capabilities/preset-library.js +247 -0
  32. package/src/capabilities/preset-package.js +212 -1
  33. package/src/capabilities/same-name-policy.js +3 -41
  34. package/src/capabilities/selection-notify.js +324 -21
  35. package/src/capabilities/skill-selection-plugin.js +315 -729
  36. package/src/capabilities/store/checked-contract.d.ts +5 -0
  37. package/src/capabilities/store/fs-adapter.js +52 -0
  38. package/src/capabilities/store/lock.js +86 -0
  39. package/src/capabilities/store/paths.js +5 -0
  40. package/src/capabilities/store/store.js +105 -5
  41. package/src/delegate/group-coordinator.js +1 -17
  42. package/src/delegate/index.js +4 -2
  43. package/src/delegate/rehydrate.js +2 -1
  44. package/src/delegate/robash-guard-core.js +2 -2
  45. package/src/delegate/supervision-mount.js +5 -4
  46. package/src/delegate/tool.js +26 -3
  47. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  48. package/src/edit-lock/admin-ledger.js +8 -11
  49. package/src/edit-lock/admin-recovery.js +2 -1
  50. package/src/edit-lock/admission.js +12 -2
  51. package/src/edit-lock/binding-guidance.js +28 -10
  52. package/src/edit-lock/binding-machine.js +312 -0
  53. package/src/edit-lock/derived-facts.js +67 -0
  54. package/src/edit-lock/index.js +44 -244
  55. package/src/edit-lock/inspect.js +113 -1
  56. package/src/edit-lock/lifecycle.js +8 -5
  57. package/src/edit-lock/limits.js +61 -0
  58. package/src/edit-lock/maintenance.js +5 -2
  59. package/src/edit-lock/manager.js +6 -2
  60. package/src/edit-lock/operation-history.js +1 -1
  61. package/src/edit-lock/peer-client.js +9 -2
  62. package/src/edit-lock/peer-transport.js +7 -1
  63. package/src/edit-lock/remote.js +11 -4
  64. package/src/edit-lock/state.js +2 -4
  65. package/src/edit-lock/view.js +10 -11
  66. package/src/lsp/admin.js +2 -24
  67. package/src/lsp/index.js +1 -1
  68. package/src/lsp/manager.js +1 -1
  69. package/src/notify/permissions-admin.js +1 -1
  70. package/src/{lsp/child-process.js → process/index.js} +12 -4
  71. package/src/settings/env-admin.js +1 -1
  72. package/src/settings/index.js +1 -1
  73. package/src/settings/sections.js +0 -54
  74. package/src/shared/audit.js +5 -0
  75. package/src/shared/http-json.js +28 -0
  76. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  77. package/src/worktree/errors.js +3 -0
  78. package/src/worktree/index.js +1 -2
  79. package/src/worktree/lane-core.js +641 -0
  80. package/src/worktree/lane-views.js +147 -0
  81. package/src/worktree/lane-workflows.js +853 -0
  82. package/src/worktree/lanes.js +24 -1513
  83. package/src/worktree/runner.js +5 -4
  84. package/src/capabilities/content-pin.js +0 -88
  85. package/src/capabilities/content-refresh.js +0 -331
  86. package/src/capabilities/distribution-manifest.js +0 -78
  87. package/src/capabilities/isolated-exec.js +0 -106
  88. package/src/capabilities/publisher.js +0 -129
  89. package/src/capabilities/skill-admission.js +0 -181
  90. package/src/capabilities/update-lifecycle.js +0 -106
  91. package/src/edit-lock/remote-service.js +0 -28
  92. package/src/worktree/contract-guidance.test.js +0 -100
@@ -8,24 +8,26 @@
8
8
  // no permission publication, no external installs, no notifications.
9
9
  // 4. Enter the session admission fence; close the gate and start draining
10
10
  // removed managed MCP servers (drain is a stub seam until group 8.6).
11
- // 5. CAS-commit the new selection and receipt through the group-2 store,
12
- // then in ONE non-async segment: swap the in-memory snapshot, call the
13
- // provider's control.invalidate() and release the fence. invalidate()
11
+ // 5. CAS-commit the new selection and receipt through the group-2 store, which
12
+ // reports the payload its CAS replaced — the authority for the net-change
13
+ // notice and for the gate/drain set — then in ONE non-async segment: swap
14
+ // the in-memory snapshot, call the provider's control.invalidate() and
15
+ // release the fence. invalidate()
14
16
  // happens before the fence release and before the step-6 response.
15
17
  // 6. Send the response: accepted revision, effective sets, unresolved
16
18
  // warnings and a drain snapshot taken at send time (never awaited).
17
19
  // Audit and notification are scheduled best-effort after the response; their
18
20
  // failure never rolls back accepted policy. Commands and audit logs are not
19
21
  // commit evidence: durable acceptance is only the store record.
20
- // Tasks 4.5 run content-refresh transactions through the SAME per-session
21
- // serialization point (coordinate); tasks 4.6 gate capability calls on the
22
- // fence and the authority snapshot exposed here.
22
+ // Tasks 4.6 gate capability calls on the fence and the authority snapshot
23
+ // exposed here.
23
24
  import { digestOf } from './store/record.js'
24
25
  import { isSegment } from './store/paths.js'
25
26
  import { skillIdentityKey } from './skill-identity.js'
26
27
  import { validateSkillSelection } from './skill-selection-provider.js'
27
28
  import { normalizeEnabledSets } from './selection-draft.js'
28
29
  import { AUDIT_TYPES } from '../shared/audit.js'
30
+ import { selectionDelta } from './selection-notify.js'
29
31
 
30
32
  /** Audit event type (without the 'weir/' prefix) emitted after acceptance.
31
33
  * Registered in src/shared/audit.js (AUDIT_TYPES); emit sites never invent
@@ -36,6 +38,14 @@ export const APPLY_AUDIT_TYPE = AUDIT_TYPES.capabilityApply
36
38
  * @typedef {import('./skill-selection-provider.js')} SelectionProviderModule
37
39
  * @typedef {{ kind: 'skill'|'mcp', ref: string, reason: string }} UnresolvedRef
38
40
  * @typedef {{ server: string, state: 'draining'|'settled', inFlight: number }} DrainStatus
41
+ * The engine drain seam (step 4). begin(removed, sessionId) closes the gates
42
+ * of the removed servers FOR THAT SESSION and returns a handle: snapshot()
43
+ * reports the live in-flight counts (sent with the response, never awaited)
44
+ * and abort() reopens exactly the gates begin() closed when the Apply does
45
+ * not commit — abort() and begin() act on the SAME drain, so a failed Apply
46
+ * never leaves a session permanently gated. Production wires the manager's
47
+ * (agent, server)-keyed drain here (createSessionDrainAdapter, mcp-drain.js).
48
+ * @typedef {{ begin(removed: string[], sessionId: string): { snapshot(): DrainStatus[], abort(): void } }} ApplyDrain
39
49
  * A step-tagged rejection keeps the old authority and the user's draft.
40
50
  * @typedef {{ status: 'applied', revision: number, receipt: unknown, effective: unknown, unresolvedWarnings: UnresolvedRef[], draining?: DrainStatus[], warnings?: string[] }
41
51
  * | { status: 'duplicate', revision: number, receipt: unknown, effective: unknown, warnings?: string[] }
@@ -94,10 +104,13 @@ export function createAdmissionFence() {
94
104
  }
95
105
 
96
106
  /**
97
- * Drain coordinator for managed MCP servers removed by an Apply (step 4 seam;
98
- * group 8.6 wires it to the real MCP facade with timeouts). Closing a gate is
99
- * reversible until the commit succeeds: a rejected or failed Apply aborts the
100
- * closure. In-flight calls are reported, never force-cancelled.
107
+ * Drain coordinator for managed MCP servers removed by an Apply: the
108
+ * server-keyed DEFAULT seam implementation (the test surface). Production
109
+ * wires the manager's (agent, server)-keyed drain through
110
+ * createSessionDrainAdapter in mcp-drain.js — the engine passes the session
111
+ * id to begin(), which a server-keyed coordinator simply ignores. Closing a
112
+ * gate is reversible until the commit succeeds: a rejected or failed Apply
113
+ * aborts the closure. In-flight calls are reported, never force-cancelled.
101
114
  */
102
115
  export function createDrainCoordinator() {
103
116
  /** @type {Map<string, { closed: boolean, inFlight: Set<Promise<unknown>> }>} */
@@ -176,12 +189,12 @@ export function digestApplyRequest(normalized) {
176
189
  * inventory: (options: { cwd?: string, scope: { session: { id: string } } }) => Promise<{ complete: boolean, candidates: any[] }>,
177
190
  * provider: { acceptSelection(identities: unknown[], options?: unknown): { accepted: boolean, conflicts?: unknown[] } },
178
191
  * fence?: ReturnType<typeof createAdmissionFence>,
179
- * drain?: ReturnType<typeof createDrainCoordinator>,
192
+ * drain?: ApplyDrain,
180
193
  * conditions?: () => string[] | Promise<string[]>,
181
194
  * verifyContent?: (candidate: unknown) => Promise<void> | void,
182
195
  * audit?: (session: { id?: string }, type: string, data?: unknown) => void,
183
196
  * notify?: (response: unknown) => unknown,
184
- * onAccepted?: (sessionId: string, delta: { added: string[], removed: string[] }) => void,
197
+ * onAccepted?: (sessionId: string, delta: { added: string[], removed: string[] }, info: { revision: number }) => void,
185
198
  * invalidate?: (sessionId: string, presetId: string) => void,
186
199
  * publishSnapshot?: (sessionId: string, snapshot: { revision: number, skills: unknown[], mcpServers: string[] }) => void,
187
200
  * warn?: (message: string) => void,
@@ -197,7 +210,7 @@ export function createApplyEngine(options) {
197
210
  verifyContent,
198
211
  audit, notify, invalidate, publishSnapshot, onAccepted, warn = () => {}, trace = () => {},
199
212
  } = options
200
- /** In-memory authority snapshots per session (D6 seam; group 6 loads them at mount). */
213
+ /** In-memory authority snapshots per session (D6 seam): written only by the publication segment, never loaded from disk — after a restart the durable accepted record is the only delta/recovery basis (apply-delta-durable-basis D2). */
201
214
  const snapshots = new Map()
202
215
  /** Per-session in-process serialization; cross-process exclusion is the store lock. */
203
216
  const queues = new Map()
@@ -205,8 +218,15 @@ export function createApplyEngine(options) {
205
218
  * Sessions whose last write ended indeterminate: the fence lease stays held
206
219
  * and the prepared (never published) transaction is kept so a receipt query
207
220
  * can settle it from durable evidence — publish on proof, reopen on proven
208
- * absence, keep blocking while the record cannot vouch either way.
209
- * @type {Map<string, { lease: { release(): void }, requestId: string, prepared: { handles: unknown[], identities: unknown[], view: unknown }, payload: unknown, drainHandle: { abort(): void } | null, viewOptions: unknown }>}
221
+ * absence, keep blocking while the record cannot vouch either way. The entry
222
+ * captures the publication basis (`basisSets`) of the aborted transaction: at
223
+ * settle time the durable record already holds the LANDED write, so the
224
+ * recovery notice delta cannot re-read it. Which record that is depends on the
225
+ * site — the committed-over record when the commit result proved it (a landed
226
+ * write whose fence was lost before publication), otherwise the transaction-
227
+ * start store read (the commit threw or its outcome is unknown: this process
228
+ * never observed the locked record, so no committed-over proof exists).
229
+ * @type {Map<string, { lease: { release(): void }, requestId: string, prepared: { handles: unknown[], identities: unknown[], view: unknown }, payload: unknown, basisSets: { skills: unknown[], mcpServers: string[] }, drainHandle: { abort(): void } | null, viewOptions: unknown }>}
210
230
  */
211
231
  const blocked = new Map()
212
232
 
@@ -381,7 +401,7 @@ export function createApplyEngine(options) {
381
401
  }
382
402
  let drainHandle = null
383
403
  try {
384
- drainHandle = removedServers.length ? drain.begin(removedServers) : null
404
+ drainHandle = removedServers.length ? drain.begin(removedServers, sessionId) : null
385
405
  } catch (error) {
386
406
  lease.release()
387
407
  return { status: 'rejected', reason: `drain-failed:${message(error)}`, step: 4 }
@@ -395,80 +415,90 @@ export function createApplyEngine(options) {
395
415
  // The write outcome is UNKNOWN: stay blocked (fence held, gates closed)
396
416
  // and let a receipt query confirm the authority; never roll back on a guess.
397
417
  trace('indeterminate', { sessionId })
398
- blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, drainHandle, viewOptions })
418
+ blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, basisSets: previousSets, drainHandle, viewOptions })
399
419
  return { status: 'indeterminate', step: 5 }
400
420
  }
401
421
  trace('commit', { sessionId, status: result.status })
402
422
 
403
423
  if (result.status === 'committed') {
424
+ // Basis authority (D2/D5): the store hands back the payload its CAS replaced,
425
+ // read under the same per-unit lock. The transaction-start read above is NOT
426
+ // that authority — a foreign writer can land between it and this commit, and
427
+ // the CAS then passes on a revision this engine never saw. A committed result
428
+ // that cannot prove the record (a store double, a future store that does not
429
+ // honour the interface) keeps that read and reports
430
+ // `committed-over-unavailable`: never a fabricated full-set delta.
431
+ const proven = Object.hasOwn(result, 'committedOver')
432
+ const basisSets = proven
433
+ ? (result.committedOver === undefined ? { skills: [], mcpServers: [] } : safeSets(result.committedOver))
434
+ : previousSets
435
+ /** @type {string[]} */
436
+ const warnings = []
437
+ const degraded = (code, error) => {
438
+ warnings.push(code)
439
+ if (code === 'accepted-notification-degraded') warn(`apply onAccepted hook failed: ${message(error)}`)
440
+ else if (code === 'committed-over-unavailable') warn('apply commit result cannot prove the committed-over record: notice and drain basis fall back to the transaction-start read')
441
+ }
442
+ if (!proven) degraded('committed-over-unavailable')
443
+ // Two-phase drain (D3): the pre-commit closure above started draining from
444
+ // the transaction-start read — early, but possibly one revision stale.
445
+ // Servers the committed-over record drops that the earlier read could not
446
+ // name get their gates closed HERE, inside the still-held fence and before
447
+ // publication. The residual is a strict difference against the early set, so
448
+ // the two handles are disjoint by construction, and the reconciled handle is
449
+ // never abortable: a commit already landed.
450
+ const residualServers = basisSets.mcpServers.filter(server => !normalized.mcpServers.includes(server) && !removedServers.includes(server))
451
+ let reconciledHandle = null
452
+ if (residualServers.length) {
453
+ try {
454
+ reconciledHandle = drain.begin(residualServers, sessionId)
455
+ } catch (error) {
456
+ // The write is already durable: a reconciliation failure must never
457
+ // become a rejection. Report it and keep what the early handle closed.
458
+ degraded('drain-reconciliation-degraded', error)
459
+ }
460
+ }
404
461
  // Re-check the fence before publishing: the lease must still be ours.
405
462
  // The revision was already re-checked by the store CAS (expectedRevision).
406
463
  if (!fence.isHeld(sessionId)) {
407
464
  // The write LANDED but an atomic publication can no longer be
408
465
  // guaranteed: stay blocked and let a receipt query publish the
409
- // authority from durable evidence.
466
+ // authority from durable evidence. The entry carries the basis this
467
+ // transaction would have published with (D4).
410
468
  trace('indeterminate', { sessionId, reason: 'fence-lost-before-publish' })
411
- blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, drainHandle, viewOptions })
469
+ blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, basisSets, drainHandle, viewOptions })
412
470
  return { status: 'indeterminate', step: 5 }
413
471
  }
414
472
  // Synchronous publication segment: no await between the snapshot swap,
415
473
  // the provider invalidation and the fence release. invalidate() must
416
474
  // complete before the fence release and before the step-6 response, so
417
- // a client that sees the new receipt never refetches a stale list.
418
- /** @type {string[]} */
419
- const warnings = []
475
+ // a client that sees the new receipt never refetches a stale list. The
476
+ // SAME segment runs on the recovery-'published' path (D4).
420
477
  trace('snapshot-swap', { sessionId, revision: result.revision })
421
- // Task 12.4: the net accepted delta is known only HERE (previous
422
- // authority snapshot vs the just-committed payload); notify consumers
423
- // queue it for the next safe request. A failed hook warns, never
424
- // changes the accepted policy.
425
- try {
426
- const previous = snapshots.get(sessionId)
427
- const names = identities => new Set((Array.isArray(identities) ? identities : []).map(identity => String(identity?.name ?? '')).filter(Boolean))
428
- const before = names(previous?.selection?.skills)
429
- const after = names(payload?.skills)
430
- const beforeMcp = new Set(Array.isArray(previous?.selection?.mcpServers) ? previous.selection.mcpServers : [])
431
- const afterMcp = new Set(Array.isArray(payload?.mcpServers) ? payload.mcpServers : [])
432
- const delta = {
433
- added: [...after].filter(name => !before.has(name)).concat([...afterMcp].filter(name => !beforeMcp.has(name))).sort(),
434
- removed: [...before].filter(name => !after.has(name)).concat([...beforeMcp].filter(name => !afterMcp.has(name))).sort(),
435
- }
436
- if (delta.added.length > 0 || delta.removed.length > 0) onAccepted?.(sessionId, delta)
437
- } catch (error) {
438
- warnings.push('accepted-notification-degraded')
439
- warn(`apply onAccepted hook failed: ${message(error)}`)
440
- }
441
- snapshots.set(sessionId, { revision: result.revision, selection: structuredClone(payload), handles: prepared.handles, view: prepared.view })
442
- // Task 6.1: load the accepted selection into the shared lifecycle
443
- // memory snapshot inside the SAME non-async segment, so the
444
- // agent/created listener's synchronous read never observes a durable
445
- // acceptance this process has not published. A publication failure
446
- // degrades to the listener's blocking synchronous disk read of the
447
- // just-committed record — reported, never fatal.
448
- try {
449
- publishSnapshot?.(sessionId, { revision: result.revision, skills: structuredClone(payload.skills), mcpServers: [...payload.mcpServers] })
450
- } catch {
451
- warnings.push('snapshot-publication-degraded')
452
- }
453
- try {
454
- trace('invalidate', { sessionId })
455
- const accepted = provider.acceptSelection(prepared.identities, viewOptions)
456
- if (!accepted?.accepted) warnings.push('provider-publication-degraded')
457
- } catch {
458
- // The record is durably accepted; a failed cache invalidation degrades
459
- // convergence, never the policy. The warning is reported, not hidden.
460
- warnings.push('invalidate-failed')
461
- } finally {
462
- trace('fence-release', { sessionId })
463
- lease.release()
464
- }
478
+ publishAccepted({
479
+ sessionId,
480
+ revision: result.revision,
481
+ payload,
482
+ prepared,
483
+ viewOptions,
484
+ basisSets,
485
+ lease,
486
+ degraded,
487
+ })
488
+ // The response's drain snapshot is the union of both closures: the early
489
+ // one from the transaction-start read and the reconciled one from the
490
+ // committed-over record. Disjoint by construction; absent when empty.
491
+ const draining = [
492
+ ...(drainHandle ? drainHandle.snapshot() : []),
493
+ ...(reconciledHandle ? reconciledHandle.snapshot() : []),
494
+ ]
465
495
  const response = {
466
496
  status: 'applied',
467
497
  revision: result.revision,
468
498
  receipt: result.receipt,
469
499
  effective: { skills: structuredClone(prepared.identities), mcpServers: [...normalized.mcpServers] },
470
500
  unresolvedWarnings: normalized.unresolved.map(entry => ({ ...entry })),
471
- ...(drainHandle ? { draining: drainHandle.snapshot() } : {}),
501
+ ...(draining.length ? { draining } : {}),
472
502
  ...(warnings.length ? { warnings } : {}),
473
503
  }
474
504
  trace('response', { sessionId, revision: result.revision })
@@ -482,7 +512,7 @@ export function createApplyEngine(options) {
482
512
  const KNOWN_UNCOMMITTED = new Set(['duplicate', 'request-conflict', 'revision-conflict', 'locked', 'unreadable', 'unsupported', 'write-failed'])
483
513
  if (!KNOWN_UNCOMMITTED.has(result.status)) {
484
514
  trace('indeterminate', { sessionId, status: result.status })
485
- blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, drainHandle, viewOptions })
515
+ blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, basisSets: previousSets, drainHandle, viewOptions })
486
516
  return { status: 'indeterminate', step: 5 }
487
517
  }
488
518
  drainHandle?.abort()
@@ -537,6 +567,71 @@ export function createApplyEngine(options) {
537
567
  }
538
568
  }
539
569
 
570
+ /**
571
+ * The ONE synchronous publication segment (apply-delta-durable-basis D4),
572
+ * run by BOTH the committed branch and the recovery-'published' branch:
573
+ * net delta → notice queue → snapshot swap → lifecycle publication →
574
+ * provider invalidation → fence release (→ blocked settle on recovery).
575
+ * No await anywhere in the segment: the atomicity invariant the committed
576
+ * branch documents (a client that sees the new receipt never refetches a
577
+ * stale list) holds for a recovered publication too.
578
+ *
579
+ * The delta basis is the durable evidence only (D2): `basisSets` is the
580
+ * accepted state this publication replaces — on the committed path the payload
581
+ * `store.commit` reports it committed over (read under the commit's own lock),
582
+ * carried into the recovery path by the blocked entry. It is never the
583
+ * in-memory snapshots map and never an unlocked re-read. A failed hook
584
+ * degrades, never changes the accepted policy; per-branch surfacing travels
585
+ * through `degraded` — the committed branch collects response warnings, the
586
+ * recovery branch warns only (it has no response payload).
587
+ *
588
+ * @param {{ sessionId: string, revision: number, payload: unknown,
589
+ * prepared: { handles: unknown[], identities: unknown[], view: unknown },
590
+ * viewOptions: unknown,
591
+ * basisSets: { skills: unknown[], mcpServers: string[] },
592
+ * lease: { release(): void }, settle?: (() => void) | null,
593
+ * degraded: (code: string, error?: unknown) => void }} segment
594
+ */
595
+ function publishAccepted({ sessionId, revision, payload, prepared, viewOptions, basisSets, lease, settle = null, degraded }) {
596
+ // Task 12.4: the net accepted delta is known only HERE (durable pre-write
597
+ // record vs the just-committed payload); notify consumers queue it for
598
+ // the next safe request. durable-pending-notice D2: the acceptance revision
599
+ // rides along as a third argument for diagnosis and audit — additive, so
600
+ // every two-argument collector keeps working.
601
+ try {
602
+ const delta = selectionDelta(basisSets, safeSets(payload))
603
+ if (delta.added.length > 0 || delta.removed.length > 0) onAccepted?.(sessionId, delta, { revision })
604
+ } catch (error) {
605
+ degraded('accepted-notification-degraded', error)
606
+ }
607
+ snapshots.set(sessionId, { revision, selection: structuredClone(payload), handles: prepared.handles, view: prepared.view })
608
+ // Task 6.1: load the accepted selection into the shared lifecycle memory
609
+ // snapshot inside the SAME non-async segment (committed or recovered), so
610
+ // the agent/created listener's synchronous read never observes a durable
611
+ // acceptance this process has not published. A publication failure
612
+ // degrades to the listener's blocking synchronous disk read of the
613
+ // just-committed record — reported, never fatal.
614
+ try {
615
+ const sets = safeSets(payload)
616
+ publishSnapshot?.(sessionId, { revision, skills: structuredClone(sets.skills), mcpServers: [...sets.mcpServers] })
617
+ } catch (error) {
618
+ degraded('snapshot-publication-degraded', error)
619
+ }
620
+ try {
621
+ trace('invalidate', { sessionId })
622
+ const accepted = provider.acceptSelection(prepared.identities, viewOptions)
623
+ if (!accepted?.accepted) degraded('provider-publication-degraded')
624
+ } catch (error) {
625
+ // The record is durably accepted; a failed cache invalidation degrades
626
+ // convergence, never the policy. The warning is reported, not hidden.
627
+ degraded('invalidate-failed', error)
628
+ } finally {
629
+ trace('fence-release', { sessionId })
630
+ lease.release()
631
+ settle?.()
632
+ }
633
+ }
634
+
540
635
  /**
541
636
  * Receipt query (tasks 4.4): after a lost response or an indeterminate
542
637
  * write, the client enters "result pending confirmation" and queries the
@@ -548,8 +643,12 @@ export function createApplyEngine(options) {
548
643
  * the query is serialized behind any in-flight Apply, then settles the
549
644
  * pending transaction from durable evidence only —
550
645
  * - pending receipt FOUND in a clean record: the write had landed; run the
551
- * publication segment the lost response never ran (snapshot swap,
552
- * provider invalidation, fence release) and report recovery 'published';
646
+ * publication segment the lost response never ran — the SAME segment the
647
+ * committed path runs (delta → notice queue → snapshot swap → lifecycle
648
+ * publication → provider invalidation → fence release, D4), with the
649
+ * delta computed against the basis the blocked entry captured (the
650
+ * committed-over record when the commit result proved it, D4) — and report
651
+ * recovery 'published';
553
652
  * - a digest-verified record WITHOUT the pending receipt: durable proof the
554
653
  * write never landed; reopen the drain gates, release the fence and
555
654
  * report recovery 'not-committed' (proven absence, NOT a rollback);
@@ -585,27 +684,27 @@ export function createApplyEngine(options) {
585
684
  if (pending) {
586
685
  const confirmed = record.kind === 'ok' ? record.receipts.find(entry => entry.requestId === pending.requestId) ?? null : null
587
686
  if (confirmed) {
588
- // The indeterminate write LANDED: publish the prepared authority and
589
- // invalidate the provider BEFORE releasing the fence — the same
590
- // ordering the synchronous publication segment guarantees.
687
+ // The indeterminate write LANDED: run the ONE publication segment the
688
+ // lost response never ran (D4), delta against the basis the blocked
689
+ // entry captured included, so the recovered acceptance notifies the
690
+ // model exactly once (the segment settles the blocked entry).
591
691
  trace('recovery-publish', { sessionId, revision: confirmed.revision })
592
- snapshots.set(sessionId, { revision: confirmed.revision, selection: structuredClone(pending.payload), handles: pending.prepared.handles, view: pending.prepared.view })
593
- // Task 6.1: the recovered publication loads the lifecycle memory
594
- // snapshot too — the lost response never ran the segment.
595
- try {
596
- const sets = safeSets(pending.payload)
597
- publishSnapshot?.(sessionId, { revision: confirmed.revision, skills: structuredClone(sets.skills), mcpServers: sets.mcpServers })
598
- } catch (error) {
599
- warn(`snapshot publication failed: ${message(error)}`)
600
- }
601
- try {
602
- provider.acceptSelection(pending.prepared.identities, pending.viewOptions)
603
- } catch (error) {
604
- warn(`receipt-recovery invalidate failed: ${message(error)}`)
605
- } finally {
606
- pending.lease.release()
607
- blocked.delete(sessionId)
608
- }
692
+ publishAccepted({
693
+ sessionId,
694
+ revision: confirmed.revision,
695
+ payload: pending.payload,
696
+ prepared: pending.prepared,
697
+ viewOptions: pending.viewOptions,
698
+ basisSets: pending.basisSets,
699
+ lease: pending.lease,
700
+ settle: () => blocked.delete(sessionId),
701
+ // Recovery has no response payload: degradation is warn-only.
702
+ degraded: (code, error) => {
703
+ if (code === 'snapshot-publication-degraded') warn(`snapshot publication failed: ${message(error)}`)
704
+ else if (code === 'invalidate-failed') warn(`receipt-recovery invalidate failed: ${message(error)}`)
705
+ else warn(`receipt-recovery publication degraded: ${code}${error instanceof Error ? `: ${message(error)}` : ''}`)
706
+ },
707
+ })
609
708
  recovery = 'published'
610
709
  queueMicrotask(() => {
611
710
  try { audit?.({ id: sessionId }, APPLY_AUDIT_TYPE, { requestId: confirmed.requestId, revision: confirmed.revision, recovery }) } catch (error) { warn(`apply audit failed: ${message(error)}`) }
@@ -642,17 +741,6 @@ export function createApplyEngine(options) {
642
741
  return {
643
742
  apply,
644
743
  queryReceipt,
645
- /**
646
- * Shared in-process commit coordinator (tasks 4.5): a content refresh runs
647
- * its whole transaction — selection re-check and durable commit — through
648
- * the SAME per-session serialization point as selection Applies, so the
649
- * two never interleave in-process. Cross-process exclusion stays the
650
- * store lock.
651
- * @template T
652
- * @param {string} sessionId @param {() => Promise<T>} task
653
- * @returns {Promise<T>}
654
- */
655
- coordinate: (sessionId, task) => serialize(sessionId, task),
656
744
  /** Synchronous in-memory authority snapshot for a session (null until loaded). */
657
745
  authority(sessionId) {
658
746
  const snapshot = snapshots.get(sessionId)
@@ -7,7 +7,9 @@
7
7
  // (an explicit empty set is a real, savable choice). The binding is the
8
8
  // workspace's stable identity (canonical root path). Save and clear go
9
9
  // through the group-2 CAS — a conflict never overwrites.
10
- import { workspaceKeyOf } from './preset-library.js'
10
+ import { workspaceKeyOf, draftSelectionSets, noWorkspaceError } from './preset-library.js'
11
+ import { resolveSkillNamesForSave } from './initial-selection.js'
12
+ import { isSegment } from './store/paths.js'
11
13
 
12
14
  /**
13
15
  * @param {{ store: { read(unit: unknown): Promise<any>, commit(unit: unknown, expectedRevision: number, mutate: (payload: unknown) => unknown, request?: unknown): Promise<any> },
@@ -58,3 +60,136 @@ export function createDefaultsTransaction({ store, now = Date.now }) {
58
60
 
59
61
  return { read, save, clear, unitOf, workspaceKeyOf }
60
62
  }
63
+
64
+ // ---- /capabilities default verbs (capabilities-deepening D2) ----------------
65
+ // The three workspace-default verb bodies of the /capabilities command, moved
66
+ // verbatim out of the handler into the module that owns the defaults CAS
67
+ // contract (read/save/clear above). Per-invocation state arrives through the
68
+ // `call` argument ({ agent, options }); the handler's former closures are
69
+ // injected in `wiring` — the payload builder included, so this module keeps
70
+ // its import graph acyclic (read-payloads.js imports this module, never the
71
+ // reverse).
72
+
73
+ const message = error => (error instanceof Error ? error.message : String(error))
74
+
75
+ /**
76
+ * The /capabilities default verb bodies. `wiring`:
77
+ * @param {{
78
+ * openStore: () => any,
79
+ * provider: any,
80
+ * inventory: (options: any) => Promise<any>,
81
+ * lifecycle: any,
82
+ * builders: { buildDefaultGetPayload: Function },
83
+ * }} wiring
84
+ */
85
+ export function createDefaultsVerbs(wiring) {
86
+ /**
87
+ * The sets a save records. from:'applied' is server-authoritative: the sets
88
+ * come from the same provider/lifecycle state the receipt verb reports —
89
+ * client-sent set fields are never trusted then.
90
+ */
91
+ const selectionSetsOf = async (spec, call) => {
92
+ if (spec.from === 'applied') {
93
+ const result = await wiring.provider.list(call.options)
94
+ const candidates = Array.isArray(result?.candidates) ? result.candidates : []
95
+ const skills = []
96
+ for (const candidate of candidates) {
97
+ if (candidate?.selected && candidate.identity) skills.push(candidate.identity)
98
+ }
99
+ const snapshot = wiring.lifecycle?.snapshotFor?.(call.agent.id)
100
+ return { skills, mcpServers: Array.isArray(snapshot?.mcpServers) ? [...snapshot.mcpServers] : [], unresolvedRefs: [] }
101
+ }
102
+ return draftSelectionSets(spec)
103
+ }
104
+
105
+ /**
106
+ * default-get: thin shell over the shared builder. The no-workspace status
107
+ * is the ONLY error mapping (the historical noWorkspace object); every
108
+ * other status — including a non-ok record — stays success.
109
+ */
110
+ async function defaultGet(_spec, call) {
111
+ try {
112
+ const payload = await wiring.builders.buildDefaultGetPayload({ store: wiring.openStore() }, call.options)
113
+ if (payload.status === 'no-workspace') return { kind: 'error', text: JSON.stringify(payload) }
114
+ return { kind: 'success', text: JSON.stringify(payload) }
115
+ } catch (cause) {
116
+ return { kind: 'error', text: `capabilities default-get failed: ${message(cause)}` }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * default-save: save this workspace's new-session default as a COPY
122
+ * snapshot of the draft or the applied sets. Saving never changes the
123
+ * current session; an explicit empty set is a real choice.
124
+ */
125
+ async function defaultSave(spec, call) {
126
+ try {
127
+ if (spec?.from !== 'draft' && spec?.from !== 'applied') return { kind: 'error', text: 'capabilities default-save: from must be "draft" | "applied"' }
128
+ const workspaceKey = workspaceKeyOf(call.options)
129
+ if (!isSegment(workspaceKey)) return noWorkspaceError()
130
+ const transaction = createDefaultsTransaction({ store: wiring.openStore() })
131
+ let expectedRevision = spec.expectedRevision
132
+ if (expectedRevision === undefined) {
133
+ const current = await transaction.read(workspaceKey)
134
+ if (current.kind !== 'ok' && current.kind !== 'absent') return { kind: 'error', text: JSON.stringify({ status: current.kind }) }
135
+ expectedRevision = current.kind === 'ok' ? current.revision : 0
136
+ }
137
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
138
+ return { kind: 'error', text: 'capabilities default-save: expectedRevision must be a non-negative integer' }
139
+ }
140
+ const sets = await selectionSetsOf(spec, call)
141
+ let skills = sets.skills
142
+ if (spec.from === 'draft') {
143
+ // The draft's wire shape is name strings: resolve them to exact
144
+ // SkillIdentity objects BEFORE persisting, so the default record
145
+ // carries the resolved-sets contract and a duplicated name never
146
+ // lands as an unbindable string. The session's applied selection is
147
+ // the authoritative disambiguation; unresolvable or still-ambiguous
148
+ // names are an explicit error with ZERO writes.
149
+ const inventoryResult = await wiring.inventory(call.options)
150
+ const inventoryCandidates = Array.isArray(inventoryResult?.candidates) ? inventoryResult.candidates : []
151
+ const appliedResult = await wiring.provider.list(call.options)
152
+ const appliedCandidates = Array.isArray(appliedResult?.candidates) ? appliedResult.candidates : []
153
+ const appliedIdentities = []
154
+ for (const candidate of appliedCandidates) {
155
+ if (candidate?.selected && candidate.identity) appliedIdentities.push(candidate.identity)
156
+ }
157
+ const resolved = resolveSkillNamesForSave(spec.skills, inventoryCandidates, appliedIdentities)
158
+ if (resolved.missing.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'missing', missing: resolved.missing }) }
159
+ if (resolved.ambiguous.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'ambiguous', ambiguous: resolved.ambiguous }) }
160
+ skills = resolved.identities
161
+ }
162
+ const result = await transaction.save({ workspaceKey, expectedRevision, snapshot: { skills, mcpServers: sets.mcpServers, unresolvedRefs: sets.unresolvedRefs } })
163
+ return { kind: 'success', text: JSON.stringify({ ...result, workspaceKey }) }
164
+ } catch (cause) {
165
+ return { kind: 'error', text: `capabilities default-save failed: ${message(cause)}` }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * default-clear: clear this workspace's default (absent afterwards — NOT
171
+ * an explicit empty set). The JSON payload is optional.
172
+ */
173
+ async function defaultClear(spec = {}, call) {
174
+ try {
175
+ const workspaceKey = workspaceKeyOf(call.options)
176
+ if (!isSegment(workspaceKey)) return noWorkspaceError()
177
+ const transaction = createDefaultsTransaction({ store: wiring.openStore() })
178
+ let expectedRevision = spec.expectedRevision
179
+ if (expectedRevision === undefined) {
180
+ const current = await transaction.read(workspaceKey)
181
+ if (current.kind !== 'ok' && current.kind !== 'absent') return { kind: 'error', text: JSON.stringify({ status: current.kind }) }
182
+ expectedRevision = current.kind === 'ok' ? current.revision : 0
183
+ }
184
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
185
+ return { kind: 'error', text: 'capabilities default-clear: expectedRevision must be a non-negative integer' }
186
+ }
187
+ const result = await transaction.clear({ workspaceKey, expectedRevision })
188
+ return { kind: 'success', text: JSON.stringify({ ...result, workspaceKey }) }
189
+ } catch (cause) {
190
+ return { kind: 'error', text: `capabilities default-clear failed: ${message(cause)}` }
191
+ }
192
+ }
193
+
194
+ return { 'default-get': defaultGet, 'default-save': defaultSave, 'default-clear': defaultClear }
195
+ }