@adhdev/mesh-shared 1.0.60-rc.72 → 1.0.60-rc.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/mesh-shared",
3
- "version": "1.0.60-rc.72",
3
+ "version": "1.0.60-rc.73",
4
4
  "description": "ADHDev mesh-shared — pure mesh/git status normalizers shared by daemon-core and web-core",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,477 @@
1
+ /**
2
+ * turn-ipc — graph gates, task stats and maintenance commands: graph_gate_claim,
3
+ * graph_gate_release, graph_gate_abandon, graph_node_patch, graph_view_query,
4
+ * task_stats_query, prune_stale_direct, orphaned_pin_notify. Part of the turn-ipc
5
+ * wire contract (./turn-ipc.ts).
6
+ */
7
+
8
+ import {
9
+ TURN_IPC_PROTOCOL_VERSION,
10
+ hasOnlyKeys,
11
+ isFiniteNumber,
12
+ isOptionalBoolean,
13
+ isOptionalRecord,
14
+ isNonNegativeInt,
15
+ isOptionalString,
16
+ isRecordArray,
17
+ } from './turn-ipc-guards';
18
+ import { isRecord } from './protocol/envelope';
19
+ import { isEvidenceIdentifier } from './turn-evidence';
20
+
21
+ // ─── graph_gate_claim / graph_gate_release / graph_gate_abandon / graph_node_patch / graph_view_query ──
22
+ //
23
+ // Wiring-unification Phase C, workstream C-W9c (2026-09-24 19:00 stamp
24
+ // "mcp-server graph gates/plan/patch ... still call daemon-core in-process").
25
+ //
26
+ // `mesh-tools-graph.ts`'s five graph-orchestration tools (the gate claim
27
+ // / release / abandon verbs, now `mesh_graph_gate`, `mesh_graph_node_patch`, `mesh_graph_view`) called
28
+ // daemon-core's MeshRuntimeStore-backed graph module in-process
29
+ // (`claimMeshGraphGate` / `releaseMeshGraphGate` / `abandonMeshGraphGate` /
30
+ // `patchGraphNodeAndRetry` / `buildMeshGraphViews`, all in `mesh-graph-gates.ts` /
31
+ // `mesh-graph-transition-runner.ts` / `mesh-graph-view.ts`), plus the read-only
32
+ // git-based `collectGateConvergenceEvidence` probe. These five commands move
33
+ // every one of those calls into the daemon that owns the graph rows.
34
+ //
35
+ // CONTENT BOUNDARY: a gate/node-patch result is exposed to the CALLER as
36
+ // identifiers, enums, counts and a `result`/`evidence`/`patches` passthrough
37
+ // the coordinator itself supplied on the request (release/patch echo back only
38
+ // what they were given) — never anything the daemon derives from chat content.
39
+ // `MeshGraphGateRow` / `MeshGraphView` are large, evolving daemon-core shapes;
40
+ // rather than duplicate their structure here (the same risk `active_work_query`'s
41
+ // `activeWork` avoided), the gate/graph payload is a JSON passthrough
42
+ // (`Record<string, unknown>`), exactly like `ActiveWorkQueryResponse.activeWork`.
43
+
44
+ export interface GraphGateClaimRequest {
45
+ v: typeof TURN_IPC_PROTOCOL_VERSION
46
+ meshId: string
47
+ gateId: string
48
+ coordinatorSessionId: string
49
+ leaseSeconds?: number
50
+ extendDeadlineSeconds?: number
51
+ /** Also collect `collectGateConvergenceEvidence` on a successful claim (mesh_graph_gate action=claim's own behavior). */
52
+ probeConvergenceEvidence?: boolean
53
+ }
54
+
55
+ export interface GraphGateClaimResponse {
56
+ claimed: boolean
57
+ /** gate_not_found / gate_not_eligible / gate_not_awaiting / gate_lease_held / gate_claim_race / gate_terminal:<state> / gate_not_claimable, when `claimed: false`. */
58
+ reason?: string
59
+ /** `MeshGraphGateRow`, JSON passthrough; present whenever the core found the gate. */
60
+ gate?: Record<string, unknown>
61
+ leaseGeneration?: number
62
+ fencingToken?: string
63
+ leaseExpiresAt?: string
64
+ deadlineAt?: string
65
+ ambiguousExternalOutcome?: boolean
66
+ previousLeaseOwnerSessionId?: string
67
+ /** `GateConvergenceEvidence`, JSON passthrough; present only when requested and found. */
68
+ convergenceEvidence?: Record<string, unknown>
69
+ }
70
+
71
+ function isGraphGateClaimRequest(value: unknown): value is GraphGateClaimRequest {
72
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'gateId', 'coordinatorSessionId', 'leaseSeconds', 'extendDeadlineSeconds', 'probeConvergenceEvidence'])) return false
73
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.gateId) || !isEvidenceIdentifier(value.coordinatorSessionId)) return false
74
+ if (value.leaseSeconds !== undefined && !isFiniteNumber(value.leaseSeconds)) return false
75
+ if (value.extendDeadlineSeconds !== undefined && !isFiniteNumber(value.extendDeadlineSeconds)) return false
76
+ return isOptionalBoolean(value.probeConvergenceEvidence)
77
+ }
78
+
79
+ export function decodeGraphGateClaimRequest(value: unknown): GraphGateClaimRequest | null {
80
+ return isGraphGateClaimRequest(value) ? value : null
81
+ }
82
+
83
+ function isGraphGateClaimResponse(value: unknown): value is GraphGateClaimResponse {
84
+ if (!isRecord(value) || !hasOnlyKeys(value, ['claimed', 'reason', 'gate', 'leaseGeneration', 'fencingToken', 'leaseExpiresAt', 'deadlineAt', 'ambiguousExternalOutcome', 'previousLeaseOwnerSessionId', 'convergenceEvidence'])) return false
85
+ if (typeof value.claimed !== 'boolean') return false
86
+ return isOptionalRecord(value.gate) && isOptionalRecord(value.convergenceEvidence) && isOptionalBoolean(value.ambiguousExternalOutcome)
87
+ }
88
+
89
+ export function decodeGraphGateClaimResponse(value: unknown): GraphGateClaimResponse | null {
90
+ return isGraphGateClaimResponse(value) ? value : null
91
+ }
92
+
93
+ export interface GraphGateReleaseRequest {
94
+ v: typeof TURN_IPC_PROTOCOL_VERSION
95
+ meshId: string
96
+ gateId: string
97
+ fencingToken: string
98
+ leaseGeneration: number
99
+ idempotencyKey: string
100
+ outcome: string
101
+ /** Action-specific structured result/evidence the CALLER supplied — JSON passthrough, echoed back unmodified. */
102
+ result?: unknown
103
+ evidence?: unknown
104
+ patches?: readonly { node: string, baseSpecPatch: Record<string, unknown> }[]
105
+ }
106
+
107
+ // `releaseMeshGraphGate` THROWS for every rejection (its whole transaction must
108
+ // roll back — mesh-graph-gates.ts file header). The daemon handler catches
109
+ // that and reports it as a RESULT, not an IPC-level failure (the same
110
+ // success:true-carries-a-refusal shape `queue_enqueue_graph` already uses) —
111
+ // `mesh_graph_gate` action=release's tool layer classifies `refusalCode` by prefix
112
+ // exactly as it did the in-process thrown message.
113
+ export type GraphGateReleaseResponse =
114
+ | {
115
+ released: true
116
+ duplicate: boolean
117
+ gate?: Record<string, unknown>
118
+ materializedNodeIds: readonly string[]
119
+ downstreamNodeCount?: number
120
+ graphCompleted?: boolean
121
+ }
122
+ | { released: false, refusalCode?: string, message: string }
123
+
124
+ function isGraphGateReleaseRequest(value: unknown): value is GraphGateReleaseRequest {
125
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'gateId', 'fencingToken', 'leaseGeneration', 'idempotencyKey', 'outcome', 'result', 'evidence', 'patches'])) return false
126
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.gateId)) return false
127
+ if (typeof value.fencingToken !== 'string' || !value.fencingToken) return false
128
+ if (!isFiniteNumber(value.leaseGeneration)) return false
129
+ if (typeof value.idempotencyKey !== 'string' || !value.idempotencyKey) return false
130
+ if (typeof value.outcome !== 'string' || !value.outcome) return false
131
+ if (value.patches !== undefined) {
132
+ if (!Array.isArray(value.patches)) return false
133
+ for (const p of value.patches) {
134
+ if (!isRecord(p) || typeof p.node !== 'string' || !p.node || !isRecord(p.baseSpecPatch)) return false
135
+ }
136
+ }
137
+ return true
138
+ }
139
+
140
+ export function decodeGraphGateReleaseRequest(value: unknown): GraphGateReleaseRequest | null {
141
+ return isGraphGateReleaseRequest(value) ? value : null
142
+ }
143
+
144
+ function isGraphGateReleaseResponse(value: unknown): value is GraphGateReleaseResponse {
145
+ if (!isRecord(value)) return false
146
+ if (value.released === true) {
147
+ if (!hasOnlyKeys(value, ['released', 'duplicate', 'gate', 'materializedNodeIds', 'downstreamNodeCount', 'graphCompleted'])) return false
148
+ if (typeof value.duplicate !== 'boolean') return false
149
+ if (!Array.isArray(value.materializedNodeIds) || !value.materializedNodeIds.every((n) => typeof n === 'string')) return false
150
+ if (value.downstreamNodeCount !== undefined && !isNonNegativeInt(value.downstreamNodeCount)) return false
151
+ return isOptionalRecord(value.gate) && isOptionalBoolean(value.graphCompleted)
152
+ }
153
+ if (value.released === false) {
154
+ return hasOnlyKeys(value, ['released', 'refusalCode', 'message']) && typeof value.message === 'string' && isOptionalString(value.refusalCode)
155
+ }
156
+ return false
157
+ }
158
+
159
+ export function decodeGraphGateReleaseResponse(value: unknown): GraphGateReleaseResponse | null {
160
+ return isGraphGateReleaseResponse(value) ? value : null
161
+ }
162
+
163
+ export interface GraphGateAbandonRequest {
164
+ v: typeof TURN_IPC_PROTOCOL_VERSION
165
+ meshId: string
166
+ gateId: string
167
+ reason: string
168
+ coordinatorSessionId?: string
169
+ force?: boolean
170
+ }
171
+
172
+ export interface GraphGateAbandonResponse {
173
+ abandoned: boolean
174
+ /** gate_not_found / gate_lease_held / gate_abandon_race / gate_terminal:<state> / gate_not_abandonable, when `abandoned: false`. */
175
+ reason?: string
176
+ gate?: Record<string, unknown>
177
+ cancelledNodeIds: readonly string[]
178
+ cancelledTaskIds: readonly string[]
179
+ graphStatus?: string
180
+ }
181
+
182
+ function isGraphGateAbandonRequest(value: unknown): value is GraphGateAbandonRequest {
183
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'gateId', 'reason', 'coordinatorSessionId', 'force'])) return false
184
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.gateId)) return false
185
+ if (typeof value.reason !== 'string' || !value.reason) return false
186
+ if (value.coordinatorSessionId !== undefined && !isEvidenceIdentifier(value.coordinatorSessionId)) return false
187
+ return isOptionalBoolean(value.force)
188
+ }
189
+
190
+ export function decodeGraphGateAbandonRequest(value: unknown): GraphGateAbandonRequest | null {
191
+ return isGraphGateAbandonRequest(value) ? value : null
192
+ }
193
+
194
+ function isGraphGateAbandonResponse(value: unknown): value is GraphGateAbandonResponse {
195
+ if (!isRecord(value) || !hasOnlyKeys(value, ['abandoned', 'reason', 'gate', 'cancelledNodeIds', 'cancelledTaskIds', 'graphStatus'])) return false
196
+ if (typeof value.abandoned !== 'boolean') return false
197
+ if (!Array.isArray(value.cancelledNodeIds) || !value.cancelledNodeIds.every((n) => typeof n === 'string')) return false
198
+ if (!Array.isArray(value.cancelledTaskIds) || !value.cancelledTaskIds.every((n) => typeof n === 'string')) return false
199
+ return isOptionalRecord(value.gate)
200
+ }
201
+
202
+ export function decodeGraphGateAbandonResponse(value: unknown): GraphGateAbandonResponse | null {
203
+ return isGraphGateAbandonResponse(value) ? value : null
204
+ }
205
+
206
+ export interface GraphNodePatchRequest {
207
+ v: typeof TURN_IPC_PROTOCOL_VERSION
208
+ meshId: string
209
+ node: string
210
+ graphId?: string
211
+ /** Only {@link MESH_NODE_PATCH_KEYS}-shaped keys are permitted; the daemon enforces the allow-list. */
212
+ baseSpecPatch: Record<string, unknown>
213
+ }
214
+
215
+ // `patchGraphNodeAndRetry` THROWS for every rejection (mesh-graph-transition-runner.ts) —
216
+ // same released:false treatment as `GraphGateReleaseResponse` above.
217
+ export type GraphNodePatchResponse =
218
+ | {
219
+ patched: true
220
+ graphId: string
221
+ nodeId: string
222
+ ref?: string
223
+ queueTaskId?: string
224
+ materializationVersion: number
225
+ state: string
226
+ /** `SettleOutcome['kind']` — materialized / deferred / skipped / error. */
227
+ outcomeKind: string
228
+ /** Present when `outcomeKind === 'skipped'`. */
229
+ skippedReason?: string
230
+ blockedReason?: string
231
+ }
232
+ | { patched: false, refusalCode?: string, message: string }
233
+
234
+ function isGraphNodePatchRequest(value: unknown): value is GraphNodePatchRequest {
235
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'node', 'graphId', 'baseSpecPatch'])) return false
236
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
237
+ if (typeof value.node !== 'string' || !value.node) return false
238
+ if (value.graphId !== undefined && !isEvidenceIdentifier(value.graphId)) return false
239
+ return isRecord(value.baseSpecPatch) && Object.keys(value.baseSpecPatch).length > 0
240
+ }
241
+
242
+ export function decodeGraphNodePatchRequest(value: unknown): GraphNodePatchRequest | null {
243
+ return isGraphNodePatchRequest(value) ? value : null
244
+ }
245
+
246
+ function isGraphNodePatchResponse(value: unknown): value is GraphNodePatchResponse {
247
+ if (!isRecord(value)) return false
248
+ if (value.patched === true) {
249
+ if (!hasOnlyKeys(value, ['patched', 'graphId', 'nodeId', 'ref', 'queueTaskId', 'materializationVersion', 'state', 'outcomeKind', 'skippedReason', 'blockedReason'])) return false
250
+ if (!isEvidenceIdentifier(value.graphId) || !isEvidenceIdentifier(value.nodeId)) return false
251
+ if (!isNonNegativeInt(value.materializationVersion)) return false
252
+ return typeof value.state === 'string' && typeof value.outcomeKind === 'string'
253
+ }
254
+ if (value.patched === false) {
255
+ return hasOnlyKeys(value, ['patched', 'refusalCode', 'message']) && typeof value.message === 'string' && isOptionalString(value.refusalCode)
256
+ }
257
+ return false
258
+ }
259
+
260
+ export function decodeGraphNodePatchResponse(value: unknown): GraphNodePatchResponse | null {
261
+ return isGraphNodePatchResponse(value) ? value : null
262
+ }
263
+
264
+ export interface GraphViewQueryRequest {
265
+ v: typeof TURN_IPC_PROTOCOL_VERSION
266
+ meshId: string
267
+ graphId?: string
268
+ batchId?: string
269
+ /** Default true: only graphs that still need attention. */
270
+ activeOnly?: boolean
271
+ limit?: number
272
+ /** Attach `collectGateConvergenceEvidence` to the first few waiting gates (mesh_graph_view's G4 opt-in probe). */
273
+ probeGateEvidence?: boolean
274
+ }
275
+
276
+ export interface GraphViewQueryResponse {
277
+ /** `MeshGraphView[]`, JSON passthrough — see this section's content-boundary note. */
278
+ graphs: readonly Record<string, unknown>[]
279
+ }
280
+
281
+ export function isGraphViewQueryRequest(value: unknown): value is GraphViewQueryRequest {
282
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'graphId', 'batchId', 'activeOnly', 'limit', 'probeGateEvidence'])) return false
283
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
284
+ if (value.graphId !== undefined && !isEvidenceIdentifier(value.graphId)) return false
285
+ if (value.batchId !== undefined && !isEvidenceIdentifier(value.batchId)) return false
286
+ if (value.limit !== undefined && !isNonNegativeInt(value.limit)) return false
287
+ return isOptionalBoolean(value.activeOnly) && isOptionalBoolean(value.probeGateEvidence)
288
+ }
289
+
290
+ export function decodeGraphViewQueryRequest(value: unknown): GraphViewQueryRequest | null {
291
+ return isGraphViewQueryRequest(value) ? value : null
292
+ }
293
+
294
+ function isGraphViewQueryResponse(value: unknown): value is GraphViewQueryResponse {
295
+ return isRecord(value) && hasOnlyKeys(value, ['graphs']) && isRecordArray(value.graphs)
296
+ }
297
+
298
+ export function decodeGraphViewQueryResponse(value: unknown): GraphViewQueryResponse | null {
299
+ return isGraphViewQueryResponse(value) ? value : null
300
+ }
301
+
302
+ // ─── task_stats_query ───────────────────────────────────────────────────────
303
+ //
304
+ // C-W9c: replaces the in-process `computeMeshTaskStats` / `computeMeshMissionStats`
305
+ // (daemon-core `mesh-task-stats.ts`) — both scan the daemon's queue + local
306
+ // records, so both move behind one command. Exactly one of `taskIds` /
307
+ // `missionId` selects the per-task mode; `missionId` alone with `rollup: true`
308
+ // additionally returns the mission-level aggregate.
309
+
310
+ export interface TaskStatsQueryRequest {
311
+ v: typeof TURN_IPC_PROTOCOL_VERSION
312
+ meshId: string
313
+ taskIds?: readonly string[]
314
+ missionId?: string
315
+ tail?: number
316
+ /** Also compute `computeMeshMissionStats` — requires `missionId`. */
317
+ rollup?: boolean
318
+ /**
319
+ * Batch rollups: `computeMeshMissionStats` for every id, over ONE queue read and
320
+ * ONE record read (answers `missions`, `tasks` empty). Exclusive with
321
+ * `taskIds`/`missionId`. An older daemon rejects the key.
322
+ */
323
+ missionIds?: readonly string[]
324
+ }
325
+
326
+ export interface TaskStatsQueryResponse {
327
+ /** `MeshTaskStats[]`, JSON passthrough. */
328
+ tasks: readonly Record<string, unknown>[]
329
+ /** `MeshMissionStats`, JSON passthrough; present only when `rollup: true` was requested and satisfiable. */
330
+ mission?: Record<string, unknown>
331
+ /** Batch mode: `MeshMissionStats` per requested mission id, JSON passthrough. */
332
+ missions?: Record<string, Record<string, unknown>>
333
+ }
334
+
335
+ function isTaskStatsQueryRequest(value: unknown): value is TaskStatsQueryRequest {
336
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'taskIds', 'missionId', 'tail', 'rollup', 'missionIds'])) return false
337
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
338
+ if (value.taskIds !== undefined) {
339
+ if (!Array.isArray(value.taskIds) || value.taskIds.length === 0 || !value.taskIds.every(isEvidenceIdentifier)) return false
340
+ }
341
+ if (value.missionId !== undefined && !isEvidenceIdentifier(value.missionId)) return false
342
+ if (value.tail !== undefined && !isNonNegativeInt(value.tail)) return false
343
+ if (value.missionIds !== undefined) {
344
+ if (!Array.isArray(value.missionIds) || value.missionIds.length === 0 || !value.missionIds.every(isEvidenceIdentifier)) return false
345
+ if (value.taskIds !== undefined || value.missionId !== undefined) return false
346
+ }
347
+ if (value.rollup === true && value.missionId === undefined) return false
348
+ return isOptionalBoolean(value.rollup)
349
+ }
350
+
351
+ export function decodeTaskStatsQueryRequest(value: unknown): TaskStatsQueryRequest | null {
352
+ return isTaskStatsQueryRequest(value) ? value : null
353
+ }
354
+
355
+ function isTaskStatsQueryResponse(value: unknown): value is TaskStatsQueryResponse {
356
+ return isRecord(value) && hasOnlyKeys(value, ['tasks', 'mission', 'missions']) && isRecordArray(value.tasks) && isOptionalRecord(value.mission)
357
+ && (value.missions === undefined || (isRecord(value.missions) && Object.values(value.missions).every(isRecord)))
358
+ }
359
+
360
+ export function decodeTaskStatsQueryResponse(value: unknown): TaskStatsQueryResponse | null {
361
+ return isTaskStatsQueryResponse(value) ? value : null
362
+ }
363
+
364
+ // ─── prune_stale_direct ─────────────────────────────────────────────────────
365
+ //
366
+ // C-W9c: replaces the in-process `pruneStaleDirectDispatches` call
367
+ // (`mesh_cleanup_sessions` mode=prune_stale_direct, `mesh-tools-session.ts`). Unlike the other
368
+ // C-W9 commands this one does NOT take its inputs (queue/records/direct
369
+ // dispatches) on the wire — the daemon already reads them itself
370
+ // (`getQueue`/`readLocalRecords`/`getActiveDirectDispatches`), so the request
371
+ // carries only the decision knobs. `closeDispatches` (the C-W8 `turn_cancel`
372
+ // callback) also runs daemon-side now, since it is the same process.
373
+
374
+ export interface PruneStaleDirectRequest {
375
+ v: typeof TURN_IPC_PROTOCOL_VERSION
376
+ meshId: string
377
+ execute?: boolean
378
+ includeTerminal?: boolean
379
+ source?: string
380
+ }
381
+
382
+ export interface PruneStaleDirectResponse {
383
+ mode: 'execute' | 'dry_run'
384
+ includeTerminal: boolean
385
+ candidateCount: number
386
+ /** `MeshActiveWorkRecord[]`, JSON passthrough. */
387
+ prunable: readonly Record<string, unknown>[]
388
+ prunedCount: number
389
+ preservedUnacknowledged: readonly Record<string, unknown>[]
390
+ preservedLedgerOnly: readonly Record<string, unknown>[]
391
+ preservedNotOrphan: readonly Record<string, unknown>[]
392
+ }
393
+
394
+ function isPruneStaleDirectRequest(value: unknown): value is PruneStaleDirectRequest {
395
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'execute', 'includeTerminal', 'source'])) return false
396
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
397
+ if (value.source !== undefined && typeof value.source !== 'string') return false
398
+ return isOptionalBoolean(value.execute) && isOptionalBoolean(value.includeTerminal)
399
+ }
400
+
401
+ export function decodePruneStaleDirectRequest(value: unknown): PruneStaleDirectRequest | null {
402
+ return isPruneStaleDirectRequest(value) ? value : null
403
+ }
404
+
405
+ function isPruneStaleDirectResponse(value: unknown): value is PruneStaleDirectResponse {
406
+ if (!isRecord(value) || !hasOnlyKeys(value, ['mode', 'includeTerminal', 'candidateCount', 'prunable', 'prunedCount', 'preservedUnacknowledged', 'preservedLedgerOnly', 'preservedNotOrphan'])) return false
407
+ if (value.mode !== 'execute' && value.mode !== 'dry_run') return false
408
+ if (typeof value.includeTerminal !== 'boolean') return false
409
+ if (!isNonNegativeInt(value.candidateCount) || !isNonNegativeInt(value.prunedCount)) return false
410
+ return isRecordArray(value.prunable) && isRecordArray(value.preservedUnacknowledged)
411
+ && isRecordArray(value.preservedLedgerOnly) && isRecordArray(value.preservedNotOrphan)
412
+ }
413
+
414
+ export function decodePruneStaleDirectResponse(value: unknown): PruneStaleDirectResponse | null {
415
+ return isPruneStaleDirectResponse(value) ? value : null
416
+ }
417
+
418
+ // ─── orphaned_pin_notify ────────────────────────────────────────────────────
419
+ //
420
+ // C-W9c: replaces the in-process `notifyCoordinatorOfOrphanedPins` call
421
+ // (`mesh_queue_cancel` tool, `mesh-tools-queue.ts`, CANCEL-ORPHANS-PINNED-TASK).
422
+ // The core reads the daemon's live queue (`getQueue`) and, when it finds
423
+ // orphans, calls `notifyMeshCoordinator` to page the coordinator — both are
424
+ // daemon-only concerns, so the whole call moves, not just its queue read.
425
+ // `title` is a task-message-derived label (first line, truncated) — free text,
426
+ // hence local IPC only, same rationale as `record_local`'s payload.
427
+
428
+ export interface OrphanedPinNotifyRequest {
429
+ v: typeof TURN_IPC_PROTOCOL_VERSION
430
+ meshId: string
431
+ stoppedSessionId: string
432
+ excludeTaskId?: string
433
+ cause?: string
434
+ nodeId?: string
435
+ coordinatorSessionId?: string
436
+ }
437
+
438
+ export interface OrphanedPinnedTaskWire {
439
+ taskId: string
440
+ title: string
441
+ targetSessionId: string
442
+ targetNodeId?: string
443
+ missionId?: string
444
+ }
445
+
446
+ export interface OrphanedPinNotifyResponse {
447
+ orphans: readonly OrphanedPinnedTaskWire[]
448
+ }
449
+
450
+ function isOrphanedPinnedTaskWire(value: unknown): value is OrphanedPinnedTaskWire {
451
+ if (!isRecord(value) || !hasOnlyKeys(value, ['taskId', 'title', 'targetSessionId', 'targetNodeId', 'missionId'])) return false
452
+ if (!isEvidenceIdentifier(value.taskId) || !isEvidenceIdentifier(value.targetSessionId)) return false
453
+ if (typeof value.title !== 'string') return false
454
+ if (value.targetNodeId !== undefined && !isEvidenceIdentifier(value.targetNodeId)) return false
455
+ return value.missionId === undefined || isEvidenceIdentifier(value.missionId)
456
+ }
457
+
458
+ function isOrphanedPinNotifyRequest(value: unknown): value is OrphanedPinNotifyRequest {
459
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'stoppedSessionId', 'excludeTaskId', 'cause', 'nodeId', 'coordinatorSessionId'])) return false
460
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.stoppedSessionId)) return false
461
+ if (value.excludeTaskId !== undefined && !isEvidenceIdentifier(value.excludeTaskId)) return false
462
+ if (value.cause !== undefined && typeof value.cause !== 'string') return false
463
+ if (value.nodeId !== undefined && !isEvidenceIdentifier(value.nodeId)) return false
464
+ return value.coordinatorSessionId === undefined || isEvidenceIdentifier(value.coordinatorSessionId)
465
+ }
466
+
467
+ export function decodeOrphanedPinNotifyRequest(value: unknown): OrphanedPinNotifyRequest | null {
468
+ return isOrphanedPinNotifyRequest(value) ? value : null
469
+ }
470
+
471
+ function isOrphanedPinNotifyResponse(value: unknown): value is OrphanedPinNotifyResponse {
472
+ return isRecord(value) && hasOnlyKeys(value, ['orphans']) && Array.isArray(value.orphans) && value.orphans.every(isOrphanedPinnedTaskWire)
473
+ }
474
+
475
+ export function decodeOrphanedPinNotifyResponse(value: unknown): OrphanedPinNotifyResponse | null {
476
+ return isOrphanedPinNotifyResponse(value) ? value : null
477
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * turn-ipc shared runtime-guard primitives (the protocol version stamp and the
3
+ * small structural checks every command decoder composes). Part of the turn-ipc
4
+ * wire contract (./turn-ipc.ts).
5
+ */
6
+
7
+ import { isEvidenceIdentifier } from './turn-evidence';
8
+ import { isRecord } from './protocol/envelope';
9
+
10
+ // ─── shared primitives ─────────────────────────────────────────────────────
11
+
12
+ /** Every request in this file is versioned so a responder can reject a shape it predates. */
13
+ export const TURN_IPC_PROTOCOL_VERSION = 1 as const
14
+
15
+ export function isFiniteNumber(value: unknown): value is number {
16
+ return typeof value === 'number' && Number.isFinite(value)
17
+ }
18
+
19
+ export function isNonNegativeInt(value: unknown): value is number {
20
+ return Number.isSafeInteger(value) && (value as number) >= 0
21
+ }
22
+
23
+ export function isOptionalId(value: unknown): boolean {
24
+ return value === undefined || isEvidenceIdentifier(value)
25
+ }
26
+
27
+ export function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
28
+ for (const key of Object.keys(value)) {
29
+ if (!allowed.includes(key)) return false
30
+ }
31
+ return true
32
+ }
33
+
34
+ export function makeGuard<T extends readonly string[]>(values: T): (value: unknown) => value is T[number] {
35
+ const set: ReadonlySet<string> = new Set(values)
36
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
37
+ }
38
+
39
+ export function isStringArray(value: unknown): value is readonly string[] {
40
+ return Array.isArray(value) && value.every((v) => typeof v === 'string')
41
+ }
42
+
43
+ export function isOptionalShortString(value: unknown, max = 512): boolean {
44
+ return value === undefined || (typeof value === 'string' && value.trim().length > 0 && value.length <= max)
45
+ }
46
+
47
+ export function isRecordArray(value: unknown): value is readonly Record<string, unknown>[] {
48
+ return Array.isArray(value) && value.every(isRecord)
49
+ }
50
+
51
+ export function isOptionalRecord(value: unknown): boolean {
52
+ return value === undefined || isRecord(value)
53
+ }
54
+
55
+ export function isOptionalBoolean(value: unknown): boolean {
56
+ return value === undefined || typeof value === 'boolean'
57
+ }
58
+
59
+ export function isOptionalString(value: unknown): boolean {
60
+ return value === undefined || typeof value === 'string'
61
+ }