weir-harness 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/cordis.patch.yml +0 -2
  2. package/lib/client.blackboard-model.js +172 -56
  3. package/lib/client.capability-manager.js +0 -1
  4. package/lib/client.capability-model.js +378 -31
  5. package/lib/client.chain-model.js +80 -0
  6. package/lib/client.disabled-categories-editor.js +22 -0
  7. package/lib/client.edit-lock-maintenance.js +239 -246
  8. package/lib/client.edit-lock-panel.js +110 -0
  9. package/lib/client.hash-edit-model.js +48 -0
  10. package/lib/client.js +267 -1549
  11. package/lib/client.lsp-model.js +92 -0
  12. package/lib/client.lsp-panel.js +11 -16
  13. package/lib/client.lsp-toggle.js +18 -0
  14. package/lib/client.notify-permissions.js +112 -20
  15. package/lib/client.notify-web.js +14 -11
  16. package/lib/client.robash-model.js +26 -0
  17. package/lib/client.settings-page.js +287 -17
  18. package/lib/client.worktree-model.js +225 -0
  19. package/package.json +5 -2
  20. package/src/blackboard/durable.js +12 -0
  21. package/src/blackboard/index.js +2 -2
  22. package/src/blackboard/kernel.js +23 -6
  23. package/src/blackboard/notify.js +10 -3
  24. package/src/blackboard/remote.js +31 -13
  25. package/src/blackboard/storage.js +13 -5
  26. package/src/capabilities/apply-engine.js +189 -101
  27. package/src/capabilities/defaults-transaction.js +136 -1
  28. package/src/capabilities/lifecycle-snapshot.js +83 -204
  29. package/src/capabilities/mcp-admission.js +1 -23
  30. package/src/capabilities/mcp-drain.js +30 -19
  31. package/src/capabilities/mcp-facade.js +23 -3
  32. package/src/capabilities/preset-library.js +247 -0
  33. package/src/capabilities/preset-package.js +212 -1
  34. package/src/capabilities/same-name-policy.js +3 -41
  35. package/src/capabilities/selection-notify.js +324 -21
  36. package/src/capabilities/skill-office-adapter.js +7 -1
  37. package/src/capabilities/skill-selection-plugin.js +315 -729
  38. package/src/capabilities/store/checked-contract.d.ts +5 -0
  39. package/src/capabilities/store/fs-adapter.js +52 -0
  40. package/src/capabilities/store/lock.js +86 -0
  41. package/src/capabilities/store/paths.js +5 -0
  42. package/src/capabilities/store/store.js +105 -5
  43. package/src/creative-guide/guide.js +1 -1
  44. package/src/delegate/child-composition.js +258 -0
  45. package/src/delegate/group-coordinator.js +25 -27
  46. package/src/delegate/index.js +21 -2
  47. package/src/delegate/rehydrate.js +24 -4
  48. package/src/delegate/robash-guard-core.js +2 -2
  49. package/src/delegate/spawn-adapter.js +214 -100
  50. package/src/delegate/status-tool.js +9 -6
  51. package/src/delegate/supervision-mount.js +35 -6
  52. package/src/delegate/tool.js +29 -4
  53. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  54. package/src/edit-lock/admin-ledger.js +8 -11
  55. package/src/edit-lock/admin-recovery.js +2 -1
  56. package/src/edit-lock/admission.js +12 -2
  57. package/src/edit-lock/binding-guidance.js +28 -10
  58. package/src/edit-lock/binding-machine.js +312 -0
  59. package/src/edit-lock/derived-facts.js +67 -0
  60. package/src/edit-lock/index.js +44 -244
  61. package/src/edit-lock/inspect.js +113 -1
  62. package/src/edit-lock/lifecycle.js +8 -5
  63. package/src/edit-lock/limits.js +61 -0
  64. package/src/edit-lock/maintenance.js +5 -2
  65. package/src/edit-lock/manager.js +6 -2
  66. package/src/edit-lock/operation-history.js +1 -1
  67. package/src/edit-lock/peer-client.js +9 -2
  68. package/src/edit-lock/peer-transport.js +7 -1
  69. package/src/edit-lock/remote.js +11 -4
  70. package/src/edit-lock/state.js +2 -4
  71. package/src/edit-lock/view.js +10 -11
  72. package/src/lsp/admin.js +2 -24
  73. package/src/lsp/index.js +1 -1
  74. package/src/lsp/manager.js +1 -1
  75. package/src/notify/permissions-admin.js +1 -1
  76. package/src/{lsp/child-process.js → process/index.js} +23 -6
  77. package/src/settings/env-admin.js +1 -1
  78. package/src/settings/index.js +1 -1
  79. package/src/settings/sections.js +0 -54
  80. package/src/shared/audit.js +12 -0
  81. package/src/shared/child-scope.js +3 -1
  82. package/src/shared/http-json.js +28 -0
  83. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  84. package/src/worktree/errors.js +3 -0
  85. package/src/worktree/index.js +1 -2
  86. package/src/worktree/lane-core.js +641 -0
  87. package/src/worktree/lane-views.js +147 -0
  88. package/src/worktree/lane-workflows.js +853 -0
  89. package/src/worktree/lanes.js +24 -1513
  90. package/src/worktree/runner.js +5 -4
  91. package/src/capabilities/content-pin.js +0 -88
  92. package/src/capabilities/content-refresh.js +0 -331
  93. package/src/capabilities/distribution-manifest.js +0 -78
  94. package/src/capabilities/isolated-exec.js +0 -106
  95. package/src/capabilities/publisher.js +0 -129
  96. package/src/capabilities/skill-admission.js +0 -181
  97. package/src/capabilities/update-lifecycle.js +0 -106
  98. package/src/edit-lock/remote-service.js +0 -28
  99. package/src/worktree/contract-guidance.test.js +0 -100
