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
@@ -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
- return { status: 'committed', revision, payload, receipt }
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
- return { support, read, commit, publishPointer, inspect, clear, withPresetNamespace, withSelectionSnapshot }
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 an already-installed bundle is rejected as ambiguous. 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.
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
+ }