weir-harness 1.2.3 → 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 (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -4,28 +4,63 @@ import { anchorFor } from './anchors.js'
4
4
  import { applyOps, renderMismatch, validateOps } from './apply-ops.js'
5
5
  import { diffResult } from './diff.js'
6
6
  import { escalationHintMarker, probeEscalation, resolveCallPolicy, sandboxDenialMarker } from './sandbox.js'
7
+ import { createManagedWriteTool, MANAGED_WRITE_NAME } from './write-tool.js'
8
+ import { createEditorLease } from '../file-lease/singleton.js'
9
+ import { isLeaseConflict } from '../file-lease/client.js'
7
10
 
8
11
  const name = 'weir-hashline-edit'
9
12
  const inject = ['tools', 'fs']
10
13
 
11
14
  export const HASH_EDIT_NAME = 'hash_edit'
12
15
  const STOCK_EDIT_NAME = 'edit'
16
+ const STOCK_WRITE_NAME = 'write'
13
17
 
14
18
  /**
15
- * Hide the stock edit tool. Uniform mechanism: per-agent restriction on
16
- * creation. At an agent's scope the stock edit is always *inherited* (from
17
- * the global layer, or from the preset layer in preset compositions), and
18
- * inherited tools are restrictable — the agent's catalog hides it. This
19
- * covers host-level mounts (headless compositions) and preset mounts alike;
20
- * a mount-time restrict cannot work in either (unscoped at host level; at
21
- * preset standing scope the fs row's scoped edit is not restrictable).
22
- * Listeners are awaited before creation resolves, so the restriction lands
23
- * before the agent's first request.
19
+ * Install the managed editors on every created agent (design D11): hide the
20
+ * stock `edit` AND the stock `write`, then provide our own `write` at the SAME
21
+ * scope.
22
+ *
23
+ * Uniform mechanism: per-agent restriction on creation. At an agent's scope the
24
+ * stock tools are always *inherited* (from the global layer, or from the preset
25
+ * layer in preset compositions), and inherited tools are restrictable — the
26
+ * agent's catalog hides them — while a definition registered at that same scope
27
+ * wins over the denied name. This covers host-level mounts (headless
28
+ * compositions) and preset mounts alike; a mount-time restrict cannot work in
29
+ * either (unscoped at host level; at preset standing scope the fs row's scoped
30
+ * tools are not restrictable). Listeners are awaited before creation resolves,
31
+ * so the denial and the registration both land before the agent's first
32
+ * request.
33
+ *
34
+ * The two steps must stay in one listener: a deny without the registration
35
+ * leaves the agent with no writer at all, and a registration without the deny
36
+ * leaves the unmanaged stock writer reachable. The registration is verified —
37
+ * if our definition is not the one this agent resolves, it is disposed and the
38
+ * stock writers stay hidden, because an unmanaged write is exactly what this
39
+ * path exists to prevent.
40
+ * @param {any} ctx @param {any} lease @param {() => any} sandboxPolicy
24
41
  */
25
- function hideStockEdit(ctx) {
42
+ function installManagedEditors(ctx, lease, sandboxPolicy) {
43
+ /** @type {WeakMap<object, any>} definitions this mount installed, per agent */
44
+ const installed = new WeakMap()
26
45
  ctx.on('agent/created', ({ agent }) => {
27
- if (!ctx.tools.get(STOCK_EDIT_NAME, agent)) return // this agent has no stock edit
28
- agent.ctx.tools.restrict({ deny: [STOCK_EDIT_NAME] })
46
+ const scope = agent?.ctx?.tools
47
+ if (typeof scope?.restrict !== 'function') return
48
+ // Nothing to replace: this agent sees neither stock editor (a composition
49
+ // without the host fs row). Registering our writer there would only add a
50
+ // tool whose observation guard could never be answered.
51
+ if (typeof ctx.tools?.get === 'function'
52
+ && !ctx.tools.get(STOCK_EDIT_NAME, agent)
53
+ && !ctx.tools.get(STOCK_WRITE_NAME, agent)) return
54
+ if (installed.has(agent)) return
55
+ scope.restrict({ deny: [STOCK_EDIT_NAME, STOCK_WRITE_NAME] })
56
+ const definition = createManagedWriteTool(ctx, lease, sandboxPolicy())
57
+ const dispose = typeof scope.register === 'function' ? scope.register(definition) : undefined
58
+ installed.set(agent, definition)
59
+ const resolved = typeof ctx.tools?.get === 'function' ? ctx.tools.get(MANAGED_WRITE_NAME, agent) : definition
60
+ if (resolved === definition) return
61
+ installed.delete(agent)
62
+ try { dispose?.() } catch { /* the scope's own teardown may have run first */ }
63
+ ctx.logger?.warn?.('hashline-edit: the managed write was not installed for this agent; the stock writers stay hidden')
29
64
  })
30
65
  }
31
66
 
@@ -87,11 +122,11 @@ function isSandboxDenial(error) {
87
122
  }
88
123
 
89
124
  function apply(ctx, config = {}) {
90
- // Capture once: disposal of managed editing must fail closed, not downgrade.
91
- // A sibling row's service becomes visible only after its own apply, so a
92
- // deferred provider is accepted too (see the inject after registration).
93
- let editLock = ctx.get?.('weirEditLock')
94
- let editLockRemoved = false
125
+ // The injection seam (unit tests, embedders): an explicit client wins and
126
+ // creates no singleton. Read BEFORE the settings overlay so a settings value
127
+ // can never shadow it. Default: the process-wide client (D11), resolved on
128
+ // the first managed write.
129
+ const leaseClient = config.leaseClient
95
130
  // Settings overlay (absent service = no-op): hashlineEdit section wins over row config.
96
131
  const settingsOverride = ctx.get?.('weirSettings')?.get('hashlineEdit')
97
132
  if (settingsOverride && typeof settingsOverride === 'object') {
@@ -104,6 +139,7 @@ function apply(ctx, config = {}) {
104
139
  ctx.inject?.(['sandboxPolicy'], (scope) => {
105
140
  sandboxPolicyRef = scope.sandboxPolicy
106
141
  })
142
+ const lease = createEditorLease({ leaseClient, ctx })
107
143
 
108
144
  // Sandbox escalation capability fact (mirrors the stock fs tools): a
109
145
  // confining backend exposes ctx.fs.sandboxMode; only then does hash_edit
@@ -120,9 +156,9 @@ function apply(ctx, config = {}) {
120
156
  return { ...downstream, content: rewritten }
121
157
  })
122
158
 
123
- // Optional: hide the stock edit tool from this preset's agents.
159
+ // Managed editors: hide the stock `edit` and `write`, provide our `write`.
124
160
  if (config.hideStockEdit === true) {
125
- hideStockEdit(ctx)
161
+ installManagedEditors(ctx, lease, () => sandboxPolicyRef)
126
162
  }
127
163
 
128
164
  const definition = {
@@ -199,21 +235,31 @@ function apply(ctx, config = {}) {
199
235
  }
200
236
 
201
237
  const after = applyOps(lines, args.edits).join('\n')
202
- if (editLockRemoved) throw new Error('hash_edit: the Edit Lock service was removed; refusing an unmanaged write.')
238
+ // Managed commit (design D2, D11): the lease is acquired for this lineage
239
+ // BEFORE the write and kept for the remainder of the turn, so a sibling
240
+ // session is refused with the target untouched. A real conflict is never
241
+ // rewritten as anything else; an unhealthy arbiter degrades and this edit
242
+ // proceeds (D7). Anchors, the version guard and the sandbox policy are
243
+ // unchanged by the wrap.
203
244
  let outcome
204
245
  try {
205
- outcome = editLock ? await editLock.publish(exec, {
206
- tool: 'hash_edit', filePath: args.file_path, args, content: after,
207
- expected: { kind: 'replaceIfVersion', version: info.version },
208
- cwd: resolveCwd, effectivePolicy: policy,
209
- }) : await ctx.fs.writeText(
210
- target,
211
- after,
212
- { kind: 'replaceIfVersion', version: info.version },
213
- exec.signal,
214
- policy,
246
+ outcome = await lease.client().withLeases(
247
+ lease.ownerOf(exec.agent),
248
+ [target.displayPath],
249
+ lease.scopeOf(exec.agent, resolveCwd),
250
+ () => ctx.fs.writeText(
251
+ target,
252
+ after,
253
+ { kind: 'replaceIfVersion', version: info.version },
254
+ exec.signal,
255
+ policy,
256
+ ),
215
257
  )
216
258
  } catch (error) {
259
+ // A refused claim is the arbiter's answer: pass it through untouched so
260
+ // the model sees the conflict (and never a stale-version or sandbox
261
+ // story that did not happen).
262
+ if (isLeaseConflict(error)) throw error
217
263
  if (String(error?.code ?? error?.message ?? '').includes('FS_STALE_VERSION')) {
218
264
  throw new Error('hash_edit: the file changed on disk between your read and this edit. Re-read it and copy the current anchors before retrying.')
219
265
  }
@@ -235,19 +281,7 @@ function apply(ctx, config = {}) {
235
281
  }
236
282
  },
237
283
  }
238
- // Managed mounts claim their exact definition; Edit Lock denies any
239
- // unclaimed hash_edit so a mis-ordered composition cannot bypass it.
240
- editLock?.claim?.(definition)
241
284
  ctx.tools.register(definition)
242
- if (!editLock) {
243
- ctx.inject?.(['weirEditLock'], (scope) => {
244
- if (editLock || editLockRemoved) return
245
- editLock = scope.weirEditLock
246
- editLock?.claim?.(definition)
247
- // Once managed, removal never re-enables direct writes.
248
- return () => { editLockRemoved = true }
249
- })
250
- }
251
285
  }
252
286
 
253
287
  export { name, inject, apply }
@@ -0,0 +1,90 @@
1
+ // The managed `write` tool (design D11), now owned by hashline-edit.
2
+ //
3
+ // Byte-for-byte the tool that used to live in `src/edit-lock/write-tool.js`:
4
+ // same description of the contract, same stock observation guard, same diff
5
+ // metadata, same `fs/observed` event and the same error texts. The single
6
+ // difference is the commit itself — instead of asking a publisher service to
7
+ // write on our behalf, the write happens INSIDE
8
+ // `client.withLeases(owner, [path], scope, () => ctx.fs.writeText(...))`.
9
+ //
10
+ // Why that is not a downgrade: the version guard (`replaceIfVersion` /
11
+ // `createIfAbsent`) still runs in the host filesystem, so no unread overwrite is
12
+ // possible; the lease adds what the version guard cannot give — a sibling
13
+ // session is refused BEFORE any target-side effect, and that claim lasts for the
14
+ // remainder of the turn (design D2). A real conflict refuses the write; an
15
+ // unhealthy arbiter degrades and lets the write through (design D7).
16
+ //
17
+ // Nothing here imports `src/edit-lock/**`, and nothing imports `@deepseek-ai/*`:
18
+ // the filesystem arrives through `ctx`.
19
+
20
+ import { probeEscalation, resolveCallPolicy } from './sandbox.js'
21
+ import { diffResult } from './diff.js'
22
+
23
+ /** The stock name this definition replaces at the agent's scope. */
24
+ export const MANAGED_WRITE_NAME = 'write'
25
+
26
+ /** The observation guards the stock policy may hand back (`fs/write-intent`). */
27
+ const OBSERVATION_GUARDS = ['createIfAbsent', 'replaceIfVersion']
28
+
29
+ /**
30
+ * Build the definition only. The caller must hide the inherited stock `write`
31
+ * at the agent's scope and register THIS definition there, so the managed
32
+ * writer is the one the agent resolves (fail-closed otherwise).
33
+ *
34
+ * @param {any} ctx the row context (`fs`, `waterfall`, `emit`, `emit`-style hooks)
35
+ * @param {any} lease `{ client(), ownerOf(agent), scopeOf(agent, fallback) }` — see `file-lease/singleton.js`
36
+ * @param {any} sandboxPolicy the effective-policy provider captured at mount (may be null)
37
+ * @returns {any} a plain tool definition
38
+ */
39
+ export function createManagedWriteTool(ctx, lease, sandboxPolicy) {
40
+ const escalation = probeEscalation(ctx.fs)
41
+ return {
42
+ name: MANAGED_WRITE_NAME,
43
+ description: 'Create or fully replace a UTF-8 text file through the file lease. Read an existing file before overwriting it.',
44
+ parameters: { type: 'object', properties: {
45
+ file_path: { type: 'string', description: 'Path to write, resolved by the filesystem backend.' },
46
+ content: { type: 'string', description: 'Full UTF-8 text content to write.' },
47
+ ...(escalation?.fields ?? {}),
48
+ }, required: ['file_path', 'content'] },
49
+ output: {
50
+ schema: { type: 'object' },
51
+ render: (/** @type {any} */ _args, /** @type {any} */ value) => [{ type: 'text', text: `${value.operation === 'create' ? 'Created' : 'Updated'} file: ${value.path}` }],
52
+ presentationMeta: (/** @type {any} */ _args, /** @type {any} */ value) => ({ diffs: value.fragments }),
53
+ },
54
+ async execute(/** @type {any} */ args, /** @type {any} */ exec) {
55
+ if (typeof args.file_path !== 'string' || !args.file_path.trim() || typeof args.content !== 'string') throw new Error('write requires file_path and content strings')
56
+ const { policy, resolveCwd } = await resolveCallPolicy(args, {
57
+ escalation, sandboxPolicy, session: exec.agent?.session, sessionCwd: exec.agent?.session?.header?.cwd,
58
+ approval: { approver: ctx.get?.('approval'), agent: exec.agent, toolName: MANAGED_WRITE_NAME,
59
+ ...(exec.callId !== undefined ? {callId: exec.callId} : {}), ...(exec.signal ? {signal: exec.signal} : {}) },
60
+ })
61
+ if (!resolveCwd || !policy) throw new Error('managed write requires trusted cwd and effective policy')
62
+ const target = await ctx.fs.resolve(args.file_path, {cwd: resolveCwd})
63
+ // Keep the stock observation policy's exact guard; do not stat a fresh
64
+ // version and thereby silently authorize an unread overwrite.
65
+ const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
66
+ if (!intent || !OBSERVATION_GUARDS.includes(intent.kind)) throw new Error('managed write requires an explicit observation guard')
67
+ const before = intent.kind === 'replaceIfVersion' ? await ctx.fs.readText(target, exec.signal) : null
68
+ // The claim is taken before the write and NOT released here: it belongs to
69
+ // the turn (D2), so a second write of this file in the same turn reuses it.
70
+ const outcome = await lease.client().withLeases(
71
+ lease.ownerOf(exec.agent),
72
+ [target.displayPath],
73
+ lease.scopeOf(exec.agent, resolveCwd),
74
+ () => ctx.fs.writeText(target, args.content, intent, exec.signal, policy),
75
+ )
76
+ if (!outcome || !('version' in outcome)) throw new Error('write publication not acknowledged')
77
+ ctx.emit('fs/observed', target, {kind: 'present', version: outcome.version}, exec)
78
+ const fragments = before === null ? [] : diffResult(target.displayPath, before, args.content).fragments
79
+ return {
80
+ path: target.displayPath,
81
+ // The guard is what decides create-vs-update: `createIfAbsent` can only
82
+ // have produced a new file, `replaceIfVersion` only replaced one.
83
+ operation: intent.kind === 'createIfAbsent' ? 'create' : 'update',
84
+ before,
85
+ after: args.content,
86
+ fragments,
87
+ }
88
+ },
89
+ }
90
+ }
@@ -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', '深度工作', '深度作业'],
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // Executable entry for `weir-editlockd`, the edit-lock lease broker.
3
+ //
4
+ // Plain Node: it knows nothing about DSH, cordis or `ctx` — the adapter that
5
+ // talks to it lives in the bundle and speaks the protocol in
6
+ // docs/features/edit-lock-protocol.md.
7
+ //
8
+ // Environment:
9
+ // WEIR_EDITLOCKD_SOCKET endpoint address (default: defaultSocketPath())
10
+ // WEIR_EDITLOCKD_IDLE_MS idle window before self-exit (default 60000)
11
+ // WEIR_EDITLOCKD_SWEEP_MS memory-reclaim period (default 30000)
12
+ //
13
+ // The process ends in exactly two ways: SIGTERM/SIGINT (close, then exit 0), or
14
+ // the idle window elapsing with no connected client (close, then the event loop
15
+ // simply drains). Nothing is written to disk, so both are equally recoverable.
16
+
17
+ import { readFileSync } from 'node:fs'
18
+ import process from 'node:process'
19
+ import { PROTOCOL } from './protocol.js'
20
+ import { createBroker } from './state.js'
21
+ import { DEFAULT_IDLE_MS, DEFAULT_SWEEP_MS, defaultSocketPath, startServer } from './server.js'
22
+
23
+ const VERSION_FALLBACK = '0.0.0'
24
+
25
+ /**
26
+ * Version reported by the handshake, read from this package's manifest.
27
+ * @returns {string}
28
+ */
29
+ function readDaemonVersion() {
30
+ try {
31
+ const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
32
+ return typeof manifest.version === 'string' && manifest.version.length > 0 ? manifest.version : VERSION_FALLBACK
33
+ } catch {
34
+ // A missing manifest must not stop the daemon: the version is diagnostic.
35
+ return VERSION_FALLBACK
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Read an interval override; anything that is not a positive integer falls back
41
+ * to the default so a typo cannot turn the daemon into a busy loop or a
42
+ * permanent resident.
43
+ * @param {string} name
44
+ * @param {number} fallback
45
+ * @returns {number}
46
+ */
47
+ function readInterval(name, fallback) {
48
+ const raw = process.env[name]
49
+ if (raw === undefined) return fallback
50
+ const value = Number.parseInt(raw, 10)
51
+ return Number.isFinite(value) && value > 0 ? value : fallback
52
+ }
53
+
54
+ const daemonVersion = readDaemonVersion()
55
+ const socketPath = process.env.WEIR_EDITLOCKD_SOCKET || defaultSocketPath()
56
+ const idleMs = readInterval('WEIR_EDITLOCKD_IDLE_MS', DEFAULT_IDLE_MS)
57
+ const sweepMs = readInterval('WEIR_EDITLOCKD_SWEEP_MS', DEFAULT_SWEEP_MS)
58
+
59
+ /** @type {Awaited<ReturnType<typeof startServer>> | null} */
60
+ let server = null
61
+
62
+ // The kernel hands events to the transport by connection id; the sink reads the
63
+ // late-bound server handle because the broker must exist before startServer can
64
+ // register connections on it.
65
+ const broker = createBroker({
66
+ onEvent: (connectionId, event) => {
67
+ if (server !== null) server.send(connectionId, event)
68
+ },
69
+ })
70
+
71
+ try {
72
+ server = await startServer({ socketPath, broker, protocol: PROTOCOL, daemonVersion, idleMs, sweepMs })
73
+ } catch (error) {
74
+ const detail = error instanceof Error ? error.message : String(error)
75
+ process.stderr.write(`weir-editlockd: ${detail}\n`)
76
+ process.exit(1)
77
+ }
78
+
79
+ process.stderr.write(`weir-editlockd ${daemonVersion} listening on ${socketPath}\n`)
80
+
81
+ let shuttingDown = false
82
+
83
+ async function shutdown() {
84
+ if (shuttingDown) return
85
+ shuttingDown = true
86
+ const handle = server
87
+ if (handle !== null) {
88
+ try {
89
+ await handle.close()
90
+ } catch {
91
+ // Shutting down regardless: there is no state to preserve.
92
+ }
93
+ }
94
+ process.exit(0)
95
+ }
96
+
97
+ process.on('SIGTERM', shutdown)
98
+ process.on('SIGINT', shutdown)
@@ -0,0 +1,63 @@
1
+ // Node transport contracts consumed by the lease-broker daemon, declared next
2
+ // to the code that uses them.
3
+ //
4
+ // `checkJs` runs with `types: []` (no @types/node), so the Node surface this
5
+ // package compiles against is declared in types/platform.d.ts. That file covers
6
+ // the modules the rest of the bundle consumes; the daemon is the only consumer
7
+ // of `node:net`, `node:os`, the global `process`, and `unlinkSync`, and its
8
+ // subtree is a self-contained pure-Node module (no DSH, no cordis), so its
9
+ // daemon-only contracts stay here. Ambient declarations merge with the shared
10
+ // ones, which is why the added `unlinkSync` below extends `node:fs` instead of
11
+ // redeclaring it.
12
+
13
+ declare module 'node:net' {
14
+ interface SocketError extends Error {
15
+ code?: string;
16
+ }
17
+ export interface Socket {
18
+ destroyed: boolean;
19
+ setEncoding(encoding: string): void;
20
+ setNoDelay(noDelay?: boolean): void;
21
+ write(chunk: string): boolean;
22
+ end(data?: string): void;
23
+ destroy(error?: Error): void;
24
+ on(event: 'data', listener: (chunk: string) => void): Socket;
25
+ on(event: 'close' | 'end', listener: () => void): Socket;
26
+ on(event: 'error', listener: (error: SocketError) => void): Socket;
27
+ once(event: 'connect', listener: () => void): Socket;
28
+ once(event: 'error', listener: (error: SocketError) => void): Socket;
29
+ }
30
+ export interface Server {
31
+ listen(options: { path: string }, callback?: () => void): Server;
32
+ close(callback?: () => void): Server;
33
+ on(event: 'connection', listener: (socket: Socket) => void): Server;
34
+ on(event: 'error' | 'close', listener: (error?: SocketError) => void): Server;
35
+ }
36
+ export function createServer(options: { allowHalfOpen?: boolean }, connectionListener?: (socket: Socket) => void): Server;
37
+ export function createConnection(options: { path: string }): Socket;
38
+ }
39
+
40
+ declare module 'node:os' {
41
+ export function tmpdir(): string;
42
+ export function homedir(): string;
43
+ }
44
+
45
+ declare module 'node:fs' {
46
+ export function unlinkSync(path: string): void;
47
+ }
48
+
49
+ // The global `process` that arrives with the compiler's own libs is not the
50
+ // Node one (no `exit`/`on`/`stderr`/`getuid`), so the daemon imports the module
51
+ // explicitly instead of relying on the global.
52
+ declare module 'node:process' {
53
+ const process: {
54
+ platform: string;
55
+ pid: number;
56
+ env: Record<string, string | undefined>;
57
+ getuid?: () => number;
58
+ exit(code?: number): never;
59
+ on(event: string, listener: (...args: any[]) => void): void;
60
+ stderr: { write(chunk: string): boolean };
61
+ };
62
+ export default process;
63
+ }