weir-harness 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +3 -3
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +368 -142
  24. package/src/capabilities/mcp-mount.js +219 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +161 -56
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -0,0 +1,473 @@
1
+ // The in-memory release-subscription coordinator behind `edit_lock_watch`.
2
+ //
3
+ // There is ONE coordinator per lease client (design D2/D4), not one per preset
4
+ // mount. Every mount of this bundle shares one client, so it must also share one
5
+ // view of "which session is waiting for which path": a per-mount registry would
6
+ // let two mounts subscribe the same `(session, path)` twice — two entries, two
7
+ // notices for one release — and would let one mount's teardown cancel a wait
8
+ // another mount still owns.
9
+ //
10
+ // The coordinator holds three collections and nothing else:
11
+ //
12
+ // active one entry per `(session, normalized path)`, in `watching`
13
+ // pendingDelivery consumed entries (`hit` / `reset`) whose notice has not
14
+ // been delivered yet, so disposal can still cancel them
15
+ // mounts the mount references that may deliver a notice
16
+ //
17
+ // The five states are a closed set:
18
+ //
19
+ // watching — registered, waiting for a release fact
20
+ // hit — a release fact consumed this subscription; it is over
21
+ // reset — the connection that could have delivered the fact is gone; it
22
+ // is over, and NOTHING is claimed about ownership
23
+ // self — this session already owns the path (an authoritative read said
24
+ // so, or a transfer settled); it is over, and there is nothing
25
+ // left to wait for
26
+ // cancelled — the subscriber went away (agent disposal, unmount, or a read
27
+ // that found the path free); it is over
28
+ //
29
+ // Consumption is one-way and happens in the event callback, BEFORE any notice is
30
+ // scheduled, so a duplicate or repeated release observation can never deliver a
31
+ // subscription twice. The notice itself is deferred by one turn of the event
32
+ // loop, because its caller is a socket callback and a message must never be
33
+ // injected from inside the transport's publish stack.
34
+ //
35
+ // Nothing here talks to a socket or a cordis context. A mount handle supplies
36
+ // the two things only the plugin knows: how to resolve the live agent for a
37
+ // session, and how to deliver a notice to it. Everything else — entries, the
38
+ // one shared status flight per entry, the pending-delivery window, the deferred
39
+ // timer — is decided here.
40
+
41
+ /** The closed subscription-state vocabulary. */
42
+ export const WATCH_STATES = Object.freeze(['watching', 'hit', 'reset', 'self', 'cancelled'])
43
+
44
+ /**
45
+ * @typedef {'watching' | 'hit' | 'reset' | 'self' | 'cancelled'} WatchState
46
+ */
47
+
48
+ /**
49
+ * @typedef {object} WatchEntry
50
+ * @property {string} sessionId the session that subscribed
51
+ * @property {string} path normalized absolute path
52
+ * @property {WatchState} state
53
+ * @property {boolean} settledByTool true once a running tool already reported
54
+ * this episode to the model, so a deferred notice would only repeat it
55
+ * @property {any} agent the agent that subscribed: delivery re-resolves the
56
+ * session and refuses to land on a replacement agent
57
+ * @property {Promise<any> | null} statusFlight the ONE authoritative read this
58
+ * entry is currently sharing, so overlapping calls cannot disagree
59
+ * @property {Set<WatchMount>} mounts the mount references that still want it
60
+ * @property {ReturnType<typeof setTimeout> | null} timer the deferred notice
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} WatchMount
65
+ * @property {boolean} disposed set by the coordinator when the mount leaves
66
+ * @property {(sessionId: string) => any} agentFor the live agent for a session
67
+ * @property {(entry: WatchEntry, agent: any) => void} notify deliver one notice
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} WatchProjection
72
+ * @property {string} sessionId
73
+ * @property {string} path
74
+ * @property {WatchState} state
75
+ * @property {boolean} settledByTool
76
+ */
77
+
78
+ /**
79
+ * Do not keep a finished process alive for a notice (design D4).
80
+ * @param {ReturnType<typeof setTimeout>} timer
81
+ */
82
+ function unrefTimer(timer) {
83
+ const handle = /** @type {{ unref?: () => void }} */ (/** @type {unknown} */ (timer))
84
+ if (typeof handle.unref === 'function') handle.unref()
85
+ }
86
+
87
+ /**
88
+ * The coordinator surface: one factory is the single definition of what a
89
+ * consumer holds, so the tool surface and the plugin row never re-describe it.
90
+ * @typedef {ReturnType<typeof createWatchCoordinator>} WatchCoordinator
91
+ */
92
+ export function createWatchCoordinator() {
93
+ /** @type {Map<string, WatchEntry>} */
94
+ const active = new Map()
95
+ /** @type {Set<WatchEntry>} */
96
+ const pendingDelivery = new Set()
97
+ /** @type {Set<WatchMount>} */
98
+ const mounts = new Set()
99
+
100
+ /** @param {string} sessionId @param {string} path @returns {string} */
101
+ function keyOf(sessionId, path) {
102
+ return `${sessionId}\u0000${path}`
103
+ }
104
+
105
+ /**
106
+ * The public shape of an entry. Internals (mounts, flight, timer, agent
107
+ * identity) stay inside the coordinator.
108
+ * @param {WatchEntry} entry
109
+ * @returns {WatchProjection}
110
+ */
111
+ function project(entry) {
112
+ return { sessionId: entry.sessionId, path: entry.path, state: entry.state, settledByTool: entry.settledByTool }
113
+ }
114
+
115
+ /**
116
+ * The mount that may deliver one notice: the entry's own references first, so
117
+ * the mount that registered the wait speaks for it, then any live mount of the
118
+ * same client — a notice survives the unmount that created it (design D4).
119
+ * @param {WatchEntry} entry
120
+ * @returns {WatchMount | undefined}
121
+ */
122
+ function liveMount(entry) {
123
+ for (const mount of entry.mounts) if (!mount.disposed) return mount
124
+ for (const mount of mounts) if (!mount.disposed) return mount
125
+ return undefined
126
+ }
127
+
128
+ /** @param {WatchEntry} entry */
129
+ function cancelTimer(entry) {
130
+ if (entry.timer === null) return
131
+ clearTimeout(entry.timer)
132
+ entry.timer = null
133
+ }
134
+
135
+ /**
136
+ * Consume one entry and schedule its notice: it joins `pendingDelivery` first,
137
+ * so a disposal between now and the timer still cancels it.
138
+ *
139
+ * A notice consumed while an authoritative read is still outstanding waits for
140
+ * that read to settle. The read's awaiting callers resume as microtasks and can
141
+ * then report the same episode as a tool result (`released` / `reset`), while
142
+ * this notice needs a macrotask to be delivered; arming after the flight is
143
+ * therefore enough for the tool's own answer to win, and a zero-delay timer
144
+ * can never duplicate it.
145
+ * @param {WatchEntry} entry
146
+ */
147
+ function scheduleNotice(entry) {
148
+ pendingDelivery.add(entry)
149
+ const flight = entry.statusFlight
150
+ if (flight === null) {
151
+ armNotice(entry)
152
+ return
153
+ }
154
+ // Both outcomes arm: a rejected read is caught by the tool as well, and its
155
+ // terminal rules run before this reaction's timer can fire.
156
+ void flight.then(
157
+ () => armNotice(entry),
158
+ () => armNotice(entry),
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Start the one deferred timer for a consumed entry. The pending set is the
164
+ * source of truth: a disposal, unmount or transfer may have ended the entry
165
+ * while the read was still outstanding, and that must win over the notice.
166
+ *
167
+ * The timer re-checks everything that could have ended the subscription in the
168
+ * meantime — the terminal state, the mount references, and the live agent
169
+ * identity — so a late notice can never revive one or land on a replacement
170
+ * agent (design D4/D5).
171
+ * @param {WatchEntry} entry
172
+ */
173
+ function armNotice(entry) {
174
+ if (!pendingDelivery.has(entry)) return
175
+ if (entry.state !== 'hit' && entry.state !== 'reset') {
176
+ pendingDelivery.delete(entry)
177
+ return
178
+ }
179
+ if (entry.timer !== null) return
180
+ const timer = setTimeout(() => {
181
+ entry.timer = null
182
+ pendingDelivery.delete(entry)
183
+ if (entry.state !== 'hit' && entry.state !== 'reset') return
184
+ if (entry.settledByTool === true) return
185
+ const mount = liveMount(entry)
186
+ if (mount === undefined) return
187
+ const agent = mount.agentFor(entry.sessionId)
188
+ if (agent === undefined || agent === null) return
189
+ if (entry.agent !== undefined && agent !== entry.agent) return
190
+ try {
191
+ mount.notify(entry, agent)
192
+ } catch {
193
+ // A notice is best effort: delivery must never break the transport.
194
+ }
195
+ }, 0)
196
+ entry.timer = timer
197
+ unrefTimer(timer)
198
+ }
199
+
200
+ /**
201
+ * End a PENDING entry: its notice is over and its timer must not fire.
202
+ * @param {WatchEntry} entry
203
+ * @param {WatchState} state
204
+ */
205
+ function endPending(entry, state) {
206
+ if (pendingDelivery.delete(entry)) cancelTimer(entry)
207
+ entry.state = state
208
+ }
209
+
210
+ /**
211
+ * Attach one mount reference. The first reference starts the subscription
212
+ * lifetime of this coordinator; a later mount of the same client reuses the
213
+ * same entries, flights and notices.
214
+ * @param {WatchMount} mount
215
+ */
216
+ function join(mount) {
217
+ mounts.add(mount)
218
+ }
219
+
220
+ /**
221
+ * Drop one mount reference (unmount). A single mount leaving removes only its
222
+ * own references: entries another mount still wants stay active, and a pending
223
+ * notice transfers to a surviving reference. When the LAST mount leaves, the
224
+ * whole coordinator — entries, pending notices, timers — is cleared so a later
225
+ * mount starts with no inherited wait.
226
+ * @param {WatchMount} mount
227
+ */
228
+ function leave(mount) {
229
+ if (!mounts.delete(mount)) return
230
+ mount.disposed = true
231
+ for (const [key, entry] of [...active]) {
232
+ if (!entry.mounts.delete(mount)) continue
233
+ if (entry.mounts.size > 0) continue
234
+ active.delete(key)
235
+ entry.state = 'cancelled'
236
+ }
237
+ for (const entry of [...pendingDelivery]) entry.mounts.delete(mount)
238
+ if (mounts.size === 0) clear()
239
+ }
240
+
241
+ /**
242
+ * Find or create the single entry for one session and path. Repeating a
243
+ * subscription is idempotent: the same entry comes back, so one release can
244
+ * produce at most one notice however many times or from however many mounts
245
+ * the subscription was requested.
246
+ * @param {string} sessionId
247
+ * @param {string} path
248
+ * @param {WatchMount} [mount]
249
+ * @param {any} [agent]
250
+ * @returns {WatchEntry}
251
+ */
252
+ function register(sessionId, path, mount, agent) {
253
+ const key = keyOf(sessionId, path)
254
+ const known = active.get(key)
255
+ if (known !== undefined) {
256
+ if (mount !== undefined) known.mounts.add(mount)
257
+ if (agent !== undefined && agent !== null) known.agent = agent
258
+ return known
259
+ }
260
+ /** @type {WatchEntry} */
261
+ const entry = {
262
+ sessionId,
263
+ path,
264
+ state: 'watching',
265
+ settledByTool: false,
266
+ agent,
267
+ statusFlight: null,
268
+ mounts: new Set(),
269
+ timer: null,
270
+ }
271
+ if (mount !== undefined) entry.mounts.add(mount)
272
+ active.set(key, entry)
273
+ return entry
274
+ }
275
+
276
+ /**
277
+ * @param {string} sessionId
278
+ * @param {string} path
279
+ * @returns {WatchEntry | undefined}
280
+ */
281
+ function peek(sessionId, path) {
282
+ return active.get(keyOf(sessionId, path))
283
+ }
284
+
285
+ /**
286
+ * One authoritative read per entry, shared by every overlapping caller
287
+ * (design D2). Two calls for the same `(session, path)` must not run two
288
+ * reads and then disagree about whether the entry is still active: they await
289
+ * the same answer and apply the same terminal rules to the same object.
290
+ * @param {WatchEntry} entry
291
+ * @param {() => any} run the arbitration read
292
+ * @returns {Promise<any>}
293
+ */
294
+ function shareStatus(entry, run) {
295
+ if (entry.statusFlight !== null) return entry.statusFlight
296
+ /** @type {Promise<any>} */
297
+ const flight = Promise.resolve()
298
+ .then(run)
299
+ .finally(() => {
300
+ if (entry.statusFlight === flight) entry.statusFlight = null
301
+ })
302
+ entry.statusFlight = flight
303
+ return flight
304
+ }
305
+
306
+ /**
307
+ * End one subscription because the caller no longer needs it. `expected`
308
+ * makes the removal conditional on the SAME entry object still being active:
309
+ * a late caller can then never cancel a newer episode, and can never report
310
+ * `watching` for an entry it no longer owns.
311
+ * @param {string} sessionId
312
+ * @param {string} path
313
+ * @param {WatchEntry} [expected]
314
+ * @param {WatchState} [state]
315
+ * @returns {WatchEntry | undefined}
316
+ */
317
+ function cancel(sessionId, path, expected, state = 'cancelled') {
318
+ const key = keyOf(sessionId, path)
319
+ const entry = active.get(key)
320
+ if (entry === undefined) return undefined
321
+ if (expected !== undefined && entry !== expected) return undefined
322
+ active.delete(key)
323
+ if (pendingDelivery.delete(entry)) cancelTimer(entry)
324
+ entry.state = state
325
+ return entry
326
+ }
327
+
328
+ /**
329
+ * This session acquired the path by another route (a transfer settled as
330
+ * `transferred`). The wait is over because its goal was reached, and any
331
+ * notice already consumed for that path is moot: waking the session would
332
+ * only make it compete with itself (design D4).
333
+ * @param {string} sessionId
334
+ * @param {string} path
335
+ * @returns {WatchEntry | undefined}
336
+ */
337
+ function settleSelf(sessionId, path) {
338
+ /** @type {WatchEntry | undefined} */
339
+ let ended
340
+ for (const entry of [...pendingDelivery]) {
341
+ if (entry.sessionId !== sessionId || entry.path !== path) continue
342
+ endPending(entry, 'self')
343
+ ended = ended ?? entry
344
+ }
345
+ const key = keyOf(sessionId, path)
346
+ const activeEntry = active.get(key)
347
+ if (activeEntry !== undefined) {
348
+ active.delete(key)
349
+ activeEntry.state = 'self'
350
+ ended = ended ?? activeEntry
351
+ }
352
+ return ended
353
+ }
354
+
355
+ /**
356
+ * Consume every subscription for one path (the release fact carries no
357
+ * session, and one path may be watched by several sessions of this client).
358
+ * Each returned entry is already in `hit` and out of the active map, so a
359
+ * second observation of the same release finds nothing.
360
+ * @param {string} path
361
+ * @returns {WatchEntry[]}
362
+ */
363
+ function consumePath(path) {
364
+ /** @type {WatchEntry[]} */
365
+ const consumed = []
366
+ for (const [key, entry] of [...active]) {
367
+ if (entry.path !== path) continue
368
+ active.delete(key)
369
+ entry.state = 'hit'
370
+ scheduleNotice(entry)
371
+ consumed.push(entry)
372
+ }
373
+ return consumed
374
+ }
375
+
376
+ /**
377
+ * The connection that could have delivered a release is gone. Every active
378
+ * subscription becomes `reset` — a terminal state that says nothing about
379
+ * ownership — and is scheduled for the re-check notice.
380
+ * @returns {WatchEntry[]}
381
+ */
382
+ function resetAll() {
383
+ const entries = [...active.values()]
384
+ active.clear()
385
+ for (const entry of entries) {
386
+ entry.state = 'reset'
387
+ scheduleNotice(entry)
388
+ }
389
+ return entries
390
+ }
391
+
392
+ /**
393
+ * End every subscription of one session — active waits AND consumed entries
394
+ * whose notice has not been delivered yet (agent disposal, design D4).
395
+ * @param {string} sessionId
396
+ * @returns {WatchEntry[]}
397
+ */
398
+ function cancelSession(sessionId) {
399
+ /** @type {WatchEntry[]} */
400
+ const cancelled = []
401
+ for (const [key, entry] of [...active]) {
402
+ if (entry.sessionId !== sessionId) continue
403
+ active.delete(key)
404
+ entry.state = 'cancelled'
405
+ cancelled.push(entry)
406
+ }
407
+ for (const entry of [...pendingDelivery]) {
408
+ if (entry.sessionId !== sessionId) continue
409
+ endPending(entry, 'cancelled')
410
+ cancelled.push(entry)
411
+ }
412
+ return cancelled
413
+ }
414
+
415
+ /**
416
+ * End everything: no entry can be revived afterwards, and no scheduled notice
417
+ * survives. Called when the last mount of the client leaves.
418
+ * @returns {WatchEntry[]}
419
+ */
420
+ function clear() {
421
+ /** @type {WatchEntry[]} */
422
+ const entries = [...active.values(), ...pendingDelivery]
423
+ active.clear()
424
+ for (const entry of [...pendingDelivery]) cancelTimer(entry)
425
+ pendingDelivery.clear()
426
+ for (const entry of entries) entry.state = 'cancelled'
427
+ return entries
428
+ }
429
+
430
+ /** @returns {number} how many subscriptions are still active */
431
+ function size() {
432
+ return active.size
433
+ }
434
+
435
+ /** @returns {number} how many consumed notices are still undelivered */
436
+ function pendingSize() {
437
+ return pendingDelivery.size
438
+ }
439
+
440
+ /** @returns {number} how many mount references are attached */
441
+ function mountCount() {
442
+ return mounts.size
443
+ }
444
+
445
+ /** @returns {WatchProjection[]} the active entries, as copies */
446
+ function entries() {
447
+ return [...active.values()].map(project)
448
+ }
449
+
450
+ /** @returns {WatchProjection[]} the pending notices, as copies */
451
+ function pendingEntries() {
452
+ return [...pendingDelivery].map(project)
453
+ }
454
+
455
+ return Object.freeze({
456
+ join,
457
+ leave,
458
+ register,
459
+ peek,
460
+ shareStatus,
461
+ cancel,
462
+ settleSelf,
463
+ consumePath,
464
+ resetAll,
465
+ cancelSession,
466
+ clear,
467
+ size,
468
+ pendingSize,
469
+ mountCount,
470
+ entries,
471
+ pendingEntries,
472
+ })
473
+ }
@@ -11,6 +11,7 @@ import {
11
11
  compileIntentTable,
12
12
  DEFAULT_INTENTS,
13
13
  matchIntents,
14
+ intentSuppressed,
14
15
  renderReminder,
15
16
  renderSkillPointer,
16
17
  stripQuotedRegions,
@@ -36,7 +37,7 @@ function latestUserText(messages) {
36
37
  return undefined
37
38
  }
38
39
 
39
- function apply(ctx, config = {}) {
40
+ function apply(ctx, config = {}, dependencies = {}) {
40
41
  const audit = createAudit(ctx)
41
42
  const rowConfig = config
42
43
  const nestKeys = { jev: { endpoint: 'jevEndpoint', model: 'jevModel', apiKeyEnv: 'jevApiKeyEnv' } }
@@ -45,6 +46,7 @@ function apply(ctx, config = {}) {
45
46
  const table = compileIntentTable(config.intents ?? DEFAULT_INTENTS)
46
47
  const disabled = new Set(Array.isArray(config.disabled) ? config.disabled : [])
47
48
  const intents = table.filter((intent) => !disabled.has(intent.id))
49
+ const selectionFor = dependencies.selectionFor ?? skillSelectionFor
48
50
 
49
51
  // Semantic classifier: null in regex mode (regex-only behavior, zero cost).
50
52
  const classifier = createClassifier({
@@ -78,15 +80,18 @@ function apply(ctx, config = {}) {
78
80
  let hits = matchIntents(stripped, intents)
79
81
 
80
82
  // Semantic front-end: only when regex missed entirely (short-circuit).
81
- if (hits.length === 0 && classifier) {
82
- const semanticHit = await classifier(stripped, intents, {
83
+ // Negated/discussion-only setup wording is removed from its catalog too,
84
+ // so a semantic hit cannot undo an explicit suppression.
85
+ const semanticIntents = intents.filter(intent => !intentSuppressed(stripped, intent))
86
+ if (hits.length === 0 && classifier && semanticIntents.length > 0) {
87
+ const semanticHit = await classifier(stripped, semanticIntents, {
83
88
  agent,
84
89
  onAudit: (event) => {
85
90
  audit(agent.session, AUDIT_TYPES.intentClassify, event)
86
91
  },
87
92
  })
88
93
  if (semanticHit) {
89
- const intent = intents.find((entry) => entry.id === semanticHit)
94
+ const intent = semanticIntents.find((entry) => entry.id === semanticHit)
90
95
  if (intent) hits = [intent]
91
96
  }
92
97
  }
@@ -105,11 +110,14 @@ function apply(ctx, config = {}) {
105
110
  // stays unarmed (the ledger below never sees it), so a later hit once
106
111
  // the skill is available still injects the full initial pointer; nothing
107
112
  // is injected in its place and nothing already injected is withdrawn.
108
- const selection = skillSelectionFor(ctx)
109
- const skillView = selection?.provider ? createSkillConsumerView({ provider: selection.provider }) : null
110
- const eligibility = skillView
111
- ? skill => skillView.conclusion({ cwd: agent.session?.header?.cwd, scope: { session: { id: sessionId } } }, skill, 'model')
112
- : null
113
+ let eligibility = null
114
+ try {
115
+ const selection = selectionFor(ctx)
116
+ const skillView = selection?.provider ? createSkillConsumerView({ provider: selection.provider }) : null
117
+ eligibility = skillView
118
+ ? skill => skillView.conclusion({ cwd: agent.session?.header?.cwd, scope: { session: { id: sessionId } } }, skill, 'model')
119
+ : null
120
+ } catch { eligibility = null }
113
121
 
114
122
  const injections = []
115
123
  for (const intent of hits) {
@@ -123,9 +131,18 @@ function apply(ctx, config = {}) {
123
131
  record(ctx, agent, intent, true)
124
132
  continue
125
133
  }
126
- if (intent.injection.kind === 'skill-pointer' && eligibility) {
127
- const verdict = await eligibility(intent.injection.skill)
128
- if (!verdict?.invocable) {
134
+ if (intent.injection.kind === 'skill-pointer') {
135
+ if (!eligibility) {
136
+ record(ctx, agent, intent, false, { suppressed: 'provider-unavailable' })
137
+ continue
138
+ }
139
+ let verdict
140
+ try { verdict = await eligibility(intent.injection.skill) }
141
+ catch {
142
+ record(ctx, agent, intent, false, { suppressed: 'view-unavailable' })
143
+ continue
144
+ }
145
+ if (verdict?.invocable !== true) {
129
146
  record(ctx, agent, intent, false, { suppressed: verdict?.reason ?? 'not-selected' })
130
147
  continue
131
148
  }
@@ -8,8 +8,11 @@ export function stripQuotedRegions(text) {
8
8
  text
9
9
  // fenced code blocks (``` or ~~~, optional info string)
10
10
  .replace(/(^|\n)[ \t]*(```|~~~)[\s\S]*?\2[ \t]*(?=\n|$)/g, '$1')
11
- // inline code spans
11
+ // Any remaining opener starts an unclosed example: blank through EOF.
12
+ .replace(/(^|\n)[ \t]*(```|~~~)[\s\S]*$/g, '$1')
13
+ // inline code spans, including an unclosed trailing span
12
14
  .replace(/`[^`\n]*`/g, ' ')
15
+ .replace(/`[^`\n]*$/gm, ' ')
13
16
  // blockquote lines
14
17
  .replace(/^[ \t]*>.*$/gm, '')
15
18
  )
@@ -21,6 +24,7 @@ export function stripQuotedRegions(text) {
21
24
  * @property {RegExp[]} patterns - compiled matchers
22
25
  * @property {boolean} oncePerSession
23
26
  * @property {string} [summary] - one-line catalog description for the semantic classifier
27
+ * @property {RegExp[]} [suppressed] - negation/discussion matchers that also suppress semantic hits
24
28
  * @property {{ kind: 'skill-pointer', skill: string }
25
29
  * | { kind: 'message', text: string }
26
30
  * | { kind: 'effort', reasoningEffort: string }} injection
@@ -54,6 +58,12 @@ export function compileIntentTable(raw) {
54
58
  throw new Error(`intent-gate: intent "${entry.id}" has an invalid matcher ${JSON.stringify(matcher)}: ${String(error)}`)
55
59
  }
56
60
  })
61
+ if (entry.suppressedMatchers !== undefined && !Array.isArray(entry.suppressedMatchers)) throw new Error(`intent-gate: intent "${entry.id}" suppressedMatchers must be an array`)
62
+ const suppressed = (entry.suppressedMatchers ?? []).map((matcher) => {
63
+ if (typeof matcher !== 'string' || matcher.length === 0) throw new Error(`intent-gate: intent "${entry.id}" has a non-string suppressed matcher`)
64
+ try { return new RegExp(matcher, 'i') }
65
+ catch (error) { throw new Error(`intent-gate: intent "${entry.id}" has an invalid suppressed matcher ${JSON.stringify(matcher)}: ${String(error)}`) }
66
+ })
57
67
  const injection = entry.injection
58
68
  if (!injection || typeof injection !== 'object') {
59
69
  throw new Error(`intent-gate: intent "${entry.id}" needs an injection object`)
@@ -78,6 +88,7 @@ export function compileIntentTable(raw) {
78
88
  patterns,
79
89
  oncePerSession: entry.oncePerSession !== false,
80
90
  ...(typeof entry.summary === 'string' && entry.summary.length > 0 ? { summary: entry.summary } : {}),
91
+ ...(suppressed.length > 0 ? { suppressed } : {}),
81
92
  injection,
82
93
  }
83
94
  })
@@ -90,7 +101,12 @@ export function compileIntentTable(raw) {
90
101
  * @returns {IntentEntry[]} matched intents in table order
91
102
  */
92
103
  export function matchIntents(stripped, intents) {
93
- return intents.filter((intent) => intent.patterns.some((pattern) => pattern.test(stripped)))
104
+ return intents.filter((intent) => !intentSuppressed(stripped, intent) && intent.patterns.some((pattern) => pattern.test(stripped)))
105
+ }
106
+
107
+ /** Explicit negation/discussion suppression, also applied before semantic classification. */
108
+ export function intentSuppressed(stripped, intent) {
109
+ return Boolean(intent.suppressed?.some(pattern => pattern.test(stripped)))
94
110
  }
95
111
 
96
112
  /** Verbatim directive injected on every writing intent hit. */
@@ -105,6 +121,24 @@ Write the deliverable as the finished product, not as a record of producing it.
105
121
 
106
122
  /** The default intent table (English templates; runtime content follows the session language). */
107
123
  export const DEFAULT_INTENTS = [
124
+ {
125
+ id: 'mcp-server-setup',
126
+ matchers: [
127
+ '\\b(?:install|add|configure|set[ \\t]+up)\\b[^\\n.!?]{0,80}\\bmcp(?:[ \\t]+server)?\\b',
128
+ '\\bmcp(?:[ \\t]+server)?\\b[^\\n.!?]{0,60}\\b(?:install|add|configure|set[ \\t]+up)\\b',
129
+ '(?:安装|添加|配置)[^\\n。!?]{0,40}mcp(?:服务器)?',
130
+ 'mcp(?:服务器)?[^\\n。!?]{0,40}(?:安装|添加|配置)',
131
+ ],
132
+ suppressedMatchers: [
133
+ '\\b(?:do[ \\t]+not|don[\'’]t|never|should[ \\t]+not|shouldn[\'’]t)\\b[^\\n.!?]{0,40}\\b(?:install|add|configure|set[ \\t]+up)\\b[^\\n.!?]{0,80}\\bmcp\\b',
134
+ '(?:不要|不用|别|请勿|禁止)[^\\n。!?]{0,30}(?:安装|添加|配置)[^\\n。!?]{0,40}mcp',
135
+ '(?:^|\\n)[ \\t]*(?:what|why|explain|discuss|describe|tell[ \\t]+me[ \\t]+about)\\b[^\\n]{0,100}\\bmcp\\b',
136
+ '\\b(?:explain|discuss|describe)\\b[^\\n.!?]{0,80}\\b(?:install|add|configure|set[ \\t]+up)\\b[^\\n.!?]{0,80}\\bmcp\\b',
137
+ '(?:^|\\n)[ \\t]*(?:什么是|解释|讨论|介绍|说明)[^\\n。!?]{0,80}mcp',
138
+ ],
139
+ summary: 'install, add, or configure a new MCP server; not for calling or troubleshooting an existing MCP tool or general MCP discussion',
140
+ injection: { kind: 'skill-pointer', skill: 'mcp-server-setup' },
141
+ },
108
142
  {
109
143
  id: 'deep-work',
110
144
  matchers: ['\\bdeep[- ]?work\\b', '深度工作', '深度作业'],
@@ -18,8 +18,9 @@ export const PROTOCOL = 1
18
18
  /** Request operations accepted after the handshake (`hello` is the handshake). */
19
19
  export const OPS = Object.freeze(['hello', 'acquire', 'release', 'hold', 'status', 'request', 'reply'])
20
20
 
21
- /** Unsolicited events pushed by the broker to one connection. */
22
- export const EVENTS = Object.freeze(['request', 'settled'])
21
+ /** Unsolicited events pushed by the broker. `request` and `settled` go to one
22
+ * connection; `released` is a fact offered to every connected client. */
23
+ export const EVENTS = Object.freeze(['request', 'settled', 'released'])
23
24
 
24
25
  /** Closed error vocabulary. No code may describe an operator action: every
25
26
  * refusal either resolves by negotiation or by validity elapsing. */
@@ -6,7 +6,7 @@
6
6
  // Wire shape: newline-delimited JSON, one frame per line, in both directions.
7
7
  // Requests carry `op`; replies carry `ok` plus either the result fields or
8
8
  // `error: {code, ...details}` and always echo the client's `reqId` when it sent
9
- // one. Unsolicited events carry `event` ('request' | 'settled').
9
+ // one. Unsolicited events carry `event` ('request' | 'settled' | 'released').
10
10
  //
11
11
  // Process lifetime (design D1/D8): the daemon is a cache, so it is allowed to
12
12
  // die. It exits after `idleMs` with no connected client, and stays alive as
@@ -18,7 +18,7 @@ import { unlinkSync } from 'node:fs'
18
18
  import { homedir as osHomedir, tmpdir as osTmpdir } from 'node:os'
19
19
  import { join } from 'node:path'
20
20
  import process from 'node:process'
21
- import { PROTOCOL, validateFrame } from './protocol.js'
21
+ import { EVENTS, PROTOCOL, validateFrame } from './protocol.js'
22
22
 
23
23
  /** Idle window before a client-less daemon exits. */
24
24
  export const DEFAULT_IDLE_MS = 60_000
@@ -237,7 +237,10 @@ export async function startServer({
237
237
  return
238
238
  }
239
239
  state.greeted = true
240
- writeFrame(socket, toWire({ ok: true, protocol, daemonVersion, startedAt }, hello.reqId))
240
+ // The handshake advertises the push events this incarnation supports,
241
+ // inside major 1: a client that needs one (a release subscription) can
242
+ // fail explicitly instead of waiting for an event this broker never sends.
243
+ writeFrame(socket, toWire({ ok: true, protocol, daemonVersion, startedAt, events: [...EVENTS] }, hello.reqId))
241
244
  return
242
245
  }
243
246
  if (!parsed.ok) {