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.
- package/cordis.patch.yml +7 -18
- package/lib/client.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1299 -0
- package/src/file-lease/index.js +34 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/src/worktree/rules.js +1 -1
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- 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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
159
|
+
// Managed editors: hide the stock `edit` and `write`, provide our `write`.
|
|
124
160
|
if (config.hideStockEdit === true) {
|
|
125
|
-
|
|
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
|
-
|
|
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 =
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
}
|
package/src/intent-gate/index.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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'
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|