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.
Files changed (92) hide show
  1. package/lib/client.blackboard-model.js +172 -56
  2. package/lib/client.capability-manager.js +0 -1
  3. package/lib/client.capability-model.js +378 -31
  4. package/lib/client.chain-model.js +80 -0
  5. package/lib/client.disabled-categories-editor.js +22 -0
  6. package/lib/client.edit-lock-maintenance.js +239 -246
  7. package/lib/client.edit-lock-panel.js +110 -0
  8. package/lib/client.hash-edit-model.js +48 -0
  9. package/lib/client.js +267 -1549
  10. package/lib/client.lsp-model.js +92 -0
  11. package/lib/client.lsp-panel.js +11 -16
  12. package/lib/client.lsp-toggle.js +18 -0
  13. package/lib/client.notify-permissions.js +112 -20
  14. package/lib/client.notify-web.js +14 -11
  15. package/lib/client.robash-model.js +26 -0
  16. package/lib/client.settings-page.js +287 -17
  17. package/lib/client.worktree-model.js +225 -0
  18. package/package.json +1 -1
  19. package/src/blackboard/durable.js +12 -0
  20. package/src/blackboard/index.js +2 -2
  21. package/src/blackboard/kernel.js +23 -6
  22. package/src/blackboard/notify.js +10 -3
  23. package/src/blackboard/remote.js +31 -13
  24. package/src/blackboard/storage.js +13 -5
  25. package/src/capabilities/apply-engine.js +189 -101
  26. package/src/capabilities/defaults-transaction.js +136 -1
  27. package/src/capabilities/lifecycle-snapshot.js +83 -204
  28. package/src/capabilities/mcp-admission.js +1 -23
  29. package/src/capabilities/mcp-drain.js +30 -19
  30. package/src/capabilities/mcp-facade.js +23 -3
  31. package/src/capabilities/preset-library.js +247 -0
  32. package/src/capabilities/preset-package.js +212 -1
  33. package/src/capabilities/same-name-policy.js +3 -41
  34. package/src/capabilities/selection-notify.js +324 -21
  35. package/src/capabilities/skill-selection-plugin.js +315 -729
  36. package/src/capabilities/store/checked-contract.d.ts +5 -0
  37. package/src/capabilities/store/fs-adapter.js +52 -0
  38. package/src/capabilities/store/lock.js +86 -0
  39. package/src/capabilities/store/paths.js +5 -0
  40. package/src/capabilities/store/store.js +105 -5
  41. package/src/delegate/group-coordinator.js +1 -17
  42. package/src/delegate/index.js +4 -2
  43. package/src/delegate/rehydrate.js +2 -1
  44. package/src/delegate/robash-guard-core.js +2 -2
  45. package/src/delegate/supervision-mount.js +5 -4
  46. package/src/delegate/tool.js +26 -3
  47. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  48. package/src/edit-lock/admin-ledger.js +8 -11
  49. package/src/edit-lock/admin-recovery.js +2 -1
  50. package/src/edit-lock/admission.js +12 -2
  51. package/src/edit-lock/binding-guidance.js +28 -10
  52. package/src/edit-lock/binding-machine.js +312 -0
  53. package/src/edit-lock/derived-facts.js +67 -0
  54. package/src/edit-lock/index.js +44 -244
  55. package/src/edit-lock/inspect.js +113 -1
  56. package/src/edit-lock/lifecycle.js +8 -5
  57. package/src/edit-lock/limits.js +61 -0
  58. package/src/edit-lock/maintenance.js +5 -2
  59. package/src/edit-lock/manager.js +6 -2
  60. package/src/edit-lock/operation-history.js +1 -1
  61. package/src/edit-lock/peer-client.js +9 -2
  62. package/src/edit-lock/peer-transport.js +7 -1
  63. package/src/edit-lock/remote.js +11 -4
  64. package/src/edit-lock/state.js +2 -4
  65. package/src/edit-lock/view.js +10 -11
  66. package/src/lsp/admin.js +2 -24
  67. package/src/lsp/index.js +1 -1
  68. package/src/lsp/manager.js +1 -1
  69. package/src/notify/permissions-admin.js +1 -1
  70. package/src/{lsp/child-process.js → process/index.js} +12 -4
  71. package/src/settings/env-admin.js +1 -1
  72. package/src/settings/index.js +1 -1
  73. package/src/settings/sections.js +0 -54
  74. package/src/shared/audit.js +5 -0
  75. package/src/shared/http-json.js +28 -0
  76. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  77. package/src/worktree/errors.js +3 -0
  78. package/src/worktree/index.js +1 -2
  79. package/src/worktree/lane-core.js +641 -0
  80. package/src/worktree/lane-views.js +147 -0
  81. package/src/worktree/lane-workflows.js +853 -0
  82. package/src/worktree/lanes.js +24 -1513
  83. package/src/worktree/runner.js +5 -4
  84. package/src/capabilities/content-pin.js +0 -88
  85. package/src/capabilities/content-refresh.js +0 -331
  86. package/src/capabilities/distribution-manifest.js +0 -78
  87. package/src/capabilities/isolated-exec.js +0 -106
  88. package/src/capabilities/publisher.js +0 -129
  89. package/src/capabilities/skill-admission.js +0 -181
  90. package/src/capabilities/update-lifecycle.js +0 -106
  91. package/src/edit-lock/remote-service.js +0 -28
  92. 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
  }