@@ -1,3 +1,4 @@
1
+ import { asBindingFailure } from './binding-guidance.js'
1
2
  import { canonicalRequestData } from './request-data.js'
2
3
  import { PEER_KINDS } from './peer.js'
3
4
 
@@ -39,15 +40,21 @@ export function createEditLockPeerClient(stream, hooks = {}) {
39
40
  try { hooks.onEvent?.(message.event) } catch { /* notices never imply consent */ }
40
41
  continue
41
42
  }
43
+ const frameKeys = !message || typeof message !== 'object' ? '' : Object.keys(message).sort().join(',')
44
+ // The error frame carries the structured binding refusal as an additive
45
+ // optional field (change edit-lock-deepening, design D2).
42
46
  if (!message || typeof message.callId !== 'string' || typeof message.ok !== 'boolean'
43
- || Object.keys(message).sort().join(',') !== (message.ok ? 'callId,ok,outcome' : 'callId,error,ok')
47
+ || (message.ok ? frameKeys !== 'callId,ok,outcome' : frameKeys !== 'callId,error,ok' && frameKeys !== 'bindingFailure,callId,error,ok')
44
48
  || (!message.ok && typeof message.error !== 'string')) { close(); return }
45
49
  const entry = pending.get(message.callId)
46
50
  if (!entry) { close(); return }
47
51
  pending.delete(message.callId)
48
52
  entry.cleanup()
49
53
  if (message.ok) entry.resolve(message.outcome)
50
- else entry.reject(new Error(message.error))
54
+ else {
55
+ const failure = asBindingFailure(message.bindingFailure)
56
+ entry.reject(Object.assign(new Error(message.error), failure ? { bindingFailure: failure } : {}))
57
+ }
51
58
  }
52
59
  }
53
60
  stream.on('data', receive)
@@ -1,3 +1,4 @@
1
+ import { asBindingFailure } from './binding-guidance.js'
1
2
  import { canonicalRequestData } from './request-data.js'
2
3
 
3
4
  /** Attach ONLY an authenticated, host-owned byte stream. Never accepts public
@@ -54,7 +55,12 @@ export function serveEditLockPeer(stream, peer) {
54
55
  const callId = request.callId
55
56
  void peer.receive(request).then(
56
57
  outcome => { try { send({callId, ok:true, outcome:outcome ?? null}) } catch { close() } },
57
- error => { try { send({callId, ok:false, error:String(error?.message ?? error).slice(0,4096)}) } catch { close() } },
58
+ error => {
59
+ // The wire carries the structured binding refusal additively (change
60
+ // edit-lock-deepening, design D2); the text stays for display.
61
+ const failure = asBindingFailure(/** @type {any} */ (error)?.bindingFailure)
62
+ try { send({ callId, ok: false, error: String(/** @type {any} */ (error)?.message ?? error).slice(0, 4096), ...(failure ? { bindingFailure: failure } : {}) }) } catch { close() }
63
+ },
58
64
  )
59
65
  }
60
66
  }
