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
@@ -14,23 +14,19 @@ import { createPresetInvalidation, emitPresetSelected } from './preset-invalidat
14
14
  import { createLifecycleSnapshots } from './lifecycle-snapshot.js'
15
15
  import { preloadLifecycleSnapshots } from './lifecycle-preload.js'
16
16
  import { classifySelectionFailure } from './selection-status.js'
17
- import { resolveInitialSelection, baselineSkillIdentities, bindDefaultSkillNames, resolveSkillNamesForSave } from './initial-selection.js'
18
- import { createPresetLibrary, workspaceKeyOf } from './preset-library.js'
19
- import { createDefaultsTransaction } from './defaults-transaction.js'
17
+ import { resolveInitialSelection, baselineSkillIdentities, bindDefaultSkillNames } from './initial-selection.js'
18
+ import { workspaceKeyOf, createPresetVerbs } from './preset-library.js'
19
+ import { createDefaultsVerbs } from './defaults-transaction.js'
20
20
  import { buildPresetsPayload, buildDefaultGetPayload } from './read-payloads.js'
21
- import { bindImportedSelection, validatePortableDocument } from './portable-refs.js'
22
- import { inspectPresetNamespace } from './store/enumerate.js'
23
- import { bindPackageSelection, packPresetPackage, planPackageInstall, validatePresetPackage } from './preset-package.js'
24
- import { installBundledSkills } from './preset-package-install.js'
25
- import { isSegment } from './store/paths.js'
21
+ import { packPresetPackage, createPackageVerbs } from './preset-package.js'
26
22
  import { createApplyEngine } from './apply-engine.js'
27
- import { createSelectionNotifier, NOTIFY_SOURCE } from './selection-notify.js'
23
+ import { createSessionDrainAdapter } from './mcp-drain.js'
24
+ import { createSelectionNotifier } from './selection-notify.js'
28
25
  import { createMcpRegistry } from './mcp-registry.js'
29
26
  import { buildReceiptPayload, buildListPayload, buildConditionsPayload } from './read-payloads.js'
30
27
  import { feedCapabilityReadBridge } from './capability-remote.js'
31
28
  import { parseFrontmatter } from './frontmatter.js'
32
- import { userTextMessage } from '../shared/user-message.js'
33
- import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
29
+ import { createAudit } from '../shared/audit.js'
34
30
 
35
31
  const mounted = new WeakMap()