@@ -1,6 +1,7 @@
1
1
  // Supervised group coordinator: registry state machine for grouped delegation.
2
2
  // Pure logic (no DSH imports) — the mount layer (index.js/tool.js) feeds it
3
3
  // session events and injects the effectors (sendMessage/interrupt/timers).
4
+ import { isTerminalStatus, parseTerminalStatus } from '../shared/terminal-evidence.js'
4
5
 
5
6
  export const SUPERVISION_CONTRACT = `
6
7
 
@@ -30,15 +31,6 @@ export const DEFAULT_SUPERVISION = {
30
31
  /** Bounded wait for the last settlement notice before the fallback delivery. */
31
32
  export const SIGNAL_FALLBACK_MS = 1000
32
33
 
33
- /** Parse a child's final assistant text into a terminal status, or null. */
34
- export function parseTerminalStatus(text) {
35
- if (typeof text !== 'string' || text.length === 0) return null
36
- const match = /^STATUS:[ \t]*(completed|blocked)[ \t]*$/m.exec(text)
37
- if (!match) return null
38
- const reportMatch = /^REPORT:[ \t]*([\s\S]*)$/m.exec(text.slice(match.index))
39
- return { status: match[1], report: (reportMatch?.[1] ?? '').trim() }
40
- }
41
-
42
34
  /**
43
35
  * Canonical member record shape: eight fields, fixed order. The single source
44
36
  * every construction site converges on (registerMember, hydrate, rehydrate).
@@ -54,14 +46,6 @@ export function createGroupRecord(name, { sealed = false, settled = false, membe
54
46
  return { name, memberIds: [...memberIds], sealed, settled }
55
47
  }
56
48
 
57
- /**
58
- * Single exit for the terminal-status predicate: statuses that count toward
59
- * group completion (blocked waits for a resume, so it is NOT terminal here).
60
- */
61
- export function isTerminalStatus(status) {
62
- return status === 'completed' || status === 'terminated'
63
- }
64
-
65
49
  /**
66
50
  * Single exit for the untracked-catalog predicate: continuable catalog entries
67
51
  * the registry does not track. mode is normalized (undefined → 'continuable');
@@ -1,7 +1,9 @@
1
1
  // Weir delegate plugin: category registry + curated agents + the delegate
2
2
  // tool. Plain ESM, ctx-only. Composition root: all policy lives in the sibling
3
3
  // modules (settings-overlay / target-resolver / supervision-tools /
4
- // supervision-mount / audit-readers); apply() only constructs and wires them.
4
+ // supervision-mount); apply() only constructs and wires them. The terminal-
5
+ // evidence cold readers live in src/shared/terminal-evidence.js (shared with
6
+ // the worktree plugin).
5
7
  import { CURATED_AGENTS } from './agents.js'
6
8
  import { createSettingsOverlay } from './settings-overlay.js'
7
9
  import { createTargetResolver } from './target-resolver.js'
@@ -11,7 +13,7 @@ import { skillSelectionFor } from '../capabilities/skill-selection-plugin.js'
11
13
  import { createResumeTool, createTerminateTool } from './supervision-tools.js'
12
14
  import { mountSupervision } from './supervision-mount.js'
13
15
  import { createAudit } from '../shared/audit.js'
14
- import { FALLBACK_TABLES } from '../shared/whitelist-defaults.js'
16
+ import { FALLBACK_TABLES } from './whitelist-defaults.js'
15
17
  import { createDelegateTool } from './tool.js'
16
18
  import { attachWorktreeModeGuard } from './worktree-mode.js'
17
19
  import { DOCTRINE_SECTION_ORDER } from '../core/doctrine.js'
@@ -2,7 +2,8 @@
2
2
  // restart. Pure logic — the mount layer feeds it parsed audit records and the
3
3
  // DSH catalog; no DSH imports, no fs.
4
4
 
5
- import { parseTerminalStatus, createMemberRecord, createGroupRecord, isTerminalStatus, untrackedCatalogEntries } from './group-coordinator.js'
5
+ import { createMemberRecord, createGroupRecord, untrackedCatalogEntries } from './group-coordinator.js'
6
+ import { parseTerminalStatus, isTerminalStatus } from '../shared/terminal-evidence.js'
6
7
 
7
8
  /**
8
9
  * Rebuild supervision state by replaying structured audit facts and
@@ -1,7 +1,7 @@
1
1
  // Read-only shell guard core: the single canonical home of every cross-shell
2
2
  // policy the bash and pwsh adapters share — the whitelist tables (one
3
3
  // representation, consumed by the guard adapters, delegate/index.js, and
4
- // shared/whitelist-defaults.js), the git gate, the GIT_CONFIG_* environment
4
+ // whitelist-defaults.js), the git gate, the GIT_CONFIG_* environment
5
5
  // smuggle refusal, the recursion budget, the redirect sink policy, and the
6
6
  // per-executable verdict tail. Pure functions, no ctx access.
7
7
  //
@@ -10,7 +10,7 @@
10
10
  // the three & roles, alias expansion), so the scanners are not merged.
11
11
 
12
12
  /** Canonical whitelist tables, organized by the five published keys so
13
- * shared/whitelist-defaults.js derives FALLBACK_TABLES with zero translation.
13
+ * whitelist-defaults.js derives FALLBACK_TABLES with zero translation.
14
14
  * Entries moved verbatim from the former DEFAULT_ROBASH (robash-guard.js) and
15
15
  * DEFAULT_ROBASH_PWSH (robash-guard-pwsh.js); pwsh entries keep their original
16
16
  * casing (the pwsh adapter lowercases at lookup) so the shipped
@@ -4,9 +4,9 @@
4
4
  // into live coordinators. This module is a ctx adapter — per the jsconfig
5
5
  // purity curation it stays out of include (same precedent as index.js and
6
6
  // group-coordinator.js).
7
- import { createGroupCoordinator, isTerminalStatus } from './group-coordinator.js'
7
+ import { createGroupCoordinator } from './group-coordinator.js'
8
8
  import { rehydrateSupervision, applyChildLogRecovery } from './rehydrate.js'
9
- import { auditFilePathOf, readAuditTail, readChildFinalText } from './audit-readers.js'
9
+ import { auditFilePathOf, isTerminalStatus, readAuditTail, readChildFinalText } from '../shared/terminal-evidence.js'
10
10
  import { AUDIT_TYPES } from '../shared/audit.js'
11
11
  import { userTextMessage } from '../shared/user-message.js'
12
12
  import { contentText } from '../shared/content-text.js'
@@ -75,8 +75,9 @@ export function mountSupervision({ ctx, audit, settings, supervisionNow, onChild
75
75
  audit(parent.session, `${AUDIT_TYPES.supervision}/${fact.kind}`, fact)
76
76
  // A settled or terminated member frees its worktree lane (the host
77
77
  // checks the lane) — TERMINAL outcomes only (resumable-lane-workers
78
- // D1): a blocked member stands by for resume_agent, so its binding
79
- // must survive; the blocked fact still rides the audit channel.
78
+ // D1), with terminality defined by the shared isTerminalStatus
79
+ // predicate: a blocked member's binding must survive a possible
80
+ // resume; the blocked fact still rides the audit channel.
80
81
  // Deferred: never run lane work inside the fact.
81
82
  const settlesLane = fact.kind === 'terminate' || (fact.kind === 'settle' && isTerminalStatus(fact.status))
82
83
  if (settlesLane && !handoffs.has(fact.childId)) {
@@ -3,6 +3,7 @@
3
3
  // @deepseek-ai packages do not resolve from a linked bundle).
4
4
  import { parseEscalation } from './escalate.js'
5
5
  import { continuableLane, oneShotLane, spawnGuardedChild, supervisedLane } from './spawn-adapter.js'
6
+ import { WORKTREE_CODES } from '../worktree/errors.js'
6
7
  import { contentText } from '../shared/content-text.js'
7
8
  import { settleDelegateEditLifecycle } from '../shared/delegate-edit-lifecycle.js'
8
9
 
@@ -473,16 +474,38 @@ async function laneBindingFor(target, args, deps, exec) {
473
474
  }
474
475
  }
475
476
 
476
- /** Spawn under a reserved binding: commit the child id, or roll back on failure. */
477
+ /**
478
+ * Spawn under a reserved binding: commit the child id, or roll back on
479
+ * failure. A commit refusing LANE_RESERVATION_LOST means the reservation was
480
+ * reclaimed while the child spawned (lane-binding-authoritative-commit): the
481
+ * child was never authorised, so it is disposed BEFORE the error surfaces
482
+ * (one-shot dispose precedent: spawn-adapter.js onGuardFailure). The rollback
483
+ * afterwards is a nonce-conditional no-op by then (D5). A spawn failure has
484
+ * no child to tear down; supervised-group commits roll back in the group
485
+ * catch (terminate per member), never here.
486
+ */
477
487
  async function spawnBound(binding, spawn, idOf) {
488
+ let started
489
+ try {
490
+ started = await spawn()
491
+ } catch (error) {
492
+ await binding?.rollback().catch(() => {})
493
+ throw error
494
+ }
478
495
  try {
479
- const started = await spawn()
480
496
  await binding?.commit(idOf(started))
481
- return started
482
497
  } catch (error) {
498
+ if (/** @type {any} */ (error)?.code === WORKTREE_CODES.LANE_RESERVATION_LOST) {
499
+ try {
500
+ await started.dispose?.()
501
+ } catch {
502
+ // best-effort teardown of the unauthorised child
503
+ }
504
+ }
483
505
  await binding?.rollback().catch(() => {})
484
506
  throw error
485
507
  }
508
+ return started
486
509
  }
487
510
 
488
511
  /** Lane guard spec with the path resolver bound to the parent's cwd. */
@@ -23,7 +23,7 @@
23
23
  // so tests can drive it without a runtime.
24
24
 
25
25
  import { readFileSync } from 'node:fs'
26
- import { DEFAULT_TABLES } from '../delegate/robash-guard-core.js'
26
+ import { DEFAULT_TABLES } from './robash-guard-core.js'
27
27
  /** @typedef {{ readFile?: (path: string | URL) => string, logger?: { warn?: (message: string) => void } }} ReaderOptions */
28
28
 
29
29
  /** The five table names, in the order they are published. */
@@ -51,7 +51,7 @@ export const FALLBACK_TABLES = Object.freeze({
51
51
  /**
52
52
  * The shipped defaults file, resolved against THIS module's location so it
53
53
  * travels with the bundle rather than with whatever working directory the host
54
- * happens to have. `src/shared/` → bundle root.
54
+ * happens to have. `src/delegate/` → bundle root.
55
55
  */
56
56
  export const DEFAULT_WHITELIST_PATH = new URL('../../whitelist-defaults.json', import.meta.url)
57
57
 
@@ -2,6 +2,7 @@
2
2
  // of publication outcome or old-writer quiescence. It lives in the atomic image.
3
3
  import { createHash } from 'node:crypto'
4
4
  import { canonical } from './snapshot.js'
5
+ import { revokedOwner } from './derived-facts.js'
5
6
  import { expectedRegistry, isRegistryUpgrade } from './incarnations.js'
6
7
 
7
8
  export const LATE_WRITER_RISK = 'Detached historic writers may still modify files after this override.'
@@ -12,17 +13,13 @@ export const ONLINE_RECOVERY_ACTOR = 'online-administrator'
12
13
  * settlement (design D3) are the only writers. */
13
14
  const ACTORS = ['authenticated-settings-administrator', AUTOMATIC_RECOVERY_ACTOR, ONLINE_RECOVERY_ACTOR]
14
15
  export const digest = value => createHash('sha256').update(canonical(value)).digest('hex')
15
- export const dispositionFor = (state, op) => state.adminRecoveries?.find(row => row.owner === op.sessionId && row.operations.some(item => item.operationId === op.operationId))
16
- /** Epoch-scoped revocation: a disposition revokes its owner's execution
17
- * authority exactly at the recorded revokedEpoch. The owner stays revoked
18
- * while its recorded epoch is within it; a trusted resume advances the epoch
19
- * past every revokedEpoch and ends the revoked state atomically, without any
20
- * ledger change (append-only: dispositions are never removed or altered). */
21
- export const revokedOwner = (state, owner) => {
22
- const session = state.sessions?.find(s => s.sessionId === owner)
23
- if (!session) return false
24
- return state.adminRecoveries?.some(row => row.owner === owner && session.executionEpoch <= row.revokedEpoch) === true
25
- }
16
+ // Epoch-scoped revocation (`revokedOwner`) and the disposition lookup
17
+ // (`dispositionFor`) live in derived-facts.js (change edit-lock-deepening,
18
+ // design D1) — one pure home shared by the manager status path, the cold view
19
+ // and the inspector; `revokedOwner` is imported above for this module's own
20
+ // validation. The re-export keeps existing external importers working (the
21
+ // integration scenario child imports dispositionFor from this module).
22
+ export { dispositionFor, revokedOwner } from './derived-facts.js'
26
23
 
27
24
  function requireThat(condition, message) { if (!condition) throw new Error(`invalid administrative recovery: ${message}`) }
28
25
  function keys(value, names) {
@@ -6,7 +6,8 @@ import { createHash } from 'node:crypto'
6
6
  import { reservePublisher } from './reservation.js'
7
7
  import { openEditLockStore } from './store.js'
8
8
  import { canonical, parseSnapshot, validateImage } from './snapshot.js'
9
- import { administrativeState, digest, dispositionFor, LATE_WRITER_RISK } from './admin-ledger.js'
9
+ import { administrativeState, digest, LATE_WRITER_RISK } from './admin-ledger.js'
10
+ import { dispositionFor } from './derived-facts.js'
10
11
 
11
12
  export const MAX_RECOVERY_BYTES = 16 * 1024 * 1024
12
13
  const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
@@ -2,8 +2,15 @@ import { posix, win32 } from 'node:path'
2
2
 
3
3
  /** Candidates describe effects, not merely explicit API arguments. Session-wide
4
4
  * operations must enumerate affected ownership at their FIFO position.
5
+ * Modes: 'normal' covers every mutating ingress; 'release' is pure removal and
6
+ * must retain the ownership unresolved updates require; 'revocation' is strictly
7
+ * subtractive and crosses fences. 'resume' is reserved for session-level
8
+ * recovery transactions (the trusted Continue): it publishes nothing, so an
9
+ * unresolved resource-level fence never denies it, while domain fences and
10
+ * every shape denial bind it exactly as they bind 'normal'. It must never be
11
+ * used for a transaction that publishes, confirms, acquires or releases.
5
12
  * @typedef {{kind: 'resources', resourceIds: string[]} | {kind: 'scope', ancestor: string} | {kind: 'none'}} Candidate
6
- * @typedef {'normal'|'release'|'revocation'} Mode
13
+ * @typedef {'normal'|'release'|'revocation'|'resume'} Mode
7
14
  */
8
15
 
9
16
  /** Shape check only, NOT filesystem proof. The trusted manager ingress already
@@ -59,7 +66,10 @@ export function admitMutation(operations, candidate, mode = 'normal') {
59
66
  for (const resourceId of candidate.resourceIds) {
60
67
  if (!canonicalResource(resourceId)) deny('identity-unproved')
61
68
  if (fence?.kind !== 'resource') deny('scope-continuity-unproved')
62
- if (fence?.kind === 'resource' && (!canonicalResource(fence.resourceId) || fence.resourceId === resourceId)) deny('resource-conflict')
69
+ // 'resume' publishes nothing: a resource-level fence never bars the
70
+ // session-level recovery transaction; it keeps barring the resource
71
+ // itself on every other mode until administrative settlement.
72
+ if (mode !== 'resume' && fence?.kind === 'resource' && (!canonicalResource(fence.resourceId) || fence.resourceId === resourceId)) deny('resource-conflict')
63
73
  }
64
74
  }
65
75
  }