@@ -3,7 +3,7 @@ import { lstatSync, realpathSync, unlinkSync } from 'node:fs'
3
3
  import { createConnection, createServer } from 'node:net'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { bindingGuidance, bindingRefusal } from './binding-guidance.js'
6
+ import { asBindingFailure, bindingGuidance, bindingRefusal } from './binding-guidance.js'
7
7
  import { createEditLockPeer } from './peer.js'
8
8
  import { serveEditLockPeer } from './peer-transport.js'
9
9
  import { createEditLockPeerClient } from './peer-client.js'
@@ -275,12 +275,16 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
275
275
  for (let attempt = 0; ; attempt++) {
276
276
  try { return await openChannel(agent, retry) } catch (error) {
277
277
  if (/** @type {any} */ (error)?.answeredRefusal !== true) throw error
278
- if (!retry && /session already bound/.test(String(/** @type {any} */ (error)?.message)) && attempt < 20) {
278
+ // The retry budget classifies on the STRUCTURED refusal field, never on
279
+ // the display text (change edit-lock-deepening, design D2): only an
280
+ // answered, publisher-classified duplicate binding is retried — the
281
+ // publisher may not yet have processed the earlier channel's death.
282
+ if (!retry && /** @type {any} */ (error)?.bindingFailure?.code === 'binding-duplicate' && attempt < 20) {
279
283
  await new Promise(resolve => setTimeout(resolve, 50))
280
284
  continue
281
285
  }
282
286
  const reason = String(/** @type {any} */ (error)?.message ?? error)
283
- const guidance = bindingGuidance(agent.id, reason)
287
+ const guidance = bindingGuidance(agent.id, /** @type {any} */ (error)?.bindingFailure)
284
288
  throw Object.assign(new Error(`edit lock duplicate session binding: ${guidance} (publisher answered: ${reason})`), { bindingFailure: /** @type {any} */ (error)?.bindingFailure })
285
289
  }
286
290
  }
@@ -317,7 +321,10 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
317
321
  let opened
318
322
  try {
319
323
  opened = await channel.client.request('open', 'open', { sessionId: agent.id, nonce, pid: process.pid, bindingFailures: true, retry }, never)
320
- if (opened?.state === 'unavailable' && opened.bindingFailure?.code === 'binding-duplicate') throw Object.assign(new Error(opened.reason), { bindingFailure: opened.bindingFailure })
324
+ // The answered refusal's structured field is validated on receipt;
325
+ // only the exact minted shape becomes decision input.
326
+ const refused = opened?.state === 'unavailable' ? asBindingFailure(opened.bindingFailure) : undefined
327
+ if (refused) throw Object.assign(new Error(opened.reason), { bindingFailure: refused })
321
328
  } catch (error) {
322
329
  const message = String(/** @type {any} */ (error)?.message ?? error)
323
330
  // No answer ever arrived — the channel died mid-open or the request
@@ -24,6 +24,7 @@
24
24
  * @typedef {{ now?: number, singleMaxMs: number, cumulativeMaxMs: number }} HoldCaps
25
25
  */
26
26
 
27
+ import { settleHoldFacts } from './derived-facts.js'
27
28
  /** Private transfer seam; no raw hydration is exported. */
28
29
  const cores = new WeakMap()
29
30
  /** @param {string} managerIncarnation */
@@ -165,10 +166,7 @@ export function createEditLockState(managerIncarnation) {
165
166
  const held = holds.get(sessionId)
166
167
  if (!held) return Object.freeze({ sessionId, held: false, expired: false, holdUntil: null, remainingMs: 0, holdCumulativeMs: 0 })
167
168
  const owns = [...locks.values()].some(lock => lock.owner === sessionId)
168
- const remainingMs = held.holdUntil === null ? 0 : Math.max(0, held.holdUntil - now)
169
- const holding = held.holding && owns
170
- return Object.freeze({ sessionId, held: holding, expired: holding && held.holdUntil !== null && remainingMs === 0,
171
- holdUntil: held.holdUntil, remainingMs, holdCumulativeMs: held.holdCumulativeMs })
169
+ return Object.freeze(settleHoldFacts(sessionId, held, owns, now))
172
170
  },
173
171
  }
174
172
 
@@ -10,6 +10,7 @@
10
10
  * in `technical` and per-lock `detail`, for the expandable detail only.
11
11
  */
12
12
  import { isAbsolute, relative, sep } from 'node:path'
13
+ import { revokedOwner, settleHoldFacts } from './derived-facts.js'
13
14
 
14
15
  /** Session states the panel renders, in precedence order. */
15
16
  export const VIEW_STATES = Object.freeze(['revoked', 'unavailable', 'stopped', 'attention', 'confirm', 'holding', 'editing', 'idle'])
@@ -140,21 +141,19 @@ export function buildColdView({ image, sessionId, cwd, root, autoResume = true,
140
141
  const locks = (Array.isArray(image?.locks) ? image.locks : []).filter(lock => lock !== null && typeof lock === 'object')
141
142
  const holds = Array.isArray(image?.holds) ? image.holds : []
142
143
  const recoveries = Array.isArray(image?.recovery) ? image.recovery : []
143
- const adminRecoveries = Array.isArray(image?.adminRecoveries) ? image.adminRecoveries : []
144
144
  const session = sessions.find(row => row?.sessionId === sessionId) ?? null
145
145
  // The manager derives the revoked flag from the administrative ledger at
146
- // status time (revokedOwner); the cold read applies the same rule to the image.
147
- const revoked = session != null && adminRecoveries.some(row => row?.owner === sessionId && Number.isSafeInteger(row?.revokedEpoch) && session.executionEpoch <= row.revokedEpoch)
148
- // Read-time retention settlement, byte-equivalent to the kernel's settleHold:
149
- // a hold counts only while the session owns a lock, and the remaining budget
150
- // is computed against the read instant, so a missed expiry timer can never
151
- // leave stale ownership behind a status read.
146
+ // status time (revokedOwner); the cold read applies the SAME shared rule in
147
+ // its deliberately tolerant partial mode (design D1).
148
+ const revoked = revokedOwner(image, sessionId, { partial: true })
149
+ // Read-time retention settlement through the shared derived fact (design D5,
150
+ // partial mode — byte-equivalent to the kernel's settleHold): a hold counts
151
+ // only while the session owns a lock, and the remaining budget is computed
152
+ // against the read instant, so a missed expiry timer can never leave stale
153
+ // ownership behind a status read. No hold row settles to no retention (null).
152
154
  const held = holds.find(row => row?.sessionId === sessionId) ?? null
153
155
  const owns = locks.some(lock => lock.owner === sessionId)
154
- const remainingMs = held?.holdUntil == null ? 0 : Math.max(0, held.holdUntil - now)
155
- const holding = held?.holding === true && owns
156
- const retention = held ? { sessionId, held: holding, expired: holding && held.holdUntil !== null && remainingMs === 0,
157
- holdUntil: held.holdUntil, remainingMs, holdCumulativeMs: held.holdCumulativeMs } : null
156
+ const retention = held ? settleHoldFacts(sessionId, held, owns, now, { partial: true }) : null
158
157
  const status = {
159
158
  sessionId,
160
159
  // Interrupted records a stopped session (a restarted host interrupts every
package/src/lsp/admin.js CHANGED
@@ -4,40 +4,18 @@
4
4
  // resolve through ctx.get so the module mounts harmlessly in compositions
5
5
  // without them (headless).
6
6
  import { buildRegistry, DEFAULT_SERVERS, displayInstallCommand, installSpecFor, languageIdsForFamily } from './registry.js'
7
- import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from './child-process.js'
7
+ import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from '../process/index.js'
8
+ import { jsonResponse, readJsonBody } from '../shared/http-json.js'
8
9
 
9
10
  const name = 'weir-lsp-admin'
10
11
  const inject = []
11
12
  const DEFAULT_INSTALL_TIMEOUT_MS = 600_000
12
13
  const VERSION_PROBE_TIMEOUT_MS = 8_000
13
14
 
14
- export function jsonResponse(payload, status = 200) {
15
- return new Response(JSON.stringify(payload), {
16
- status,
17
- headers: { 'content-type': 'application/json' },
18
- })
19
- }
20
-
21
15
  export function errorResponse(message, status = 400) {
22
16
  return jsonResponse({ ok: false, error: { code: `weir-lsp/${status === 400 ? 'invalid' : 'internal'}`, message } }, status)
23
17
  }
24
18
 
25
- /** Read the JSON body of a connection request (Request-like or stream). */
26
- export async function readJsonBody(request) {
27
- try {
28
- if (typeof request?.json === 'function') return await request.json()
29
- } catch {
30
- return null
31
- }
32
- try {
33
- const chunks = []
34
- for await (const chunk of request?.body ?? []) chunks.push(Buffer.from(chunk))
35
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
36
- } catch {
37
- return null
38
- }
39
- }
40
-
41
19
  /** Accept only lines that plausibly carry a version (stack traces and
42
20
  * paths from servers without --version support are dropped). */
43
21
  export function looksLikeVersion(line) {
package/src/lsp/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // ESM, ctx-only.
9
9
  import { createLspManager, LSP_DEFAULTS } from './manager.js'
10
10
  import { buildRegistry } from './registry.js'
11
- import { resolveExecutable as extendedResolveExecutable } from './child-process.js'
11
+ import { resolveExecutable as extendedResolveExecutable } from '../process/index.js'
12
12
  import { createLspTools } from './tools.js'
13
13
 
14
14
  const name = 'weir-lsp'
@@ -2,7 +2,7 @@
2
2
  // lazy start, full-document sync, idle shutdown, per-session holder refcounts.
3
3
  import { createLspClient, handshake, shutdownClient } from './client.js'
4
4
  import { familyForLanguageId } from './registry.js'
5
- import { childEnvironment, spawnArgv } from './child-process.js'
5
+ import { childEnvironment, spawnArgv } from '../process/index.js'
6
6
  import { pathToUri } from './uri.js'
7
7
 
8
8
  export const LSP_DEFAULTS = {
@@ -4,7 +4,7 @@
4
4
  // connection service resolves from that scope (S20). The platform and the
5
5
  // process spawner are injected for tests.
6
6
  import { execFile as nodeExecFile } from 'node:child_process'
7
- import { jsonResponse, readJsonBody } from '../lsp/admin.js'
7
+ import { jsonResponse, readJsonBody } from '../shared/http-json.js'
8
8
  import { ACTIONS, readNotificationPermission, runPermissionAction, SENDER } from './permissions.js'
9
9
 
10
10
  const STATUS_PATH = '/api/weir-notify/permissions/status'
@@ -1,15 +1,21 @@
1
- // Executable resolution for LSP servers and installers. The desktop app is
1
+ // Generic process machinery: executable resolution, launch-shape
2
+ // adjudication, and deadline-bounded runs — one concept, three stages
3
+ // (resolve → launch shape → bounded run). The desktop app is
2
4
  // a GUI process launched by LaunchServices, so its PATH is the minimal
3
5
  // `/usr/bin:/bin:/usr/sbin:/sbin` — Homebrew (/opt/homebrew, /usr/local),
4
6
  // nvm, cargo, go, pipx and npm-global bins are all missing, and
5
7
  // ctx.subprocess.resolveExecutable() fails for them (S21). This helper
6
- // falls back to scanning well-known installation directories so servers and
7
- // their installers resolve the same way they do in the user's shell.
8
+ // falls back to scanning well-known installation directories so spawned
9
+ // tools resolve the same way they do in the user's shell.
8
10
  //
9
11
  // Windows resolves through the same helper with a different dialect: npm puts
10
12
  // its global shims in `%APPDATA%\npm` (not `~/.npm-global/bin`), bare names
11
13
  // carry a PATHEXT extension, and the PATH separator is `;`.
12
- // Renamed-and-expanded from executable.js (design D1): one concept, three stages — resolve → launch shape → bounded run.
14
+ //
15
+ // No LSP-specific semantics live here: the LSP subsystem (server lifecycle,
16
+ // version probe, panel installer) and the worktree git runner are peer
17
+ // consumers. Moved verbatim out of src/lsp/child-process.js
18
+ // (subsystem-seam-hygiene, D1); npm install POLICY stays in src/lsp/.
13
19
  import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
14
20
  import { homedir } from 'node:os'
15
21
  import { dirname, join, join as nodeJoin } from 'node:path'
@@ -77,13 +83,22 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
77
83
  * User-writable npm global prefix for panel installs: `npm --prefix <dir>
78
84
  * install -g …` lands servers here instead of a possibly root-owned global
79
85
  * prefix, and this directory is already part of the extended scan.
86
+ *
87
+ * `create` is a test seam: the win32 layout assertion must be runnable on any
88
+ * host WITHOUT materializing a literal `C:\Users\...` directory in the
89
+ * process CWD (the artifact that used to pollute the package directory after
90
+ * every unit run). Production callers keep the default (create the prefix).
91
+ * @param {Record<string, string | undefined>} [env]
92
+ * @param {string} [platform] - 'win32' | 'darwin' | 'linux' (process.platform values)
93
+ * @param {{ create?: boolean }} [options]
94
+ * @returns {string}
80
95
  */
81
- export function npmGlobalPrefix(env = process.env, platform = process.platform) {
96
+ export function npmGlobalPrefix(env = process.env, platform = process.platform, options = {}) {
82
97
  const home = env.HOME ?? homedir()
83
98
  const prefix = platform === 'win32'
84
99
  ? join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'npm')
85
100
  : join(home, '.npm-global')
86
- mkdirSync(prefix, { recursive: true })
101
+ if (options.create !== false) mkdirSync(prefix, { recursive: true })
87
102
  return prefix
88
103
  }
89
104
 
@@ -304,6 +319,7 @@ function expandShimToken(token, text, shimDirectory, join) {
304
319
  function tokenizeShimLine(line) {
305
320
  const tokens = []
306
321
  let current = ''
322
+ /** @type {string | null} */
307
323
  let quote = null
308
324
  let escaped = false
309
325
  for (const character of line) {
@@ -495,6 +511,7 @@ export async function runBounded(subprocess, { argv, timeoutMs, unref = false })
495
511
  })
496
512
  let timedOut = false
497
513
  let timer
514
+ /** @type {Promise<void>} */
498
515
  const deadline = new Promise((resolve) => {
499
516
  timer = setTimeout(() => {
500
517
  timedOut = true
@@ -4,7 +4,7 @@
4
4
  // settings row through ctx.inject so no new package subpath is needed (S19),
5
5
  // same pattern as wireLspAdmin / wireNotifyPermissions. The platform is
6
6
  // injected for tests.
7
- import { jsonResponse } from '../lsp/admin.js'
7
+ import { jsonResponse } from '../shared/http-json.js'
8
8
 
9
9
  const ENV_PATH = '/api/weir-settings/env'
10
10
 
@@ -9,7 +9,7 @@
9
9
  // config contains only the values the user actually set, so the settings
10
10
  // service can overlay them over preset-row config without clobbering
11
11
  // bundle-shipped defaults (layering: module defaults ← row config ← service).
12
- import { createWhitelistDefaultsCache, DEFAULT_WHITELIST_PATH } from '../shared/whitelist-defaults.js'
12
+ import { createWhitelistDefaultsCache, DEFAULT_WHITELIST_PATH } from '../delegate/whitelist-defaults.js'
13
13
  import { configValue } from './volatile.js'
14
14
  import { computeSections } from './sections.js'
15
15
  import { wireLspAdmin } from '../lsp/admin.js'
@@ -149,60 +149,6 @@ export const FIELD_DEFAULTS = Object.freeze({
149
149
  blackboardWriteTokenTtlMinutes: 60,
150
150
  })
151
151
 
152
- /** Resolved Edit Lock retention policy. The keys are declared above; this is the
153
- * ONE place that turns them into the values the lifecycle consumes, so defaults
154
- * and validation cannot drift apart. */
155
- export const EDIT_LOCK_DEFAULTS = Object.freeze({
156
- holdDefaultMinutes: 30,
157
- holdSingleMaxMinutes: 30,
158
- holdCumulativeMaxMinutes: 120,
159
- nudgeAttempts: 2,
160
- nudgeFallback: 'release',
161
- })
162
-
163
- /** Overlay one Edit Lock section onto the defaults. A key the user never set
164
- * falls back to its default; a key that is present but unusable (alone or in
165
- * combination) throws with the flat settings key named, because silently clamping
166
- * a retention cap would change how long other sessions stay blocked from those
167
- * files. The Edit Lock composition refuses retention requests with that error and
168
- * runs every other path (settling, status, release, stop, unlock) on defaults.
169
- * @param {any} section */
170
- export function editLockLimits(section) {
171
- const value = section && typeof section === 'object' ? section : {}
172
- /** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
173
- const limits = { ...EDIT_LOCK_DEFAULTS }
174
- /** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
175
- const take = (key, name) => {
176
- const input = value[key]
177
- if (input === undefined) return
178
- if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
179
- throw new Error(`weir-settings: ${name} must be a positive number`)
180
- }
181
- limits[key] = input
182
- }
183
- take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
184
- take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
185
- take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
186
- if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
187
- throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
188
- }
189
- if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
190
- throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
191
- }
192
- if (value.nudgeAttempts !== undefined) {
193
- if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
194
- throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
195
- }
196
- limits.nudgeAttempts = value.nudgeAttempts
197
- }
198
- if (value.nudgeFallback !== undefined) {
199
- if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
200
- throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
201
- }
202
- limits.nudgeFallback = value.nudgeFallback
203
- }
204
- return Object.freeze(limits)
205
- }
206
152
  const zs = z
207
153
 
208
154
  function fieldSchema(type) {
@@ -35,10 +35,19 @@ export const AUDIT_TYPES = Object.freeze({
35
35
  // identity, feed census and provider status, never skill names; the receipt
36
36
  // itself stays the only effective-state authority.
37
37
  capabilityRead: 'capability-read',
38
+ // The pending model-facing capability notice (durable-pending-notice D7):
39
+ // queue / degrade / deliver / clear outcomes of the durable notice unit.
40
+ // Counts only, never names — the same rule as capability-read above; the
41
+ // accepted selection record stays the only effective-state authority.
42
+ capabilityNotice: 'capability-notice',
38
43
  // Session blackboard release facts (design D3): one record per key release
39
44
  // that had waiting subscribers; the emission lives in the blackboard plugin
40
45
  // and never touches a session log.
41
46
  blackboard: 'blackboard',
47
+ // Child-composition spawn facts (DSH 0.2.1 delegate shim): one record per
48
+ // spawn carrying the composition the child's creation window applies, so a
49
+ // host restart can replay it — the shim itself is runtime-only state.
50
+ delegate: 'delegate',
42
51
  })
43
52
  /**
44
53
  * Known dynamic sub-event kinds: `<type>/<kind>` events emitted beside a base
@@ -53,6 +62,9 @@ export const AUDIT_SUBTYPES = Object.freeze({
53
62
  // family, plus reconciliation reports.
54
63
  worktree: Object.freeze(['open', 'setup', 'bind', 'checked', 'check', 'invalidate', 'ask', 'decline', 'conflict', 'land', 'cleanup', 'abandon', 'reconcile', 'watch']),
55
64
  blackboard: Object.freeze(['released']),
65
+ // Child-composition spawn facts: one record per spawn, replayed on cold
66
+ // resume from this same channel (audit only — never a session log).
67
+ delegate: Object.freeze(['spawn']),
56
68
  })
57
69
 
58
70
  /**
@@ -59,7 +59,9 @@ export const CHILD_DENY_TOOLS = [
59
59
  * returned unchanged — allow semantics stay untouched. Deny-list or absent
60
60
  * filters get CHILD_DENY_TOOLS merged in, deduped. Same merge pattern as
61
61
  * supervisedToolFilter in src/delegate/spawn-adapter.js, applied AFTER the
62
- * lane transform at the single request-assembly point.
62
+ * lane transform at the single activation-assembly point, and re-applied by
63
+ * the DSH 0.2.1 creation-window shim (src/delegate/child-composition.js) with
64
+ * the CHILD scope's restrictable names before tools.restrict().
63
65
  *
64
66
  * DSH's tools.restrict() REJECTS deny names the composition never registered
65
67
  * (e.g. ask_user_question/present/edit_lock_* are absent from a headless
@@ -0,0 +1,28 @@
1
+ // Generic HTTP JSON helpers for the host-side admin endpoints: Response
2
+ // shaping and request-body reading shared by the lsp, settings, and notify
3
+ // admin modules. Sunk verbatim out of src/lsp/admin.js
4
+ // (subsystem-seam-hygiene, D2); the LSP-specific error namespace
5
+ // (errorResponse) and endpoint assembly (wireLspAdmin) stay in src/lsp/.
6
+
7
+ export function jsonResponse(payload, status = 200) {
8
+ return new Response(JSON.stringify(payload), {
9
+ status,
10
+ headers: { 'content-type': 'application/json' },
11
+ })
12
+ }
13
+
14
+ /** Read the JSON body of a connection request (Request-like or stream). */
15
+ export async function readJsonBody(request) {
16
+ try {
17
+ if (typeof request?.json === 'function') return await request.json()
18
+ } catch {
19
+ return null
20
+ }
21
+ try {
22
+ const chunks = []
23
+ for await (const chunk of request?.body ?? []) chunks.push(Buffer.from(chunk))
24
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
25
+ } catch {
26
+ return null
27
+ }
28
+ }
@@ -1,9 +1,39 @@
1
- // Best-effort cold readers for supervision rebuild: the audit JSONL tail and a
2
- // child's final assistant text. These are the only node: consumers in the
3
- // delegate subsystem's cold path — the composition root stays node:-free.
1
+ // Terminal evidence — the single definition site for what proves a supervised
2
+ // child reached a terminal state: the STATUS/REPORT parser, the terminal-status
3
+ // predicate (its JSDoc carries the one blocked-vs-terminal rule every consumer
4
+ // refers to), and the two best-effort cold readers (the bounded audit-JSONL
5
+ // tail; a child log's final assistant text). Delegate supervision and worktree
6
+ // zombie-lane reconciliation both import from here, so neither subsystem
7
+ // reaches into the other for this concept.
8
+ //
9
+ // Load-bearing tolerance semantics (zombie-lane safety): readAuditTail's 256KB
10
+ // bounded window and error tolerance (missing/unreadable → [], a partial first
11
+ // line dropped, malformed lines skipped) make an aged-out fact degrade to "no
12
+ // evidence", never to a wrong release. Keep them byte-exact.
13
+ //
14
+ // Ports & adapters: the readers take their services as parameters (never ctx).
15
+ // node: imports are the src/shared precedent (audit.js);
16
+ // the node:-free constraint guards the delegate composition root, not shared.
4
17
  import { openSync, readSync, closeSync, statSync } from 'node:fs'
5
18
  import { join } from 'node:path'
6
- import { contentText } from '../shared/content-text.js'
19
+ import { contentText } from './content-text.js'
20
+
21
+ /** Parse a child's final assistant text into a terminal status, or null. */
22
+ export function parseTerminalStatus(text) {
23
+ if (typeof text !== 'string' || text.length === 0) return null
24
+ const match = /^STATUS:[ \t]*(completed|blocked)[ \t]*$/m.exec(text)
25
+ if (!match) return null
26
+ const reportMatch = /^REPORT:[ \t]*([\s\S]*)$/m.exec(text.slice(match.index))
27
+ return { status: match[1], report: (reportMatch?.[1] ?? '').trim() }
28
+ }
29
+
30
+ /**
31
+ * Single exit for the terminal-status predicate: statuses that count toward
32
+ * group completion (blocked waits for a resume, so it is NOT terminal here).
33
+ */
34
+ export function isTerminalStatus(status) {
35
+ return status === 'completed' || status === 'terminated'
36
+ }
7
37
 
8
38
  /** Audit JSONL location for one session (cold-safe channel; may not exist). */
9
39
  export function auditFilePathOf(session) {
@@ -18,6 +18,9 @@ export const WORKTREE_CODES = Object.freeze({
18
18
  UNKNOWN_LANE: 'UNKNOWN_LANE',
19
19
  ILLEGAL_TRANSITION: 'ILLEGAL_TRANSITION',
20
20
  LANE_NOT_DISPATCHABLE: 'LANE_NOT_DISPATCHABLE',
21
+ // The binding commit's reservation is gone (force-reclaim / reconcile
22
+ // --rebuild) or the lane left `working` before the commit landed.
23
+ LANE_RESERVATION_LOST: 'LANE_RESERVATION_LOST',
21
24
  LANE_BUSY: 'LANE_BUSY',
22
25
  NOT_LANDABLE: 'NOT_LANDABLE',
23
26
  STALE_LANDABLE: 'STALE_LANDABLE',
@@ -19,8 +19,7 @@ import { DEFAULT_ROOT } from './rules.js'
19
19
  import { LANES_CONTEXT_NAME, LANES_CONTEXT_ORDER, LANES_SECTION_NAME, LANES_SECTION_TEXT, LANES_VARIABLE_NAME } from './prompts.js'
20
20
  import { DEFAULT_WATCH_TIMEOUT_MINUTES } from './watches.js'
21
21
  import { APPROVE_MODES, WORKTREE_PROJECTION_KEY, foldWorktreeState, initialWorktreeState, worktreeStateSchema, worktreeView, worktreeViewSchema } from './projection.js'
22
- import { readAuditTail, readChildFinalText } from '../delegate/audit-readers.js'
23
- import { isTerminalStatus, parseTerminalStatus } from '../delegate/group-coordinator.js'
22
+ import { isTerminalStatus, parseTerminalStatus, readAuditTail, readChildFinalText } from '../shared/terminal-evidence.js'
24
23
 
25
24
  const name = 'weir-worktree'
26
25
  // subprocess is a host-plane service every composition mounts; it is a hard