36
32
  /**
@@ -240,24 +236,33 @@ export function createSkillSelectionPlugin(dependencies = {}) {
240
236
  // next safe request consumes them as a full UserMessage riding WITH that
241
237
  // request (never an autonomous turn; source marked non-'user' so the
242
238
  // intent gate and continuation classifiers exclude it by construction).
243
- const notifier = createSelectionNotifier()
239
+ //
240
+ // durable-pending-notice D6: the queue is durable, so the notifier needs the
241
+ // SAME capability store the engine writes — memoized here (the engine was
242
+ // already lazy per process) so the queue write and the acceptance share one
243
+ // unit layout, one support gate and one set of lock files. The audit channel
244
+ // is created before the notifier so its outcomes reach the cold-safe
245
+ // `weir/capability-notice` event (D7) — created once, shared with the hooks.
246
+ const audit = createAudit(ctx)
247
+ let sharedStore = null
248
+ const storeFor = () => {
249
+ if (!sharedStore) sharedStore = openCapabilityStore({ profileContext: ctx.get?.('profileContext') })
250
+ return sharedStore
251
+ }
252
+ const notifier = createSelectionNotifier({ store: storeFor(), audit })
244
253
  let sharedEngine = null
245
254
  const engineFor = () => {
246
255
  if (sharedEngine) return sharedEngine
256
+ // The production hook set (drain / publishSnapshot / onAccepted /
257
+ // invalidate / audit / trace / warn) lives in the exported
258
+ // applyEngineHooks assembly so the wiring pin drives the exact
259
+ // production hooks (design D5).
247
260
  sharedEngine = createApplyEngine({
248
- store: openCapabilityStore({ profileContext: ctx.get?.('profileContext') }),
261
+ store: storeFor(),
249
262
  locateSession: session => session,
250
263
  inventory: (options) => inventory(options),
251
264
  provider,
252
- publishSnapshot: (sessionId, snapshot) => lifecycle?.publish?.(sessionId, snapshot),
253
- onAccepted: (sessionId, delta) => notifier.queue(sessionId, delta),
254
- // The Apply-path convergence emission (5.2 parity with agent/created):
255
- // an accepted Apply re-emits the preset invalidation event so every
256
- // connected client drops cached capability state and refetches —
257
- // without this hook the engine never re-emits and every Badge/panel
258
- // goes stale until a remount.
259
- invalidate: (sessionId, presetId) => emitPresetSelected((...args) => ctx.emit(...args), sessionId, presetId, text => ctx.logger?.warn?.(text)),
260
- warn: text => ctx.logger?.warn?.(text),
265
+ ...applyEngineHooks({ ctx, lifecycle, notifier, audit }),
261
266
  })
262
267
  return sharedEngine
263
268
  }
@@ -277,722 +282,261 @@ export function createSkillSelectionPlugin(dependencies = {}) {
277
282
  })
278
283
  }
279
284
  // The shared cold-safe audit channel (cordis emit + .weir/audit.jsonl
280
- // mirror; never session.append). The package-import install outcome and
281
- // its collision decisions are audited through it (task 6.3).
282
- const audit = createAudit(ctx)
285
+ // mirror; never session.append) is the one created above, so the notice
286
+ // queue, the Apply hooks and the package-import outcomes share it.
287
+ // (The package-import install outcome and its collision decisions are
288
+ // audited through it — task 6.3.)
283
289
  // Group 12 client surface: a /capabilities command serving the Badge and
284
290
  // manager (receipt / listing / conditions). Values travel as command
285
291
  // results — no projection state and no custom session-log event types.
292
+ //
293
+ // capabilities-deepening D2: the handler is now only the dispatch surface
294
+ // — registration, the verb table (each entry's payload shape and usage
295
+ // line) and the case-insensitive dispatch with its usage fall-through.
296
+ // The preset/default/package verb bodies live in the domain modules that
297
+ // own their contracts (preset-library.js / defaults-transaction.js /
298
+ // preset-package.js), instantiated here with their former handler
299
+ // closures injected explicitly. receipt/list/apply/mcp-add/conditions
300
+ // stay local: their contracts are the read-payload builders and the
301
+ // manager/engine wiring themselves.
286
302
  try {
287
303
  const commands = ctx.get?.('commands')
288
304
  if (commands) {
305
+ // Shared wiring — every verb opens the same store and resolves the
306
+ // same workspace binding as before; every failure is an explicit
307
+ // status in the JSON payload, never a throw escaping the handler.
308
+ const openStore = () => openCapabilityStore({ profileContext: ctx.get?.('profileContext') })
309
+ const presetVerbs = createPresetVerbs({
310
+ openStore, provider, inventory, lifecycle,
311
+ builders: { buildPresetsPayload, buildReceiptPayload },
312
+ packPresetPackage,
313
+ })
314
+ const defaultsVerbs = createDefaultsVerbs({
315
+ openStore, provider, inventory, lifecycle,
316
+ builders: { buildDefaultGetPayload },
317
+ })
318
+ const packageVerbs = createPackageVerbs({ openStore, inventory, skillRootsFor, audit })
319
+
320
+ /** receipt: thin shell over the shared builder (silent-capability-reads 2.1). */
321
+ const receiptVerb = async (_spec, { options }) => {
322
+ try {
323
+ // Shared builder (silent-capability-reads 2.1): the read remote
324
+ // serializes the same payload from the same code.
325
+ const payload = await buildReceiptPayload({
326
+ provider,
327
+ lifecycle,
328
+ store: openStore(),
329
+ }, options)
330
+ // The commands registry normalizes results to {kind, text}
331
+ // (CITED dsh-commands normalizeResult): structured payloads
332
+ // travel as JSON text.
333
+ return { kind: 'success', text: JSON.stringify(payload) }
334
+ } catch (cause) {
335
+ return { kind: 'error', text: `capabilities receipt failed: ${cause instanceof Error ? cause.message : String(cause)}` }
336
+ }
337
+ }
338
+
339
+ /** list: full inventory joined with the session's effective selection. */
340
+ const listVerb = async (_spec, { options }) => {
341
+ try {
342
+ // Shared builder (silent-capability-reads 2.1): the FULL
343
+ // inventory joined with the session's EFFECTIVE selection, plus
344
+ // the MCP manager listing (read at call time).
345
+ const payload = await buildListPayload({
346
+ inventory: (inventoryOptions, previous) => inventory(inventoryOptions, previous),
347
+ provider,
348
+ mcpManager: ctx.get?.('weirMcpManager'),
349
+ }, options)
350
+ // Degraded-listing fidelity (resume-incarnation-recovery D3):
351
+ // when the selection view is in an error state, provider.list
352
+ // fail-closed empty made buildListPayload list the inventory
353
+ // with every mark false — but the error state itself was
354
+ // invisible, indistinguishable from a successful empty
355
+ // inventory. Rebuild the rows from the RAW inventory with the
356
+ // real scope/provenance labels (candidate identity/root scope —
357
+ // never the 'unknown'/'other' degradation), every selection mark
358
+ // false, and carry the classified failure alongside, so the
359
+ // panel can still drive the Apply self-heal. The healthy path
360
+ // above stays byte-identical.
361
+ const status = provider.status(options)
362
+ if (status?.error) {
363
+ const rawInventory = await inventory(options)
364
+ const rawCandidates = Array.isArray(rawInventory?.candidates) ? rawInventory.candidates : []
365
+ return { kind: 'success', text: JSON.stringify({
366
+ skills: rawCandidates.map(candidate => ({
367
+ name: candidate?.name,
368
+ description: candidate?.description ?? '',
369
+ scope: candidate?.identity?.scope ?? candidate?.root?.scope ?? 'unknown',
370
+ status: candidate?.status ?? 'unknown',
371
+ selected: false,
372
+ conflict: Boolean(candidate?.conflict),
373
+ identity: candidate?.identity ?? null,
374
+ })),
375
+ mcpServers: payload.mcpServers,
376
+ selectionError: { error: status.error, reason: status.reason ?? null, hint: status.hint ?? null },
377
+ }) }
378
+ }
379
+ return { kind: 'success', text: JSON.stringify(payload) }
380
+ } catch (cause) {
381
+ return { kind: 'error', text: `capabilities list failed: ${cause instanceof Error ? cause.message : String(cause)}` }
382
+ }
383
+ }
384
+
385
+ /** apply: the manager editor's commit (12.3). */
386
+ const applyVerb = async (draft, { agent, options }) => {
387
+ // The client sends {requestId, expectedRevision, skills:
388
+ // [identities or names], mcpServers: [names]} as JSON text after
389
+ // the verb; identities match exactly and names must match one
390
+ // parsed CURRENT candidate, never a silent winner.
391
+ try {
392
+ // The full inventory (not the effective selection): an Apply may
393
+ // name any discovered candidate, selected or not.
394
+ const inventoryResult = await inventory(options)
395
+ const candidates = Array.isArray(inventoryResult?.candidates) ? inventoryResult.candidates : []
396
+ const byKey = new Map()
397
+ const parsedByName = new Map()
398
+ for (const candidate of candidates) {
399
+ if (!candidate.identity) continue
400
+ byKey.set(skillIdentityKey(candidate.identity), candidate)
401
+ if (candidate.status !== 'parsed') continue
402
+ const matches = parsedByName.get(candidate.name) ?? []
403
+ matches.push(candidate)
404
+ parsedByName.set(candidate.name, matches)
405
+ }
406
+ const missing = []
407
+ const identities = []
408
+ const admitted = new Set()
409
+ for (const entry of draft.skills ?? []) {
410
+ let candidate
411
+ if (typeof entry === 'string') {
412
+ const matches = parsedByName.get(entry) ?? []
413
+ if (matches.length === 1) candidate = matches[0]
414
+ else missing.push(entry)
415
+ } else {
416
+ const identity = createSkillIdentity(entry)
417
+ candidate = byKey.get(skillIdentityKey(identity))
418
+ if (!candidate) missing.push(identity.name)
419
+ }
420
+ if (!candidate) continue
421
+ const key = skillIdentityKey(candidate.identity)
422
+ if (admitted.has(key)) continue
423
+ admitted.add(key)
424
+ identities.push(candidate.identity)
425
+ }
426
+ // preset-skill-applicability (D2): partition the missing names —
427
+ // KNOWN-BUT-INAPPLICABLE (resolvable in a profile-supplemental
428
+ // directory, e.g. creative-preset skills carried by a workspace
429
+ // default) are SKIPPED with a per-name reason while the
430
+ // resolvable remainder commits; genuinely unknown names keep the
431
+ // hard-failure contract. Records are never rewritten for
432
+ // applicability, so a preset round-trip stays lossless.
433
+ const unknown = []
434
+ const skipped = []
435
+ for (const name of missing) {
436
+ if (supplementalSkillKnown(config.supplementalSkillDirs, name)) skipped.push({ name, reason: 'not applicable in this agent preset' })
437
+ else unknown.push(name)
438
+ }
439
+ if (unknown.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'missing', missing: unknown, ...(skipped.length > 0 ? { skipped } : {}) }) }
440
+ const response = await face.applySelection(
441
+ { sessionId: agent.id, cwd: agent.session?.header?.cwd, presetId: 'weir' },
442
+ { requestId: draft.requestId, expectedRevision: draft.expectedRevision, selection: { skills: identities, mcpServers: draft.mcpServers ?? [] }, unresolved: [] },
443
+ options,
444
+ )
445
+ return { kind: 'success', text: JSON.stringify(skipped.length > 0 ? { ...response, skipped } : response) }
446
+ } catch (cause) {
447
+ return { kind: 'error', text: `capabilities apply failed: ${cause instanceof Error ? cause.message : String(cause)}` }
448
+ }
449
+ }
450
+
451
+ /**
452
+ * mcp-add: register a new managed MCP server and mount it
453
+ * immediately (8.1 registry + manager start). Payload:
454
+ * {identity, label, command, args?: [], env?: {}, serverName?}
455
+ */
456
+ const mcpAddVerb = async (spec) => {
457
+ try {
458
+ if (typeof spec.identity !== 'string' || typeof spec.label !== 'string' || typeof spec.command !== 'string' || spec.command.length === 0) {
459
+ return { kind: 'error', text: 'mcp-add needs identity, label and command strings' }
460
+ }
461
+ const store = openStore()
462
+ const registry = createMcpRegistry({ store })
463
+ const current = await registry.read()
464
+ const expectedRevision = current.kind === 'ok' ? current.revision : 0
465
+ const entry = {
466
+ identity: spec.identity,
467
+ label: spec.label,
468
+ owner: { kind: 'global', key: 'installation' },
469
+ transport: { kind: 'stdio', ref: `cmd:${spec.command}` },
470
+ client: {
471
+ transport: 'stdio',
472
+ serverName: typeof spec.serverName === 'string' && spec.serverName.length ? spec.serverName : spec.identity,
473
+ command: spec.command,
474
+ args: Array.isArray(spec.args) ? spec.args.map(String) : [],
475
+ env: spec.env && typeof spec.env === 'object' && !Array.isArray(spec.env) ? spec.env : {},
476
+ reconnect: { enabled: true },
477
+ },
478
+ }
479
+ const committed = await registry.register(entry, expectedRevision)
480
+ if (committed.status !== 'committed') return { kind: 'error', text: JSON.stringify({ status: committed.status }) }
481
+ const manager = ctx.get?.('weirMcpManager')
482
+ if (manager?.start) await manager.start()
483
+ return { kind: 'success', text: JSON.stringify({ status: 'registered', identity: entry.identity, generation: 1, mounted: Boolean(manager?.start) }) }
484
+ } catch (cause) {
485
+ return { kind: 'error', text: `capabilities mcp-add failed: ${cause instanceof Error ? cause.message : String(cause)}` }
486
+ }
487
+ }
488
+
489
+ /** conditions: the 1.12 consistency conditions; empty = supported. */
490
+ const conditionsVerb = async () => ({ kind: 'success', text: JSON.stringify(buildConditionsPayload()) })
491
+
492
+ // The dispatch table. `payload` declares the argument shape: 'none'
493
+ // (bare verb only), 'required' (verb + JSON), 'optional'
494
+ // (default-clear). A payload-less call of a required-payload verb
495
+ // and extra words after a bare verb both land on the usage
496
+ // fall-through — the historical behavior.
497
+ const verbTable = {
498
+ receipt: { payload: 'none', run: receiptVerb },
499
+ list: { payload: 'none', run: listVerb },
500
+ apply: { payload: 'required', usage: 'Usage: /capabilities apply <json selection>', run: applyVerb },
501
+ 'mcp-add': { payload: 'required', usage: 'Usage: /capabilities mcp-add <json {identity,label,command,args?,env?,serverName?}>', run: mcpAddVerb },
502
+ presets: { payload: 'none', run: presetVerbs.presets },
503
+ 'preset-save': { payload: 'required', usage: 'Usage: /capabilities preset-save <json>', run: presetVerbs['preset-save'] },
504
+ 'preset-rename': { payload: 'required', usage: 'Usage: /capabilities preset-rename <json>', run: presetVerbs['preset-rename'] },
505
+ 'preset-resave': { payload: 'required', usage: 'Usage: /capabilities preset-resave <json>', run: presetVerbs['preset-resave'] },
506
+ 'preset-load': { payload: 'required', usage: 'Usage: /capabilities preset-load <json {scope,presetId}>', run: presetVerbs['preset-load'] },
507
+ 'preset-delete': { payload: 'required', usage: 'Usage: /capabilities preset-delete <json {scope,presetId,expectedRevision}>', run: presetVerbs['preset-delete'] },
508
+ 'preset-export': { payload: 'required', usage: 'Usage: /capabilities preset-export <json {scope,presetId,format?:"package"|"document"}>', run: presetVerbs['preset-export'] },
509
+ 'preset-import': { payload: 'required', usage: 'Usage: /capabilities preset-import <json {document,scope,presetId?,name?,onNameConflict?,dryRun?,onCollision?}>', run: packageVerbs['preset-import'] },
510
+ 'default-get': { payload: 'none', run: defaultsVerbs['default-get'] },
511
+ 'default-save': { payload: 'required', usage: 'Usage: /capabilities default-save <json {from:"draft"|"applied",skills?,mcpServers?,unresolvedRefs?,expectedRevision?}>', run: defaultsVerbs['default-save'] },
512
+ 'default-clear': { payload: 'optional', usage: 'Usage: /capabilities default-clear [json {expectedRevision?}]', run: defaultsVerbs['default-clear'] },
513
+ conditions: { payload: 'none', run: conditionsVerb },
514
+ }
515
+
289
516
  ctx.effect(() => commands.register({
290
517
  name: 'capabilities',
291
518
  description: 'Session capability surface for the Weir Badge and manager (receipt | list | conditions | presets | preset-save | preset-rename | preset-resave | preset-load | preset-delete | preset-export | preset-import | default-get | default-save | default-clear).',
292
519
  handler: async (invocation) => {
293
520
  const agent = invocation?.agent
294
521
  if (!agent) return { kind: 'error', text: 'capabilities: requires an owning agent session' }
295
- // Verb matching is case-insensitive; the apply payload is JSON and
296
- // must keep its original casing (requestId/expectedRevision/...).
522
+ // Verb matching is case-insensitive; the apply payload is JSON
523
+ // and must keep its original casing
524
+ // (requestId/expectedRevision/...).
297
525
  const rawInput = String(invocation.rawInput ?? '').trim()
298
526
  const verb = rawInput.toLowerCase()
299
527
  const options = { cwd: agent.session?.header?.cwd, scope: { session: { id: agent.id } } }
300
- // Preset/workspace-default verbs (capability-manager-ux D2): the
301
- // command surface is the UI's only data source. Shared wiring —
302
- // every verb opens the same store and resolves the same workspace
303
- // binding as the existing verbs; every failure is an explicit
304
- // status in the JSON payload, never a throw escaping the handler.
305
- const capabilityStore = () => openCapabilityStore({ profileContext: ctx.get?.('profileContext') })
306
- const sessionWorkspaceKey = () => workspaceKeyOf(options)
307
- const noWorkspace = { kind: 'error', text: JSON.stringify({ status: 'no-workspace' }) }
308
- /**
309
- * The sets a save records. from:'applied' is server-authoritative:
310
- * the sets come from the same provider/lifecycle state the receipt
311
- * verb reports — client-sent set fields are never trusted then.
312
- */
313
- const selectionSetsOf = async spec => {
314
- if (spec.from === 'applied') {
315
- const result = await provider.list(options)
316
- const candidates = Array.isArray(result?.candidates) ? result.candidates : []
317
- const skills = []
318
- for (const candidate of candidates) {
319
- if (candidate?.selected && candidate.identity) skills.push(candidate.identity)
320
- }
321
- const snapshot = lifecycle?.snapshotFor?.(agent.id)
322
- return { skills, mcpServers: Array.isArray(snapshot?.mcpServers) ? [...snapshot.mcpServers] : [], unresolvedRefs: [] }
323
- }
324
- return {
325
- skills: Array.isArray(spec.skills) ? spec.skills : [],
326
- mcpServers: Array.isArray(spec.mcpServers) ? spec.mcpServers : [],
327
- unresolvedRefs: Array.isArray(spec.unresolvedRefs) ? spec.unresolvedRefs : [],
328
- }
528
+ const head = verb.split(' ', 1)[0]
529
+ const entry = verbTable[head]
530
+ const hasArgs = verb.length > head.length
531
+ if (!entry || (entry.payload === 'none' && hasArgs) || (entry.payload === 'required' && !hasArgs)) {
532
+ return { kind: 'error', text: 'Usage: /capabilities receipt|list|conditions|presets|preset-save|preset-load|preset-delete|preset-export|preset-import|default-get|default-save|default-clear' }
329
533
  }
330
- // Applied preset writers share one locked source proof. The library
331
- // enters this callback only under its namespace guard, and holds the
332
- // selection lock until the target unit CAS has finished.
333
- const appliedPresetSource = (spec, sets, store, agent, options) => commit => store.withSelectionSnapshot(agent.id, async current => {
334
- if (!Number.isSafeInteger(spec.expectedSelectionRevision) || spec.expectedSelectionRevision < 0
335
- || typeof spec.expectedSelectionDigest !== 'string' || !/^[a-f0-9]{64}$/.test(spec.expectedSelectionDigest)
336
- || current.revision !== spec.expectedSelectionRevision) return { status: 'source-changed', revision: current.revision }
337
- const receipt = await buildReceiptPayload({ provider, lifecycle, store }, options)
338
- const effective = receipt.effective
339
- const selected = effective.skillIdentities
340
- const raw = current.kind === 'ok' ? current.payload?.skills : null
341
- if (current.kind === 'ok') {
342
- const rawKeys = new Set()
343
- const effectiveKeys = new Set(selected.map(skillIdentityKey))
344
- const missing = []
345
- for (const identity of Array.isArray(raw) ? raw : []) {
346
- try {
347
- const key = skillIdentityKey(createSkillIdentity(identity))
348
- rawKeys.add(key)
349
- if (!effectiveKeys.has(key)) missing.push(identity.name)
350
- } catch { missing.push(String(identity?.name ?? identity ?? 'unknown')) }
351
- }
352
- if (!Array.isArray(raw) || missing.length || rawKeys.size !== selected.length || receipt.warnings.length) {
353
- return { status: 'source-unavailable', missing, reason: receipt.warnings[0] ?? 'Selected Skills are not completely available' }
354
- }
355
- if (!Array.isArray(current.payload?.mcpServers)
356
- || JSON.stringify(current.payload.mcpServers) !== JSON.stringify(effective.mcpServers)) {
357
- return { status: 'source-unavailable', missing: [], reason: 'Applied MCP snapshot is unavailable' }
358
- }
359
- } else if (receipt.warnings.length) return { status: 'source-unavailable', missing: [], reason: receipt.warnings[0] }
360
- // Provider enumeration can short-circuit an empty selection.
361
- const observed = await inventory(options)
362
- if (observed?.complete !== true) return { status: 'source-unavailable', missing: [], reason: 'Skill inventory is incomplete' }
363
- if (effective.digest !== spec.expectedSelectionDigest || receipt.revision !== current.revision) {
364
- return { status: 'source-changed', revision: current.revision }
365
- }
366
- sets.skills.push(...selected)
367
- sets.mcpServers.push(...effective.mcpServers)
368
- return commit()
369
- })
370
- /** Shared scope validation for the preset verbs. */
371
- const presetScopeError = spec => (spec?.scope !== 'global' && spec?.scope !== 'workspace')
372
- ? { kind: 'error', text: `capabilities: scope must be "global" | "workspace", got ${JSON.stringify(spec?.scope ?? null)}` }
373
- : null
374
- /**
375
- * D6 version-2 package import (tasks 6.2/6.3). Phase one
376
- * (dryRun:true) is a read-only summary — the exact install
377
- * targets, file counts, unresolved refs and collisions, ZERO
378
- * writes. Phase two (confirmed, onCollision default 'cancel')
379
- * installs the bundled Skills into the resolved roots and then
380
- * commits the preset record by CAS. A poison package is rejected
381
- * atomically: nothing is written, no preset is created, the
382
- * reason is named. Installed Skills stay UNSELECTED in every
383
- * established set — selection remains a later draft + Apply.
384
- */
385
- const importPresetPackage = async spec => {
386
- const verdict = validatePresetPackage(spec.document)
387
- if (!verdict.ok) return { kind: 'error', text: JSON.stringify({ status: 'rejected', reason: verdict.reason }) }
388
- if (spec.dryRun !== undefined && typeof spec.dryRun !== 'boolean') {
389
- return { kind: 'error', text: 'capabilities preset-import: dryRun must be a boolean' }
390
- }
391
- const onCollision = spec.onCollision ?? 'cancel'
392
- if (onCollision !== 'cancel' && onCollision !== 'replace' && onCollision !== 'coexist') {
393
- return { kind: 'error', text: 'capabilities preset-import: onCollision must be "cancel" | "replace" | "coexist"' }
394
- }
395
- const workspaceKey = sessionWorkspaceKey()
396
- if (spec.scope === 'workspace' && !isSegment(workspaceKey)) return noWorkspace
397
- const pkg = /** @type {any} */ (spec.document)
398
- const store = capabilityStore()
399
- // Local binding context: the managed MCP identities this
400
- // machine configured, the builtin names it actually has, and
401
- // the D6 install roots (project = this workspace, user = user
402
- // root) via the existing skill-inventory root resolution.
403
- const registry = createMcpRegistry({ store })
404
- const record = await registry.read()
405
- const localMcpIdentities = record.kind === 'ok' ? Object.keys(record.servers) : []
406
- const bound = bindPackageSelection(pkg, { localMcpIdentities })
407
- const inventoryResult = await inventory(options)
408
- const candidates = Array.isArray(inventoryResult?.candidates) ? inventoryResult.candidates : []
409
- const builtinNames = new Set()
410
- for (const candidate of candidates) {
411
- const identity = /** @type {Record<string, any>} */ (candidate)?.identity
412
- if (identity?.scope === 'weir-builtin' && typeof identity.name === 'string') builtinNames.add(identity.name)
413
- }
414
- const missingBuiltin = pkg.builtin.filter(name => !builtinNames.has(name))
415
- const roots = await skillRootsFor(options)
416
- const targetRoots = {
417
- project: roots.find(root => root.source === 'project-dsh')?.path ?? null,
418
- user: roots.find(root => root.source === 'user-dsh')?.path ?? null,
419
- }
420
- const plan = await planPackageInstall(pkg, { roots: targetRoots })
421
- const carried = [
422
- ...bound.unresolvedRefs,
423
- ...missingBuiltin.map(name => ({ kind: 'skill', ref: { name, source: 'builtin' } })),
424
- ]
425
- if (spec.dryRun === true) {
426
- const view = await inspectPresetNamespace(store, spec.scope, workspaceKey)
427
- if (view.status !== 'ok') return { kind: 'error', text: JSON.stringify(view) }
428
- const name = typeof spec.name === 'string' && spec.name.length ? spec.name : pkg.name
429
- const conflicts = view.names.get(name) ?? []
430
- const presetConflict = conflicts.length ? { with: conflicts[0], withRevision: view.activeById.get(conflicts[0]).revision, duplicates: conflicts.length > 1 } : null
431
- return { kind: 'success', text: JSON.stringify({ status: 'dry-run', install: plan.install, unresolved: carried, collisions: plan.collisions, presetConflict }) }
432
- }
433
- // Confirmed: the install is this import's explicit main effect.
434
- const neededScopes = [...new Set(pkg.bundled.map(entry => entry.targetScope))]
435
- const missingRoot = neededScopes.find(scope => targetRoots[scope] === null)
436
- if (missingRoot !== undefined) {
437
- return { kind: 'error', text: JSON.stringify({ status: 'no-target-root', targetScope: missingRoot }) }
438
- }
439
- const name = typeof spec.name === 'string' && spec.name.length ? spec.name : pkg.name
440
- const requestedId = spec.presetId
441
- if (requestedId !== undefined && requestedId !== null && !isSegment(requestedId)) return { kind: 'error', text: `capabilities preset-import: invalid presetId ${JSON.stringify(requestedId)}` }
442
- const library = createPresetLibrary({ store })
443
- const guardedResult = await library.withNamespace(spec.scope, workspaceKey, async guarded => {
444
- const request = { scope: spec.scope, presetId: requestedId, workspaceKey,
445
- document: { name, selection: { skills: [], mcpServers: [], unresolvedRefs: [] } },
446
- onNameConflict: spec.onNameConflict ?? 'cancel', replaceTarget: spec.replaceTarget }
447
- const admission = await guarded.checkCreate(request)
448
- if (admission.status !== 'ready') return { kind: 'success', text: JSON.stringify(admission) }
449
- let install
450
- try {
451
- install = await installBundledSkills(pkg.bundled, { roots: targetRoots, onCollision }, {
452
- audit: data => audit(agent.session, AUDIT_TYPES.capabilityPresetImport, data),
453
- })
454
- if (!install.ok) {
455
- // installBundledSkills already rolled back what it wrote.
456
- return { kind: 'error', text: JSON.stringify({ status: 'install-failed', reason: install.reason, rolledBack: install.rolledBack }) }
457
- }
458
- // Resolve what is now local (installed bundled Skills, present
459
- // builtin names) against a FRESH inventory: the preset record
460
- // names real identities; everything else stays unresolved.
461
- const afterResult = await inventory(options)
462
- const afterCandidates = Array.isArray(afterResult?.candidates) ? afterResult.candidates : []
463
- const resolveLocal = (scope, name) => {
464
- const matches = []
465
- for (const candidate of afterCandidates) {
466
- const identity = /** @type {Record<string, any>} */ (candidate)?.identity
467
- if (identity?.scope === scope && identity.name === name) matches.push(identity)
468
- }
469
- // An ambiguous name stays unresolved rather than guessing.
470
- return matches.length === 1 ? matches[0] : null
471
- }
472
- const resolvedSkills = []
473
- const unresolvedRefs = [...carried]
474
- for (const entry of install.installed) {
475
- const identity = resolveLocal(entry.targetScope === 'project' ? 'project' : 'user', entry.target)
476
- if (identity) resolvedSkills.push(identity)
477
- else unresolvedRefs.push({ kind: 'skill', ref: { name: entry.target, targetScope: entry.targetScope, source: 'bundled' } })
478
- }
479
- for (const entry of install.collisions) {
480
- unresolvedRefs.push({ kind: 'skill', ref: { name: entry.name, targetScope: entry.targetScope, source: 'bundled', collision: entry.decision } })
481
- }
482
- for (const name of pkg.builtin) {
483
- if (missingBuiltin.includes(name)) continue
484
- const identity = resolveLocal('weir-builtin', name)
485
- if (identity) resolvedSkills.push(identity)
486
- else unresolvedRefs.push({ kind: 'skill', ref: { name, source: 'builtin' } })
487
- }
488
- const document = { name, selection: { skills: resolvedSkills, mcpServers: bound.mcpServers, unresolvedRefs } }
489
- const result = await guarded.create({ ...request, document })
490
- return { kind: 'success', text: JSON.stringify({
491
- ...result,
492
- bound: { mcpServers: bound.mcpServers.length, unresolvedRefs: unresolvedRefs.length },
493
- installed: install.installed,
494
- collisions: install.collisions,
495
- unresolved: unresolvedRefs,
496
- ...(result.status !== 'created' && result.status !== 'edited' ? { presetStatus: 'unconfirmed' } : {}),
497
- }) }
498
- } catch (cause) {
499
- return { kind: 'error', text: JSON.stringify({ status: 'preset-unconfirmed',
500
- presetStatus: 'unconfirmed', installed: install?.installed ?? [], reason: cause instanceof Error ? cause.message : String(cause) }) }
501
- }
502
- })
503
- return guardedResult?.kind === 'success' || guardedResult?.kind === 'error'
504
- ? guardedResult : { kind: 'success', text: JSON.stringify(guardedResult) }
505
- }
506
- if (verb === 'receipt') {
507
- try {
508
- // Shared builder (silent-capability-reads 2.1): the read
509
- // remote serializes the same payload from the same code.
510
- const payload = await buildReceiptPayload({
511
- provider,
512
- lifecycle,
513
- store: openCapabilityStore({ profileContext: ctx.get?.('profileContext') }),
514
- }, options)
515
- // The commands registry normalizes results to {kind, text}
516
- // (CITED dsh-commands normalizeResult): structured payloads
517
- // travel as JSON text.
518
- return { kind: 'success', text: JSON.stringify(payload) }
519
- } catch (cause) {
520
- return { kind: 'error', text: `capabilities receipt failed: ${cause instanceof Error ? cause.message : String(cause)}` }
521
- }
522
- }
523
- if (verb === 'list') {
524
- try {
525
- // Shared builder (silent-capability-reads 2.1): the FULL
526
- // inventory joined with the session's EFFECTIVE selection,
527
- // plus the MCP manager listing (read at call time).
528
- const payload = await buildListPayload({
529
- inventory: (inventoryOptions, previous) => inventory(inventoryOptions, previous),
530
- provider,
531
- mcpManager: ctx.get?.('weirMcpManager'),
532
- }, options)
533
- // Degraded-listing fidelity (resume-incarnation-recovery D3):
534
- // when the selection view is in an error state, provider.list
535
- // fail-closed empty made buildListPayload list the inventory
536
- // with every mark false — but the error state itself was
537
- // invisible, indistinguishable from a successful empty
538
- // inventory. Rebuild the rows from the RAW inventory with the
539
- // real scope/provenance labels (candidate identity/root scope
540
- // — never the 'unknown'/'other' degradation), every selection
541
- // mark false, and carry the classified failure alongside, so
542
- // the panel can still drive the Apply self-heal. The healthy
543
- // path above stays byte-identical.
544
- const status = provider.status(options)
545
- if (status?.error) {
546
- const rawInventory = await inventory(options)
547
- const rawCandidates = Array.isArray(rawInventory?.candidates) ? rawInventory.candidates : []
548
- return { kind: 'success', text: JSON.stringify({
549
- skills: rawCandidates.map(candidate => ({
550
- name: candidate?.name,
551
- description: candidate?.description ?? '',
552
- scope: candidate?.identity?.scope ?? candidate?.root?.scope ?? 'unknown',
553
- status: candidate?.status ?? 'unknown',
554
- selected: false,
555
- conflict: Boolean(candidate?.conflict),
556
- identity: candidate?.identity ?? null,
557
- })),
558
- mcpServers: payload.mcpServers,
559
- selectionError: { error: status.error, reason: status.reason ?? null, hint: status.hint ?? null },
560
- }) }
561
- }
562
- return { kind: 'success', text: JSON.stringify(payload) }
563
- } catch (cause) {
564
- return { kind: 'error', text: `capabilities list failed: ${cause instanceof Error ? cause.message : String(cause)}` }
565
- }
566
- }
567
- if (verb.startsWith('apply ')) {
568
- // The manager editor's commit (12.3): the client sends
569
- // {requestId, expectedRevision, skills: [identities or names], mcpServers: [names]}
570
- // as JSON text after the verb; identities match exactly and names
571
- // must match one parsed CURRENT candidate, never a silent winner.
572
- let draft
573
- try {
574
- draft = JSON.parse(rawInput.slice('apply '.length))
575
- } catch {
576
- return { kind: 'error', text: 'Usage: /capabilities apply <json selection>' }
577
- }
578
- try {
579
- // The full inventory (not the effective selection): an Apply
580
- // may name any discovered candidate, selected or not.
581
- const inventoryResult = await inventory(options)
582
- const candidates = Array.isArray(inventoryResult?.candidates) ? inventoryResult.candidates : []
583
- const byKey = new Map()
584
- const parsedByName = new Map()
585
- for (const candidate of candidates) {
586
- if (!candidate.identity) continue
587
- byKey.set(skillIdentityKey(candidate.identity), candidate)
588
- if (candidate.status !== 'parsed') continue
589
- const matches = parsedByName.get(candidate.name) ?? []
590
- matches.push(candidate)
591
- parsedByName.set(candidate.name, matches)
592
- }
593
- const missing = []
594
- const identities = []
595
- const admitted = new Set()
596
- for (const entry of draft.skills ?? []) {
597
- let candidate
598
- if (typeof entry === 'string') {
599
- const matches = parsedByName.get(entry) ?? []
600
- if (matches.length === 1) candidate = matches[0]
601
- else missing.push(entry)
602
- } else {
603
- const identity = createSkillIdentity(entry)
604
- candidate = byKey.get(skillIdentityKey(identity))
605
- if (!candidate) missing.push(identity.name)
606
- }
607
- if (!candidate) continue
608
- const key = skillIdentityKey(candidate.identity)
609
- if (admitted.has(key)) continue
610
- admitted.add(key)
611
- identities.push(candidate.identity)
612
- }
613
- // preset-skill-applicability (D2): partition the missing names —
614
- // KNOWN-BUT-INAPPLICABLE (resolvable in a profile-supplemental
615
- // directory, e.g. creative-preset skills carried by a workspace
616
- // default) are SKIPPED with a per-name reason while the
617
- // resolvable remainder commits; genuinely unknown names keep
618
- // the hard-failure contract. Records are never rewritten for
619
- // applicability, so a preset round-trip stays lossless.
620
- const unknown = []
621
- const skipped = []
622
- for (const name of missing) {
623
- if (supplementalSkillKnown(config.supplementalSkillDirs, name)) skipped.push({ name, reason: 'not applicable in this agent preset' })
624
- else unknown.push(name)
625
- }
626
- if (unknown.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'missing', missing: unknown, ...(skipped.length > 0 ? { skipped } : {}) }) }
627
- const response = await face.applySelection(
628
- { sessionId: agent.id, cwd: agent.session?.header?.cwd, presetId: 'weir' },
629
- { requestId: draft.requestId, expectedRevision: draft.expectedRevision, selection: { skills: identities, mcpServers: draft.mcpServers ?? [] }, unresolved: [] },
630
- options,
631
- )
632
- return { kind: 'success', text: JSON.stringify(skipped.length > 0 ? { ...response, skipped } : response) }
633
- } catch (cause) {
634
- return { kind: 'error', text: `capabilities apply failed: ${cause instanceof Error ? cause.message : String(cause)}` }
635
- }
636
- }
637
- if (verb.startsWith('mcp-add ')) {
638
- // Register a new managed MCP server and mount it immediately
639
- // (8.1 registry + manager start). Payload:
640
- // {identity, label, command, args?: [], env?: {}, serverName?}
641
- let spec
642
- try {
643
- spec = JSON.parse(rawInput.slice('mcp-add '.length))
644
- } catch {
645
- return { kind: 'error', text: 'Usage: /capabilities mcp-add <json {identity,label,command,args?,env?,serverName?}>' }
646
- }
647
- try {
648
- if (typeof spec.identity !== 'string' || typeof spec.label !== 'string' || typeof spec.command !== 'string' || spec.command.length === 0) {
649
- return { kind: 'error', text: 'mcp-add needs identity, label and command strings' }
650
- }
651
- const store = openCapabilityStore({ profileContext: ctx.get?.('profileContext') })
652
- const registry = createMcpRegistry({ store })
653
- const current = await registry.read()
654
- const expectedRevision = current.kind === 'ok' ? current.revision : 0
655
- const entry = {
656
- identity: spec.identity,
657
- label: spec.label,
658
- owner: { kind: 'global', key: 'installation' },
659
- transport: { kind: 'stdio', ref: `cmd:${spec.command}` },
660
- client: {
661
- transport: 'stdio',
662
- serverName: typeof spec.serverName === 'string' && spec.serverName.length ? spec.serverName : spec.identity,
663
- command: spec.command,
664
- args: Array.isArray(spec.args) ? spec.args.map(String) : [],
665
- env: spec.env && typeof spec.env === 'object' && !Array.isArray(spec.env) ? spec.env : {},
666
- reconnect: { enabled: true },
667
- },
668
- }
669
- const committed = await registry.register(entry, expectedRevision)
670
- if (committed.status !== 'committed') return { kind: 'error', text: JSON.stringify({ status: committed.status }) }
671
- const manager = ctx.get?.('weirMcpManager')
672
- if (manager?.start) await manager.start()
673
- return { kind: 'success', text: JSON.stringify({ status: 'registered', identity: entry.identity, generation: 1, mounted: Boolean(manager?.start) }) }
674
- } catch (cause) {
675
- return { kind: 'error', text: `capabilities mcp-add failed: ${cause instanceof Error ? cause.message : String(cause)}` }
676
- }
677
- }
678
- if (verb === 'presets') {
679
- // Thin shell over the shared builder (read-payloads.js): domain
680
- // failures stay JSON status text (the panel categorizes them);
681
- // the remote resolves the same object as a plain value.
682
- try {
683
- const payload = await buildPresetsPayload({ store: capabilityStore() }, options)
684
- if (typeof payload.status === 'string') return { kind: 'error', text: JSON.stringify(payload) }
685
- return { kind: 'success', text: JSON.stringify(payload) }
686
- } catch (cause) {
687
- return { kind: 'error', text: `capabilities presets failed: ${cause instanceof Error ? cause.message : String(cause)}` }
688
- }
689
- }
690
- if (verb.startsWith('preset-save ') || verb.startsWith('preset-rename ') || verb.startsWith('preset-resave ')) {
691
- const action = rawInput.split(' ', 1)[0].toLowerCase()
692
- let spec
693
- try { spec = JSON.parse(rawInput.slice(action.length + 1)) }
694
- catch { return { kind: 'error', text: `Usage: /capabilities ${action} <json>` } }
695
- try {
696
- const scopeError = presetScopeError(spec)
697
- if (scopeError) return scopeError
698
- const workspaceKey = sessionWorkspaceKey()
699
- if (spec.scope === 'workspace' && !isSegment(workspaceKey)) return noWorkspace
700
- const library = createPresetLibrary({ store: capabilityStore() })
701
- if (action === 'preset-rename' || action === 'preset-resave') {
702
- if (!isSegment(spec.presetId)) return { kind: 'error', text: `${action}: invalid presetId` }
703
- if (!Number.isSafeInteger(spec.expectedRevision) || spec.expectedRevision < 1) return { kind: 'error', text: `${action}: expectedRevision must be positive` }
704
- if (action === 'preset-rename') {
705
- const result = await library.rename({ scope: spec.scope, presetId: spec.presetId, workspaceKey, expectedRevision: spec.expectedRevision, name: spec.name })
706
- return { kind: 'success', text: JSON.stringify(result) }
707
- }
708
- if (spec.from !== 'draft' && spec.from !== 'applied') return { kind: 'error', text: 'preset-resave: from must be draft or applied' }
709
- if (spec.from === 'draft' && ![spec.skills, spec.mcpServers, spec.unresolvedRefs].every(Array.isArray)) {
710
- return { kind: 'error', text: JSON.stringify({ status: 'invalid-selection' }) }
711
- }
712
- const store = capabilityStore()
713
- const sets = spec.from === 'applied' ? { skills: [], mcpServers: [], unresolvedRefs: [] } : await selectionSetsOf(spec)
714
- const withSource = spec.from === 'applied' ? appliedPresetSource(spec, sets, store, agent, options) : undefined
715
- const result = await library.resave({
716
- scope: spec.scope, presetId: spec.presetId, workspaceKey, expectedRevision: spec.expectedRevision,
717
- selection: sets, withSource,
718
- })
719
- return { kind: 'success', text: JSON.stringify(result) }
720
- }
721
- if (spec.from !== 'draft' && spec.from !== 'applied') return { kind: 'error', text: 'preset-save: from must be draft or applied' }
722
- if (typeof spec.name !== 'string' || !spec.name.length) return { kind: 'error', text: 'preset-save: name must be non-empty' }
723
- if (spec.expectedRevision !== undefined && spec.from === 'draft' && ![spec.skills, spec.mcpServers, spec.unresolvedRefs].every(Array.isArray)) {
724
- return { kind: 'error', text: JSON.stringify({ status: 'invalid-selection' }) }
725
- }
726
- const store = capabilityStore()
727
- const sets = spec.from === 'applied' ? { skills: [], mcpServers: [], unresolvedRefs: [] } : await selectionSetsOf(spec)
728
- const selection = { skills: sets.skills, mcpServers: sets.mcpServers, unresolvedRefs: sets.unresolvedRefs }
729
- const withSource = spec.from === 'applied' ? appliedPresetSource(spec, sets, store, agent, options) : undefined
730
- if (spec.expectedRevision !== undefined) {
731
- if (!isSegment(spec.presetId)) return { kind: 'error', text: 'preset-save: edit needs presetId' }
732
- const result = await library.edit({ scope: spec.scope, presetId: spec.presetId, workspaceKey,
733
- expectedRevision: spec.expectedRevision, mutate: () => ({ name: spec.name, selection }), withSource })
734
- return { kind: 'success', text: JSON.stringify(result) }
735
- }
736
- const result = await library.create({ scope: spec.scope, presetId: spec.presetId, workspaceKey,
737
- document: { name: spec.name, selection }, onNameConflict: spec.onNameConflict ?? 'cancel', replaceTarget: spec.replaceTarget, withSource })
738
- return { kind: 'success', text: JSON.stringify(result) }
739
- } catch (cause) {
740
- return { kind: 'error', text: `capabilities ${action} failed: ${cause instanceof Error ? cause.message : String(cause)}` }
741
- }
534
+ let spec
535
+ if (entry.payload !== 'none' && hasArgs) {
536
+ try { spec = JSON.parse(rawInput.slice(head.length + 1)) }
537
+ catch { return { kind: 'error', text: entry.usage } }
742
538
  }
743
- if (verb.startsWith('preset-load ')) {
744
- // Load one preset document; the client stages its selection into
745
- // the local draft (stagePreset semantics) — Apply stays the only
746
- // path that changes the session's applied set.
747
- let spec
748
- try {
749
- spec = JSON.parse(rawInput.slice('preset-load '.length))
750
- } catch {
751
- return { kind: 'error', text: 'Usage: /capabilities preset-load <json {scope,presetId}>' }
752
- }
753
- try {
754
- const scopeError = presetScopeError(spec)
755
- if (scopeError) return scopeError
756
- if (!isSegment(spec.presetId)) return { kind: 'error', text: 'capabilities preset-load: presetId must be a valid store segment' }
757
- const workspaceKey = sessionWorkspaceKey()
758
- if (spec.scope === 'workspace' && !isSegment(workspaceKey)) return noWorkspace
759
- const library = createPresetLibrary({ store: capabilityStore() })
760
- const loaded = await library.load(spec.scope, spec.presetId, workspaceKey)
761
- if (loaded.kind !== 'ok') return { kind: 'success', text: JSON.stringify({ status: loaded.kind }) }
762
- return { kind: 'success', text: JSON.stringify({ status: 'ok', revision: loaded.revision, document: loaded.document }) }
763
- } catch (cause) {
764
- return { kind: 'error', text: `capabilities preset-load failed: ${cause instanceof Error ? cause.message : String(cause)}` }
765
- }
766
- }
767
- if (verb.startsWith('preset-delete ')) {
768
- let spec
769
- try {
770
- spec = JSON.parse(rawInput.slice('preset-delete '.length))
771
- } catch {
772
- return { kind: 'error', text: 'Usage: /capabilities preset-delete <json {scope,presetId,expectedRevision}>' }
773
- }
774
- try {
775
- const scopeError = presetScopeError(spec)
776
- if (scopeError) return scopeError
777
- if (!isSegment(spec.presetId)) return { kind: 'error', text: 'capabilities preset-delete: presetId must be a valid store segment' }
778
- if (!Number.isSafeInteger(spec.expectedRevision) || spec.expectedRevision < 0) {
779
- return { kind: 'error', text: 'capabilities preset-delete: expectedRevision must be a non-negative integer' }
780
- }
781
- const workspaceKey = sessionWorkspaceKey()
782
- if (spec.scope === 'workspace' && !isSegment(workspaceKey)) return noWorkspace
783
- const library = createPresetLibrary({ store: capabilityStore() })
784
- const result = await library.remove({ scope: spec.scope, presetId: spec.presetId, workspaceKey, expectedRevision: spec.expectedRevision })
785
- return { kind: 'success', text: JSON.stringify(result) }
786
- } catch (cause) {
787
- return { kind: 'error', text: `capabilities preset-delete failed: ${cause instanceof Error ? cause.message : String(cause)}` }
788
- }
789
- }
790
- if (verb.startsWith('preset-export ')) {
791
- // format:'document' is the v1 portable document (verbatim via
792
- // library.exportPreset — inspect/backup); the default 'package'
793
- // is the D6 version-2 package: remote Skills as portable refs,
794
- // workspace/local Skills as bundled content, builtin Skills by
795
- // name, managed MCP as {identity,label}.
796
- let spec
797
- try {
798
- spec = JSON.parse(rawInput.slice('preset-export '.length))
799
- } catch {
800
- return { kind: 'error', text: 'Usage: /capabilities preset-export <json {scope,presetId,format?:"package"|"document"}>' }
801
- }
802
- try {
803
- const scopeError = presetScopeError(spec)
804
- if (scopeError) return scopeError
805
- if (!isSegment(spec.presetId)) return { kind: 'error', text: 'capabilities preset-export: presetId must be a valid store segment' }
806
- const format = spec.format ?? 'package'
807
- if (format !== 'package' && format !== 'document') {
808
- return { kind: 'error', text: 'capabilities preset-export: format must be "package" | "document"' }
809
- }
810
- const workspaceKey = sessionWorkspaceKey()
811
- if (spec.scope === 'workspace' && !isSegment(workspaceKey)) return noWorkspace
812
- const library = createPresetLibrary({ store: capabilityStore() })
813
- if (format === 'document') {
814
- const exported = await library.exportPreset(spec.scope, spec.presetId, workspaceKey)
815
- return { kind: 'success', text: JSON.stringify(exported) }
816
- }
817
- const loaded = await library.load(spec.scope, spec.presetId, workspaceKey)
818
- if (loaded.kind !== 'ok') return { kind: 'success', text: JSON.stringify({ status: loaded.kind }) }
819
- // Pack against the live inventory (Skill file content comes
820
- // from the matched candidates' locators) and the managed
821
- // registry (MCP labels).
822
- const inventoryResult = await inventory(options)
823
- const candidates = Array.isArray(inventoryResult?.candidates) ? inventoryResult.candidates : []
824
- const registry = createMcpRegistry({ store: capabilityStore() })
825
- const record = await registry.read()
826
- /** @type {Record<string, string>} */
827
- const labels = {}
828
- if (record.kind === 'ok') for (const entry of Object.values(record.servers)) labels[entry.identity] = entry.label
829
- const source = /** @type {Record<string, unknown>} */ (loaded.document ?? {})
830
- const packed = await packPresetPackage({ name: source.name, selection: source.selection }, {
831
- candidates,
832
- mcpLabels: identity => labels[identity],
833
- })
834
- if (!packed.ok) return { kind: 'error', text: JSON.stringify({ status: 'rejected', reason: packed.reason }) }
835
- return { kind: 'success', text: JSON.stringify({ status: 'ok', format: 'package', document: packed.package }) }
836
- } catch (cause) {
837
- return { kind: 'error', text: `capabilities preset-export failed: ${cause instanceof Error ? cause.message : String(cause)}` }
838
- }
839
- }
840
- if (verb.startsWith('preset-import ')) {
841
- // Import a portable document: validate atomically first (a
842
- // poison document is rejected with zero writes), then bind only
843
- // the identities the local MCP registry already configured —
844
- // everything else stays an unresolved ref. Nothing is installed,
845
- // started or connected here.
846
- let spec
847
- try {
848
- spec = JSON.parse(rawInput.slice('preset-import '.length))
849
- } catch {
850
- return { kind: 'error', text: 'Usage: /capabilities preset-import <json {document,scope,presetId?,name?,onNameConflict?,dryRun?,onCollision?}>' }
851
- }
852
- try {
853
- const scopeError = presetScopeError(spec)
854
- if (scopeError) return scopeError
855
- // Version dispatch (D6): a version-2 package takes the
856
- // two-phase path (dryRun summary → confirmed install + CAS
857
- // preset create). Everything else keeps the v1 portable
858
- // path, whose own gate rejects unknown versions atomically.
859
- if (spec.document !== null && typeof spec.document === 'object' && !Array.isArray(spec.document)
860
- && /** @type {Record<string, unknown>} */ (spec.document).version === 2) {
861
- return await importPresetPackage(spec)
862
- }
863
- const verdict = validatePortableDocument(spec.document)
864
- if (!verdict.ok) return { kind: 'error', text: JSON.stringify({ status: 'rejected', reason: verdict.reason }) }
865
- const workspaceKey = sessionWorkspaceKey()
866
- if (spec.scope === 'workspace' && !isSegment(workspaceKey)) return noWorkspace
867
- const presetId = spec.presetId ?? spec.document.presetId
868
- if (!isSegment(presetId)) return { kind: 'error', text: 'capabilities preset-import: presetId must be a valid store segment' }
869
- const name = typeof spec.name === 'string' && spec.name.length ? spec.name : spec.document.name
870
- const store = capabilityStore()
871
- const registry = createMcpRegistry({ store })
872
- const record = await registry.read()
873
- const localMcpIdentities = record.kind === 'ok' ? Object.keys(record.servers) : []
874
- const selection = /** @type {Record<string, unknown>} */ (spec.document.selection ?? {})
875
- const bound = bindImportedSelection(selection, { localMcpIdentities })
876
- const document = {
877
- name,
878
- selection: {
879
- skills: [],
880
- mcpServers: bound.mcpServers,
881
- unresolvedRefs: [
882
- ...(Array.isArray(selection.unresolvedRefs) ? selection.unresolvedRefs : []),
883
- ...bound.unresolvedRefs,
884
- ],
885
- },
886
- }
887
- const library = createPresetLibrary({ store })
888
- const result = await library.create({ scope: spec.scope, presetId, workspaceKey, document,
889
- onNameConflict: spec.onNameConflict ?? 'cancel', replaceTarget: spec.replaceTarget })
890
- return { kind: 'success', text: JSON.stringify({ ...result, bound: { mcpServers: bound.mcpServers.length, unresolvedRefs: bound.unresolvedRefs.length } }) }
891
- } catch (cause) {
892
- return { kind: 'error', text: `capabilities preset-import failed: ${cause instanceof Error ? cause.message : String(cause)}` }
893
- }
894
- }
895
- if (verb === 'default-get') {
896
- // Thin shell over the shared builder. The no-workspace status is
897
- // the ONLY error mapping (the historical noWorkspace object);
898
- // every other status — including a non-ok record — stays success.
899
- try {
900
- const payload = await buildDefaultGetPayload({ store: capabilityStore() }, options)
901
- if (payload.status === 'no-workspace') return { kind: 'error', text: JSON.stringify(payload) }
902
- return { kind: 'success', text: JSON.stringify(payload) }
903
- } catch (cause) {
904
- return { kind: 'error', text: `capabilities default-get failed: ${cause instanceof Error ? cause.message : String(cause)}` }
905
- }
906
- }
907
- if (verb.startsWith('default-save ')) {
908
- // Save this workspace's new-session default as a COPY snapshot
909
- // of the draft or the applied sets. Saving never changes the
910
- // current session; an explicit empty set is a real choice.
911
- let spec
912
- try {
913
- spec = JSON.parse(rawInput.slice('default-save '.length))
914
- } catch {
915
- return { kind: 'error', text: 'Usage: /capabilities default-save <json {from:"draft"|"applied",skills?,mcpServers?,unresolvedRefs?,expectedRevision?}>' }
916
- }
917
- try {
918
- if (spec?.from !== 'draft' && spec?.from !== 'applied') return { kind: 'error', text: 'capabilities default-save: from must be "draft" | "applied"' }
919
- const workspaceKey = sessionWorkspaceKey()
920
- if (!isSegment(workspaceKey)) return noWorkspace
921
- const transaction = createDefaultsTransaction({ store: capabilityStore() })
922
- let expectedRevision = spec.expectedRevision
923
- if (expectedRevision === undefined) {
924
- const current = await transaction.read(workspaceKey)
925
- if (current.kind !== 'ok' && current.kind !== 'absent') return { kind: 'error', text: JSON.stringify({ status: current.kind }) }
926
- expectedRevision = current.kind === 'ok' ? current.revision : 0
927
- }
928
- if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
929
- return { kind: 'error', text: 'capabilities default-save: expectedRevision must be a non-negative integer' }
930
- }
931
- const sets = await selectionSetsOf(spec)
932
- let skills = sets.skills
933
- if (spec.from === 'draft') {
934
- // The draft's wire shape is name strings: resolve them to
935
- // exact SkillIdentity objects BEFORE persisting, so the
936
- // default record carries the resolved-sets contract and a
937
- // duplicated name never lands as an unbindable string. The
938
- // session's applied selection is the authoritative
939
- // disambiguation; unresolvable or still-ambiguous names are
940
- // an explicit error with ZERO writes.
941
- const inventoryResult = await inventory(options)
942
- const inventoryCandidates = Array.isArray(inventoryResult?.candidates) ? inventoryResult.candidates : []
943
- const appliedResult = await provider.list(options)
944
- const appliedCandidates = Array.isArray(appliedResult?.candidates) ? appliedResult.candidates : []
945
- const appliedIdentities = []
946
- for (const candidate of appliedCandidates) {
947
- if (candidate?.selected && candidate.identity) appliedIdentities.push(candidate.identity)
948
- }
949
- const resolved = resolveSkillNamesForSave(spec.skills, inventoryCandidates, appliedIdentities)
950
- if (resolved.missing.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'missing', missing: resolved.missing }) }
951
- if (resolved.ambiguous.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'ambiguous', ambiguous: resolved.ambiguous }) }
952
- skills = resolved.identities
953
- }
954
- const result = await transaction.save({ workspaceKey, expectedRevision, snapshot: { skills, mcpServers: sets.mcpServers, unresolvedRefs: sets.unresolvedRefs } })
955
- return { kind: 'success', text: JSON.stringify({ ...result, workspaceKey }) }
956
- } catch (cause) {
957
- return { kind: 'error', text: `capabilities default-save failed: ${cause instanceof Error ? cause.message : String(cause)}` }
958
- }
959
- }
960
- if (verb === 'default-clear' || verb.startsWith('default-clear ')) {
961
- // Clear this workspace's default (absent afterwards — NOT an
962
- // explicit empty set). The JSON payload is optional.
963
- let spec = {}
964
- const rest = rawInput.slice('default-clear'.length).trim()
965
- if (rest.length > 0) {
966
- try {
967
- spec = JSON.parse(rest)
968
- } catch {
969
- return { kind: 'error', text: 'Usage: /capabilities default-clear [json {expectedRevision?}]' }
970
- }
971
- }
972
- try {
973
- const workspaceKey = sessionWorkspaceKey()
974
- if (!isSegment(workspaceKey)) return noWorkspace
975
- const transaction = createDefaultsTransaction({ store: capabilityStore() })
976
- let expectedRevision = spec.expectedRevision
977
- if (expectedRevision === undefined) {
978
- const current = await transaction.read(workspaceKey)
979
- if (current.kind !== 'ok' && current.kind !== 'absent') return { kind: 'error', text: JSON.stringify({ status: current.kind }) }
980
- expectedRevision = current.kind === 'ok' ? current.revision : 0
981
- }
982
- if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
983
- return { kind: 'error', text: 'capabilities default-clear: expectedRevision must be a non-negative integer' }
984
- }
985
- const result = await transaction.clear({ workspaceKey, expectedRevision })
986
- return { kind: 'success', text: JSON.stringify({ ...result, workspaceKey }) }
987
- } catch (cause) {
988
- return { kind: 'error', text: `capabilities default-clear failed: ${cause instanceof Error ? cause.message : String(cause)}` }
989
- }
990
- }
991
- if (verb === 'conditions') {
992
- // The 1.12 consistency conditions; empty = supported.
993
- return { kind: 'success', text: JSON.stringify(buildConditionsPayload()) }
994
- }
995
- return { kind: 'error', text: 'Usage: /capabilities receipt|list|conditions|presets|preset-save|preset-load|preset-delete|preset-export|preset-import|default-get|default-save|default-clear' }
539
+ return entry.run(spec, { agent, options })
996
540
  },
