weir-harness 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/cordis.patch.yml +0 -2
  2. package/lib/client.blackboard-model.js +172 -56
  3. package/lib/client.capability-manager.js +0 -1
  4. package/lib/client.capability-model.js +378 -31
  5. package/lib/client.chain-model.js +80 -0
  6. package/lib/client.disabled-categories-editor.js +22 -0
  7. package/lib/client.edit-lock-maintenance.js +239 -246
  8. package/lib/client.edit-lock-panel.js +110 -0
  9. package/lib/client.hash-edit-model.js +48 -0
  10. package/lib/client.js +267 -1549
  11. package/lib/client.lsp-model.js +92 -0
  12. package/lib/client.lsp-panel.js +11 -16
  13. package/lib/client.lsp-toggle.js +18 -0
  14. package/lib/client.notify-permissions.js +112 -20
  15. package/lib/client.notify-web.js +14 -11
  16. package/lib/client.robash-model.js +26 -0
  17. package/lib/client.settings-page.js +287 -17
  18. package/lib/client.worktree-model.js +225 -0
  19. package/package.json +5 -2
  20. package/src/blackboard/durable.js +12 -0
  21. package/src/blackboard/index.js +2 -2
  22. package/src/blackboard/kernel.js +23 -6
  23. package/src/blackboard/notify.js +10 -3
  24. package/src/blackboard/remote.js +31 -13
  25. package/src/blackboard/storage.js +13 -5
  26. package/src/capabilities/apply-engine.js +189 -101
  27. package/src/capabilities/defaults-transaction.js +136 -1
  28. package/src/capabilities/lifecycle-snapshot.js +83 -204
  29. package/src/capabilities/mcp-admission.js +1 -23
  30. package/src/capabilities/mcp-drain.js +30 -19
  31. package/src/capabilities/mcp-facade.js +23 -3
  32. package/src/capabilities/preset-library.js +247 -0
  33. package/src/capabilities/preset-package.js +212 -1
  34. package/src/capabilities/same-name-policy.js +3 -41
  35. package/src/capabilities/selection-notify.js +324 -21
  36. package/src/capabilities/skill-office-adapter.js +7 -1
  37. package/src/capabilities/skill-selection-plugin.js +315 -729
  38. package/src/capabilities/store/checked-contract.d.ts +5 -0
  39. package/src/capabilities/store/fs-adapter.js +52 -0
  40. package/src/capabilities/store/lock.js +86 -0
  41. package/src/capabilities/store/paths.js +5 -0
  42. package/src/capabilities/store/store.js +105 -5
  43. package/src/creative-guide/guide.js +1 -1
  44. package/src/delegate/child-composition.js +258 -0
  45. package/src/delegate/group-coordinator.js +25 -27
  46. package/src/delegate/index.js +21 -2
  47. package/src/delegate/rehydrate.js +24 -4
  48. package/src/delegate/robash-guard-core.js +2 -2
  49. package/src/delegate/spawn-adapter.js +214 -100
  50. package/src/delegate/status-tool.js +9 -6
  51. package/src/delegate/supervision-mount.js +35 -6
  52. package/src/delegate/tool.js +29 -4
  53. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  54. package/src/edit-lock/admin-ledger.js +8 -11
  55. package/src/edit-lock/admin-recovery.js +2 -1
  56. package/src/edit-lock/admission.js +12 -2
  57. package/src/edit-lock/binding-guidance.js +28 -10
  58. package/src/edit-lock/binding-machine.js +312 -0
  59. package/src/edit-lock/derived-facts.js +67 -0
  60. package/src/edit-lock/index.js +44 -244
  61. package/src/edit-lock/inspect.js +113 -1
  62. package/src/edit-lock/lifecycle.js +8 -5
  63. package/src/edit-lock/limits.js +61 -0
  64. package/src/edit-lock/maintenance.js +5 -2
  65. package/src/edit-lock/manager.js +6 -2
  66. package/src/edit-lock/operation-history.js +1 -1
  67. package/src/edit-lock/peer-client.js +9 -2
  68. package/src/edit-lock/peer-transport.js +7 -1
  69. package/src/edit-lock/remote.js +11 -4
  70. package/src/edit-lock/state.js +2 -4
  71. package/src/edit-lock/view.js +10 -11
  72. package/src/lsp/admin.js +2 -24
  73. package/src/lsp/index.js +1 -1
  74. package/src/lsp/manager.js +1 -1
  75. package/src/notify/permissions-admin.js +1 -1
  76. package/src/{lsp/child-process.js → process/index.js} +23 -6
  77. package/src/settings/env-admin.js +1 -1
  78. package/src/settings/index.js +1 -1
  79. package/src/settings/sections.js +0 -54
  80. package/src/shared/audit.js +12 -0
  81. package/src/shared/child-scope.js +3 -1
  82. package/src/shared/http-json.js +28 -0
  83. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  84. package/src/worktree/errors.js +3 -0
  85. package/src/worktree/index.js +1 -2
  86. package/src/worktree/lane-core.js +641 -0
  87. package/src/worktree/lane-views.js +147 -0
  88. package/src/worktree/lane-workflows.js +853 -0
  89. package/src/worktree/lanes.js +24 -1513
  90. package/src/worktree/runner.js +5 -4
  91. package/src/capabilities/content-pin.js +0 -88
  92. package/src/capabilities/content-refresh.js +0 -331
  93. package/src/capabilities/distribution-manifest.js +0 -78
  94. package/src/capabilities/isolated-exec.js +0 -106
  95. package/src/capabilities/publisher.js +0 -129
  96. package/src/capabilities/skill-admission.js +0 -181
  97. package/src/capabilities/update-lifecycle.js +0 -106
  98. package/src/edit-lock/remote-service.js +0 -28
  99. package/src/worktree/contract-guidance.test.js +0 -100
