weir-harness 2.0.0 → 2.1.1

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 +40 -9
  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 +575 -146
  24. package/src/capabilities/mcp-mount.js +220 -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 +170 -57
  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,103 @@
1
+ // Synchronous per-identity replacement fence. The runtime phase is authoritative
2
+ // for admission; registry/mount joins are for inventory, never for reopening a
3
+ // closed replacement window early.
4
+ import { randomUUID } from 'node:crypto'
5
+
6
+ const OPEN = new Set(['mounted'])
7
+ const RESTORABLE = new Set(['mounted', 'registered', 'saved-degraded', 'unknown', 'cleanup-failed', 'cleanup-unknown'])
8
+ const clone = value => value == null ? value : structuredClone(value)
9
+
10
+ export function createMcpRuntime() {
11
+ const states = new Map()
12
+
13
+ function snapshot(identity) {
14
+ const state = states.get(identity)
15
+ return state ? clone(state) : { phase: 'unknown', generation: null, incarnation: null, operationId: null }
16
+ }
17
+ function begin(identity, { phase = 'replacing', generation = null, incarnation = null, operationId = randomUUID() } = {}) {
18
+ const previous = states.get(identity) ?? null
19
+ const token = { identity, operationId, previous }
20
+ // This transition is deliberately synchronous and happens before the
21
+ // caller's first registry/credential/probe await.
22
+ states.set(identity, { phase, generation, incarnation, operationId })
23
+ return token
24
+ }
25
+ function current(token) {
26
+ return states.get(token.identity)?.operationId === token.operationId
27
+ }
28
+ function finish(token, phase, details = {}) {
29
+ if (!current(token)) return false
30
+ states.set(token.identity, {
31
+ phase,
32
+ generation: details.generation ?? states.get(token.identity).generation,
33
+ incarnation: details.incarnation === undefined ? states.get(token.identity).incarnation : details.incarnation,
34
+ operationId: token.operationId,
35
+ })
36
+ return true
37
+ }
38
+ function restore(token) {
39
+ if (!current(token)) return false
40
+ const previous = token.previous
41
+ if (previous && RESTORABLE.has(previous.phase)) states.set(token.identity, previous)
42
+ else states.set(token.identity, { phase: 'unknown', generation: null, incarnation: null, operationId: token.operationId })
43
+ return true
44
+ }
45
+ function enabledFor(identity) {
46
+ return OPEN.has(snapshot(identity).phase)
47
+ }
48
+ function registrationFor(identity) {
49
+ const state = snapshot(identity)
50
+ return enabledFor(identity)
51
+ ? { identity, generation: state.generation, incarnation: state.incarnation, operationId: state.operationId }
52
+ : null
53
+ }
54
+ function permit(identity, { generation, incarnation }) {
55
+ const state = snapshot(identity)
56
+ if (!enabledFor(identity) || state.generation !== generation || (state.incarnation ?? null) !== (incarnation ?? null)) return null
57
+ return { identity, generation, incarnation: incarnation ?? null, operationId: state.operationId }
58
+ }
59
+ function assertPermit(value) {
60
+ if (!value || typeof value !== 'object') return false
61
+ const state = snapshot(value.identity)
62
+ return enabledFor(value.identity)
63
+ && state.operationId === value.operationId
64
+ && state.generation === value.generation
65
+ && (state.incarnation ?? null) === (value.incarnation ?? null)
66
+ }
67
+ return {
68
+ begin, finish, restore, snapshot, enabledFor, registrationFor, permit, assertPermit,
69
+ beforeRead: enabledFor,
70
+ afterRead: enabledFor,
71
+ states,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Per-identity FIFO for every operation that opens a runtime fence
77
+ * (runtime.begin). Same-identity operations run strictly one after another
78
+ * (design D6), so a Restart can never begin while an edit-mode Test holds the
79
+ * fence and leave the server mounted but refusing calls. A task's failure never
80
+ * poisons the queue; different identities never wait on each other. Not
81
+ * reentrant: callers already inside serialize() pass their token instead.
82
+ */
83
+ export function createMcpIdentityLock() {
84
+ const tails = new Map()
85
+ return function serialize(identity, task) {
86
+ const previous = tails.get(identity) ?? Promise.resolve()
87
+ const run = previous.then(() => task())
88
+ const tail = run.then(() => {}, () => {})
89
+ tails.set(identity, tail)
90
+ void tail.then(() => { if (tails.get(identity) === tail) tails.delete(identity) })
91
+ return run
92
+ }
93
+ }
94
+
95
+ const fallbackLocks = new WeakMap()
96
+ /** The manager's own lock, or one stable lock per manager object for fakes/older faces. */
97
+ export function mcpIdentityLockFor(manager) {
98
+ if (typeof manager?.serialize === 'function') return (identity, task) => manager.serialize(identity, task)
99
+ if (!manager || (typeof manager !== 'object' && typeof manager !== 'function')) return createMcpIdentityLock()
100
+ let lock = fallbackLocks.get(manager)
101
+ if (!lock) { lock = createMcpIdentityLock(); fallbackLocks.set(manager, lock) }
102
+ return lock
103
+ }
@@ -50,6 +50,7 @@ export async function buildReceiptPayload(deps, options) {
50
50
  skills: selected.map(candidate => candidate.name),
51
51
  skillIdentities,
52
52
  mcpServers,
53
+ ...(snapshot?.mcpIncarnations && typeof snapshot.mcpIncarnations === 'object' ? { mcpIncarnations: structuredClone(snapshot.mcpIncarnations) } : {}),
53
54
  digest: effectiveSelectionDigest(skillIdentities, mcpServers),
54
55
  },
55
56
  warnings: result?.complete === false || status?.error
@@ -78,7 +79,7 @@ export async function buildListPayload(deps, options) {
78
79
  if (!candidate.selected || !candidate.identity) continue
79
80
  try { selectedKeys.add(skillIdentityKey(candidate.identity)) } catch { /* an unstamped shape carries no mark */ }
80
81
  }
81
- const listing = deps.mcpManager?.list?.() ?? { managed: [], unmanaged: [] }
82
+ const listing = await (deps.mcpManager?.list?.() ?? Promise.resolve({ managed: [], unmanaged: [] }))
82
83
  return {
83
84
  skills: candidates.map(candidate => {
84
85
  /** @type {string | null} */
@@ -95,9 +96,13 @@ export async function buildListPayload(deps, options) {
95
96
  }
96
97
  }),
97
98
  mcpServers: [
98
- ...(listing.managed ?? []).map(server => ({ identity: server.identity, state: server.state })),
99
- ...(listing.unmanaged ?? []).map(server => ({ serverName: server.serverName, state: 'unmanaged' })),
99
+ ...(listing.managed ?? []).map(server => ({ ...server })),
100
+ ...(listing.unmanaged ?? []).map(server => ({ ...server, state: 'unmanaged', readOnly: true })),
100
101
  ],
102
+ mcpWarnings: Array.isArray(listing.warnings) ? [...listing.warnings] : [],
103
+ mcpUnavailable: listing.unavailable ?? null,
104
+ mcpCapabilities: listing.capabilities ?? null,
105
+ mcpRegistryRevision: Number.isSafeInteger(listing.registryRevision) ? listing.registryRevision : 0,
101
106
  }
102
107
  }
103
108
 
@@ -0,0 +1,185 @@
1
+ // Cross-workspace preset applicability (fix-capability-fork-and-preset-workspaces
2
+ // D4): the server-side partition of Skill entries an Apply draft names but the
3
+ // CURRENT inventory did not resolve exactly.
4
+ //
5
+ // A project-scoped Skill identity is machine-local: its canonical root names
6
+ // the workspace it was discovered in. A global preset carrying such an identity
7
+ // therefore names an entry that does not exist in another workspace at all —
8
+ // the preset contract is "skip it, warn, keep the rest", never an unactionable
9
+ // same-name conflict with a local Skill that happens to share the invocation
10
+ // name, and never a silent substitution.
11
+ //
12
+ // Evidence discipline (the whole point of this module): ABSENCE IS NEVER PROOF.
13
+ // The current workspace's project Skill roots are derived from the inventory's
14
+ // own root observations (canonical `projectRoot` + the two project Skill root
15
+ // suffixes, plus each observed project root path). A skip is authorized only
16
+ // when the inventory is COMPLETE (`inventoryResult.complete === true`) and at
17
+ // least one current project root resolved. An incomplete inventory, a root set
18
+ // that cannot be resolved, and a legacy name string with no identity all keep
19
+ // the pre-existing behavior: profile-supplemental knowledge may still skip with
20
+ // its own reason, everything else stays a hard failure with the draft kept.
21
+ //
22
+ // Path comparison is by canonical exact key — never a string prefix — so a
23
+ // lexical alias of this very workspace (`/var` vs `/private/var`, a symlinked
24
+ // checkout, win32 case folding) is NOT misread as another workspace. A local
25
+ // identity whose root IS one of this workspace's project roots but which the
26
+ // inventory did not resolve stays a hard failure: it is missing, not external.
27
+ import { realpathSync } from 'node:fs'
28
+ import { basename, dirname, isAbsolute, join, sep } from 'node:path'
29
+
30
+ /** Reason reported for a project identity proven to belong to another workspace. */
31
+ export const NOT_APPLICABLE_IN_WORKSPACE = 'not applicable in this workspace'
32
+ /** Reason reported for a name known only to a profile-supplemental preset root. */
33
+ export const NOT_APPLICABLE_IN_PRESET = 'not applicable in this agent preset'
34
+
35
+ /** The two project Skill discovery roots of one workspace, relative to its project root. */
36
+ const PROJECT_SKILL_SUFFIXES = Object.freeze(['.dsh/skills', '.agents/skills'])
37
+
38
+ /**
39
+ * Comparable form of a path: realpath when it exists, '/'-separated, and
40
+ * case-folded on win32. A path that does not exist yet keeps its lexical form
41
+ * with the deepest EXISTING ancestor canonicalized, so the macOS `/var` →
42
+ * `/private/var` alias is resolved for `<root>/.dsh/skills` even before that
43
+ * directory is created.
44
+ * (Same contract as the worktree lane's `pathKey`; kept local so the capability
45
+ * modules never depend on the worktree feature's internals.)
46
+ * @param {unknown} path
47
+ * @param {string} [platform]
48
+ * @returns {string | null} canonical key, or null when the path cannot be resolved safely
49
+ */
50
+ export function canonicalPathKey(path, platform = process.platform) {
51
+ const lexical = String(path)
52
+ let real = lexical
53
+ const missing = error => error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
54
+ try {
55
+ real = realpathSync.native(lexical)
56
+ } catch (error) {
57
+ // Only a genuinely absent path may be reconstructed through its deepest
58
+ // existing ancestor. EINVAL/EACCES/ELOOP/etc. prove nothing and must not
59
+ // become a lexical key that could authorize a cross-workspace skip.
60
+ if (!missing(error)) return null
61
+ let parent = dirname(lexical)
62
+ const tail = [basename(lexical)]
63
+ while (parent !== dirname(parent)) {
64
+ try {
65
+ real = join(realpathSync.native(parent), ...tail.reverse())
66
+ break
67
+ } catch (ancestorError) {
68
+ if (!missing(ancestorError)) return null
69
+ tail.push(basename(parent))
70
+ parent = dirname(parent)
71
+ }
72
+ }
73
+ }
74
+ const slashed = real.split(sep).join('/').replace(/\/+$/, '')
75
+ return platform === 'win32' ? slashed.toLowerCase() : slashed
76
+ }
77
+
78
+ /**
79
+ * The canonical project Skill roots of the workspace the inventory observed.
80
+ * Empty means "this inventory proves nothing about the current workspace";
81
+ * callers must treat that as NO skip authorization, never as a foreign verdict.
82
+ * @param {unknown} roots inventory root observations (`inventoryResult.roots`)
83
+ * @param {{ platform?: string }} [options]
84
+ * @returns {Set<string>}
85
+ */
86
+ export function workspaceProjectSkillRoots(roots, options = {}) {
87
+ const platform = options.platform ?? process.platform
88
+ const keys = new Set()
89
+ for (const observation of Array.isArray(roots) ? roots : []) {
90
+ const root = /** @type {Record<string, unknown> | undefined} */ (
91
+ observation && typeof observation === 'object' ? (/** @type {any} */ (observation).root ?? observation) : undefined)
92
+ if (!root || root.scope !== 'project') continue
93
+ if (typeof root.projectRoot === 'string' && root.projectRoot.length > 0) {
94
+ const projectRoot = canonicalPathKey(root.projectRoot, platform)
95
+ if (projectRoot !== null) {
96
+ for (const suffix of PROJECT_SKILL_SUFFIXES) {
97
+ const key = canonicalPathKey(join(projectRoot, suffix), platform)
98
+ if (key !== null) keys.add(key)
99
+ }
100
+ }
101
+ }
102
+ // The observed root itself is canonicalized by discovery (a symlinked
103
+ // project Skill root keeps ITS OWN identity), so it names this workspace
104
+ // too: a broken local Skill must fail as missing, never read as external.
105
+ if (typeof root.path === 'string' && root.path.length > 0) {
106
+ const key = canonicalPathKey(root.path, platform)
107
+ if (key !== null) keys.add(key)
108
+ }
109
+ }
110
+ return keys
111
+ }
112
+
113
+ /**
114
+ * Is this identity a project identity rooted OUTSIDE every current project root?
115
+ * The caller must have authorized the classification (complete inventory and a
116
+ * non-empty project root set) — this predicate only answers the path question.
117
+ * @param {unknown} identity
118
+ * @param {Set<string>} projectRoots
119
+ * @param {{ platform?: string }} [options]
120
+ * @returns {boolean}
121
+ */
122
+ export function isForeignProjectIdentity(identity, projectRoots, options = {}) {
123
+ const value = /** @type {Record<string, unknown> | null} */ (
124
+ identity && typeof identity === 'object' ? identity : null)
125
+ if (!value || value.scope !== 'project') return false
126
+ if (typeof value.root !== 'string' || value.root.length === 0 || !isAbsolute(value.root)) return false
127
+ const key = canonicalPathKey(value.root, options.platform ?? process.platform)
128
+ return key !== null && !projectRoots.has(key)
129
+ }
130
+
131
+ /**
132
+ * Partition the draft Skill entries the CURRENT inventory did not resolve
133
+ * exactly. Order follows the draft; duplicates are preserved (the request is
134
+ * reported as received, never silently deduplicated).
135
+ *
136
+ * - an identity proven to be a project identity of another workspace →
137
+ * `skipped` with `not applicable in this workspace`
138
+ * - a legacy name or identity name known to a profile-supplemental preset root →
139
+ * `not applicable in this agent preset` (the pre-existing contract)
140
+ * - everything else → `unknown` (the caller keeps the hard-failure contract and
141
+ * preserves the draft)
142
+ * @param {unknown[]} entries unresolved draft entries (identity objects or name strings)
143
+ * @param {{
144
+ * roots?: unknown,
145
+ * complete?: unknown,
146
+ * knownSupplemental?: (name: string) => boolean,
147
+ * platform?: string,
148
+ * }} [options]
149
+ * @returns {{ skipped: Array<{ name: string, reason: string }>, unknown: string[] }}
150
+ */
151
+ export function partitionUnresolvedSkills(entries, options = {}) {
152
+ const knownSupplemental = typeof options.knownSupplemental === 'function' ? options.knownSupplemental : () => false
153
+ // Evidence gate: an incomplete inventory, or a current workspace whose project
154
+ // roots did not resolve, authorizes no cross-workspace skip at all.
155
+ const projectRoots = options.complete === true
156
+ ? workspaceProjectSkillRoots(options.roots, options)
157
+ : new Set()
158
+ const canProveForeign = projectRoots.size > 0
159
+ const skipped = []
160
+ const unknown = []
161
+ for (const entry of Array.isArray(entries) ? entries : []) {
162
+ if (typeof entry === 'string') {
163
+ if (knownSupplemental(entry)) skipped.push({ name: entry, reason: NOT_APPLICABLE_IN_PRESET })
164
+ else unknown.push(entry)
165
+ continue
166
+ }
167
+ const identity = /** @type {Record<string, unknown> | null} */ (
168
+ entry && typeof entry === 'object' ? entry : null)
169
+ if (!identity) {
170
+ unknown.push(String(entry))
171
+ continue
172
+ }
173
+ const name = typeof identity.name === 'string' && identity.name.length > 0 ? identity.name : String(entry)
174
+ if (knownSupplemental(name)) {
175
+ skipped.push({ name, reason: NOT_APPLICABLE_IN_PRESET })
176
+ continue
177
+ }
178
+ if (canProveForeign && isForeignProjectIdentity(identity, projectRoots, options)) {
179
+ skipped.push({ name, reason: NOT_APPLICABLE_IN_WORKSPACE })
180
+ continue
181
+ }
182
+ unknown.push(name)
183
+ }
184
+ return { skipped, unknown }
185
+ }
@@ -14,6 +14,7 @@ export const WEIR_BUILTIN_SKILLS = Object.freeze([
14
14
  'debugging',
15
15
  'deep-work',
16
16
  'git-master',
17
+ 'mcp-server-setup',
17
18
  'programming',
18
19
  'refactor',
19
20
  'remove-ai-slops',
@@ -33,7 +34,7 @@ export const MIGRATION_FAILURE = 'Weir builtin skill migration check failed'
33
34
  /**
34
35
  * Verify a discovery snapshot against the pre-migration contract: exactly one
35
36
  * readable weir-builtin root whose parsed candidates are exactly the
36
- * contracted ten, each carrying the custom source label and rank 300.
37
+ * contracted catalog, each carrying the custom source label and rank 300.
37
38
  * @param {{ roots?: Array<{ root: Record<string, unknown>, complete: boolean, error?: string }>,
38
39
  * candidates?: Array<Record<string, any>> }} snapshot
39
40
  * @returns {{ ok: true } | { ok: false, reason: string }}