997
541
  }), 'weir-capabilities-command')
998
542
  }
@@ -1001,14 +545,15 @@ export function createSkillSelectionPlugin(dependencies = {}) {
1001
545
  }
1002
546
  try {
1003
547
  ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
1004
- const pending = notifier.consume(agent?.id)
1005
- if (pending === null) return next()
1006
- // Injection failure must never affect the accepted commit: a failed
1007
- // append warns and the request proceeds without the notice.
1008
- try {
1009
- if (Array.isArray(messages)) messages.push(userTextMessage(pending.text, NOTIFY_SOURCE))
1010
- } catch (cause) {
1011
- ctx.logger?.warn?.(`selection notification injection failed: ${cause instanceof Error ? cause.message : String(cause)}`)
548
+ // durable-pending-notice D4: read → build → inject → clear. The
549
+ // obligation is cleared only AFTER the message landed in this request,
550
+ // so a failed injection (a non-array `messages`, a throwing push)
551
+ // leaves it pending for the next safe boundary instead of dropping it.
552
+ // Delivery is advisory: it never throws into the request and never
553
+ // affects an accepted commit.
554
+ const outcome = await notifier.deliver(agent?.id, messages)
555
+ if (outcome === 'injection-failed' || outcome === 'clear-failed') {
556
+ ctx.logger?.warn?.(`selection notification delivery: ${outcome}`)
1012
557
  }
1013
558
  return next()
1014
559
  })