@@ -1,15 +1,16 @@
1
1
  // Process runners for the worktree service.
2
2
  //
3
3
  // - gitRunner: argv-only execution of git through ctx.subprocess (no shell,
4
- // no interpolation). The executable resolves through the LSP module's
5
- // extended resolver because a LaunchServices-started desktop host has a
6
- // minimal PATH (S21). Output is collected per stream with a deadline.
4
+ // no interpolation). The executable resolves through the process
5
+ // subsystem's extended resolver because a LaunchServices-started desktop
6
+ // host has a minimal PATH (S21). Output is collected per stream with a
7
+ // deadline.
7
8
  // - shellRunner: user-configured setup / verification commands (shell
8
9
  // strings) through ctx.shell with the calling session's per-call sandbox
9
10
  // policy — the same boundary the bash tool enforces (S23 / S26 S-G).
10
11
  import { existsSync, readdirSync, statSync } from 'node:fs'
11
12
  import { homedir } from 'node:os'
12
- import { childEnvironment, extraBinDirectories, resolveExecutable } from '../lsp/child-process.js'
13
+ import { childEnvironment, extraBinDirectories, resolveExecutable } from '../process/index.js'
13
14
  import { resolveDerivedSetup } from './pkgmgr.js'
14
15
 
15
16
  /** Vendor git install locations, probed when PATH resolution fails. */
