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.
- package/cordis.patch.yml +0 -2
- 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 +5 -2
- 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-office-adapter.js +7 -1
- 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/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +25 -27
- package/src/delegate/index.js +21 -2
- package/src/delegate/rehydrate.js +24 -4
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +35 -6
- package/src/delegate/tool.js +29 -4
- 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} +23 -6
- 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 +12 -0
- package/src/shared/child-scope.js +3 -1
- 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
|
@@ -8,6 +8,7 @@ declare module 'node:fs' {
|
|
|
8
8
|
export function renameSync(from: string, to: string): void;
|
|
9
9
|
export function unlinkSync(path: string): void;
|
|
10
10
|
export function fsyncSync(fd: number): void;
|
|
11
|
+
export function linkSync(existingPath: string, newPath: string): void;
|
|
11
12
|
}
|
|
12
13
|
declare module 'node:fs/promises' {
|
|
13
14
|
export function link(existingPath: string, newPath: string): Promise<void>;
|
|
@@ -23,10 +24,14 @@ declare module 'node:child_process' {
|
|
|
23
24
|
}
|
|
24
25
|
declare module 'node:os' {
|
|
25
26
|
export function hostname(): string;
|
|
27
|
+
export function homedir(): string;
|
|
26
28
|
}
|
|
27
29
|
declare var process: {
|
|
28
30
|
pid: number;
|
|
29
31
|
platform: string;
|
|
32
|
+
execPath: string;
|
|
33
|
+
cwd(): string;
|
|
34
|
+
env: Record<string, string | undefined>;
|
|
30
35
|
kill(pid: number, signal?: number): void;
|
|
31
36
|
};
|
|
32
37
|
|
|
@@ -106,3 +106,55 @@ export function instrumentFs(inner, hook) {
|
|
|
106
106
|
}
|
|
107
107
|
return /** @type {StoreFs} */ (wrapped)
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
// ---- Synchronous counterpart (capabilities-deepening D4) ---------------------
|
|
111
|
+
// The lifecycle listener may not yield the event loop (G4b), so the store's
|
|
112
|
+
// sync commit/read path needs blocking I/O with the SAME durability
|
|
113
|
+
// discipline as the async Adapter above: full-content write, file fsync,
|
|
114
|
+
// link/rename publication, directory fsync (POSIX-only — the same platform
|
|
115
|
+
// stance as fsyncDir). One seam, so a test can wrap these calls exactly like
|
|
116
|
+
// instrumentFs wraps the async ones.
|
|
117
|
+
|
|
118
|
+
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @typedef {{
|
|
122
|
+
* readTextSync(path: string): string,
|
|
123
|
+
* writeFullSync(path: string, text: string): void,
|
|
124
|
+
* fsyncFileSync(path: string): void,
|
|
125
|
+
* linkSync(from: string, to: string): void,
|
|
126
|
+
* renameSync(from: string, to: string): void,
|
|
127
|
+
* unlinkSync(path: string): void,
|
|
128
|
+
* mkdirpSync(path: string): void,
|
|
129
|
+
* fsyncDirSync(path: string): void,
|
|
130
|
+
* }} StoreSyncFs
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/** @returns {StoreSyncFs} */
|
|
134
|
+
export function createNodeSyncFs() {
|
|
135
|
+
return {
|
|
136
|
+
readTextSync: path => readFileSync(path, 'utf8'),
|
|
137
|
+
writeFullSync(path, text) { writeFileSync(path, text, { mode: 0o600 }) },
|
|
138
|
+
fsyncFileSync(path) {
|
|
139
|
+
const fd = openSync(path, 'r+')
|
|
140
|
+
try {
|
|
141
|
+
fsyncSync(fd)
|
|
142
|
+
} finally {
|
|
143
|
+
closeSync(fd)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
linkSync: (from, to) => linkSync(from, to),
|
|
147
|
+
renameSync: (from, to) => renameSync(from, to),
|
|
148
|
+
unlinkSync: path => unlinkSync(path),
|
|
149
|
+
mkdirpSync(path) { mkdirSync(path, { recursive: true, mode: 0o700 }) },
|
|
150
|
+
fsyncDirSync(path) {
|
|
151
|
+
if (!DIR_SYNC_SUPPORTED) return
|
|
152
|
+
const fd = openSync(path, 'r')
|
|
153
|
+
try {
|
|
154
|
+
fsyncSync(fd)
|
|
155
|
+
} finally {
|
|
156
|
+
closeSync(fd)
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -264,3 +264,89 @@ export function createLockProtocol(options) {
|
|
|
264
264
|
|
|
265
265
|
return { acquire, release, cleanup, inspect, clear }
|
|
266
266
|
}
|
|
267
|
+
|
|
268
|
+
// ---- Synchronous nominal-lease variant (capabilities-deepening D4) -----------
|
|
269
|
+
// For callers that may not yield the event loop (the agent/created lifecycle
|
|
270
|
+
// listener): the SAME publication convention as the async protocol above —
|
|
271
|
+
// private candidate + fsync + link (EEXIST = contention), the same OwnerDoc
|
|
272
|
+
// schema, remove-only-if-ours release — but with NO backoff and NO
|
|
273
|
+
// reclamation: contention fails closed, and a crashed holder's leftover lock
|
|
274
|
+
// becomes reclaimable to the async protocol after the nominal lease expires
|
|
275
|
+
// ('sync-capture' marks an owner whose start identity was never probed, so a
|
|
276
|
+
// same-process leftover reads as dead once the lease lapses).
|
|
277
|
+
|
|
278
|
+
import { hostname } from 'node:os'
|
|
279
|
+
import { createNodeSyncFs } from './fs-adapter.js'
|
|
280
|
+
|
|
281
|
+
/** Nominal lease of a synchronously acquired lock (acquired, used, released in one segment). */
|
|
282
|
+
export const SYNC_LOCK_LEASE_MS = 10_000
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param {{ fs?: import('./fs-adapter.js').StoreSyncFs, now?: () => number, token?: () => string }} [options]
|
|
286
|
+
*/
|
|
287
|
+
export function createSyncLock(options = {}) {
|
|
288
|
+
const fs = options.fs ?? createNodeSyncFs()
|
|
289
|
+
const now = options.now ?? Date.now
|
|
290
|
+
const token = options.token ?? (() => randomBytes(12).toString('hex'))
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Acquire `<dir>/<name>.lock` synchronously or fail closed. Never writes
|
|
294
|
+
* the unit record. Contention (EEXIST) is a 'locked' refusal, not a wait.
|
|
295
|
+
* @param {string} dir @param {string} name
|
|
296
|
+
* @returns {{ ok: true, token: string } | { ok: false, reason: 'locked' }}
|
|
297
|
+
*/
|
|
298
|
+
function acquire(dir, name) {
|
|
299
|
+
const ownerToken = token()
|
|
300
|
+
const lockFile = join(dir, `${name}.lock`)
|
|
301
|
+
const candidate = `${lockFile}.${ownerToken}.cand`
|
|
302
|
+
const at = now()
|
|
303
|
+
/** @type {OwnerDoc} */
|
|
304
|
+
const doc = {
|
|
305
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
306
|
+
ownerToken,
|
|
307
|
+
pid: process.pid,
|
|
308
|
+
host: hostname(),
|
|
309
|
+
startIdentity: { osStart: null, bootNonce: 'sync-capture' },
|
|
310
|
+
acquiredAt: at,
|
|
311
|
+
leaseUntil: at + SYNC_LOCK_LEASE_MS,
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
fs.writeFullSync(candidate, JSON.stringify(doc))
|
|
315
|
+
fs.fsyncFileSync(candidate)
|
|
316
|
+
fs.linkSync(candidate, lockFile)
|
|
317
|
+
} catch (error) {
|
|
318
|
+
try { fs.unlinkSync(candidate) } catch { /* the candidate may never have landed */ }
|
|
319
|
+
// EEXIST = the lock is held; ENOENT = the holder's orphan sweep removed
|
|
320
|
+
// our candidate mid-race (the async publish() convention) — both mean
|
|
321
|
+
// this round is lost, and the caller fails closed or retries.
|
|
322
|
+
if (hasCode(error, 'EEXIST', 'ENOENT')) return { ok: false, reason: /** @type {const} */ ('locked') }
|
|
323
|
+
throw error
|
|
324
|
+
}
|
|
325
|
+
// Candidate cleanup is best-effort (the async publish() convention): a
|
|
326
|
+
// leftover candidate is swept by the next async acquire's cleanup.
|
|
327
|
+
try { fs.unlinkSync(candidate) } catch { /* best effort */ }
|
|
328
|
+
return { ok: true, token: ownerToken }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Remove `<dir>/<name>.lock` only while it still names our token. @param {string} dir @param {string} name @param {string} ownerToken */
|
|
332
|
+
function release(dir, name, ownerToken) {
|
|
333
|
+
const lockFile = join(dir, `${name}.lock`)
|
|
334
|
+
let text
|
|
335
|
+
try {
|
|
336
|
+
text = fs.readTextSync(lockFile)
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (hasCode(error, 'ENOENT')) return false
|
|
339
|
+
throw error
|
|
340
|
+
}
|
|
341
|
+
const doc = parseOwnerDoc(text)
|
|
342
|
+
if (doc.kind === 'ok' && doc.value.ownerToken !== ownerToken) return false
|
|
343
|
+
try {
|
|
344
|
+
fs.unlinkSync(lockFile)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (!hasCode(error, 'ENOENT')) throw error
|
|
347
|
+
}
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { acquire, release }
|
|
352
|
+
}
|
|
@@ -33,6 +33,7 @@ export function resolveStoreRoot(profileContext) {
|
|
|
33
33
|
* @typedef {{ kind: 'selection', sessionId: string }
|
|
34
34
|
* | { kind: 'content', sessionId: string }
|
|
35
35
|
* | { kind: 'inherited', sessionId: string }
|
|
36
|
+
* | { kind: 'notice', sessionId: string }
|
|
36
37
|
* | { kind: 'defaults', workspaceKey: string }
|
|
37
38
|
* | { kind: 'preset', scope: 'global', presetId: string }
|
|
38
39
|
* | { kind: 'preset', scope: 'workspace', workspaceKey: string, presetId: string }
|
|
@@ -55,6 +56,10 @@ export function unitLayout(unit) {
|
|
|
55
56
|
case 'selection':
|
|
56
57
|
case 'content':
|
|
57
58
|
case 'inherited':
|
|
59
|
+
// The pending model-facing notice (durable-pending-notice D1): its own
|
|
60
|
+
// unit, so the accepted selection record's revision — the client's CAS
|
|
61
|
+
// token — is never touched by delivery bookkeeping. Absent = nothing owed.
|
|
62
|
+
case 'notice':
|
|
58
63
|
need(u.sessionId)
|
|
59
64
|
return layout(['sessions', /** @type {string} */ (u.sessionId)], u.kind)
|
|
60
65
|
case 'defaults':
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
// read(unit), commit(unit, expectedRevision, mutate) and publishPointer(scope,
|
|
3
3
|
// next). Locking, stale-lock reclamation, temp cleanup, schema checks and the
|
|
4
4
|
// platform gate stay inside; the filesystem Adapter is the only seam.
|
|
5
|
+
// A committed result also reports the payload its CAS replaced (`committedOver`,
|
|
6
|
+
// read under the same per-unit lock that performed the write): the store is the
|
|
7
|
+
// only component that can prove a write's "before" state without a race, so no
|
|
8
|
+
// caller may re-derive it from an unlocked read.
|
|
5
9
|
import { join } from 'node:path'
|
|
6
|
-
import { createNodeFs } from './fs-adapter.js'
|
|
10
|
+
import { createNodeFs, createNodeSyncFs } from './fs-adapter.js'
|
|
7
11
|
import { createLiveness } from './liveness.js'
|
|
8
|
-
import { createLockProtocol } from './lock.js'
|
|
12
|
+
import { createLockProtocol, createSyncLock } from './lock.js'
|
|
9
13
|
import { validPresetPayload } from './enumerate.js'
|
|
10
14
|
import { isFileSegment, isSegment, resolveStoreRoot, unitLayout } from './paths.js'
|
|
11
15
|
import { decodeRecord, digestOf, encodeRecord } from './record.js'
|
|
@@ -23,7 +27,7 @@ export const SUPPORTED_PLATFORMS = Object.freeze(['darwin', 'win32'])
|
|
|
23
27
|
* @typedef {import('./record.js').Receipt} Receipt
|
|
24
28
|
* @typedef {import('./record.js').RecordRead} RecordRead
|
|
25
29
|
* @typedef {{ requestId?: string, requestDigest?: string }} RequestRef
|
|
26
|
-
* @typedef {{ status: 'committed', revision: number, payload: unknown, receipt: Receipt|null }
|
|
30
|
+
* @typedef {{ status: 'committed', revision: number, payload: unknown, receipt: Receipt|null, committedOver: unknown }
|
|
27
31
|
* | { status: 'duplicate', revision: number, receipt: Receipt }
|
|
28
32
|
* | { status: 'revision-conflict', revision: number }
|
|
29
33
|
* | { status: 'request-conflict', receipt: Receipt }
|
|
@@ -61,6 +65,11 @@ export function openCapabilityStore(options) {
|
|
|
61
65
|
? { supported: false, reason: `platform-unsupported:${platform}` }
|
|
62
66
|
: /** @type {any} */ (located)
|
|
63
67
|
const lock = createLockProtocol({ fs, liveness, leaseMs: options.leaseMs, deadlineMs: options.deadlineMs, now, token: options.token })
|
|
68
|
+
// The synchronous path (capabilities-deepening D4): same root/layout/record
|
|
69
|
+
// schema, the sync fs counterpart and the nominal-lease sync lock — acquired,
|
|
70
|
+
// used and released in one blocking segment, contention fails closed.
|
|
71
|
+
const syncFs = options.syncFs ?? createNodeSyncFs()
|
|
72
|
+
const syncLock = createSyncLock({ fs: syncFs, now, token: options.token })
|
|
64
73
|
/** @type {Map<string, Promise<unknown>>} */
|
|
65
74
|
const queues = new Map()
|
|
66
75
|
|
|
@@ -243,7 +252,9 @@ export function openCapabilityStore(options) {
|
|
|
243
252
|
const settled = await readRecord(record).catch(() => null)
|
|
244
253
|
if (settled?.kind !== 'ok' || settled.digest !== next.digest) return { status: 'write-failed', error }
|
|
245
254
|
}
|
|
246
|
-
|
|
255
|
+
// The payload the CAS replaced, read under this same lock — the only
|
|
256
|
+
// provable "before" authority of this write (undefined: no record).
|
|
257
|
+
return { status: 'committed', revision, payload, receipt, committedOver: current.kind === 'ok' ? current.payload : undefined }
|
|
247
258
|
} finally {
|
|
248
259
|
await lock.release(dir, layout.name, acquired.token).catch(() => {})
|
|
249
260
|
}
|
|
@@ -302,5 +313,94 @@ export function openCapabilityStore(options) {
|
|
|
302
313
|
return serialize(layout.key, () => lock.clear(dir, layout.name, which, ownerToken))
|
|
303
314
|
}
|
|
304
315
|
|
|
305
|
-
|
|
316
|
+
/** Synchronous record read — the same decode/fail-closed semantics as
|
|
317
|
+
* readRecord, blocking. @param {string} path @returns {RecordRead} */
|
|
318
|
+
function readRecordSync(path) {
|
|
319
|
+
let text
|
|
320
|
+
try {
|
|
321
|
+
text = syncFs.readTextSync(path)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (hasCode(error, 'ENOENT')) return { kind: 'absent', revision: 0 }
|
|
324
|
+
throw error
|
|
325
|
+
}
|
|
326
|
+
return decodeRecord(text)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Synchronous read (capabilities-deepening D4): the listener path may not
|
|
331
|
+
* yield the event loop. Same unit layout, same record schema, same
|
|
332
|
+
* fail-closed kinds as the async read.
|
|
333
|
+
* @param {Unit} unit
|
|
334
|
+
* @returns {RecordRead | { kind: 'unsupported', reason: string }}
|
|
335
|
+
*/
|
|
336
|
+
function readSync(unit) {
|
|
337
|
+
const { record } = locate(unit)
|
|
338
|
+
if (!support.supported) return { kind: 'unsupported', reason: support.reason }
|
|
339
|
+
return readRecordSync(record)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Synchronous CAS commit (capabilities-deepening D4), mirroring transact's
|
|
344
|
+
* step order: lock → dedup the request receipt → CAS the revision → mutate
|
|
345
|
+
* → temp+fsync → rename → directory fsync. The sync lock is the nominal-
|
|
346
|
+
* lease convention (acquired, used, released in one blocking segment):
|
|
347
|
+
* contention fails closed as 'locked' — never a backoff, never a second
|
|
348
|
+
* reclamation protocol. `mutate` must be synchronous.
|
|
349
|
+
* @param {Unit} unit
|
|
350
|
+
* @param {number} expectedRevision
|
|
351
|
+
* @param {(payload: unknown, info: { revision: number }) => unknown} mutate
|
|
352
|
+
* @param {RequestRef} [request]
|
|
353
|
+
* @returns {CommitResult}
|
|
354
|
+
*/
|
|
355
|
+
function commitSync(unit, expectedRevision, mutate, request = {}) {
|
|
356
|
+
const { layout, dir, record } = locate(unit)
|
|
357
|
+
if (!support.supported) return { status: 'unsupported', reason: support.reason }
|
|
358
|
+
const { requestId, requestDigest } = request
|
|
359
|
+
if (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0 || typeof requestDigest !== 'string')) {
|
|
360
|
+
throw new TypeError('requestId requires a non-empty id and a requestDigest')
|
|
361
|
+
}
|
|
362
|
+
syncFs.mkdirpSync(dir)
|
|
363
|
+
const acquired = syncLock.acquire(dir, layout.name)
|
|
364
|
+
if (!acquired.ok) return { status: 'locked', reason: acquired.reason }
|
|
365
|
+
try {
|
|
366
|
+
const current = readRecordSync(record)
|
|
367
|
+
if (current.kind !== 'ok' && current.kind !== 'absent') return { status: 'unreadable', kind: current.kind }
|
|
368
|
+
const receipts = current.kind === 'ok' ? current.receipts : []
|
|
369
|
+
if (requestId !== undefined) {
|
|
370
|
+
const prior = receipts.find(r => r.requestId === requestId)
|
|
371
|
+
if (prior) return prior.requestDigest === requestDigest ? { status: 'duplicate', revision: prior.revision, receipt: prior } : { status: 'request-conflict', receipt: prior }
|
|
372
|
+
}
|
|
373
|
+
if (current.revision !== expectedRevision) return { status: 'revision-conflict', revision: current.revision }
|
|
374
|
+
const revision = current.revision + 1
|
|
375
|
+
const payload = mutate(current.kind === 'ok' ? current.payload : undefined, { revision: current.revision })
|
|
376
|
+
/** @type {Receipt|null} */
|
|
377
|
+
const receipt = requestId === undefined ? null : { requestId, requestDigest: /** @type {string} */ (requestDigest), revision, acceptedAt: now() }
|
|
378
|
+
const next = encodeRecord(revision, payload, receipt ? [...receipts, receipt] : receipts)
|
|
379
|
+
const temp = join(dir, `${layout.name}.${acquired.token}.tmp`)
|
|
380
|
+
try {
|
|
381
|
+
syncFs.writeFullSync(temp, JSON.stringify(next))
|
|
382
|
+
syncFs.fsyncFileSync(temp)
|
|
383
|
+
} catch (error) {
|
|
384
|
+
try { syncFs.unlinkSync(temp) } catch { /* the temp may never have landed */ }
|
|
385
|
+
return { status: 'write-failed', error }
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
syncFs.renameSync(temp, record)
|
|
389
|
+
syncFs.fsyncDirSync(dir)
|
|
390
|
+
} catch (error) {
|
|
391
|
+
try { syncFs.unlinkSync(temp) } catch { /* the temp may never have landed */ }
|
|
392
|
+
if (hasCode(error, 'EXDEV')) return { status: 'unsupported', reason: 'cross-device' }
|
|
393
|
+
// Same indeterminate settle-check as the async path: the rename may or
|
|
394
|
+
// may not have landed — only a digest match proves it did.
|
|
395
|
+
let settled
|
|
396
|
+
try { settled = readRecordSync(record) } catch { settled = null }
|
|
397
|
+
if (settled?.kind !== 'ok' || settled.digest !== next.digest) return { status: 'write-failed', error }
|
|
398
|
+
}
|
|
399
|
+
// Same committed-over authority as the async path (one typedef, both writes).
|
|
400
|
+
return { status: 'committed', revision, payload, receipt, committedOver: current.kind === 'ok' ? current.payload : undefined }
|
|
401
|
+
} finally {
|
|
402
|
+
try { syncLock.release(dir, layout.name, acquired.token) } catch { /* release is best-effort */ }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return { support, read, commit, commitSync, readSync, publishPointer, inspect, clear, withPresetNamespace, withSelectionSnapshot }
|
|
306
406
|
}
|
|
@@ -20,7 +20,7 @@ Call \`cordis_inspect_list\` first to enumerate the Host and Client Inspect Prov
|
|
|
20
20
|
|
|
21
21
|
## Manage plugins deliberately
|
|
22
22
|
|
|
23
|
-
\`plugin_manager\` lists, installs, enables, disables, and removes profile bundles and plugins. List first to obtain exact identifiers. Changes affect every session in the profile and apply immediately in a live profile. To re-apply a changed bundle, disable then enable it (\`set_bundle\`) — repeating \`install_bundle\` for
|
|
23
|
+
\`plugin_manager\` lists, installs, enables, disables, and removes profile bundles and plugins. List first to obtain exact identifiers. Changes affect every session in the profile and apply immediately in a live profile. To re-apply a changed bundle, disable then enable it (\`set_bundle\`) — repeating \`install_bundle\` for a known, already-installed bundle is refused up front as \`already-installed\`. A version exemption risks crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.
|
|
24
24
|
|
|
25
25
|
## Load the development skills
|
|
26
26
|
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Child composition shim: the creation-window replacement for the
|
|
2
|
+
// request-level `persona`/`toolFilter` fields DSH 0.2.1 removed from
|
|
3
|
+
// `subagents.startActivation`. The delegate module reserves the childId before
|
|
4
|
+
// spawning, registers the composition here, and the `agent/created` listener
|
|
5
|
+
// applies it inside the child's creation window — before the subagent runtime
|
|
6
|
+
// delivers the initial prompt (startLocal → materialize → agents.create →
|
|
7
|
+
// setup → announce → submitMaterialized, verified against 0.2.1).
|
|
8
|
+
//
|
|
9
|
+
// Timing is race-free by construction and fail-closed: a fresh spawn waits for
|
|
10
|
+
// the recorded verdict and tears the child down when the composition did not
|
|
11
|
+
// land, so no child ever runs with the orchestrator-tool deny list missing.
|
|
12
|
+
//
|
|
13
|
+
// Cold resume: the applied sections/restrictions are runtime state of one
|
|
14
|
+
// agent incarnation, so a host restart loses them. Every spawn therefore also
|
|
15
|
+
// writes a `delegate/spawn` fact on the shared audit channel (the cold-safe
|
|
16
|
+
// channel — never a custom session event type, S13) and this listener replays
|
|
17
|
+
// the composition for a resumed child whose fact is still in the audit tail.
|
|
18
|
+
// Missing fact: persona/allow-lists degrade fail-open (same posture as the
|
|
19
|
+
// supervision rebuild), while the orchestrator-tool deny floor is still applied
|
|
20
|
+
// whenever the child is attributable to a weir-managed parent session.
|
|
21
|
+
import { AUDIT_TYPES, AUDIT_SUBTYPES } from '../shared/audit.js'
|
|
22
|
+
import { auditFilePathOf, readAuditTail } from '../shared/terminal-evidence.js'
|
|
23
|
+
import { CHILD_DENY_TOOLS, childToolFilter } from '../shared/child-scope.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The spawn-fact audit type AS PASSED TO the audit sink: UNPREFIXED, because
|
|
27
|
+
* createAudit prepends `weir/` itself (see src/shared/audit.js). Mirrors the
|
|
28
|
+
* AUDIT_TYPES/AUDIT_SUBTYPES registry; passing a pre-prefixed value here is
|
|
29
|
+
* what produced `weir/weir/delegate/spawn` records in the wild.
|
|
30
|
+
*/
|
|
31
|
+
export const SPAWN_FACT_TYPE = `${AUDIT_TYPES.delegate}/${AUDIT_SUBTYPES.delegate[0]}`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The spawn-fact type AS STORED in the audit channel (JSONL record + cordis
|
|
35
|
+
* event name): the sink's single `weir/` prefix is part of the record, so every
|
|
36
|
+
* comparison against a stored `record.type` must use THIS constant.
|
|
37
|
+
*/
|
|
38
|
+
export const SPAWN_FACT_RECORD_TYPE = `weir/${SPAWN_FACT_TYPE}`
|
|
39
|
+
|
|
40
|
+
/** Deployment persona prefix section (dsh-system-prompt / dsh-persona contract). */
|
|
41
|
+
const PERSONA_PREFIX_SECTION = 'deployment:persona-prefix'
|
|
42
|
+
const PERSONA_PREFIX_ORDER = 'DEPLOYMENT_PERSONA_PREFIX'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} SpawnComposition
|
|
46
|
+
* @property {string} [childId]
|
|
47
|
+
* @property {string} [lane] - one of 'one-shot' | 'continuable' | 'supervised'
|
|
48
|
+
* @property {string} [label]
|
|
49
|
+
* @property {string} persona - full persona text applied as the child's prefix section
|
|
50
|
+
* @property {{ allow?: string[], deny?: string[] }} [toolFilter] - requested child tool filter
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mount the composition listener on one plugin ctx.
|
|
55
|
+
* @param {object} deps
|
|
56
|
+
* @param {any} deps.ctx - plugin context (on / tools)
|
|
57
|
+
* @param {(session: object | null, type: string, data?: unknown) => void} deps.audit - createAudit sink
|
|
58
|
+
* @param {{ warn?: (message: string) => void }} [deps.logger]
|
|
59
|
+
* @param {() => Set<string> | undefined} [deps.restrictableNames] - fallback composition view
|
|
60
|
+
* @param {(fact: { session?: object | null, childId: string, lane?: string }) => void} [deps.onSpawn] - live spawn-fact sink (supervision catalog cross-check)
|
|
61
|
+
* @returns {{ beginSpawn: (composition: SpawnComposition & { session?: object | null }) => { outcome: () => { ok: boolean, error?: Error }, forget: () => void }, dispose: () => void }}
|
|
62
|
+
*/
|
|
63
|
+
export function createChildComposition({ ctx, audit, logger, restrictableNames, onSpawn }) {
|
|
64
|
+
/** @type {Map<string, { session: object | null, childId: string, lane: string, label: string, persona: string, toolFilter?: object, applied: boolean, error?: Error }>} */
|
|
65
|
+
const pending = new Map()
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reserve one composition and write its cold-safe spawn fact. Called
|
|
69
|
+
* synchronously BEFORE startActivation: the child's creation window runs
|
|
70
|
+
* inside that call, so the entry is always present when the listener fires.
|
|
71
|
+
* @param {SpawnComposition & { session?: object | null }} composition
|
|
72
|
+
*/
|
|
73
|
+
function beginSpawn(composition) {
|
|
74
|
+
const entry = {
|
|
75
|
+
session: composition.session ?? null,
|
|
76
|
+
childId: composition.childId,
|
|
77
|
+
lane: composition.lane ?? 'one-shot',
|
|
78
|
+
label: composition.label ?? '',
|
|
79
|
+
persona: composition.persona ?? '',
|
|
80
|
+
toolFilter: composition.toolFilter,
|
|
81
|
+
applied: false,
|
|
82
|
+
error: undefined,
|
|
83
|
+
}
|
|
84
|
+
pending.set(entry.childId, entry)
|
|
85
|
+
// Audit mirrors the fact to <cwd>/.weir/audit.jsonl (cold-safe replay) and
|
|
86
|
+
// emits it in-process. Never throws.
|
|
87
|
+
const fact = {
|
|
88
|
+
childId: entry.childId,
|
|
89
|
+
lane: entry.lane,
|
|
90
|
+
label: entry.label,
|
|
91
|
+
persona: entry.persona,
|
|
92
|
+
...(entry.toolFilter ? { toolFilter: entry.toolFilter } : {}),
|
|
93
|
+
}
|
|
94
|
+
audit(entry.session, SPAWN_FACT_TYPE, fact)
|
|
95
|
+
// Live consumers (the supervision catalog cross-check) see the same fact
|
|
96
|
+
// without re-reading the file.
|
|
97
|
+
try {
|
|
98
|
+
onSpawn?.({ session: entry.session, childId: entry.childId, lane: entry.lane })
|
|
99
|
+
} catch {
|
|
100
|
+
// a live sink failure must never break the spawn
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
outcome: () => (entry.applied
|
|
104
|
+
? { ok: true }
|
|
105
|
+
: {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: entry.error ?? new Error(`child "${entry.childId}": the composition was not applied inside the creation window`),
|
|
108
|
+
}),
|
|
109
|
+
forget: () => {
|
|
110
|
+
if (pending.get(entry.childId) === entry) pending.delete(entry.childId)
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Apply a composition to one live child agent (throws on any failure). */
|
|
116
|
+
function apply(agent, composition) {
|
|
117
|
+
const systemPrompt = agent?.ctx?.systemPrompt
|
|
118
|
+
const tools = agent?.ctx?.tools
|
|
119
|
+
if (typeof composition.persona === 'string' && composition.persona.length > 0) {
|
|
120
|
+
if (typeof systemPrompt?.section !== 'function') {
|
|
121
|
+
throw new Error('the child agent exposes no systemPrompt service')
|
|
122
|
+
}
|
|
123
|
+
// Same section name the deployment/preset persona uses, so the child's
|
|
124
|
+
// own layer shadows it instead of duplicating a prompt paragraph.
|
|
125
|
+
systemPrompt.section({
|
|
126
|
+
name: PERSONA_PREFIX_SECTION,
|
|
127
|
+
order: systemPrompt.getSectionOrder(PERSONA_PREFIX_ORDER),
|
|
128
|
+
text: composition.persona,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
const filter = composition.toolFilter
|
|
132
|
+
if (!filter) return
|
|
133
|
+
if (typeof tools?.restrict !== 'function') {
|
|
134
|
+
// An allow filter with no restriction channel would silently widen the
|
|
135
|
+
// child surface; the deny list is the safety floor. Fail closed.
|
|
136
|
+
throw new Error('the child agent exposes no tools service')
|
|
137
|
+
}
|
|
138
|
+
// tools.restrict() rejects unknown deny/allow names, so the deny list is
|
|
139
|
+
// merged against the CHILD scope's restrictable set (the composition's own
|
|
140
|
+
// view is only a fallback). childToolFilter keeps the existing merge
|
|
141
|
+
// semantics: allow-list filters pass through, every other filter gets
|
|
142
|
+
// CHILD_DENY_TOOLS intersected in, so the orchestrator-tool deny never
|
|
143
|
+
// degrades with the requested filter. The final deny list is then
|
|
144
|
+
// intersected with that same child set: a name the child scope never
|
|
145
|
+
// registered needs no deny (it is absent either way) and naming it would
|
|
146
|
+
// make restrict() throw.
|
|
147
|
+
const known = restrictableNamesOf(agent) ?? restrictableNames?.()
|
|
148
|
+
const merged = childToolFilter(filter, known)
|
|
149
|
+
const deny = Array.isArray(merged.deny) && known ? merged.deny.filter((name) => known.has(name)) : merged.deny
|
|
150
|
+
const effective = deny === merged.deny ? merged : { ...merged, deny }
|
|
151
|
+
const hasAllow = effective.allow !== undefined
|
|
152
|
+
const hasDeny = Array.isArray(effective.deny) && effective.deny.length > 0
|
|
153
|
+
if (!hasAllow && !hasDeny) return
|
|
154
|
+
tools.restrict(effective)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Resolve the child scope's restrictable tool names (undefined when unavailable). */
|
|
158
|
+
function restrictableNamesOf(agent) {
|
|
159
|
+
try {
|
|
160
|
+
const view = agent?.ctx?.tools?.view?.(agent)
|
|
161
|
+
if (view?.restrictableNames instanceof Set) return view.restrictableNames
|
|
162
|
+
} catch {
|
|
163
|
+
// view() failures fall back to the composition view
|
|
164
|
+
}
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Replay the composition of a cold-resumed child from the audit tail.
|
|
170
|
+
* @param {any} agent
|
|
171
|
+
* @param {object} header - the child session header
|
|
172
|
+
*/
|
|
173
|
+
function replayFromAudit(agent, header) {
|
|
174
|
+
const file = auditFilePathOf(agent.session)
|
|
175
|
+
const records = readAuditTail(file)
|
|
176
|
+
if (records.length === 0) return
|
|
177
|
+
let fact
|
|
178
|
+
let parentManaged = false
|
|
179
|
+
for (const record of records) {
|
|
180
|
+
if (!record || typeof record !== 'object') continue
|
|
181
|
+
if (record.session !== header.parentSession) continue
|
|
182
|
+
if (typeof record.type !== 'string' || !record.type.startsWith('weir/')) continue
|
|
183
|
+
parentManaged = true
|
|
184
|
+
if (record.type === SPAWN_FACT_RECORD_TYPE && record.data?.childId === agent.id) fact = record.data
|
|
185
|
+
}
|
|
186
|
+
if (!parentManaged) return // not a weir-managed parent session: not our child
|
|
187
|
+
if (!fact) {
|
|
188
|
+
// Degraded: the spawn fact aged out of the tail (or the audit channel was
|
|
189
|
+
// unavailable). Persona and allow-lists stay unapplied, but the
|
|
190
|
+
// orchestrator-tool deny floor never does.
|
|
191
|
+
logger?.warn?.(`weir-delegate: child "${agent.id}" has no delegate/spawn fact in the audit tail — replaying the static orchestrator-tool deny floor only`)
|
|
192
|
+
applyDenyFloor(agent)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
apply(agent, fact)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
logger?.warn?.(`weir-delegate: child "${agent.id}" composition replay failed (fail-open): ${String(error?.message ?? error)}`)
|
|
199
|
+
applyDenyFloor(agent)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Apply only the static orchestrator-tool deny floor (best-effort, warns). */
|
|
204
|
+
function applyDenyFloor(agent) {
|
|
205
|
+
try {
|
|
206
|
+
const known = restrictableNamesOf(agent) ?? restrictableNames?.()
|
|
207
|
+
const deny = known ? CHILD_DENY_TOOLS.filter((name) => known.has(name)) : [...CHILD_DENY_TOOLS]
|
|
208
|
+
if (deny.length === 0) return
|
|
209
|
+
agent.ctx.tools.restrict({ deny })
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logger?.warn?.(`weir-delegate: child "${agent.id}" deny-floor application failed: ${String(error?.message ?? error)}`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `agent/created` listener. A failure on the fresh-spawn path is thrown so
|
|
217
|
+
* the spawn call itself rejects (the runtime disposes the unpublished child
|
|
218
|
+
* after a rejected creation announcement); the recorded verdict lets the
|
|
219
|
+
* spawn's own fail-closed branch also catch a listener that never ran.
|
|
220
|
+
*/
|
|
221
|
+
function onAgentCreated(payload) {
|
|
222
|
+
const agent = payload?.agent
|
|
223
|
+
const header = agent?.session?.header
|
|
224
|
+
if (!header) return
|
|
225
|
+
if ((header.delegationDepth ?? 0) < 1) return
|
|
226
|
+
const childId = agent.id
|
|
227
|
+
const entry = pending.get(childId)
|
|
228
|
+
if (entry) {
|
|
229
|
+
pending.delete(childId)
|
|
230
|
+
try {
|
|
231
|
+
apply(agent, entry)
|
|
232
|
+
entry.applied = true
|
|
233
|
+
} catch (error) {
|
|
234
|
+
entry.error = compositionError(childId, error)
|
|
235
|
+
throw entry.error
|
|
236
|
+
}
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
replayFromAudit(agent, header)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const off = ctx.on('agent/created', onAgentCreated)
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
beginSpawn,
|
|
246
|
+
dispose: () => {
|
|
247
|
+
pending.clear()
|
|
248
|
+
off?.()
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Wrap one composition failure with the delegate-facing cause. */
|
|
254
|
+
function compositionError(childId, error) {
|
|
255
|
+
const wrapped = new Error(`delegate: failed to apply the child composition for "${childId}" — ${String(error?.message ?? error)}`)
|
|
256
|
+
wrapped.cause = error
|
|
257
|
+
return wrapped
|
|
258
|
+
}
|