@@ -1050,6 +595,47 @@ export function createSkillSelectionPlugin(dependencies = {}) {
1050
595
  }
1051
596
  }
1052
597
 
598
+ /**
599
+ * The production Apply-engine hook set (apply-delta-durable-basis D5), one
600
+ * exported assembly so `engineFor()` and the wiring pin
601
+ * (test/apply-wiring.test.js) share the exact production hooks — the store
602
+ * and the session-facing deps (locateSession/inventory/provider) stay with
603
+ * the caller. `audit` rides the shared cold-safe channel (createAudit:
604
+ * cordis emit + .weir/audit.jsonl mirror, never session.append); `trace` is
605
+ * logger-backed debug diagnostics and never throws into a transaction. The
606
+ * `notify` response-bus seam stays unwired: no production consumer exists
607
+ * (the manager reads the Apply response from its own call), and the wiring
608
+ * pin says so explicitly. The notice hook queues the DURABLE obligation
609
+ * (durable-pending-notice D2): `onAccepted` receives the accepted revision and
610
+ * `queueSync` throws on a failed write, which the engine degrades — never an
611
+ * exception into the Apply result.
612
+ * @param {{ ctx: any, lifecycle: any, audit?: (session: { id?: string } | null, type: string, data?: unknown) => void, notifier: { queueSync(sessionId: string, delta: { added: string[], removed: string[] }, info?: { revision?: number }): unknown } }} deps
613
+ */
614
+ export function applyEngineHooks({ ctx, lifecycle, notifier, audit = createAudit(ctx) }) {
615
+ return {
616
+ // The manager's (agent, server)-keyed drain is the only real in-flight
617
+ // ledger (mcp-admission enter/exit at handoff). The engine is shared
618
+ // across sessions, so the adapter keys begin/abort by the Apply's
619
+ // session id (capabilities-deepening D3); a composition without the
620
+ // manager row has no managed servers to drain and the adapter reports
621
+ // the empty truth.
622
+ drain: createSessionDrainAdapter(() => ctx.get?.('weirMcpManager')?.drain),
623
+ publishSnapshot: (sessionId, snapshot) => lifecycle?.publish?.(sessionId, snapshot),
624
+ onAccepted: (sessionId, delta, info) => notifier.queueSync(sessionId, delta, info),
625
+ // The Apply-path convergence emission (5.2 parity with agent/created):
626
+ // an accepted Apply re-emits the preset invalidation event so every
627
+ // connected client drops cached capability state and refetches —
628
+ // without this hook the engine never re-emits and every Badge/panel
629
+ // goes stale until a remount.
630
+ invalidate: (sessionId, presetId) => emitPresetSelected((...args) => ctx.emit(...args), sessionId, presetId, text => ctx.logger?.warn?.(text)),
631
+ audit,
632
+ trace: (event, data) => {
633
+ try { ctx.logger?.debug?.(`capability-apply ${event}`, data) } catch { /* diagnostics never break a transaction */ }
634
+ },
635
+ warn: text => ctx.logger?.warn?.(text),
636
+ }
637
+ }
638
+
1053
639
  /**
1054
640
  * Session cwd resolution for the capability read bridge (silent-capability-reads):
1055
641
  * the agent/created warm cache MISSES sessions whose agent was announced before