@@ -1,88 +0,0 @@
1
- // Task 11.6 of the session-capability-manager change: session content pins,
2
- // explicit refresh, local-change confirmation and GC.
3
- //
4
- // The first time a session accepts Skill content, the pin durably records
5
- // the body, relatively-referenced assets, resourceBase and manifest through
6
- // the group-2 store. Non-managed sources are copied first and verified as a
7
- // consistent snapshot — a source that changes mid-copy is retried or
8
- // refused. After an update is published the session keeps using its pinned
9
- // content and reports update-available; adopting new content needs an
10
- // explicit refresh with a content-revision CAS (the 4.5 refresh engine),
11
- // which never changes the selection identity and never removes history.
12
- // With the original install directory gone, a fully pinned identity stays
13
- // usable. When local content diverges from the last managed digest,
14
- // publication pauses and the user chooses overwrite (report: local changes
15
- // discarded) or leave unchanged (report: skipped). A generation whose
16
- // reference count is non-zero (live/durable sessions, subagents/forks or
17
- // unsettled receipts) is never collected; anomalous recovery keeps
18
- // conservatively.
19
-
20
- /**
21
- * The pin snapshot recorded for one accepted identity (11.6).
22
- * @param {{ identity: unknown, body: string, assets: Record<string, string>, resourceBase: unknown, manifest: unknown }} input
23
- */
24
- export function contentPinOf({ identity, body, assets = {}, resourceBase = null, manifest = null }) {
25
- if (typeof body !== 'string') throw new TypeError('a content pin needs a body string')
26
- return {
27
- identity,
28
- body,
29
- assets: { ...assets },
30
- resourceBase,
31
- manifest,
32
- revision: 1,
33
- }
34
- }
35
-
36
- /**
37
- * Consistent-snapshot verification for non-managed sources (11.6): two reads
38
- * of the same logical content must match exactly, or the copy is refused.
39
- * @param {string} first @param {string} second
40
- */
41
- export function snapshotVerdict(first, second) {
42
- if (first !== second) return { ok: false, reason: 'source content changed mid-copy — the pin is refused (retry only if the source settles)' }
43
- return { ok: true }
44
- }
45
-
46
- /**
47
- * Whether a pinned identity stays usable with its origin directory gone
48
- * (11.6): a full pin (body + manifest) is sufficient on its own.
49
- * @param {{ body?: unknown, manifest?: unknown }} pin
50
- */
51
- export function pinSelfSufficient(pin) {
52
- return typeof pin?.body === 'string' && pin.body.length > 0 && pin.manifest !== null && pin.manifest !== undefined
53
- }
54
-
55
- /**
56
- * The refresh verdict for adopting published content (11.6): an explicit,
57
- * CAS-guarded refresh that never changes the selection identity.
58
- * @param {{ expectedRevision: number, currentRevision: number }} input
59
- */
60
- export function refreshVerdict({ expectedRevision, currentRevision }) {
61
- if (expectedRevision !== currentRevision) {
62
- return { refresh: false, reason: `content revision moved: expected ${expectedRevision}, found ${currentRevision} — nothing adopted (retry against the fresh revision)` }
63
- }
64
- return { refresh: true }
65
- }
66
-
67
- /**
68
- * The local-change decision (11.6): publication pauses on divergence and the
69
- * user's choice is reported, never silent.
70
- * @param {'overwrite'|'leave-unchanged'} decision
71
- */
72
- export function localChangeOutcome(decision) {
73
- if (decision === 'overwrite') return { publish: true, report: 'local modifications were discarded by overwrite' }
74
- if (decision === 'leave-unchanged') return { publish: false, report: 'publication skipped; local content left unchanged' }
75
- return { publish: false, report: 'publication paused — no decision yet (overwrite / leave unchanged required)' }
76
- }
77
-
78
- /**
79
- * The GC reference-count gate (11.6): any live reference blocks collection;
80
- * an unreadable or anomalous record keeps conservatively.
81
- * @param {{ liveSessions?: number, durablePins?: number, subagentRefs?: number, unsettledReceipts?: number, anomaly?: boolean }} counts
82
- */
83
- export function gcVerdict(counts) {
84
- if (counts.anomaly) return { collect: false, reason: 'anomalous recovery state — kept conservatively' }
85
- const held = (counts.liveSessions ?? 0) + (counts.durablePins ?? 0) + (counts.subagentRefs ?? 0) + (counts.unsettledReceipts ?? 0)
86
- if (held > 0) return { collect: false, reason: `reference count non-zero (${held}) — generation kept` }
87
- return { collect: true }
88
- }
@@ -1,331 +0,0 @@
1
- // Content refresh engine of the session capability manager (design D2,
2
- // tasks 4.5). A refresh re-scans the content of the CURRENTLY selected skills
3
- // and durably records fresh content handles in the session's CONTENT unit —
4
- // an independent store record with its own revision and receipts, never the
5
- // selection record. One refresh is a four-step transaction:
6
- // 1. Locate the session server-side from the AUTHENTICATED session; the
7
- // client never supplies cwd, scope roots or server config.
8
- // 2. Validate requestId, the expected content revision, the expected
9
- // selection revision and the optional exact identity subset.
10
- // 3. Read the CURRENT selection, verify every requested identity is still
11
- // selected (a removed skill is never re-published), collect and verify
12
- // fresh content handles server-side, then RE-READ the selection
13
- // immediately before committing: if its revision moved, the refresh is
14
- // rejected — a skill removed while a refresh was collecting is never
15
- // published by that refresh.
16
- // 4. CAS-commit the content handles through the group-2 store with an
17
- // independent requestId receipt.
18
- // The whole transaction runs through the SAME commit coordinator as selection
19
- // Applies (the apply engine's per-session serialization point, exposed as
20
- // `coordinate`), so a refresh and an Apply never interleave in-process;
21
- // cross-process exclusion is the store lock plus the pre-commit selection
22
- // re-check. A refresh enters no admission fence and publishes nothing
23
- // in-memory: the recorded `selectionRevision` marks exactly which selection
24
- // authority the handles were verified against, so any consumer can detect
25
- // staleness after a later Apply.
26
- // Audit is scheduled best-effort after the response and reuses the registered
27
- // capability-apply type (adding a type would touch src/shared/audit.js, which
28
- // is outside this group); its failure never rolls back accepted policy.
29
- import { digestOf } from './store/record.js'
30
- import { isSegment } from './store/paths.js'
31
- import { createSkillIdentity, skillIdentityKey } from './skill-identity.js'
32
- import { AUDIT_TYPES } from '../shared/audit.js'
33
-
34
- /** Refresh acceptance is audited under the registered capability-apply type
35
- * with an `operation: 'refresh'` marker; the emit site never invents a type
36
- * literal and never touches session.append (cold-read red line). */
37
- export const REFRESH_AUDIT_TYPE = AUDIT_TYPES.capabilityApply
38
-
39
- /**
40
- * @typedef {{ status: 'refreshed', revision: number, receipt: unknown, selectionRevision: number, contents: unknown[] }
41
- * | { status: 'duplicate', revision: number, receipt: unknown, selectionRevision?: unknown, warnings?: string[] }
42
- * | { status: 'rejected', reason: string, step: number, missing?: string[], current?: unknown, receipt?: unknown }
43
- * | { status: 'write-failed', step: 4 }
44
- * | { status: 'indeterminate', step: 4 }} RefreshResult
45
- * @typedef {{ status: 'found', revision: number, receipt: unknown, selectionRevision: unknown, contents: unknown[] }
46
- * | { status: 'not-found', revision: number|null }
47
- * | { status: 'rejected', reason: string }} RefreshReceiptQueryResult
48
- */
49
-
50
- const message = error => error instanceof Error ? error.message : String(error)
51
-
52
- /**
53
- * Request digest: the full normalized request payload, so any field change
54
- * under a reused requestId is a different request. `skills` is the sorted
55
- * identity-key vector (null when the request refreshes the whole selection).
56
- * @param {{ expectedRevision: number, expectedSelectionRevision: number, skills: string[]|null }} normalized
57
- */
58
- export function digestRefreshRequest(normalized) {
59
- return digestOf({
60
- expectedRevision: normalized.expectedRevision,
61
- expectedSelectionRevision: normalized.expectedSelectionRevision,
62
- skills: normalized.skills,
63
- })
64
- }
65
-
66
- /** @param {unknown} payload @returns {unknown[]} */
67
- function safeSkills(payload) {
68
- const value = /** @type {Record<string, unknown>} */ (payload ?? {})
69
- return Array.isArray(value.skills) ? value.skills : []
70
- }
71
-
72
- /**
73
- * @param {{
74
- * store: { read(unit: unknown): Promise<any>, commit(unit: unknown, expectedRevision: number, mutate: (payload: unknown, info: { revision: number }) => unknown, request?: { requestId?: string, requestDigest?: string }): Promise<any> },
75
- * locateSession: (authSession: unknown) => Promise<{ sessionId: string, cwd?: string } | null> | { sessionId: string, cwd?: string } | null,
76
- * coordinate: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>,
77
- * collect: (options: { cwd?: string, scope: { session: { id: string } } }) => Promise<{ complete: boolean, candidates: any[] }>,
78
- * verifyContent?: (candidate: unknown) => Promise<void> | void,
79
- * audit?: (session: { id?: string }, type: string, data?: unknown) => void,
80
- * warn?: (message: string) => void,
81
- * trace?: (event: string, data?: unknown) => void,
82
- * }} options
83
- */
84
- export function createContentRefresh(options) {
85
- const { store, locateSession, coordinate, collect, verifyContent, audit, warn = () => {}, trace = () => {} } = options
86
- if (typeof coordinate !== 'function') throw new TypeError('The shared commit coordinator is required')
87
- if (typeof collect !== 'function') throw new TypeError('A server-side content collector is required')
88
-
89
- /** Best-effort audit scheduled after the response; failures warn and are swallowed, never rolling back policy. */
90
- function followup(sessionId, response) {
91
- queueMicrotask(() => {
92
- try {
93
- audit?.({ id: sessionId }, REFRESH_AUDIT_TYPE, { operation: 'refresh', requestId: response.receipt?.requestId ?? null, revision: response.revision ?? null, selectionRevision: response.selectionRevision ?? null })
94
- } catch (error) {
95
- warn(`refresh audit failed: ${message(error)}`)
96
- }
97
- })
98
- }
99
-
100
- /** @param {unknown} request */
101
- function validateRequest(request) {
102
- const input = /** @type {Record<string, unknown>} */ (request ?? {})
103
- if (typeof input.requestId !== 'string' || !input.requestId.length) return { error: 'invalid-request:requestId' }
104
- if (!Number.isSafeInteger(input.expectedRevision) || /** @type {number} */ (input.expectedRevision) < 0) return { error: 'invalid-request:expectedRevision' }
105
- if (!Number.isSafeInteger(input.expectedSelectionRevision) || /** @type {number} */ (input.expectedSelectionRevision) < 0) return { error: 'invalid-request:expectedSelectionRevision' }
106
- /** @type {{ keys: string[], identities: unknown[] } | null} */
107
- let skills = null
108
- if (input.skills !== undefined) {
109
- if (!Array.isArray(input.skills)) return { error: 'invalid-request:skills' }
110
- try {
111
- const byKey = new Map(input.skills.map(raw => {
112
- const identity = createSkillIdentity(raw)
113
- return [skillIdentityKey(identity), identity]
114
- }))
115
- skills = { keys: [...byKey.keys()].sort(), identities: [...byKey.values()] }
116
- } catch (error) {
117
- return { error: `invalid-request:skills:${message(error)}` }
118
- }
119
- }
120
- return {
121
- normalized: {
122
- requestId: input.requestId,
123
- expectedRevision: /** @type {number} */ (input.expectedRevision),
124
- expectedSelectionRevision: /** @type {number} */ (input.expectedSelectionRevision),
125
- skills,
126
- },
127
- }
128
- }
129
-
130
- /**
131
- * @param {unknown} authSession - authenticated session handle; the ONLY
132
- * source of the session location
133
- * @param {unknown} request - client request; only requestId, the expected
134
- * revisions and the optional identity subset are read
135
- * @returns {Promise<RefreshResult>}
136
- */
137
- async function refresh(authSession, request) {
138
- // Step 1: locate server-side from the authenticated session.
139
- let located
140
- try {
141
- located = await locateSession(authSession)
142
- } catch (error) {
143
- return { status: 'rejected', reason: `unauthenticated:${message(error)}`, step: 1 }
144
- }
145
- if (located === null || typeof located !== 'object' || !isSegment(located.sessionId)) {
146
- return { status: 'rejected', reason: 'unauthenticated:session-unavailable', step: 1 }
147
- }
148
- const { sessionId } = located
149
- // The SAME commit coordinator the selection Apply uses.
150
- return coordinate(sessionId, () => transact(sessionId, located, request))
151
- }
152
-
153
- /** @param {string} sessionId @param {{ cwd?: string }} located @param {unknown} request @returns {Promise<RefreshResult>} */
154
- async function transact(sessionId, located, request) {
155
- const contentUnit = { kind: 'content', sessionId }
156
- const selectionUnit = { kind: 'selection', sessionId }
157
- const viewOptions = { ...(located.cwd === undefined ? {} : { cwd: located.cwd }), scope: { session: { id: sessionId } } }
158
-
159
- // Step 2: validate the request shape and the exact identity subset.
160
- const validated = validateRequest(request)
161
- if (validated.error) return { status: 'rejected', reason: validated.error, step: 2 }
162
- const { normalized } = validated
163
-
164
- // Step 3: read the CURRENT selection — the only authority a refresh serves.
165
- const first = await store.read(selectionUnit)
166
- if (first.kind === 'unsupported') return { status: 'rejected', reason: `unsupported:${first.reason}`, step: 3 }
167
- if (first.kind !== 'ok' && first.kind !== 'absent') return { status: 'rejected', reason: `policy-unreadable:${first.kind}`, step: 3 }
168
- const selectionRevision = first.kind === 'ok' ? first.revision : 0
169
- trace('selection-read', { sessionId, revision: selectionRevision })
170
- if (selectionRevision !== normalized.expectedSelectionRevision) {
171
- trace('selection-revision-changed', { sessionId, from: normalized.expectedSelectionRevision, to: selectionRevision })
172
- return { status: 'rejected', reason: 'selection-revision-changed', step: 3, current: { revision: selectionRevision } }
173
- }
174
- /** @type {Map<string, unknown>} */
175
- const selected = new Map()
176
- if (first.kind === 'ok') {
177
- for (const raw of safeSkills(first.payload)) {
178
- try {
179
- const identity = createSkillIdentity(raw)
180
- selected.set(skillIdentityKey(identity), identity)
181
- } catch {
182
- // A durably accepted record holds valid identities; a stray entry is
183
- // skipped here and stays unrefreshable, never substituted by name.
184
- }
185
- }
186
- }
187
- const requested = normalized.skills ? normalized.skills.keys : [...selected.keys()].sort()
188
- const notSelected = requested.filter(key => !selected.has(key))
189
- if (notSelected.length) {
190
- // A removed skill is never re-published by a refresh.
191
- trace('skill-not-selected', { sessionId, missing: notSelected })
192
- return { status: 'rejected', reason: 'skill-not-selected', step: 3, missing: notSelected }
193
- }
194
-
195
- // Collect and verify fresh content handles server-side; nothing is
196
- // committed or published here.
197
- /** @type {Readonly<{ identity: unknown, name: string|null, digest: string|null, path: string|null }>[]} */
198
- let prepared
199
- try {
200
- const snapshot = await collect(viewOptions)
201
- if (!snapshot || snapshot.complete !== true || !Array.isArray(snapshot.candidates)) {
202
- return { status: 'rejected', reason: 'inventory-incomplete', step: 3 }
203
- }
204
- const parsed = snapshot.candidates.filter(candidate => candidate?.status === 'parsed' && candidate.identity)
205
- /** @type {Map<string, unknown[]>} */
206
- const byKey = new Map()
207
- for (const candidate of parsed) {
208
- const key = skillIdentityKey(candidate.identity)
209
- byKey.set(key, [...(byKey.get(key) ?? []), candidate])
210
- }
211
- const missing = requested.filter(key => (byKey.get(key) ?? []).length !== 1)
212
- if (missing.length) return { status: 'rejected', reason: 'selected-identity-missing', step: 3, missing }
213
- const matched = requested.map(key => /** @type {unknown[]} */ (byKey.get(key))[0])
214
- if (verifyContent) for (const candidate of matched) await verifyContent(candidate)
215
- prepared = matched.map(candidate => Object.freeze({
216
- identity: candidate.identity,
217
- name: candidate.name ?? null,
218
- digest: candidate.digest ?? null,
219
- path: candidate.path ?? null,
220
- }))
221
- } catch (error) {
222
- return { status: 'rejected', reason: `preparation-failed:${message(error)}`, step: 3 }
223
- }
224
-
225
- // Step 3 (final): RE-CHECK the selection revision immediately before the
226
- // commit. Inside the shared coordinator no in-process Apply can
227
- // interleave; against a cross-process writer this is the last durable
228
- // look — the invariant is that a skill removed AT COMMIT TIME is never
229
- // published by this refresh.
230
- const settled = await store.read(selectionUnit)
231
- if (settled.kind === 'unsupported') return { status: 'rejected', reason: `unsupported:${settled.reason}`, step: 3 }
232
- if (settled.kind !== 'ok' && settled.kind !== 'absent') return { status: 'rejected', reason: `policy-unreadable:${settled.kind}`, step: 3 }
233
- const settledRevision = settled.kind === 'ok' ? settled.revision : 0
234
- trace('selection-recheck', { sessionId, revision: settledRevision })
235
- if (settledRevision !== selectionRevision) {
236
- trace('selection-revision-changed', { sessionId, from: selectionRevision, to: settledRevision })
237
- return { status: 'rejected', reason: 'selection-revision-changed', step: 3, current: { revision: settledRevision } }
238
- }
239
-
240
- // Step 4: durable CAS write through the group-2 store; the content unit
241
- // owns its revision and receipts independently of the selection unit.
242
- const payload = { contents: prepared, selectionRevision, origin: 'refresh' }
243
- const requestDigest = digestRefreshRequest({
244
- expectedRevision: normalized.expectedRevision,
245
- expectedSelectionRevision: normalized.expectedSelectionRevision,
246
- skills: normalized.skills ? normalized.skills.keys : null,
247
- })
248
- let result
249
- try {
250
- result = await store.commit(contentUnit, normalized.expectedRevision, () => payload, { requestId: normalized.requestId, requestDigest })
251
- } catch {
252
- // The write outcome is UNKNOWN: nothing is held (no fence, no in-memory
253
- // publication), so a receipt query settles it from durable evidence.
254
- trace('indeterminate', { sessionId })
255
- return { status: 'indeterminate', step: 4 }
256
- }
257
- trace('commit', { sessionId, status: result.status })
258
-
259
- switch (result.status) {
260
- case 'committed': {
261
- const response = { status: /** @type {const} */ ('refreshed'), revision: result.revision, receipt: result.receipt, selectionRevision, contents: structuredClone(prepared) }
262
- trace('response', { sessionId, revision: result.revision })
263
- followup(sessionId, response)
264
- return response
265
- }
266
- case 'duplicate': {
267
- // The same accepted request: return the original receipt, never a
268
- // second commit. Re-read so revision and marker come from the SAME
269
- // settled record the read actually observed.
270
- const settledRecord = await store.read(contentUnit).catch(() => null)
271
- const receipt = /** @type {{ requestId: string, requestDigest: string }} */ (result.receipt)
272
- const evidenced = settledRecord?.kind === 'ok'
273
- && settledRecord.receipts.some(entry => entry.requestId === receipt.requestId && entry.requestDigest === receipt.requestDigest)
274
- if (evidenced && settledRecord?.kind === 'ok') {
275
- const settledPayload = /** @type {Record<string, unknown>} */ (settledRecord.payload ?? {})
276
- return { status: 'duplicate', revision: settledRecord.revision, receipt: result.receipt, selectionRevision: settledPayload.selectionRevision }
277
- }
278
- return { status: 'duplicate', revision: result.revision, receipt: result.receipt, warnings: ['settlement-unverified'] }
279
- }
280
- case 'request-conflict':
281
- return { status: 'rejected', reason: 'request-conflict', step: 4, receipt: result.receipt }
282
- case 'revision-conflict': {
283
- const current = await store.read(contentUnit).catch(() => null)
284
- return { status: 'rejected', reason: 'revision-conflict', step: 4, current: current?.kind === 'ok' ? { revision: current.revision } : { revision: result.revision } }
285
- }
286
- case 'locked':
287
- return { status: 'rejected', reason: `locked:${result.reason}`, step: 4 }
288
- case 'unreadable':
289
- return { status: 'rejected', reason: `policy-unreadable:${result.kind}`, step: 4 }
290
- case 'unsupported':
291
- return { status: 'rejected', reason: `unsupported:${result.reason}`, step: 4 }
292
- case 'write-failed':
293
- trace('write-failed', { sessionId })
294
- return { status: 'write-failed', step: 4 }
295
- default:
296
- // Unknown store outcome: nothing was held, report indeterminate.
297
- trace('indeterminate', { sessionId, status: result.status })
298
- return { status: 'indeterminate', step: 4 }
299
- }
300
- }
301
-
302
- /**
303
- * Receipt query on the CONTENT unit: after a lost response or an
304
- * indeterminate write, the client queries the original requestId instead of
305
- * claiming a cancellation or a rollback that never happened.
306
- * @param {unknown} authSession @param {string} requestId
307
- * @returns {Promise<RefreshReceiptQueryResult>}
308
- */
309
- async function queryReceipt(authSession, requestId) {
310
- if (typeof requestId !== 'string' || !requestId.length) return { status: 'rejected', reason: 'invalid-request:requestId' }
311
- let located
312
- try {
313
- located = await locateSession(authSession)
314
- } catch (error) {
315
- return { status: 'rejected', reason: `unauthenticated:${message(error)}` }
316
- }
317
- if (located === null || typeof located !== 'object' || !isSegment(located.sessionId)) {
318
- return { status: 'rejected', reason: 'unauthenticated:session-unavailable' }
319
- }
320
- const record = await store.read({ kind: 'content', sessionId: located.sessionId })
321
- if (record.kind === 'unsupported') return { status: 'rejected', reason: `unsupported:${record.reason}` }
322
- if (record.kind !== 'ok') return { status: 'not-found', revision: record.kind === 'absent' ? 0 : null }
323
- const payload = /** @type {Record<string, unknown>} */ (record.payload ?? {})
324
- const receipt = record.receipts.find(entry => entry.requestId === requestId)
325
- return receipt
326
- ? { status: 'found', revision: record.revision, receipt, selectionRevision: payload.selectionRevision, contents: Array.isArray(payload.contents) ? payload.contents : [] }
327
- : { status: 'not-found', revision: record.revision }
328
- }
329
-
330
- return { refresh, queryReceipt }
331
- }
@@ -1,78 +0,0 @@
1
- // Task 10.1 of the session-capability-manager change: the distribution-side
2
- // input manifest and provenance records. Every entry carries the source
3
- // repository, source kind, requested ref, subpath, install location and
4
- // content identity (reusing the group-3 raw inventory). Values that cannot
5
- // be established are reported as `unknown` — never inferred, never
6
- // fabricated, and a missing identity is never claimed as "unchanged".
7
- // Entries that are unavailable, name-conflicting or locally modified stay
8
- // listed (with their state), never silently dropped.
9
-
10
- export const DISTRIBUTION_SCHEMA_VERSION = 1
11
-
12
- /**
13
- * @typedef {{ repository: string, sourceKind: string, requestedRef: string, subpath: string|null,
14
- * installLocation: string, contentIdentity: string|null,
15
- * state: 'ok'|'unavailable'|'name-conflict'|'locally-modified'|'unknown' }} DistributionEntry
16
- */
17
-
18
- /** @param {unknown} value */
19
- const isNonEmptyString = value => typeof value === 'string' && value.length > 0
20
-
21
- /**
22
- * Normalize one raw record into a manifest entry. Anything not established
23
- * by the record becomes 'unknown' verbatim — no inference, no comparison.
24
- * @param {Record<string, unknown>} raw
25
- * @returns {DistributionEntry}
26
- */
27
- export function manifestEntryOf(raw) {
28
- const unknown = { repository: 'unknown', sourceKind: 'unknown', requestedRef: 'unknown', subpath: null, installLocation: 'unknown', contentIdentity: null, state: 'unknown' }
29
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return unknown
30
- return {
31
- repository: isNonEmptyString(raw.repository) ? raw.repository : 'unknown',
32
- sourceKind: isNonEmptyString(raw.sourceKind) ? raw.sourceKind : 'unknown',
33
- requestedRef: isNonEmptyString(raw.requestedRef) ? raw.requestedRef : 'unknown',
34
- subpath: typeof raw.subpath === 'string' ? raw.subpath : null,
35
- installLocation: isNonEmptyString(raw.installLocation) ? raw.installLocation : 'unknown',
36
- contentIdentity: isNonEmptyString(raw.contentIdentity) ? raw.contentIdentity : null,
37
- state: ['ok', 'unavailable', 'name-conflict', 'locally-modified'].includes(raw.state) ? raw.state : 'unknown',
38
- }
39
- }
40
-
41
- /**
42
- * Compare is only defined when BOTH identities are established; a missing
43
- * identity is never claimed as "unchanged" — it reports as unknown.
44
- * @param {DistributionEntry} entry @param {string|null} currentIdentity
45
- */
46
- export function changeVerdict(entry, currentIdentity) {
47
- if (entry.contentIdentity === null || typeof currentIdentity !== 'string' || currentIdentity.length === 0) {
48
- return 'unknown'
49
- }
50
- return entry.contentIdentity === currentIdentity ? 'unchanged' : 'changed'
51
- }
52
-
53
- // Task 10.2 of the session-capability-manager change: the bundle-owned
54
- // builtin guard. Builtin skills are delivered and updated ONLY through the
55
- // bundle's own workflow; no third-party process may write, update, move or
56
- // delete their content, and a same-named third-party skill is never a valid
57
- // definition of a builtin identity.
58
-
59
- /**
60
- * Whether a target location falls inside the Weir builtin skills root —
61
- * distribution writes there are always refused (10.2).
62
- * @param {string} targetPath @param {string} builtinRoot
63
- */
64
- export function isBuiltinTarget(targetPath, builtinRoot) {
65
- if (typeof targetPath !== 'string' || typeof builtinRoot !== 'string') return false
66
- const root = builtinRoot.endsWith('/') ? builtinRoot : `${builtinRoot}/`
67
- return targetPath === builtinRoot || targetPath.startsWith(root)
68
- }
69
-
70
- /**
71
- * Whether a candidate may serve as the definition of a builtin identity: a
72
- * third-party same-name candidate is NEVER valid for a builtin identity
73
- * (10.2).
74
- * @param {{ scope?: string, provider?: string }} candidate
75
- */
76
- export function isValidBuiltinDefinition(candidate) {
77
- return candidate?.scope === 'weir-builtin'
78
- }
@@ -1,106 +0,0 @@
1
- // Tasks 10.4/10.5 of the session-capability-manager change: the pinned
2
- // executor baseline and the isolated execution layer for managed external
3
- // installs/updates.
4
- //
5
- // 10.4 — the managed external executable is `vercel-labs/skills` pinned at
6
- // version 1.7.0 / commit 3694740352eeef5cdd689af694c485f1ff62eec3. It is
7
- // never a bundle npm dependency; the source, artifact integrity and the
8
- // ACTUAL version are recorded, the revision in use is reported in every
9
- // diagnostic, and any deviation from the pin is reported and REFUSES the
10
- // managed install/update until a compatibility test passes with explicit
11
- // user authorization.
12
- //
13
- // 10.5 — isolation: a private staging HOME/XDG/cwd, explicit non-interactive
14
- // argv (never shell-concatenated from source text), `shell: false`,
15
- // telemetry off, isolated user git config, no Git hooks / external diff or
16
- // filter drivers, no source scripts executed. One operation never exceeds
17
- // 5 minutes or 4 MiB of stdout+stderr combined; breaching either terminates
18
- // the whole process tree, redacts diagnostics and records the failure.
19
-
20
- export const EXECUTOR_PIN = Object.freeze({
21
- repository: 'vercel-labs/skills',
22
- version: '1.7.0',
23
- commit: '3694740352eeef5cdd689af694c485f1ff62eec3',
24
- })
25
-
26
- export const EXEC_LIMITS = Object.freeze({ timeoutMs: 300_000, maxOutputBytes: 4_194_304 })
27
-
28
- /**
29
- * The baseline verdict for one executor instance (10.4): exact pin match
30
- * runs; any deviation reports and refuses.
31
- * @param {{ version?: string, commit?: string }} actual
32
- * @returns {{ run: boolean, revision: string, deviation: string|null }}
33
- */
34
- export function pinVerdict(actual) {
35
- const revision = actual?.commit ?? actual?.version ?? 'unknown'
36
- if (actual?.version !== EXECUTOR_PIN.version || actual?.commit !== EXECUTOR_PIN.commit) {
37
- return {
38
- run: false,
39
- revision,
40
- deviation: `executor baseline deviation: expected ${EXECUTOR_PIN.repository}@${EXECUTOR_PIN.version} (${EXECUTOR_PIN.commit}), got ${actual?.version ?? 'unknown'} (${actual?.commit ?? 'unknown'}); managed install/update is refused until a compatibility test passes with user authorization`,
41
- }
42
- }
43
- return { run: true, revision: EXECUTOR_PIN.commit, deviation: null }
44
- }
45
-
46
- /**
47
- * The non-interactive argv for one managed install (10.5). Arguments are
48
- * passed VERBATIM (paths and refs with spaces stay single array items);
49
- * nothing from source text is ever concatenated into a shell string.
50
- * @param {{ repository: string, skill: string, agent?: string, yes?: never }} input
51
- */
52
- export function installArgv({ repository, skill, agent = 'universal' }) {
53
- for (const [label, value] of [['repository', repository], ['skill', skill], ['agent', agent]]) {
54
- if (typeof value !== 'string' || value.length === 0) throw new TypeError(`install argv needs a non-empty ${label}`)
55
- }
56
- return ['skills', 'add', repository, '--skill', skill, '--agent', agent, '--no-interactive']
57
- }
58
-
59
- /**
60
- * The isolated env for one managed invocation (10.5): private staging
61
- * HOME/XDG, telemetry off, git noise silenced.
62
- * @param {{ stagingHome: string }} input
63
- */
64
- export function isolatedEnv({ stagingHome }) {
65
- if (typeof stagingHome !== 'string' || stagingHome.length === 0) throw new TypeError('a staging home is required')
66
- return {
67
- HOME: stagingHome,
68
- XDG_CONFIG_HOME: stagingHome,
69
- XDG_CACHE_HOME: stagingHome,
70
- XDG_DATA_HOME: stagingHome,
71
- XDG_STATE_HOME: stagingHome,
72
- TMPDIR: stagingHome,
73
- SKILLS_TELEMETRY: '0',
74
- GIT_CONFIG_NOSYSTEM: '1',
75
- GIT_CONFIG_GLOBAL: '/dev/null',
76
- GIT_TERMINAL_PROMPT: '0',
77
- GIT_HOOKS_PATH: '/dev/null',
78
- GIT_EXTERNAL_DIFF: '',
79
- GIT_DIFF_DRIVER: '',
80
- GIT_FILTER_BRANCH_COMMAND: '',
81
- CI: '1',
82
- }
83
- }
84
-
85
- /** The spawn options for one managed invocation (10.5). */
86
- export function spawnOptionsFor({ stagingHome }) {
87
- return { argv0: 'skills', env: isolatedEnv({ stagingHome }), cwd: stagingHome, shell: false, timeout: EXEC_LIMITS.timeoutMs, maxBuffer: EXEC_LIMITS.maxOutputBytes }
88
- }
89
-
90
- /**
91
- * The diagnostics for one run (10.4/10.5): the revision in use is always
92
- * reported, and secrets are redacted from any echo.
93
- * @param {{ revision: string, argv: string[], code: number|null, stdout: string, stderr: string, timedOut?: boolean, overOutput?: boolean }} run
94
- */
95
- export function runDiagnostics({ revision, argv, code, stdout, stderr, timedOut = false, overOutput = false }) {
96
- const redact = text => String(text).replace(/(token|secret|password|key)[=:]\s*\S+/gi, '$1=<redacted>')
97
- return {
98
- revision,
99
- argv: [...argv],
100
- code,
101
- timedOut,
102
- overOutput,
103
- stdout: redact(stdout).slice(0, 2000),
104
- stderr: redact(stderr).slice(0, 2000),
105
- }
106
- }