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.
- package/cordis.patch.yml +0 -2
- package/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +5 -2
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-office-adapter.js +7 -1
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +25 -27
- package/src/delegate/index.js +21 -2
- package/src/delegate/rehydrate.js +24 -4
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +35 -6
- package/src/delegate/tool.js +29 -4
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +23 -6
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +12 -0
- package/src/shared/child-scope.js +3 -1
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- 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
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
261
|
+
store: storeFor(),
|
|
249
262
|
locateSession: session => session,
|
|
250
263
|
inventory: (options) => inventory(options),
|
|
251
264
|
provider,
|
|
252
|
-
|
|
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)
|
|
281
|
-
//
|
|
282
|
-
|
|
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
|
|
296
|
-
// must keep its original casing
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|