weir-harness 1.2.0 → 1.2.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.
- package/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +1 -1
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/delegate/group-coordinator.js +1 -17
- package/src/delegate/index.js +4 -2
- package/src/delegate/rehydrate.js +2 -1
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/supervision-mount.js +5 -4
- package/src/delegate/tool.js +26 -3
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +12 -4
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +5 -0
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- package/src/worktree/contract-guidance.test.js +0 -100
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
// Tasks 11.1/11.2 of the session-capability-manager change: pre-publish
|
|
2
|
-
// target and reference validation, plus immutable-generation publishing with
|
|
3
|
-
// the universal → DSH root bridge.
|
|
4
|
-
//
|
|
5
|
-
// 11.1 — after resolution a target path must stay INSIDE its selected scope
|
|
6
|
-
// root; a symlink escaping the root is refused and named. Absolute paths,
|
|
7
|
-
// traversal paths and credential-carrying references are refused. An
|
|
8
|
-
// unrecognized install-lock schema stops everything — never migrated,
|
|
9
|
-
// rewritten, downgraded or deleted.
|
|
10
|
-
//
|
|
11
|
-
// 11.2 — staging lands on the target filesystem first; publications are
|
|
12
|
-
// serialized per scope/identity; the active manifest pointer, provenance and
|
|
13
|
-
// operation receipt switch in ONE atomic publication (the group-2
|
|
14
|
-
// distribution pointer). The active generation and local changes are
|
|
15
|
-
// re-checked with CAS before switching. A platform without supported atomic
|
|
16
|
-
// switch is reported unsupported — never delete-then-copy. Staging output
|
|
17
|
-
// maps to the DSH-recognized global / workspace skill roots; bundled paths
|
|
18
|
-
// are never allowed targets; the workspace root comes from the host session
|
|
19
|
-
// workspace resolver.
|
|
20
|
-
import { realpathSync } from 'node:fs'
|
|
21
|
-
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
22
|
-
import { createHash } from 'node:crypto'
|
|
23
|
-
|
|
24
|
-
export const LOCK_SCHEMA_VERSION = 1
|
|
25
|
-
|
|
26
|
-
const message = error => (error instanceof Error ? error.message : String(error))
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The scope roots staging output may map to (11.2). `bundled` is present for
|
|
30
|
-
* completeness of the table and is always a REFUSED target.
|
|
31
|
-
* @param {{ agentsHome?: string, workspaceRoot?: string, bundledRoot?: string }} roots
|
|
32
|
-
*/
|
|
33
|
-
export function resolveScopeRoots({ agentsHome, workspaceRoot, bundledRoot } = {}) {
|
|
34
|
-
const roots = {}
|
|
35
|
-
if (typeof agentsHome === 'string' && agentsHome.length > 0) roots.global = join(agentsHome, 'skills')
|
|
36
|
-
if (typeof workspaceRoot === 'string' && workspaceRoot.length > 0) roots.workspace = join(workspaceRoot, '.agents', 'skills')
|
|
37
|
-
return {
|
|
38
|
-
roots,
|
|
39
|
-
/** @param {string} scope */
|
|
40
|
-
rootFor(scope) {
|
|
41
|
-
if (scope === 'bundled') return { allowed: false, reason: 'bundled paths are not publish targets' }
|
|
42
|
-
const root = roots[scope]
|
|
43
|
-
if (!root) return { allowed: false, reason: `scope root for "${scope}" is not configured` }
|
|
44
|
-
return { allowed: true, root }
|
|
45
|
-
},
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** @param {unknown} ref */
|
|
50
|
-
export function referenceProblem(ref) {
|
|
51
|
-
if (typeof ref !== 'string' || ref.length === 0) return 'reference must be a non-empty string'
|
|
52
|
-
if (isAbsolute(ref)) return `absolute reference refused: ${ref}`
|
|
53
|
-
if (ref.split(/[\\/]+/).includes('..')) return `traversal reference refused: ${ref}`
|
|
54
|
-
if (/(token|secret|password|key|credential)[:=]/i.test(ref)) return `credential-carrying reference refused`
|
|
55
|
-
return null
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The resolved target must stay inside the scope root; a symlink escaping
|
|
60
|
-
* the root is refused and named (11.1).
|
|
61
|
-
* @param {string} scopeRoot @param {string} targetPath
|
|
62
|
-
* @param {{ realpath?: (path: string) => string }} [deps]
|
|
63
|
-
*/
|
|
64
|
-
export function validateTargetInsideRoot(scopeRoot, targetPath, deps = {}) {
|
|
65
|
-
const realpath = deps.realpath ?? realpathSync
|
|
66
|
-
const root = realpath(scopeRoot)
|
|
67
|
-
let resolved
|
|
68
|
-
try {
|
|
69
|
-
resolved = realpath(targetPath)
|
|
70
|
-
} catch {
|
|
71
|
-
resolved = resolve(targetPath)
|
|
72
|
-
}
|
|
73
|
-
const inside = resolved === root || resolved.startsWith(`${root}${sep}`)
|
|
74
|
-
if (!inside) {
|
|
75
|
-
return { ok: false, reason: `target escapes the scope root: ${resolved} is outside ${root}${resolved === targetPath ? '' : ` (symlink from ${targetPath})`}` }
|
|
76
|
-
}
|
|
77
|
-
return { ok: true, resolved }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The install lock must be schema-recognized before any write (11.1):
|
|
82
|
-
* anything else stops without migrating, rewriting, downgrading or deleting.
|
|
83
|
-
* @param {unknown} lockPayload
|
|
84
|
-
*/
|
|
85
|
-
export function lockVerdict(lockPayload) {
|
|
86
|
-
if (lockPayload === null || typeof lockPayload !== 'object' || Array.isArray(lockPayload)) {
|
|
87
|
-
return { recognized: false, reason: 'install lock payload is not an object' }
|
|
88
|
-
}
|
|
89
|
-
if (lockPayload.schemaVersion !== LOCK_SCHEMA_VERSION) {
|
|
90
|
-
return { recognized: false, reason: `unrecognized install-lock schema version: ${JSON.stringify(lockPayload.schemaVersion)} — stopping without migrating, rewriting, downgrading or deleting` }
|
|
91
|
-
}
|
|
92
|
-
return { recognized: true }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The pre-publish re-check (11.2): the active generation must still match
|
|
97
|
-
* what the caller prepared against, and the local content must be untouched
|
|
98
|
-
* since the managed digest was recorded.
|
|
99
|
-
* @param {{ activeGeneration: string|null, expectedActive: string|null,
|
|
100
|
-
* localDigest: string|null, managedDigest: string|null }} input
|
|
101
|
-
*/
|
|
102
|
-
export function publishPrecondition({ activeGeneration, expectedActive, localDigest, managedDigest }) {
|
|
103
|
-
if (activeGeneration !== expectedActive) {
|
|
104
|
-
return { ok: false, reason: `active generation moved: expected ${expectedActive ?? 'none'}, found ${activeGeneration ?? 'none'}` }
|
|
105
|
-
}
|
|
106
|
-
if (typeof managedDigest === 'string' && managedDigest.length > 0 && localDigest !== managedDigest) {
|
|
107
|
-
return { ok: false, reason: 'local content diverged from the last managed digest — showing the diff and pausing publication (overwrite / leave unchanged required)' }
|
|
108
|
-
}
|
|
109
|
-
return { ok: true }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* One immutable generation record (11.2): generation id, file manifest and
|
|
114
|
-
* provenance — content-addressed by the manifest digest.
|
|
115
|
-
* @param {{ identity: string, files: Record<string, string>, provenance: unknown, receipt: unknown }} input
|
|
116
|
-
*/
|
|
117
|
-
export function generationRecordOf({ identity, files, provenance, receipt }) {
|
|
118
|
-
const manifest = Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([path, content]) => [path, createHash('sha256').update(String(content)).digest('hex')])
|
|
119
|
-
)
|
|
120
|
-
return {
|
|
121
|
-
generationId: createHash('sha256').update(JSON.stringify([identity, manifest])).digest('hex').slice(0, 16),
|
|
122
|
-
identity,
|
|
123
|
-
manifest,
|
|
124
|
-
provenance,
|
|
125
|
-
receipt,
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export const __message = message
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
// Skill-side session-level admission of the session capability manager
|
|
2
|
-
// (design D2, tasks 4.6). ONE core check against the SERVER-SIDE current
|
|
3
|
-
// selection — the accepted authority snapshot, never a client-filtered list —
|
|
4
|
-
// backs three blocking paths:
|
|
5
|
-
// 1. `skill` tool calls (admit/loadBody): an unselected skill gets an
|
|
6
|
-
// explicit 'unavailable' result and its body is NEVER loaded.
|
|
7
|
-
// 2. Slash submissions (validateSlash): the server-side revalidation mount
|
|
8
|
-
// point for the slash pipeline — a client-side filtered list can never
|
|
9
|
-
// smuggle an unselected skill through.
|
|
10
|
-
// 3. Subagent prompt publication (admitPromptPublication): a skill prompt
|
|
11
|
-
// may only be published into a child session while the skill is still
|
|
12
|
-
// selected at publication time.
|
|
13
|
-
// While the session's admission fence is held (an Apply is committing),
|
|
14
|
-
// not-yet-handed-off calls are refused; handed-off reads may complete, but a
|
|
15
|
-
// read whose authority changed before completion is revoked BEFORE any content
|
|
16
|
-
// is returned: read is not authorization. Admission writes nothing and audits
|
|
17
|
-
// nothing — durable policy events stay with the Apply and refresh engines.
|
|
18
|
-
import { isSegment } from './store/paths.js'
|
|
19
|
-
import { createSkillIdentity, skillIdentityKey } from './skill-identity.js'
|
|
20
|
-
|
|
21
|
-
const message = error => error instanceof Error ? error.message : String(error)
|
|
22
|
-
|
|
23
|
-
/** English user-facing denial messages (template layer). */
|
|
24
|
-
const MESSAGES = {
|
|
25
|
-
'invalid-session': () => 'The session is unavailable.',
|
|
26
|
-
'invalid-identity': name => `Skill "${name}" has an invalid identity.`,
|
|
27
|
-
'admission-fence-held': name => `Skill "${name}" is temporarily unavailable: the session selection is being updated.`,
|
|
28
|
-
'selection-authority-unavailable': name => `Skill "${name}" is unavailable: the session selection is not loaded.`,
|
|
29
|
-
'skill-not-selected': name => `Skill "${name}" is not selected for this session.`,
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @typedef {{ status: 'admitted', revision: number, identityKey: string }
|
|
34
|
-
* | { status: 'unavailable', reason: string, revision: number|null, message: string }} Admission
|
|
35
|
-
* @typedef {{ status: 'loaded', revision: number, content: unknown }
|
|
36
|
-
* | { status: 'revoked', reason: 'selection-revoked', revision: number|null, message: string }
|
|
37
|
-
* | { status: 'unavailable', reason: string, revision: number|null, message: string }} LoadResult
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @param {{
|
|
42
|
-
* authority: (sessionId: string) => unknown | Promise<unknown>,
|
|
43
|
-
* fence: { admit(scope: string): boolean, isHeld?(scope: string): boolean },
|
|
44
|
-
* trace?: (event: string, data?: unknown) => void,
|
|
45
|
-
* }} options
|
|
46
|
-
* `authority` returns the current in-memory authority snapshot
|
|
47
|
-
* ({ revision, selection: { skills } }) or null when not loaded; the Apply
|
|
48
|
-
* engine's authority() is the canonical source.
|
|
49
|
-
*/
|
|
50
|
-
export function createSkillAdmission(options) {
|
|
51
|
-
const { authority, fence, trace = () => {} } = options
|
|
52
|
-
if (typeof authority !== 'function') throw new TypeError('An authority accessor is required')
|
|
53
|
-
if (!fence || typeof fence.admit !== 'function') throw new TypeError('An admission fence is required')
|
|
54
|
-
|
|
55
|
-
/** Selected identity keys of an authority snapshot. @param {unknown} snapshot @returns {Set<string>|null} */
|
|
56
|
-
function selectedKeys(snapshot) {
|
|
57
|
-
const skills = snapshot !== null && typeof snapshot === 'object'
|
|
58
|
-
? /** @type {{ selection?: { skills?: unknown } }} */ (snapshot).selection?.skills
|
|
59
|
-
: null
|
|
60
|
-
if (!Array.isArray(skills)) return null
|
|
61
|
-
const keys = new Set()
|
|
62
|
-
for (const raw of skills) {
|
|
63
|
-
try { keys.add(skillIdentityKey(raw)) } catch { /* stray entries stay unauthorized */ }
|
|
64
|
-
}
|
|
65
|
-
return keys
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** @param {unknown} snapshot @returns {number|null} */
|
|
69
|
-
function revisionOf(snapshot) {
|
|
70
|
-
return snapshot !== null && typeof snapshot === 'object' && Number.isSafeInteger(/** @type {{ revision?: unknown }} */ (snapshot).revision)
|
|
71
|
-
? /** @type {number} */ (/** @type {{ revision: number }} */ (snapshot).revision)
|
|
72
|
-
: null
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** @param {string} reason @param {number|null} revision @param {string} name */
|
|
76
|
-
const denial = (reason, revision, name) => ({
|
|
77
|
-
status: /** @type {const} */ ('unavailable'), reason, revision,
|
|
78
|
-
message: (MESSAGES[reason] ?? MESSAGES['skill-not-selected'])(name),
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The single admission core: fence first (an Apply is committing), then the
|
|
83
|
-
* current authority snapshot, then exact identity membership. Exact
|
|
84
|
-
* identities only — the host still resolves skills by name; precision lives
|
|
85
|
-
* in the Weir manifest, never in a host-side identity lookup.
|
|
86
|
-
* @param {string} sessionId @param {unknown} identity
|
|
87
|
-
* @returns {Promise<Admission>}
|
|
88
|
-
*/
|
|
89
|
-
async function check(sessionId, identity) {
|
|
90
|
-
if (!isSegment(sessionId)) return denial('invalid-session', null, 'unknown')
|
|
91
|
-
/** @type {string} */ let key
|
|
92
|
-
/** @type {string} */ let name
|
|
93
|
-
try {
|
|
94
|
-
const value = createSkillIdentity(identity)
|
|
95
|
-
key = skillIdentityKey(value)
|
|
96
|
-
name = value.name
|
|
97
|
-
} catch (error) {
|
|
98
|
-
trace('admission-denied', { sessionId, reason: 'invalid-identity', error: message(error) })
|
|
99
|
-
return denial('invalid-identity', null, 'unknown')
|
|
100
|
-
}
|
|
101
|
-
if (!fence.admit(sessionId)) {
|
|
102
|
-
// The fence is held: not-yet-handed-off calls are refused while the
|
|
103
|
-
// session's Apply commits.
|
|
104
|
-
trace('admission-denied', { sessionId, reason: 'admission-fence-held', name })
|
|
105
|
-
return denial('admission-fence-held', null, name)
|
|
106
|
-
}
|
|
107
|
-
const snapshot = await authority(sessionId)
|
|
108
|
-
const revision = revisionOf(snapshot)
|
|
109
|
-
if (revision === null) {
|
|
110
|
-
// Fail closed: no loaded authority means nothing is authorized.
|
|
111
|
-
trace('admission-denied', { sessionId, reason: 'selection-authority-unavailable', name })
|
|
112
|
-
return denial('selection-authority-unavailable', null, name)
|
|
113
|
-
}
|
|
114
|
-
const selected = selectedKeys(snapshot) ?? new Set()
|
|
115
|
-
if (!selected.has(key)) {
|
|
116
|
-
trace('admission-denied', { sessionId, reason: 'skill-not-selected', name })
|
|
117
|
-
return denial('skill-not-selected', revision, name)
|
|
118
|
-
}
|
|
119
|
-
return { status: 'admitted', revision, identityKey: key }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
/** Path 1: `skill` tool call admission. */
|
|
124
|
-
admit: check,
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Path 2: slash submission revalidation. The server-side mount point for
|
|
128
|
-
* the slash pipeline: the submission is re-checked against the CURRENT
|
|
129
|
-
* selection even when the client already filtered its list.
|
|
130
|
-
* @param {string} sessionId @param {unknown} identity
|
|
131
|
-
* @returns {Promise<Admission>}
|
|
132
|
-
*/
|
|
133
|
-
async validateSlash(sessionId, identity) {
|
|
134
|
-
const result = await check(sessionId, identity)
|
|
135
|
-
trace('slash-revalidation', { sessionId, status: result.status, ...(result.status === 'unavailable' ? { reason: result.reason } : {}) })
|
|
136
|
-
return result
|
|
137
|
-
},
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Path 3: subagent prompt publication. A skill prompt may only be
|
|
141
|
-
* published into a child session under the current selection; a
|
|
142
|
-
* publication attempted after the skill's removal is refused.
|
|
143
|
-
* @param {string} sessionId @param {unknown} identity
|
|
144
|
-
* @returns {Promise<Admission>}
|
|
145
|
-
*/
|
|
146
|
-
async admitPromptPublication(sessionId, identity) {
|
|
147
|
-
const result = await check(sessionId, identity)
|
|
148
|
-
trace('prompt-publication', { sessionId, status: result.status, ...(result.status === 'unavailable' ? { reason: result.reason } : {}) })
|
|
149
|
-
return result
|
|
150
|
-
},
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Load a skill body under admission. The loader is invoked ONLY after
|
|
154
|
-
* admission (an unselected skill's body is never read); when the
|
|
155
|
-
* handed-off read completes, the authority is re-checked BEFORE any
|
|
156
|
-
* content is returned — a superseded revision or a removed identity
|
|
157
|
-
* revokes the publication. Read is not authorization.
|
|
158
|
-
* @param {string} sessionId @param {unknown} identity
|
|
159
|
-
* @param {() => Promise<unknown>} loader
|
|
160
|
-
* @returns {Promise<LoadResult>}
|
|
161
|
-
*/
|
|
162
|
-
async loadBody(sessionId, identity, loader) {
|
|
163
|
-
const admitted = await check(sessionId, identity)
|
|
164
|
-
if (admitted.status !== 'admitted') return admitted
|
|
165
|
-
// Handed-off read: it may finish even while a later Apply holds the
|
|
166
|
-
// fence; the publication check below is the gate that matters.
|
|
167
|
-
const content = await loader()
|
|
168
|
-
const snapshot = await authority(sessionId)
|
|
169
|
-
const revision = revisionOf(snapshot)
|
|
170
|
-
const selected = revision === null ? null : selectedKeys(snapshot)
|
|
171
|
-
if (revision !== admitted.revision || !selected?.has(admitted.identityKey)) {
|
|
172
|
-
trace('publication-revoked', { sessionId, from: admitted.revision, to: revision })
|
|
173
|
-
return {
|
|
174
|
-
status: 'revoked', reason: /** @type {const} */ ('selection-revoked'), revision,
|
|
175
|
-
message: 'The skill content was read under a superseded selection and will not be published.',
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return { status: 'loaded', revision: admitted.revision, content }
|
|
179
|
-
},
|
|
180
|
-
}
|
|
181
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
// Tasks 11.3/11.4/11.5 of the session-capability-manager change: the check
|
|
2
|
-
// cadence, the verified manual update, and the opt-in automatic update.
|
|
3
|
-
//
|
|
4
|
-
// 11.3 — checks are read-only: a 24h cadence with a single make-up check for
|
|
5
|
-
// an overdue startup, a manual "Check now" that either triggers immediately
|
|
6
|
-
// or joins the one in-flight check (at most one at any time), and a failure
|
|
7
|
-
// backoff of 48h / 96h capped at 7d. offline / unknown / private-source are
|
|
8
|
-
// reported separately from up-to-date. A check NEVER runs check/update/
|
|
9
|
-
// upgrade against a real install directory.
|
|
10
|
-
//
|
|
11
|
-
// 11.4 — a manual update runs only after explicit user confirmation (source
|
|
12
|
-
// and ref named), then verifies BEFORE the atomic swap: requested
|
|
13
|
-
// source/ref satisfied, expected identity at the expected location, content
|
|
14
|
-
// identity recorded, lock schema recognized. A tool reporting success while
|
|
15
|
-
// the post-conditions fail is recorded as a failure — nothing publishes and
|
|
16
|
-
// last-good stays.
|
|
17
|
-
//
|
|
18
|
-
// 11.5 — automatic updates are OFF by default. opt-in is an explicit grant
|
|
19
|
-
// per source/scope, never implied by a preset load, a check or one manual
|
|
20
|
-
// update. Before running: tool baseline, target layout, lock schema,
|
|
21
|
-
// provenance unchanged, no local edits — any failure pauses THAT entry with
|
|
22
|
-
// a report and no retry loop. A running session never auto-adopts published
|
|
23
|
-
// content.
|
|
24
|
-
|
|
25
|
-
export const CHECK_CADENCE_MS = 86_400_000
|
|
26
|
-
export const CHECK_BACKOFF_MS = Object.freeze([172_800_000, 345_600_000, 604_800_000])
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The check scheduler (11.3): at most one check in flight; manual joins the
|
|
30
|
-
* in-flight one; overdue startup makes up exactly once; failures back off.
|
|
31
|
-
*/
|
|
32
|
-
export function createCheckScheduler({ now = Date.now } = {}) {
|
|
33
|
-
let inFlight = false
|
|
34
|
-
let lastCheckAt = null
|
|
35
|
-
let failures = 0
|
|
36
|
-
return {
|
|
37
|
-
/** When the next scheduled check may run. */
|
|
38
|
-
nextAt() {
|
|
39
|
-
// No completed check yet: overdue since epoch, made up exactly once.
|
|
40
|
-
if (lastCheckAt === null) return 0
|
|
41
|
-
const interval = failures === 0 ? CHECK_CADENCE_MS : CHECK_BACKOFF_MS[Math.min(failures - 1, CHECK_BACKOFF_MS.length - 1)]
|
|
42
|
-
return lastCheckAt + interval
|
|
43
|
-
},
|
|
44
|
-
/**
|
|
45
|
-
* Whether a check should start; when one starts the caller must settle it.
|
|
46
|
-
* @param {'scheduled'|'manual'} reason
|
|
47
|
-
* @returns {{ start: boolean, joinedInFlight: boolean, reason: string|null }}
|
|
48
|
-
*/
|
|
49
|
-
requestCheck(reason) {
|
|
50
|
-
if (inFlight) return { start: false, joinedInFlight: true, reason: null }
|
|
51
|
-
if (reason === 'scheduled' && now() < this.nextAt()) return { start: false, joinedInFlight: false, reason: 'not-due' }
|
|
52
|
-
inFlight = true
|
|
53
|
-
return { start: true, joinedInFlight: false, reason: null }
|
|
54
|
-
},
|
|
55
|
-
settle(ok) {
|
|
56
|
-
inFlight = false
|
|
57
|
-
failures = ok ? 0 : failures + 1
|
|
58
|
-
lastCheckAt = now()
|
|
59
|
-
},
|
|
60
|
-
get inFlight() { return inFlight },
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The classified check outcome (11.3): offline / unknown / private-source
|
|
66
|
-
* are reported separately from up-to-date and from real changes.
|
|
67
|
-
* @param {'up-to-date'|'changed'|'offline'|'unknown'|'private-source'} outcome
|
|
68
|
-
*/
|
|
69
|
-
export function checkOutcomeOf(outcome) {
|
|
70
|
-
const known = ['up-to-date', 'changed', 'offline', 'unknown', 'private-source']
|
|
71
|
-
return known.includes(outcome) ? outcome : 'unknown'
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The manual-update post-conditions (11.4): every one must hold before the
|
|
76
|
-
* atomic swap; any failure records a failure and publishes nothing.
|
|
77
|
-
* @param {{ requestedSatisfied: boolean, identityAtLocation: boolean, contentIdentityRecorded: boolean, lockRecognized: boolean }} input
|
|
78
|
-
*/
|
|
79
|
-
export function manualUpdatePostconditions(input) {
|
|
80
|
-
const failures = []
|
|
81
|
-
if (!input.requestedSatisfied) failures.push('requested source/ref not satisfied by staging')
|
|
82
|
-
if (!input.identityAtLocation) failures.push('expected identity not at the expected location')
|
|
83
|
-
if (!input.contentIdentityRecorded) failures.push('content identity was not recorded')
|
|
84
|
-
if (!input.lockRecognized) failures.push('install-lock schema not recognized')
|
|
85
|
-
return failures.length === 0 ? { ok: true } : { ok: false, failures }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* The automatic-update gates (11.5): opt-in is a per source/scope grant and
|
|
90
|
-
* every precondition must hold; any failure pauses the entry (no retry loop).
|
|
91
|
-
* @param {{ optedIn: boolean, toolBaselineOk: boolean, layoutOk: boolean, lockRecognized: boolean, provenanceUnchanged: boolean, noLocalEdits: boolean }} input
|
|
92
|
-
*/
|
|
93
|
-
export function autoUpdateVerdict(input) {
|
|
94
|
-
if (!input.optedIn) return { run: false, reason: 'automatic updates are off for this source/scope (no explicit opt-in)' }
|
|
95
|
-
const gates = [
|
|
96
|
-
['toolBaselineOk', input.toolBaselineOk, 'executor baseline check failed'],
|
|
97
|
-
['layoutOk', input.layoutOk, 'target layout check failed'],
|
|
98
|
-
['lockRecognized', input.lockRecognized, 'install-lock schema not recognized'],
|
|
99
|
-
['provenanceUnchanged', input.provenanceUnchanged, 'provenance changed'],
|
|
100
|
-
['noLocalEdits', input.noLocalEdits, 'local edits present'],
|
|
101
|
-
]
|
|
102
|
-
for (const [, ok, reason] of gates) {
|
|
103
|
-
if (!ok) return { run: false, reason: `${reason} — this entry is paused and reported, no retry loop` }
|
|
104
|
-
}
|
|
105
|
-
return { run: true }
|
|
106
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/** Tool service for exactly one authenticated child channel. No local fs
|
|
2
|
-
* capability and no fallback. Parent must bind the other end before admission.
|
|
3
|
-
* @param {ReturnType<typeof import('./peer-client.js').createEditLockPeerClient>} client
|
|
4
|
-
* @param {object} agent
|
|
5
|
-
* @param {(agent:object)=>boolean} isRegisteredAgent */
|
|
6
|
-
export function createRemoteEditLockService(client, agent, isRegisteredAgent) {
|
|
7
|
-
let closed = false
|
|
8
|
-
/** @param {{agent:object,callId:string,signal:AbortSignal}} exec */
|
|
9
|
-
function authorize(exec) {
|
|
10
|
-
if (closed || exec.agent !== agent || !isRegisteredAgent(agent)) throw new Error('agent has no authenticated remote edit execution')
|
|
11
|
-
}
|
|
12
|
-
return Object.freeze({
|
|
13
|
-
service:Object.freeze({
|
|
14
|
-
/** @param {{agent:object,callId:string,signal:AbortSignal}} exec @param {unknown} request */
|
|
15
|
-
async publish(exec,request) {
|
|
16
|
-
authorize(exec)
|
|
17
|
-
return client.request('publish',exec.callId,request,exec.signal)
|
|
18
|
-
},
|
|
19
|
-
/** @param {{agent:object,callId:string,signal:AbortSignal}} exec @param {unknown} request */
|
|
20
|
-
async publishBatch(exec,request) {
|
|
21
|
-
authorize(exec)
|
|
22
|
-
return client.request('batch',exec.callId,request,exec.signal)
|
|
23
|
-
},
|
|
24
|
-
}),
|
|
25
|
-
// This only closes local admission; durable stop is acknowledged by parent.
|
|
26
|
-
close() {closed = true; client.close()},
|
|
27
|
-
})
|
|
28
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from 'node:fs'
|
|
4
|
-
import { tmpdir } from 'node:os'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
-
import { createLaneService } from './lanes.js'
|
|
7
|
-
import { renderChildContract } from './prompts.js'
|
|
8
|
-
|
|
9
|
-
// Exercise the real refusal/serialization path without spawning git processes.
|
|
10
|
-
async function fixture(t, states, maxActive = 4) {
|
|
11
|
-
const root = realpathSync(mkdtempSync(join(tmpdir(), 'worktree-guidance-')))
|
|
12
|
-
t.after(() => rmSync(root, { recursive: true, force: true }))
|
|
13
|
-
const commonDir = join(root, '.git')
|
|
14
|
-
mkdirSync(join(commonDir, 'info'), { recursive: true })
|
|
15
|
-
const lanes = states.map((state, i) => ({
|
|
16
|
-
id: `lane-${i}`, state, path: join(root, '.weir/worktrees', `lane-${i}`),
|
|
17
|
-
branch: `weir/lane-${i}`, base: { branch: 'main' }, scope: [],
|
|
18
|
-
}))
|
|
19
|
-
let adds = 0
|
|
20
|
-
const service = createLaneService({
|
|
21
|
-
git: {
|
|
22
|
-
version: async () => [2, 40, 0], supported: () => true,
|
|
23
|
-
repoOf: async () => ({ mainRoot: root, commonDir }),
|
|
24
|
-
currentBranch: async () => 'main', revParse: async () => 'base',
|
|
25
|
-
worktreeList: async () => [{ path: root }, ...lanes.map(({ path }) => ({ path }))],
|
|
26
|
-
worktreeAdd: async () => { adds++ },
|
|
27
|
-
},
|
|
28
|
-
settings: () => ({ enabled: true, root: '.weir/worktrees', maxActive, autoSetup: false }),
|
|
29
|
-
shellRun: null, ask: null, notify() {}, audit() {}, modeOf: () => false,
|
|
30
|
-
})
|
|
31
|
-
const repo = await service.repoFor(root)
|
|
32
|
-
await repo.ledger.replace({ schemaVersion: 1, seq: lanes.length, lanes })
|
|
33
|
-
return { service, repo, lanes, session: { id: 'owner', header: { cwd: root } }, adds: () => adds }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
test('MAX_ACTIVE lists every active lane and prefers the landable lane', async (t) => {
|
|
37
|
-
const h = await fixture(t, ['working', 'landable', 'ready', 'abandoned'], 3)
|
|
38
|
-
const before = h.repo.ledger.read()
|
|
39
|
-
await assert.rejects(h.service.open(h.session, { title: 'new' }), (error) => {
|
|
40
|
-
const payload = error.toJSON()
|
|
41
|
-
assert.equal(payload.code, 'MAX_ACTIVE')
|
|
42
|
-
assert.deepEqual(payload.data.lanes.map(({ id, state }) => ({ id, state })),
|
|
43
|
-
h.lanes.slice(0, 3).map(({ id, state }) => ({ id, state })))
|
|
44
|
-
for (const lane of payload.data.lanes) assert.ok(payload.message.includes(`${lane.id} · ${lane.state}`))
|
|
45
|
-
assert.match(payload.next.hint, /worktree_land.*lane-1/)
|
|
46
|
-
assert.match(payload.data.lanes[0].hint, /wait.*lane-0.*worktree_abandon/)
|
|
47
|
-
assert.doesNotMatch(payload.message, /lane-3/)
|
|
48
|
-
return true
|
|
49
|
-
})
|
|
50
|
-
assert.deepEqual(h.repo.ledger.read(), before)
|
|
51
|
-
assert.equal(h.adds(), 0)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test('MAX_ACTIVE without a landable lane recommends waiting or abandonment', async (t) => {
|
|
55
|
-
const h = await fixture(t, ['working'], 1)
|
|
56
|
-
await assert.rejects(h.service.open(h.session, { title: 'new' }), (error) => {
|
|
57
|
-
assert.equal(error.code, 'MAX_ACTIVE')
|
|
58
|
-
assert.match(error.next.hint, /wait.*lane-0.*worktree_abandon/)
|
|
59
|
-
assert.doesNotMatch(error.next.hint, /worktree_land/)
|
|
60
|
-
return true
|
|
61
|
-
})
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('SCOPE_OVERLAP serializes only overlapping globs from both scopes and recovery', async (t) => {
|
|
65
|
-
const h = await fixture(t, ['working'])
|
|
66
|
-
await h.repo.ledger.update((ledger) => {
|
|
67
|
-
ledger.lanes[0].scope = ['src/auth/**', 'docs/**', 'src/shared/**']
|
|
68
|
-
return { ledger }
|
|
69
|
-
})
|
|
70
|
-
const before = h.repo.ledger.read()
|
|
71
|
-
await assert.rejects(h.service.open(h.session, { title: 'new', scope: ['src/**', 'test/**'] }), (error) => {
|
|
72
|
-
const payload = error.toJSON()
|
|
73
|
-
assert.equal(payload.code, 'SCOPE_OVERLAP')
|
|
74
|
-
assert.equal(payload.lane, 'lane-0')
|
|
75
|
-
assert.deepEqual(payload.data.overlapping, { scope: ['src/**'], laneScope: ['src/auth/**', 'src/shared/**'] })
|
|
76
|
-
assert.match(payload.message, /narrow the new scope/)
|
|
77
|
-
assert.match(payload.message, /wait for lane lane-0 to land/)
|
|
78
|
-
assert.match(payload.next.hint, /src\/auth\/\*\*/)
|
|
79
|
-
return true
|
|
80
|
-
})
|
|
81
|
-
assert.deepEqual(h.repo.ledger.read(), before)
|
|
82
|
-
assert.equal(h.adds(), 0)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
test('worker guidance is present only in the write contract; read-only contract stays exact', () => {
|
|
86
|
-
const lane = { id: 'lane-1', path: '/repo/lane-1', branch: 'weir/lane-1', base: { branch: 'main' }, scope: ['src/**'] }
|
|
87
|
-
const write = renderChildContract(lane, { readOnly: false })
|
|
88
|
-
const read = renderChildContract(lane, { readOnly: true })
|
|
89
|
-
for (const line of [
|
|
90
|
-
'Report blockers and outcomes in your final report; do not send_message to the parent (its id is not available to you).',
|
|
91
|
-
'Run every command, tests included, with workdir at the lane root; the integration-test root resolves lane-locally by default.',
|
|
92
|
-
]) {
|
|
93
|
-
assert.ok(write.includes(line))
|
|
94
|
-
assert.ok(!read.includes(line))
|
|
95
|
-
}
|
|
96
|
-
assert.equal(read, `<lane id="lane-1">
|
|
97
|
-
You are investigating lane lane-1 at /repo/lane-1 (branch weir/lane-1).
|
|
98
|
-
- Pass workdir="/repo/lane-1" (or a directory inside it) on every shell call; use absolute paths under it for reads.
|
|
99
|
-
</lane>`)
|
|
100
|
-
})
|