@adhdev/mesh-shared 1.0.60-rc.7 → 1.0.60-rc.70

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 (51) hide show
  1. package/dist/brain-routing.d.ts +2 -2
  2. package/dist/index.d.ts +12 -0
  3. package/dist/index.js +3264 -35
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2931 -35
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/keyed-doc-delta.d.ts +83 -0
  8. package/dist/keyed-status-delta.d.ts +76 -0
  9. package/dist/magi.d.ts +1 -1
  10. package/dist/mesh-tool-names.d.ts +27 -2
  11. package/dist/mesh-vocabulary.d.ts +70 -0
  12. package/dist/mission-brief.d.ts +62 -0
  13. package/dist/outbound-message.d.ts +202 -0
  14. package/dist/path-ownership.d.ts +101 -0
  15. package/dist/protocol/daemon-server.d.ts +381 -0
  16. package/dist/protocol/dashboard-daemon-p2p.d.ts +216 -0
  17. package/dist/protocol/dashboard-server.d.ts +240 -0
  18. package/dist/protocol/envelope.d.ts +55 -0
  19. package/dist/protocol/index.d.ts +15 -0
  20. package/dist/session-launch.d.ts +113 -0
  21. package/dist/session-status.d.ts +61 -0
  22. package/dist/slot-proposal.d.ts +1 -1
  23. package/dist/turn-evidence.d.ts +438 -0
  24. package/dist/turn-ipc.d.ts +1040 -0
  25. package/dist/types.d.ts +11 -0
  26. package/dist/worker-protocol.d.ts +69 -0
  27. package/dist/ws-protocol.d.ts +71 -41
  28. package/package.json +1 -1
  29. package/src/brain-routing.ts +3 -3
  30. package/src/index.ts +12 -0
  31. package/src/keyed-doc-delta.ts +348 -0
  32. package/src/keyed-status-delta.ts +239 -0
  33. package/src/magi.ts +1 -1
  34. package/src/mesh-tool-names.ts +65 -19
  35. package/src/mesh-vocabulary.ts +98 -0
  36. package/src/mission-brief.ts +153 -0
  37. package/src/outbound-message.ts +320 -0
  38. package/src/path-ownership.ts +271 -0
  39. package/src/protocol/daemon-server.ts +545 -0
  40. package/src/protocol/dashboard-daemon-p2p.ts +280 -0
  41. package/src/protocol/dashboard-server.ts +340 -0
  42. package/src/protocol/envelope.ts +75 -0
  43. package/src/protocol/index.ts +16 -0
  44. package/src/session-launch.ts +247 -0
  45. package/src/session-status.ts +140 -0
  46. package/src/slot-proposal.ts +1 -1
  47. package/src/turn-evidence.ts +602 -0
  48. package/src/turn-ipc.ts +2331 -0
  49. package/src/types.ts +8 -0
  50. package/src/worker-protocol.ts +164 -0
  51. package/src/ws-protocol.ts +102 -93
@@ -0,0 +1,2331 @@
1
+ /**
2
+ * turn-ipc — typed IPC command contracts for the six commands that replace
3
+ * mcp-server's direct `mesh-runtime.db` access (design §5 C2 "MCP server").
4
+ *
5
+ * Wiring-unification Phase C, workstream C-W6
6
+ * (docs/design/2026-09-23-wiring-unification.md §5 C2/C3, C9 row C-W6).
7
+ *
8
+ * WHY THIS FILE EXISTS
9
+ * ---------------------
10
+ * mcp-server is a second, in-process writer of `mesh-runtime.db` (~45-69
11
+ * direct-mutation call sites, ~54 reads, per the C-W6 brief). C2 moves every
12
+ * one of those call sites behind IPC, executed in the daemon that owns the
13
+ * turn ledger and the seqscribe node, so mcp-server never opens the SQLite
14
+ * file itself. This file is the WIRE CONTRACT for that IPC surface — request/
15
+ * response shapes, closed enums, runtime decoders — shared by the daemon-core
16
+ * command handlers (added with C-W6 proper; NOT this file) and the mcp-server
17
+ * client (`mcp-server/src/ipc/turn-commands.ts`, this workstream).
18
+ *
19
+ * The six commands (§5 C2):
20
+ * - turn_observe — submit one TurnEvidence; returns the reducer verdict.
21
+ * - mesh_record — append a content-free scalar record (`mesh.record`
22
+ * topic entry), replacing `appendLedgerEntry`.
23
+ * - turn_cancel — cancel an attempt (`cancel` evidence, source `mcp_probe`
24
+ * or `operator`).
25
+ * - operator_status — record a fire-and-forget operator/tool-call status
26
+ * (`operator_status` evidence + the former
27
+ * `recordMeshCoordinatorToolCall` log write).
28
+ * - turn_query — read attempt/event rows (own or fleet scope).
29
+ * - mesh_index_query — read `mesh_topic_index` rows (fleet-wide, replacing
30
+ * the in-memory read model / parity reads).
31
+ *
32
+ * CONTENT BOUNDARY
33
+ * -----------------
34
+ * Every request/response field is an identifier, a closed enum, a boolean, a
35
+ * counter or a timestamp. `turn_observe` carries a `TurnEvidence`, which is
36
+ * content-free by construction (`turn-evidence.ts`). `mesh_record` carries a
37
+ * `ProjectedScalars` payload restricted to the SAME allow-list keys
38
+ * `seqscribe/mesh-event-projection.ts` already reviews and ships
39
+ * (`PROJECTED_PAYLOAD_KEYS`, 35 keys) — this file pins a STRUCTURAL COPY of
40
+ * that key list (`MESH_RECORD_PAYLOAD_KEYS`) so mesh-shared does not import
41
+ * daemon-core (leaf constraint), with a test asserting the two lists agree.
42
+ * Summaries, report bodies, progress notes and modal text never travel in any
43
+ * of these payloads — they go to the content-class `mesh.<id>.handoff` topic
44
+ * and are referenced by `SummaryRef {topic, writer, seq}` (already declared
45
+ * in `./turn-evidence.ts`).
46
+ *
47
+ * VERSIONING
48
+ * -----------
49
+ * Every request carries `v: 1` so a future incompatible change can be
50
+ * detected by the responder before it tries to interpret the args.
51
+ */
52
+
53
+ import {
54
+ isEvidenceIdentifier,
55
+ isTurnEvidence,
56
+ isTurnReason,
57
+ type ProjectedScalars,
58
+ type SummaryRef,
59
+ type TurnAttemptRef,
60
+ type TurnEvidence,
61
+ type TurnEvidenceKind,
62
+ type TurnOutcome,
63
+ type TurnReason,
64
+ } from './turn-evidence'
65
+ import { isRecord } from './protocol/envelope'
66
+
67
+ // ─── shared primitives ─────────────────────────────────────────────────────
68
+
69
+ /** Every request in this file is versioned so a responder can reject a shape it predates. */
70
+ export const TURN_IPC_PROTOCOL_VERSION = 1 as const
71
+
72
+ function isFiniteNumber(value: unknown): value is number {
73
+ return typeof value === 'number' && Number.isFinite(value)
74
+ }
75
+
76
+ function isNonNegativeInt(value: unknown): value is number {
77
+ return Number.isSafeInteger(value) && (value as number) >= 0
78
+ }
79
+
80
+ function isOptionalId(value: unknown): boolean {
81
+ return value === undefined || isEvidenceIdentifier(value)
82
+ }
83
+
84
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
85
+ for (const key of Object.keys(value)) {
86
+ if (!allowed.includes(key)) return false
87
+ }
88
+ return true
89
+ }
90
+
91
+ // ─── error codes ────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Closed error-code union every command response may carry instead of (or in
95
+ * addition to, for the client-side wrapper) a result.
96
+ *
97
+ * daemon_required — no daemon reachable (no IPC/local transport
98
+ * connection could be established). Distinct from
99
+ * every other failure: it means "there is nobody to
100
+ * ask", not "the ask failed."
101
+ * turn_ledger_unavailable — a daemon answered but its turn ledger is not
102
+ * armed yet (mid-boot, mid-migration).
103
+ * ledger_not_owner — the daemon that answered does not own the
104
+ * attempt/mesh addressed by the request (routing
105
+ * mistake, or the owner changed after a reclaim).
106
+ */
107
+ export const TURN_IPC_ERROR_CODES = ['daemon_required', 'turn_ledger_unavailable', 'ledger_not_owner'] as const
108
+ export type TurnIpcErrorCode = typeof TURN_IPC_ERROR_CODES[number]
109
+
110
+ function makeGuard<T extends readonly string[]>(values: T): (value: unknown) => value is T[number] {
111
+ const set: ReadonlySet<string> = new Set(values)
112
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
113
+ }
114
+
115
+ export const isTurnIpcErrorCode = makeGuard(TURN_IPC_ERROR_CODES)
116
+
117
+ /** Structured failure shape a responder returns instead of a result. Never thrown across the wire — the client maps it to a typed error (see mcp-server/src/ipc/turn-commands.ts). */
118
+ export interface TurnIpcError {
119
+ code: TurnIpcErrorCode
120
+ /** Short machine-oriented detail, e.g. which daemon was expected. Never free text describing content. */
121
+ detail?: string
122
+ }
123
+
124
+ export function isTurnIpcError(value: unknown): value is TurnIpcError {
125
+ return isRecord(value) && hasOnlyKeys(value, ['code', 'detail']) && isTurnIpcErrorCode(value.code)
126
+ && (value.detail === undefined || typeof value.detail === 'string')
127
+ }
128
+
129
+ // ─── turn_observe ───────────────────────────────────────────────────────────
130
+
131
+ /** Reducer verdict, mirrored from the C1 reducer's `reduce()` return shape. */
132
+ export const TURN_OBSERVE_VERDICTS = ['applied', 'recorded', 'rejected'] as const
133
+ export type TurnObserveVerdict = typeof TURN_OBSERVE_VERDICTS[number]
134
+ export const isTurnObserveVerdict = makeGuard(TURN_OBSERVE_VERDICTS)
135
+
136
+ export interface TurnObserveRequest {
137
+ v: typeof TURN_IPC_PROTOCOL_VERSION
138
+ evidence: TurnEvidence
139
+ }
140
+
141
+ export interface TurnObserveResponse {
142
+ verdict: TurnObserveVerdict
143
+ attemptRef: TurnAttemptRef
144
+ /** Present only when the evidence committed the attempt (verdict 'applied' + a terminal-class kind). */
145
+ outcome?: TurnOutcome
146
+ }
147
+
148
+ export function isTurnObserveRequest(value: unknown): value is TurnObserveRequest {
149
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'evidence'])
150
+ && value.v === TURN_IPC_PROTOCOL_VERSION
151
+ && isTurnEvidence(value.evidence)
152
+ }
153
+
154
+ export function decodeTurnObserveRequest(value: unknown): TurnObserveRequest | null {
155
+ return isTurnObserveRequest(value) ? value : null
156
+ }
157
+
158
+ function isTurnAttemptRefValue(value: unknown): value is TurnAttemptRef {
159
+ return isRecord(value) && hasOnlyKeys(value, ['attemptId', 'generation'])
160
+ && isEvidenceIdentifier(value.attemptId) && isNonNegativeInt(value.generation)
161
+ }
162
+
163
+ export function isTurnObserveResponse(value: unknown): value is TurnObserveResponse {
164
+ return isRecord(value) && hasOnlyKeys(value, ['verdict', 'attemptRef', 'outcome'])
165
+ && isTurnObserveVerdict(value.verdict)
166
+ && isTurnAttemptRefValue(value.attemptRef)
167
+ && (value.outcome === undefined || isTurnOutcomeValue(value.outcome))
168
+ }
169
+
170
+ const TURN_OUTCOMES_LOCAL = ['completed', 'failed', 'cancelled'] as const
171
+ function isTurnOutcomeValue(value: unknown): value is TurnOutcome {
172
+ return typeof value === 'string' && (TURN_OUTCOMES_LOCAL as readonly string[]).includes(value)
173
+ }
174
+
175
+ export function decodeTurnObserveResponse(value: unknown): TurnObserveResponse | null {
176
+ return isTurnObserveResponse(value) ? value : null
177
+ }
178
+
179
+ // ─── mesh_record ────────────────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Structural pin of `seqscribe/mesh-event-projection.ts` `PROJECTED_PAYLOAD_KEYS`
183
+ * (daemon-core; this leaf cannot import it). A test in this package's test
184
+ * suite AND a daemon-core test each assert their list equals this one — see
185
+ * `mesh-shared/test/turn-ipc.test.ts` and the C-W6 report's break-once table.
186
+ * Adding a key here is the SAME assertion as adding it there: non-content
187
+ * (identifier, enum, boolean, or counter), never free text.
188
+ */
189
+ export const MESH_RECORD_PAYLOAD_KEYS = [
190
+ // Identifiers
191
+ 'taskId', 'deliveryId', 'attemptId', 'missionId', 'checkpointId', 'promptId', 'providerSessionId', 'targetNoteId',
192
+ // Routing / addressing
193
+ 'nodeId', 'sessionId', 'providerType', 'transport', 'attemptedSessionId', 'holderSessionId',
194
+ // Outcome enums
195
+ 'reason', 'status', 'outcome', 'terminalKind', 'event',
196
+ 'source', 'intentionalStopReason',
197
+ // Booleans
198
+ 'retryable', 'rebound', 'forced', 'fallback', 'membershipRemoved', 'requestedForce', 'removedByRemoteDaemon',
199
+ 'completedViaReady', 'intentional', 'weak',
200
+ // Counters
201
+ 'attempt', 'attemptCount', 'count',
202
+ ] as const
203
+ export type MeshRecordPayloadKey = typeof MESH_RECORD_PAYLOAD_KEYS[number]
204
+
205
+ const MESH_RECORD_PAYLOAD_KEY_SET: ReadonlySet<string> = new Set(MESH_RECORD_PAYLOAD_KEYS)
206
+
207
+ /** Maximum length of any string value in a `mesh_record` payload (mirrors `MAX_PROJECTED_STRING`). */
208
+ export const MAX_MESH_RECORD_STRING = 200
209
+
210
+ export interface MeshRecordRequest {
211
+ v: typeof TURN_IPC_PROTOCOL_VERSION
212
+ meshId: string
213
+ /** The original ledger `kind` string (daemon-core `MeshLedgerKind`; mesh-shared cannot import that union, so it is an identifier here). */
214
+ ledgerKind: string
215
+ nodeId?: string
216
+ sessionId?: string
217
+ taskId?: string
218
+ /** Restricted to `MESH_RECORD_PAYLOAD_KEYS` — an unknown key is a decode failure, not a silently dropped field. */
219
+ payload: ProjectedScalars
220
+ }
221
+
222
+ export interface MeshRecordResponse {
223
+ /** `mesh.<id>.events` append coordinates, for callers that need to reference the entry (e.g. a `ref`'d handoff). */
224
+ eventId: string
225
+ seq: number
226
+ }
227
+
228
+ function isProjectedScalarsValue(value: unknown): value is ProjectedScalars {
229
+ if (!isRecord(value)) return false
230
+ for (const v of Object.values(value)) {
231
+ if (v === null) continue
232
+ if (typeof v === 'boolean' || typeof v === 'number') continue
233
+ if (typeof v === 'string') { if (v.length > MAX_MESH_RECORD_STRING) return false; continue }
234
+ return false
235
+ }
236
+ return true
237
+ }
238
+
239
+ /** Every payload key must be a member of `MESH_RECORD_PAYLOAD_KEYS` — the allow-list boundary at the wire. */
240
+ export function isMeshRecordPayload(value: unknown): value is ProjectedScalars {
241
+ if (!isProjectedScalarsValue(value)) return false
242
+ for (const key of Object.keys(value as Record<string, unknown>)) {
243
+ if (!MESH_RECORD_PAYLOAD_KEY_SET.has(key)) return false
244
+ }
245
+ return true
246
+ }
247
+
248
+ export function isMeshRecordRequest(value: unknown): value is MeshRecordRequest {
249
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'ledgerKind', 'nodeId', 'sessionId', 'taskId', 'payload'])
250
+ && value.v === TURN_IPC_PROTOCOL_VERSION
251
+ && isEvidenceIdentifier(value.meshId)
252
+ && isEvidenceIdentifier(value.ledgerKind)
253
+ && isOptionalId(value.nodeId) && isOptionalId(value.sessionId) && isOptionalId(value.taskId)
254
+ && isMeshRecordPayload(value.payload)
255
+ }
256
+
257
+ export function decodeMeshRecordRequest(value: unknown): MeshRecordRequest | null {
258
+ return isMeshRecordRequest(value) ? value : null
259
+ }
260
+
261
+ export function isMeshRecordResponse(value: unknown): value is MeshRecordResponse {
262
+ return isRecord(value) && hasOnlyKeys(value, ['eventId', 'seq'])
263
+ && isEvidenceIdentifier(value.eventId) && isNonNegativeInt(value.seq)
264
+ }
265
+
266
+ export function decodeMeshRecordResponse(value: unknown): MeshRecordResponse | null {
267
+ return isMeshRecordResponse(value) ? value : null
268
+ }
269
+
270
+ // ─── turn_cancel ────────────────────────────────────────────────────────────
271
+
272
+ export interface TurnCancelRequest {
273
+ v: typeof TURN_IPC_PROTOCOL_VERSION
274
+ /** Exactly one of the two must be present — the decoder rejects both-or-neither. */
275
+ attemptId?: string
276
+ taskId?: string
277
+ reason: TurnReason
278
+ }
279
+
280
+ export interface TurnCancelResponse {
281
+ attemptRef: TurnAttemptRef
282
+ verdict: TurnObserveVerdict
283
+ }
284
+
285
+ export function isTurnCancelRequest(value: unknown): value is TurnCancelRequest {
286
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'attemptId', 'taskId', 'reason'])) return false
287
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
288
+ const hasAttempt = value.attemptId !== undefined
289
+ const hasTask = value.taskId !== undefined
290
+ if (hasAttempt === hasTask) return false // exactly one of the two
291
+ if (hasAttempt && !isEvidenceIdentifier(value.attemptId)) return false
292
+ if (hasTask && !isEvidenceIdentifier(value.taskId)) return false
293
+ return isTurnReason(value.reason)
294
+ }
295
+
296
+ export function decodeTurnCancelRequest(value: unknown): TurnCancelRequest | null {
297
+ return isTurnCancelRequest(value) ? value : null
298
+ }
299
+
300
+ export function isTurnCancelResponse(value: unknown): value is TurnCancelResponse {
301
+ return isRecord(value) && hasOnlyKeys(value, ['attemptRef', 'verdict'])
302
+ && isTurnAttemptRefValue(value.attemptRef) && isTurnObserveVerdict(value.verdict)
303
+ }
304
+
305
+ export function decodeTurnCancelResponse(value: unknown): TurnCancelResponse | null {
306
+ return isTurnCancelResponse(value) ? value : null
307
+ }
308
+
309
+ // ─── operator_status ────────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * Fire-and-forget operator/tool-call status record — replaces
313
+ * `recordMeshCoordinatorToolCall` (10 sites, `mesh_tool_call_log`) and doubles
314
+ * as the `operator_status` evidence kind's wire shape.
315
+ *
316
+ * NAMING NOTE (flagged for the C-W6 implementer, see report §"mismatch"):
317
+ * the C-W6 brief's DELIVERABLES describes this request as
318
+ * `{taskId, status: MeshTaskStatus, reason}`, but C1's `operator_status`
319
+ * evidence kind (`turn-evidence.ts` `OPERATOR_STATUSES`) is the closed
320
+ * 2-value set `'completed' | 'failed'` — a verdict on an operator ACTION, not
321
+ * a `MeshTaskStatus` (5-value queue-row status: pending/assigned/completed/
322
+ * failed/cancelled). This contract follows `turn-evidence.ts` because that is
323
+ * what the C1 reducer actually consumes for this evidence kind; using the
324
+ * 5-value `MeshTaskStatus` here would let this request carry `pending` or
325
+ * `assigned`, which the reducer has no transition for.
326
+ */
327
+ export const OPERATOR_STATUS_VALUES = ['completed', 'failed'] as const
328
+ export type OperatorStatusValue = typeof OPERATOR_STATUS_VALUES[number]
329
+ export const isOperatorStatusValue = makeGuard(OPERATOR_STATUS_VALUES)
330
+
331
+ export const OPERATOR_STATUS_REASON_VALUES = ['operator_update', 'refine_terminal', 'validation_terminal'] as const
332
+ export type OperatorStatusReasonValue = typeof OPERATOR_STATUS_REASON_VALUES[number]
333
+ export const isOperatorStatusReasonValue = makeGuard(OPERATOR_STATUS_REASON_VALUES)
334
+
335
+ export interface OperatorStatusRequest {
336
+ v: typeof TURN_IPC_PROTOCOL_VERSION
337
+ taskId: string
338
+ status: OperatorStatusValue
339
+ reason: OperatorStatusReasonValue
340
+ }
341
+
342
+ /** Acknowledgement only — this command is fire-and-forget by design (the daemon owns rate-limiting the underlying log). */
343
+ export interface OperatorStatusResponse {
344
+ accepted: true
345
+ }
346
+
347
+ export function isOperatorStatusRequest(value: unknown): value is OperatorStatusRequest {
348
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'taskId', 'status', 'reason'])
349
+ && value.v === TURN_IPC_PROTOCOL_VERSION
350
+ && isEvidenceIdentifier(value.taskId)
351
+ && isOperatorStatusValue(value.status)
352
+ && isOperatorStatusReasonValue(value.reason)
353
+ }
354
+
355
+ export function decodeOperatorStatusRequest(value: unknown): OperatorStatusRequest | null {
356
+ return isOperatorStatusRequest(value) ? value : null
357
+ }
358
+
359
+ export function isOperatorStatusResponse(value: unknown): value is OperatorStatusResponse {
360
+ return isRecord(value) && hasOnlyKeys(value, ['accepted']) && value.accepted === true
361
+ }
362
+
363
+ export function decodeOperatorStatusResponse(value: unknown): OperatorStatusResponse | null {
364
+ return isOperatorStatusResponse(value) ? value : null
365
+ }
366
+
367
+ // ─── turn_query ─────────────────────────────────────────────────────────────
368
+
369
+ /** Row shape returned by `turn_query` — scalar projection of a `turn_attempts` row (C3 schema), never `data_json`/`payload_json` content. */
370
+ export interface TurnQueryAttemptRow {
371
+ attemptId: string
372
+ generation: number
373
+ meshId?: string
374
+ taskId?: string
375
+ sessionId: string
376
+ nodeId?: string
377
+ providerType?: string
378
+ state: string
379
+ terminalOutcome?: TurnOutcome
380
+ terminalReason?: TurnReason
381
+ acceptedAt: number
382
+ terminalAt?: number
383
+ }
384
+
385
+ /** Row shape returned by `turn_query` for the event stream — scalar projection of a `turn_events` row, `payload_json` NEVER included. */
386
+ export interface TurnQueryEventRow {
387
+ eventId: string
388
+ attemptId?: string
389
+ generation?: number
390
+ sessionId: string
391
+ kind: TurnEvidenceKind
392
+ source: string
393
+ verdict: TurnObserveVerdict | 'forwarded'
394
+ atMs: number
395
+ }
396
+
397
+ export interface TurnQueryRequest {
398
+ v: typeof TURN_IPC_PROTOCOL_VERSION
399
+ meshId: string
400
+ taskId?: string
401
+ attemptId?: string
402
+ sessionId?: string
403
+ state?: string
404
+ since?: number
405
+ tail?: number
406
+ }
407
+
408
+ export interface TurnQueryResponse {
409
+ attempts: readonly TurnQueryAttemptRow[]
410
+ events: readonly TurnQueryEventRow[]
411
+ /** C7-5: set while `mesh.<id>.events` replication is behind for this mesh — the caller must not treat a fleet-scope answer as complete. */
412
+ replicationPending?: boolean
413
+ }
414
+
415
+ export function isTurnQueryRequest(value: unknown): value is TurnQueryRequest {
416
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'taskId', 'attemptId', 'sessionId', 'state', 'since', 'tail'])
417
+ && value.v === TURN_IPC_PROTOCOL_VERSION
418
+ && isEvidenceIdentifier(value.meshId)
419
+ && isOptionalId(value.taskId) && isOptionalId(value.attemptId) && isOptionalId(value.sessionId)
420
+ && (value.state === undefined || isEvidenceIdentifier(value.state))
421
+ && (value.since === undefined || isFiniteNumber(value.since))
422
+ && (value.tail === undefined || isNonNegativeInt(value.tail))
423
+ }
424
+
425
+ export function decodeTurnQueryRequest(value: unknown): TurnQueryRequest | null {
426
+ return isTurnQueryRequest(value) ? value : null
427
+ }
428
+
429
+ const TURN_QUERY_ATTEMPT_ROW_KEYS = [
430
+ 'attemptId', 'generation', 'meshId', 'taskId', 'sessionId', 'nodeId', 'providerType',
431
+ 'state', 'terminalOutcome', 'terminalReason', 'acceptedAt', 'terminalAt',
432
+ ] as const
433
+
434
+ function isTurnQueryAttemptRow(value: unknown): value is TurnQueryAttemptRow {
435
+ if (!isRecord(value) || !hasOnlyKeys(value, TURN_QUERY_ATTEMPT_ROW_KEYS)) return false
436
+ if (!isEvidenceIdentifier(value.attemptId) || !isNonNegativeInt(value.generation)) return false
437
+ if (!isOptionalId(value.meshId) || !isOptionalId(value.taskId)) return false
438
+ if (!isEvidenceIdentifier(value.sessionId)) return false
439
+ if (!isOptionalId(value.nodeId) || !isOptionalId(value.providerType)) return false
440
+ if (typeof value.state !== 'string') return false
441
+ if (value.terminalOutcome !== undefined && !isTurnOutcomeValue(value.terminalOutcome)) return false
442
+ if (value.terminalReason !== undefined && !isTurnReason(value.terminalReason)) return false
443
+ if (!isFiniteNumber(value.acceptedAt)) return false
444
+ if (value.terminalAt !== undefined && !isFiniteNumber(value.terminalAt)) return false
445
+ return true
446
+ }
447
+
448
+ const TURN_QUERY_EVENT_VERDICTS = ['applied', 'recorded', 'rejected', 'forwarded'] as const
449
+ const TURN_QUERY_EVENT_ROW_KEYS = ['eventId', 'attemptId', 'generation', 'sessionId', 'kind', 'source', 'verdict', 'atMs'] as const
450
+
451
+ function isTurnQueryEventRow(value: unknown): value is TurnQueryEventRow {
452
+ if (!isRecord(value) || !hasOnlyKeys(value, TURN_QUERY_EVENT_ROW_KEYS)) return false
453
+ if (!isEvidenceIdentifier(value.eventId)) return false
454
+ if (!isOptionalId(value.attemptId)) return false
455
+ if (value.generation !== undefined && !isNonNegativeInt(value.generation)) return false
456
+ if (!isEvidenceIdentifier(value.sessionId)) return false
457
+ if (typeof value.kind !== 'string') return false
458
+ if (typeof value.source !== 'string') return false
459
+ if (!(TURN_QUERY_EVENT_VERDICTS as readonly string[]).includes(value.verdict as string)) return false
460
+ if (!isFiniteNumber(value.atMs)) return false
461
+ return true
462
+ }
463
+
464
+ export function isTurnQueryResponse(value: unknown): value is TurnQueryResponse {
465
+ if (!isRecord(value) || !hasOnlyKeys(value, ['attempts', 'events', 'replicationPending'])) return false
466
+ if (!Array.isArray(value.attempts) || !value.attempts.every(isTurnQueryAttemptRow)) return false
467
+ if (!Array.isArray(value.events) || !value.events.every(isTurnQueryEventRow)) return false
468
+ if (value.replicationPending !== undefined && typeof value.replicationPending !== 'boolean') return false
469
+ return true
470
+ }
471
+
472
+ export function decodeTurnQueryResponse(value: unknown): TurnQueryResponse | null {
473
+ return isTurnQueryResponse(value) ? value : null
474
+ }
475
+
476
+ // ─── mesh_index_query ───────────────────────────────────────────────────────
477
+
478
+ export const MESH_INDEX_QUERY_WRITER_SCOPES = ['own', 'fleet'] as const
479
+ export type MeshIndexQueryWriterScope = typeof MESH_INDEX_QUERY_WRITER_SCOPES[number]
480
+ export const isMeshIndexQueryWriterScope = makeGuard(MESH_INDEX_QUERY_WRITER_SCOPES)
481
+
482
+ export interface MeshIndexQueryRequest {
483
+ v: typeof TURN_IPC_PROTOCOL_VERSION
484
+ meshId: string
485
+ ledgerKind?: string
486
+ sessionId?: string
487
+ taskId?: string
488
+ writer?: MeshIndexQueryWriterScope
489
+ since?: number
490
+ tail?: number
491
+ }
492
+
493
+ /** Row shape returned by `mesh_index_query` — a `mesh_topic_index` row (C3 schema). */
494
+ export interface MeshTopicIndexRow {
495
+ writer: string
496
+ seq: number
497
+ meshId: string
498
+ eventId: string
499
+ kind: string
500
+ ledgerKind?: string
501
+ taskId?: string
502
+ sessionId?: string
503
+ nodeId?: string
504
+ atMs: number
505
+ payload: ProjectedScalars
506
+ }
507
+
508
+ export interface MeshIndexQueryResponse {
509
+ rows: readonly MeshTopicIndexRow[]
510
+ /** C7-5: true while `mesh.<id>.events` replication is behind for this mesh. */
511
+ replicationPending?: boolean
512
+ }
513
+
514
+ export function isMeshIndexQueryRequest(value: unknown): value is MeshIndexQueryRequest {
515
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'ledgerKind', 'sessionId', 'taskId', 'writer', 'since', 'tail'])
516
+ && value.v === TURN_IPC_PROTOCOL_VERSION
517
+ && isEvidenceIdentifier(value.meshId)
518
+ && isOptionalId(value.ledgerKind) && isOptionalId(value.sessionId) && isOptionalId(value.taskId)
519
+ && (value.writer === undefined || isMeshIndexQueryWriterScope(value.writer))
520
+ && (value.since === undefined || isFiniteNumber(value.since))
521
+ && (value.tail === undefined || isNonNegativeInt(value.tail))
522
+ }
523
+
524
+ export function decodeMeshIndexQueryRequest(value: unknown): MeshIndexQueryRequest | null {
525
+ return isMeshIndexQueryRequest(value) ? value : null
526
+ }
527
+
528
+ const MESH_TOPIC_INDEX_ROW_KEYS = [
529
+ 'writer', 'seq', 'meshId', 'eventId', 'kind', 'ledgerKind', 'taskId', 'sessionId', 'nodeId', 'atMs', 'payload',
530
+ ] as const
531
+
532
+ function isMeshTopicIndexRow(value: unknown): value is MeshTopicIndexRow {
533
+ if (!isRecord(value) || !hasOnlyKeys(value, MESH_TOPIC_INDEX_ROW_KEYS)) return false
534
+ if (!isEvidenceIdentifier(value.writer) || !isNonNegativeInt(value.seq)) return false
535
+ if (!isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.eventId)) return false
536
+ if (typeof value.kind !== 'string') return false
537
+ if (!isOptionalId(value.ledgerKind) || !isOptionalId(value.taskId) || !isOptionalId(value.sessionId) || !isOptionalId(value.nodeId)) return false
538
+ if (!isFiniteNumber(value.atMs)) return false
539
+ // Rows replay mesh.record entries, so the payload is held to the SAME
540
+ // allow-list as a mesh_record request — not just "any scalar map" — to
541
+ // stop a fleet-wide read from surfacing a key that was never reviewed.
542
+ if (!isMeshRecordPayload(value.payload)) return false
543
+ return true
544
+ }
545
+
546
+ export function isMeshIndexQueryResponse(value: unknown): value is MeshIndexQueryResponse {
547
+ if (!isRecord(value) || !hasOnlyKeys(value, ['rows', 'replicationPending'])) return false
548
+ if (!Array.isArray(value.rows) || !value.rows.every(isMeshTopicIndexRow)) return false
549
+ if (value.replicationPending !== undefined && typeof value.replicationPending !== 'boolean') return false
550
+ return true
551
+ }
552
+
553
+ export function decodeMeshIndexQueryResponse(value: unknown): MeshIndexQueryResponse | null {
554
+ return isMeshIndexQueryResponse(value) ? value : null
555
+ }
556
+
557
+ // ─── mission_upsert / mission_query ────────────────────────────────────────
558
+ //
559
+ // Decision (2026-09-23, design doc §5 C2 update): missions and graph gates
560
+ // have no home in the original six commands — `upsertMeshMission`/
561
+ // `listMeshMissionsForTool` write/read structured state with a free-text
562
+ // `goal`/`title` that cannot fit `mesh_record`'s scalar `ProjectedScalars`
563
+ // allow-list (§ "MESH_RECORD_PAYLOAD_KEYS" above has no text field, on
564
+ // purpose). Two more commands, not a widened allow-list:
565
+ //
566
+ // - mission_upsert — create/update a mission (mesh-missions.ts
567
+ // `upsertMeshMission`'s shape).
568
+ // - mission_query — list missions for a mesh, optionally filtered by
569
+ // status (`getMeshMissions`'s shape).
570
+ //
571
+ // WHY FREE TEXT IS FINE HERE, UNLIKE mesh_record's payload: this is LOCAL
572
+ // IPC between mcp-server and the daemon that owns the mission table — both
573
+ // processes run on the operator's own machine. The content boundary this
574
+ // program protects is "the SERVER never receives chat content" (CLAUDE.md);
575
+ // IPC between two local processes was never inside that boundary, the same
576
+ // way a direct in-process function call wasn't. Cross-machine mission text
577
+ // (a worker learning about a mission a coordinator defined) travels as a
578
+ // `mesh.<id>.handoff` ref per C10-1's precedent — mission_upsert/query never
579
+ // themselves cross a machine boundary.
580
+
581
+ export const MESH_MISSION_STATUSES = ['active', 'paused', 'completed', 'abandoned'] as const
582
+ export type MeshMissionStatusValue = typeof MESH_MISSION_STATUSES[number]
583
+ export const isMeshMissionStatusValue = makeGuard(MESH_MISSION_STATUSES)
584
+
585
+ export const MESH_MISSION_SOURCES = ['magi', 'coordinator'] as const
586
+ export type MeshMissionSourceValue = typeof MESH_MISSION_SOURCES[number]
587
+ export const isMeshMissionSourceValue = makeGuard(MESH_MISSION_SOURCES)
588
+
589
+ // H2 (mission brief, wiring-unification Phase H — docs/design/2026-09-23-wiring-
590
+ // unification.md §7c): free text, same "local IPC is inside the boundary" rationale
591
+ // as `goal` above (section note). `MissionBriefWire` is a structural mirror of
592
+ // mesh-shared's own `MissionBrief` (mission-brief.ts) rather than importing it —
593
+ // this file's guards are all hand-rolled structural checks, and importing the type
594
+ // only (not the class) would still couple this wire contract to that module's
595
+ // internal shape; a mirror keeps the two independently reviewable, same as every
596
+ // other wire type in this file.
597
+ export interface MissionBriefWire {
598
+ goal: string
599
+ constraints?: readonly string[]
600
+ doneCriteria?: readonly string[]
601
+ handoffNotes?: readonly string[]
602
+ ownedPaths?: readonly string[]
603
+ }
604
+
605
+ function isStringArray(value: unknown): value is readonly string[] {
606
+ return Array.isArray(value) && value.every((v) => typeof v === 'string')
607
+ }
608
+
609
+ function isMissionBriefWire(value: unknown): value is MissionBriefWire {
610
+ if (!isRecord(value) || !hasOnlyKeys(value, ['goal', 'constraints', 'doneCriteria', 'handoffNotes', 'ownedPaths'])) return false
611
+ if (typeof value.goal !== 'string' || value.goal.trim().length === 0) return false
612
+ for (const k of ['constraints', 'doneCriteria', 'handoffNotes', 'ownedPaths'] as const) {
613
+ if (value[k] !== undefined && !isStringArray(value[k])) return false
614
+ }
615
+ return true
616
+ }
617
+
618
+ export interface MissionUpsertRequest {
619
+ v: typeof TURN_IPC_PROTOCOL_VERSION
620
+ meshId: string
621
+ /** Omitted = create a new mission; present = update (must resolve to an existing mission — see mesh-missions.ts MISSION-UPSERT-SILENT-CREATE). */
622
+ id?: string
623
+ title: string
624
+ /** Free text — see file-header note on why this is fine over local IPC. */
625
+ goal?: string
626
+ status?: MeshMissionStatusValue
627
+ source?: MeshMissionSourceValue
628
+ /**
629
+ * H2: `undefined` = do not touch the stored brief. `null` = explicitly clear it.
630
+ * A present object with no usable `goal` is treated by the daemon's own
631
+ * `normalizeMissionBrief` as "no brief" (see mesh-missions.ts upsertMeshMission) —
632
+ * this wire guard only checks SHAPE, not that decision.
633
+ */
634
+ brief?: MissionBriefWire | null
635
+ }
636
+
637
+ export interface MeshMissionRecordWire {
638
+ id: string
639
+ meshId: string
640
+ title: string
641
+ goal: string
642
+ status: MeshMissionStatusValue
643
+ source?: MeshMissionSourceValue
644
+ /** H2: absent = no brief attached to this mission. */
645
+ brief?: MissionBriefWire
646
+ }
647
+
648
+ export interface MissionUpsertResponse {
649
+ mission: MeshMissionRecordWire
650
+ }
651
+
652
+ function isMeshMissionRecordWire(value: unknown): value is MeshMissionRecordWire {
653
+ if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'meshId', 'title', 'goal', 'status', 'source', 'brief'])) return false
654
+ if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
655
+ if (typeof value.title !== 'string' || typeof value.goal !== 'string') return false
656
+ if (!isMeshMissionStatusValue(value.status)) return false
657
+ if (value.source !== undefined && !isMeshMissionSourceValue(value.source)) return false
658
+ if (value.brief !== undefined && !isMissionBriefWire(value.brief)) return false
659
+ return true
660
+ }
661
+
662
+ export function isMissionUpsertRequest(value: unknown): value is MissionUpsertRequest {
663
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'id', 'title', 'goal', 'status', 'source', 'brief'])) return false
664
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
665
+ if (!isEvidenceIdentifier(value.meshId)) return false
666
+ if (value.id !== undefined && !isEvidenceIdentifier(value.id)) return false
667
+ if (typeof value.title !== 'string' || value.title.trim().length === 0) return false
668
+ if (value.goal !== undefined && typeof value.goal !== 'string') return false
669
+ if (value.status !== undefined && !isMeshMissionStatusValue(value.status)) return false
670
+ if (value.source !== undefined && !isMeshMissionSourceValue(value.source)) return false
671
+ if (value.brief !== undefined && value.brief !== null && !isMissionBriefWire(value.brief)) return false
672
+ return true
673
+ }
674
+
675
+ export function decodeMissionUpsertRequest(value: unknown): MissionUpsertRequest | null {
676
+ return isMissionUpsertRequest(value) ? value : null
677
+ }
678
+
679
+ export function isMissionUpsertResponse(value: unknown): value is MissionUpsertResponse {
680
+ return isRecord(value) && hasOnlyKeys(value, ['mission']) && isMeshMissionRecordWire(value.mission)
681
+ }
682
+
683
+ export function decodeMissionUpsertResponse(value: unknown): MissionUpsertResponse | null {
684
+ return isMissionUpsertResponse(value) ? value : null
685
+ }
686
+
687
+ export interface MissionQueryRequest {
688
+ v: typeof TURN_IPC_PROTOCOL_VERSION
689
+ meshId: string
690
+ /** Omitted = every status. */
691
+ statuses?: readonly MeshMissionStatusValue[]
692
+ /** Single-mission lookup (mesh-missions.ts `getMeshMission`'s shape) — mutually exclusive with `statuses`. */
693
+ id?: string
694
+ }
695
+
696
+ export interface MissionQueryResponse {
697
+ missions: readonly MeshMissionRecordWire[]
698
+ }
699
+
700
+ export function isMissionQueryRequest(value: unknown): value is MissionQueryRequest {
701
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'statuses', 'id'])) return false
702
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
703
+ if (!isEvidenceIdentifier(value.meshId)) return false
704
+ if (value.id !== undefined && !isEvidenceIdentifier(value.id)) return false
705
+ if (value.statuses !== undefined) {
706
+ if (!Array.isArray(value.statuses) || value.statuses.length === 0) return false
707
+ if (!value.statuses.every(isMeshMissionStatusValue)) return false
708
+ }
709
+ return true
710
+ }
711
+
712
+ export function decodeMissionQueryRequest(value: unknown): MissionQueryRequest | null {
713
+ return isMissionQueryRequest(value) ? value : null
714
+ }
715
+
716
+ export function isMissionQueryResponse(value: unknown): value is MissionQueryResponse {
717
+ return isRecord(value) && hasOnlyKeys(value, ['missions'])
718
+ && Array.isArray(value.missions) && value.missions.every(isMeshMissionRecordWire)
719
+ }
720
+
721
+ export function decodeMissionQueryResponse(value: unknown): MissionQueryResponse | null {
722
+ return isMissionQueryResponse(value) ? value : null
723
+ }
724
+
725
+ // ─── mission_list_query ─────────────────────────────────────────────────────
726
+ //
727
+ // Wiring-unification Phase C, workstream C-W9b.
728
+ //
729
+ // `mesh-tools-mission.ts`'s own file-header note (2026-09-24, C-W6) flagged
730
+ // this exact gap: `mission_query`'s landed response is `{missions:
731
+ // MeshMissionRecordWire[]}` only — no `verbose`/`includeMagi`/`withStats`/
732
+ // `limit`/`truncated`/`overflowIds`/`historyFold`, all of which
733
+ // `listMeshMissionsForTool` (daemon-core `mesh-missions.ts`) computes for the
734
+ // `mesh_mission_list` tool. Rather than widen `mission_query` itself (an
735
+ // already-landed, already-tested contract other callers rely on for its
736
+ // narrow shape), this is an ADDITIVE sibling command carrying
737
+ // `listMeshMissionsForTool`'s full output shape — request/response fields
738
+ // mirror its options/`MeshMissionListResult` one-for-one (see
739
+ // mesh-missions.ts). `goal`/`goalPreview` are free text, same local-IPC
740
+ // rationale as `mission_upsert`'s `goal` above.
741
+
742
+ export interface MissionListQueryRequest {
743
+ v: typeof TURN_IPC_PROTOCOL_VERSION
744
+ meshId: string
745
+ statuses?: readonly MeshMissionStatusValue[]
746
+ verbose?: boolean
747
+ includeMagi?: boolean
748
+ withStats?: boolean
749
+ limit?: number
750
+ historyIdLimit?: number
751
+ /**
752
+ * The mesh_status mission projection, computed in the daemon (the MCP no longer
753
+ * opens the daemon's store for it): 'compact' = `getMeshStatusMissionsCompact`
754
+ * (live missions goal-elided + folded history), 'verbose' =
755
+ * `getMeshStatusMissionSummaries({verbose: true})` (live + capped history, full
756
+ * goal, no stats — stats ride one batched `task_stats_query`). Rows then also
757
+ * carry `createdAt`/`updatedAt`/`closeCandidateEmittedAt`. When set, every other
758
+ * option except `historyIdLimit` is ignored. An older daemon rejects the key.
759
+ */
760
+ meshStatusView?: 'compact' | 'verbose'
761
+ }
762
+
763
+ export interface MeshMissionTaskAggregateWire {
764
+ total: number
765
+ pending: number
766
+ assigned: number
767
+ completed: number
768
+ failed: number
769
+ cancelled: number
770
+ blocked: number
771
+ lastActivityAt: string | null
772
+ }
773
+
774
+ export interface MeshMissionStatsWire {
775
+ missionId: string
776
+ taskCount: number
777
+ completed: number
778
+ failed: number
779
+ totalDurationMs: number
780
+ wallClockMs: number | null
781
+ retries: number
782
+ incompleteTaskIds: readonly string[]
783
+ }
784
+
785
+ /** Verbose (full-goal) mission row. `goal` present, `goalPreview`/`goalTruncated` absent. */
786
+ export interface MissionListSummaryVerboseWire {
787
+ id: string
788
+ meshId: string
789
+ title: string
790
+ goal: string
791
+ status: MeshMissionStatusValue
792
+ source?: MeshMissionSourceValue
793
+ tasks: MeshMissionTaskAggregateWire
794
+ stats?: MeshMissionStatsWire
795
+ /** H2: absent = no brief attached. */
796
+ brief?: MissionBriefWire
797
+ /** `meshStatusView` rows only. */
798
+ createdAt?: string
799
+ updatedAt?: string
800
+ closeCandidateEmittedAt?: string | null
801
+ }
802
+
803
+ /** Compact (default) mission row. `goalPreview`/`goalTruncated` present, `goal` absent. */
804
+ export interface MissionListSummarySlimWire {
805
+ id: string
806
+ meshId: string
807
+ title: string
808
+ goalPreview: string
809
+ goalTruncated: boolean
810
+ status: MeshMissionStatusValue
811
+ source?: MeshMissionSourceValue
812
+ tasks: MeshMissionTaskAggregateWire
813
+ stats?: MeshMissionStatsWire
814
+ /** H2: absent = no brief attached. */
815
+ brief?: MissionBriefWire
816
+ /** `meshStatusView` rows only. */
817
+ createdAt?: string
818
+ updatedAt?: string
819
+ closeCandidateEmittedAt?: string | null
820
+ }
821
+
822
+ export type MissionListSummaryWire = MissionListSummaryVerboseWire | MissionListSummarySlimWire
823
+
824
+ export interface MeshMissionHistoryFoldWire {
825
+ count: number
826
+ byStatus: Record<string, number>
827
+ missionIds: readonly string[]
828
+ note: string
829
+ }
830
+
831
+ export interface MissionListQueryResponse {
832
+ missions: readonly MissionListSummaryWire[]
833
+ historyFold: MeshMissionHistoryFoldWire | null
834
+ truncated: boolean
835
+ matched: number
836
+ overflowIds?: readonly string[]
837
+ }
838
+
839
+ export function isMissionListQueryRequest(value: unknown): value is MissionListQueryRequest {
840
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'statuses', 'verbose', 'includeMagi', 'withStats', 'limit', 'historyIdLimit', 'meshStatusView'])) return false
841
+ if (value.meshStatusView !== undefined && value.meshStatusView !== 'compact' && value.meshStatusView !== 'verbose') return false
842
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
843
+ if (!isEvidenceIdentifier(value.meshId)) return false
844
+ if (value.statuses !== undefined) {
845
+ if (!Array.isArray(value.statuses) || value.statuses.length === 0) return false
846
+ if (!value.statuses.every(isMeshMissionStatusValue)) return false
847
+ }
848
+ if (value.verbose !== undefined && typeof value.verbose !== 'boolean') return false
849
+ if (value.includeMagi !== undefined && typeof value.includeMagi !== 'boolean') return false
850
+ if (value.withStats !== undefined && typeof value.withStats !== 'boolean') return false
851
+ if (value.limit !== undefined && !isNonNegativeInt(value.limit)) return false
852
+ if (value.historyIdLimit !== undefined && !isNonNegativeInt(value.historyIdLimit)) return false
853
+ return true
854
+ }
855
+
856
+ export function decodeMissionListQueryRequest(value: unknown): MissionListQueryRequest | null {
857
+ return isMissionListQueryRequest(value) ? value : null
858
+ }
859
+
860
+ function isMeshMissionTaskAggregateWire(value: unknown): value is MeshMissionTaskAggregateWire {
861
+ const keys = ['total', 'pending', 'assigned', 'completed', 'failed', 'cancelled', 'blocked', 'lastActivityAt'] as const
862
+ if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
863
+ for (const k of ['total', 'pending', 'assigned', 'completed', 'failed', 'cancelled', 'blocked'] as const) {
864
+ if (!isNonNegativeInt(value[k])) return false
865
+ }
866
+ return value.lastActivityAt === null || typeof value.lastActivityAt === 'string'
867
+ }
868
+
869
+ function isMeshMissionStatsWire(value: unknown): value is MeshMissionStatsWire {
870
+ const keys = ['missionId', 'taskCount', 'completed', 'failed', 'totalDurationMs', 'wallClockMs', 'retries', 'incompleteTaskIds'] as const
871
+ if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
872
+ if (!isEvidenceIdentifier(value.missionId)) return false
873
+ for (const k of ['taskCount', 'completed', 'failed', 'totalDurationMs', 'retries'] as const) {
874
+ if (!isNonNegativeInt(value[k])) return false
875
+ }
876
+ if (value.wallClockMs !== null && !isNonNegativeInt(value.wallClockMs)) return false
877
+ if (!Array.isArray(value.incompleteTaskIds) || !value.incompleteTaskIds.every(isEvidenceIdentifier)) return false
878
+ return true
879
+ }
880
+
881
+ function isMissionListSummaryWire(value: unknown): value is MissionListSummaryWire {
882
+ if (!isRecord(value)) return false
883
+ const hasGoal = 'goal' in value
884
+ const hasPreview = 'goalPreview' in value && 'goalTruncated' in value
885
+ if (hasGoal === hasPreview) return false // exactly one of the two shapes
886
+ const baseKeys = ['id', 'meshId', 'title', 'status', 'source', 'tasks', 'stats', 'brief', 'createdAt', 'updatedAt', 'closeCandidateEmittedAt'] as const
887
+ const allowed = hasGoal ? [...baseKeys, 'goal'] : [...baseKeys, 'goalPreview', 'goalTruncated']
888
+ if (!hasOnlyKeys(value, allowed)) return false
889
+ if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
890
+ if (typeof value.title !== 'string') return false
891
+ if (!isMeshMissionStatusValue(value.status)) return false
892
+ if (value.source !== undefined && !isMeshMissionSourceValue(value.source)) return false
893
+ if (!isMeshMissionTaskAggregateWire(value.tasks)) return false
894
+ if (value.stats !== undefined && !isMeshMissionStatsWire(value.stats)) return false
895
+ if (value.brief !== undefined && !isMissionBriefWire(value.brief)) return false
896
+ if (hasGoal && typeof value.goal !== 'string') return false
897
+ if (hasPreview && (typeof value.goalPreview !== 'string' || typeof value.goalTruncated !== 'boolean')) return false
898
+ if (value.createdAt !== undefined && typeof value.createdAt !== 'string') return false
899
+ if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string') return false
900
+ if (value.closeCandidateEmittedAt !== undefined && value.closeCandidateEmittedAt !== null && typeof value.closeCandidateEmittedAt !== 'string') return false
901
+ return true
902
+ }
903
+
904
+ function isMeshMissionHistoryFoldWire(value: unknown): value is MeshMissionHistoryFoldWire {
905
+ if (!isRecord(value) || !hasOnlyKeys(value, ['count', 'byStatus', 'missionIds', 'note'])) return false
906
+ if (!isNonNegativeInt(value.count)) return false
907
+ if (!isRecord(value.byStatus) || !Object.values(value.byStatus).every((n) => isNonNegativeInt(n))) return false
908
+ if (!Array.isArray(value.missionIds) || !value.missionIds.every(isEvidenceIdentifier)) return false
909
+ return typeof value.note === 'string'
910
+ }
911
+
912
+ export function isMissionListQueryResponse(value: unknown): value is MissionListQueryResponse {
913
+ if (!isRecord(value) || !hasOnlyKeys(value, ['missions', 'historyFold', 'truncated', 'matched', 'overflowIds'])) return false
914
+ if (!Array.isArray(value.missions) || !value.missions.every(isMissionListSummaryWire)) return false
915
+ if (value.historyFold !== null && !isMeshMissionHistoryFoldWire(value.historyFold)) return false
916
+ if (typeof value.truncated !== 'boolean') return false
917
+ if (!isNonNegativeInt(value.matched)) return false
918
+ if (value.overflowIds !== undefined && (!Array.isArray(value.overflowIds) || !value.overflowIds.every(isEvidenceIdentifier))) return false
919
+ return true
920
+ }
921
+
922
+ export function decodeMissionListQueryResponse(value: unknown): MissionListQueryResponse | null {
923
+ return isMissionListQueryResponse(value) ? value : null
924
+ }
925
+
926
+ // ─── note_upsert / note_forget ──────────────────────────────────────────────
927
+ //
928
+ // Decision (2026-09-24, C-W6b; implemented C-W8): coordinator operating notes
929
+ // get their own two local-IPC commands. A note's text is coordinator-authored
930
+ // free text, so — exactly like mission_upsert's `goal` — it cannot ride
931
+ // `mesh_record`'s scalar allow-list, and it never needs to: notes live in the
932
+ // owning daemon's `mesh_operating_notes` table (local-only; never on
933
+ // `mesh.<id>.events`). The same "local IPC is inside the boundary" rationale as
934
+ // the mission commands above applies.
935
+
936
+ export const OPERATING_NOTE_CATEGORIES = ['provider_quirk', 'pattern_to_avoid', 'recovery_lesson'] as const
937
+ export type OperatingNoteCategory = typeof OPERATING_NOTE_CATEGORIES[number]
938
+ export const isOperatingNoteCategory = makeGuard(OPERATING_NOTE_CATEGORIES)
939
+
940
+ export interface NoteUpsertRequest {
941
+ v: typeof TURN_IPC_PROTOCOL_VERSION
942
+ meshId: string
943
+ /** Free text (local IPC — see the section note). */
944
+ text: string
945
+ category?: OperatingNoteCategory
946
+ pinned?: boolean
947
+ /** Explicit expiry, ISO-8601. */
948
+ expiresAt?: string
949
+ /** Note id (or subject key) this note supersedes. */
950
+ supersedes?: string
951
+ subjectKey?: string
952
+ /** Best-effort identity of the recording coordinator (session id / daemon id / host). */
953
+ sourceCoordinator?: string
954
+ }
955
+
956
+ export interface NoteUpsertResponse {
957
+ noteId: string
958
+ /** True when the same text was already among the recent live notes (no new note). */
959
+ deduped: boolean
960
+ createdAt: string
961
+ }
962
+
963
+ function isOptionalShortString(value: unknown, max = 512): boolean {
964
+ return value === undefined || (typeof value === 'string' && value.trim().length > 0 && value.length <= max)
965
+ }
966
+
967
+ export function isNoteUpsertRequest(value: unknown): value is NoteUpsertRequest {
968
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'text', 'category', 'pinned', 'expiresAt', 'supersedes', 'subjectKey', 'sourceCoordinator'])) return false
969
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
970
+ if (!isEvidenceIdentifier(value.meshId)) return false
971
+ if (typeof value.text !== 'string' || value.text.trim().length === 0) return false
972
+ if (value.category !== undefined && !isOperatingNoteCategory(value.category)) return false
973
+ if (value.pinned !== undefined && typeof value.pinned !== 'boolean') return false
974
+ if (value.expiresAt !== undefined && (typeof value.expiresAt !== 'string' || Number.isNaN(Date.parse(value.expiresAt)))) return false
975
+ return isOptionalShortString(value.supersedes) && isOptionalShortString(value.subjectKey) && isOptionalShortString(value.sourceCoordinator)
976
+ }
977
+
978
+ export function decodeNoteUpsertRequest(value: unknown): NoteUpsertRequest | null {
979
+ return isNoteUpsertRequest(value) ? value : null
980
+ }
981
+
982
+ export function isNoteUpsertResponse(value: unknown): value is NoteUpsertResponse {
983
+ return isRecord(value) && hasOnlyKeys(value, ['noteId', 'deduped', 'createdAt'])
984
+ && isEvidenceIdentifier(value.noteId) && typeof value.deduped === 'boolean' && typeof value.createdAt === 'string'
985
+ }
986
+
987
+ export function decodeNoteUpsertResponse(value: unknown): NoteUpsertResponse | null {
988
+ return isNoteUpsertResponse(value) ? value : null
989
+ }
990
+
991
+ export interface NoteForgetRequest {
992
+ v: typeof TURN_IPC_PROTOCOL_VERSION
993
+ meshId: string
994
+ /** At least one of `noteId` / `text` (a text forget retracts every note with that exact text, and any recorded later). */
995
+ noteId?: string
996
+ text?: string
997
+ reason?: string
998
+ }
999
+
1000
+ export interface NoteForgetResponse {
1001
+ /** Live notes this forget hid. */
1002
+ matched: number
1003
+ tombstoneId: string
1004
+ }
1005
+
1006
+ export function isNoteForgetRequest(value: unknown): value is NoteForgetRequest {
1007
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'noteId', 'text', 'reason'])) return false
1008
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
1009
+ if (!isEvidenceIdentifier(value.meshId)) return false
1010
+ if (value.noteId !== undefined && !isEvidenceIdentifier(value.noteId)) return false
1011
+ if (value.text !== undefined && (typeof value.text !== 'string' || value.text.trim().length === 0)) return false
1012
+ if (value.noteId === undefined && value.text === undefined) return false
1013
+ return value.reason === undefined || typeof value.reason === 'string'
1014
+ }
1015
+
1016
+ export function decodeNoteForgetRequest(value: unknown): NoteForgetRequest | null {
1017
+ return isNoteForgetRequest(value) ? value : null
1018
+ }
1019
+
1020
+ export function isNoteForgetResponse(value: unknown): value is NoteForgetResponse {
1021
+ return isRecord(value) && hasOnlyKeys(value, ['matched', 'tombstoneId'])
1022
+ && typeof value.matched === 'number' && Number.isInteger(value.matched) && value.matched >= 0
1023
+ && isEvidenceIdentifier(value.tombstoneId)
1024
+ }
1025
+
1026
+ export function decodeNoteForgetResponse(value: unknown): NoteForgetResponse | null {
1027
+ return isNoteForgetResponse(value) ? value : null
1028
+ }
1029
+
1030
+ // ─── tool_call_record ───────────────────────────────────────────────────────
1031
+ //
1032
+ // Wiring-unification Phase C, workstream C-W9b
1033
+ // (docs/design/2026-09-23-wiring-unification.md §5 C2 "MCP server" paragraph,
1034
+ // 2026-09-24 14:00 stamp "C-W9").
1035
+ //
1036
+ // Replaces `recordMeshToolCall`/`recordMeshCoordinatorToolCall`
1037
+ // (`daemon-core/src/mesh/mesh-direct-dispatch.ts`) — an in-process,
1038
+ // per-mesh-tool-call rate-limit/advisory bump mcp-server called directly
1039
+ // against its own store handle. Every field is an identifier, a closed enum,
1040
+ // a boolean or a counter — squarely inside `mesh_record`'s content boundary,
1041
+ // but this is its own command (not a `mesh_record` payload) because it is a
1042
+ // COUNTER MUTATION with a computed return value (`rateLimitExceeded`,
1043
+ // `callsInWindow`, `advisory`), not an append-only scalar record.
1044
+
1045
+ export const TOOL_CALLER_ROLES = ['coordinator', 'unknown'] as const
1046
+ export type ToolCallerRole = typeof TOOL_CALLER_ROLES[number]
1047
+ export const isToolCallerRole = makeGuard(TOOL_CALLER_ROLES)
1048
+
1049
+ export interface ToolCallRecordRequest {
1050
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1051
+ meshId: string
1052
+ /** The mesh tool name, e.g. `mesh_status` (identifier, not free text — always a fixed tool-name string). */
1053
+ tool: string
1054
+ sessionId?: string
1055
+ callerRole: ToolCallerRole
1056
+ }
1057
+
1058
+ export interface ToolCallRecordResponse {
1059
+ rateLimitExceeded: boolean
1060
+ callsInWindow: number
1061
+ /** Short machine-oriented advisory string, or null. Never free text describing content. */
1062
+ advisory: string | null
1063
+ }
1064
+
1065
+ export function isToolCallRecordRequest(value: unknown): value is ToolCallRecordRequest {
1066
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'tool', 'sessionId', 'callerRole'])
1067
+ && value.v === TURN_IPC_PROTOCOL_VERSION
1068
+ && isEvidenceIdentifier(value.meshId)
1069
+ && isEvidenceIdentifier(value.tool)
1070
+ && isOptionalId(value.sessionId)
1071
+ && isToolCallerRole(value.callerRole)
1072
+ }
1073
+
1074
+ export function decodeToolCallRecordRequest(value: unknown): ToolCallRecordRequest | null {
1075
+ return isToolCallRecordRequest(value) ? value : null
1076
+ }
1077
+
1078
+ export function isToolCallRecordResponse(value: unknown): value is ToolCallRecordResponse {
1079
+ return isRecord(value) && hasOnlyKeys(value, ['rateLimitExceeded', 'callsInWindow', 'advisory'])
1080
+ && typeof value.rateLimitExceeded === 'boolean'
1081
+ && isNonNegativeInt(value.callsInWindow)
1082
+ && (value.advisory === null || typeof value.advisory === 'string')
1083
+ }
1084
+
1085
+ export function decodeToolCallRecordResponse(value: unknown): ToolCallRecordResponse | null {
1086
+ return isToolCallRecordResponse(value) ? value : null
1087
+ }
1088
+
1089
+ // ─── ledger_query ───────────────────────────────────────────────────────────
1090
+ //
1091
+ // Wiring-unification Phase C, workstream C-W9b.
1092
+ //
1093
+ // Replaces the remaining bare `readLedgerEntries(meshId, opts)` /
1094
+ // `getLedgerSummary(meshId)` in-process reads that `turn_query` cannot serve
1095
+ // (§5 C2's `mesh-tools-mission.ts` file-header note, 2026-09-24: "general
1096
+ // ledger browsing by arbitrary `kind`/`since`/`node` filters —
1097
+ // `turn_query`'s shape... has no free-form kind-list or node filter, so this
1098
+ // is a real API mismatch, not a mechanical rename"). `turn_query` stays
1099
+ // scoped to the turn-ledger's own attempt/event rows; `ledger_query` is the
1100
+ // general record browse surface (C-W9a: the daemon's `mesh_local_records` plus
1101
+ // the turn ledger's task outcomes, `readLocalRecords`) `meshTaskHistory` /
1102
+ // `meshLedgerQuery` / the MAGI ledger-scan helpers need.
1103
+ //
1104
+ // CONTENT BOUNDARY: `MeshLedgerEntry.payload` (daemon-core `mesh-ledger.ts`)
1105
+ // is `Record<string, unknown>` — genuinely unbounded JSON (a MAGI synthesis
1106
+ // object, a graph plan, a free-text checkpoint message), NOT constrained to
1107
+ // `mesh_record`'s scalar allow-list. This is fine for the SAME reason
1108
+ // `mission_upsert`'s `goal` and `note_upsert`'s `text` are fine (see that
1109
+ // section's note above): this is local IPC between mcp-server and the
1110
+ // daemon that owns the ledger, both on the operator's own machine — never a
1111
+ // cross-machine or server hop. `entryPayload` below is therefore an
1112
+ // intentionally-unvalidated `Record<string, unknown>` passthrough, exactly
1113
+ // like `MissionUpsertRequest.goal` is unvalidated free text.
1114
+
1115
+ export interface LedgerQueryRequest {
1116
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1117
+ meshId: string
1118
+ /** One or more ledger `kind` strings (daemon-core `MeshLedgerKind`; mesh-shared cannot import that union, so these are identifiers here). Omitted = every kind. */
1119
+ kind?: readonly string[]
1120
+ /** ISO-8601 or epoch-ms-as-string — passed through to `readLedgerEntries`'s `since` (parsed via `new Date()`). */
1121
+ since?: string
1122
+ /** Filter to entries whose nodeId is equivalent to this daemon id (matched via `daemonIdsEquivalent` daemon-side, not raw `===`). */
1123
+ node?: string
1124
+ tail?: number
1125
+ /** Also return `getLedgerSummary(meshId)` alongside the entries — saves a second round trip for callers that want both (the common case: every existing call site fetches both together). */
1126
+ includeSummary?: boolean
1127
+ }
1128
+
1129
+ export interface LedgerQueryEntryWire {
1130
+ id: string
1131
+ meshId: string
1132
+ timestamp: string
1133
+ kind: string
1134
+ nodeId?: string
1135
+ sessionId?: string
1136
+ providerType?: string
1137
+ taskId?: string
1138
+ /** Unbounded JSON passthrough — see file section's CONTENT BOUNDARY note. */
1139
+ payload: Record<string, unknown>
1140
+ }
1141
+
1142
+ export interface LedgerQuerySummaryWire {
1143
+ meshId: string
1144
+ totalEntries: number
1145
+ taskDispatched: number
1146
+ taskCompleted: number
1147
+ taskFailed: number
1148
+ taskStalled: number
1149
+ sessionLaunched: number
1150
+ checkpointCreated: number
1151
+ lastActivityAt: string | null
1152
+ recentFailures: number
1153
+ }
1154
+
1155
+ export interface LedgerQueryResponse {
1156
+ entries: readonly LedgerQueryEntryWire[]
1157
+ summary?: LedgerQuerySummaryWire
1158
+ }
1159
+
1160
+ export function isLedgerQueryRequest(value: unknown): value is LedgerQueryRequest {
1161
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'kind', 'since', 'node', 'tail', 'includeSummary'])) return false
1162
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
1163
+ if (!isEvidenceIdentifier(value.meshId)) return false
1164
+ if (value.kind !== undefined && (!Array.isArray(value.kind) || value.kind.length === 0 || !value.kind.every(isEvidenceIdentifier))) return false
1165
+ if (value.since !== undefined && typeof value.since !== 'string') return false
1166
+ if (!isOptionalId(value.node)) return false
1167
+ if (value.tail !== undefined && !isNonNegativeInt(value.tail)) return false
1168
+ if (value.includeSummary !== undefined && typeof value.includeSummary !== 'boolean') return false
1169
+ return true
1170
+ }
1171
+
1172
+ export function decodeLedgerQueryRequest(value: unknown): LedgerQueryRequest | null {
1173
+ return isLedgerQueryRequest(value) ? value : null
1174
+ }
1175
+
1176
+ const LEDGER_QUERY_ENTRY_KEYS = ['id', 'meshId', 'timestamp', 'kind', 'nodeId', 'sessionId', 'providerType', 'taskId', 'payload'] as const
1177
+
1178
+ function isLedgerQueryEntryWire(value: unknown): value is LedgerQueryEntryWire {
1179
+ if (!isRecord(value) || !hasOnlyKeys(value, LEDGER_QUERY_ENTRY_KEYS)) return false
1180
+ if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
1181
+ if (typeof value.timestamp !== 'string' || typeof value.kind !== 'string') return false
1182
+ if (!isOptionalId(value.nodeId) || !isOptionalId(value.sessionId) || !isOptionalId(value.providerType) || !isOptionalId(value.taskId)) return false
1183
+ if (!isRecord(value.payload)) return false
1184
+ return true
1185
+ }
1186
+
1187
+ function isLedgerQuerySummaryWire(value: unknown): value is LedgerQuerySummaryWire {
1188
+ const keys = ['meshId', 'totalEntries', 'taskDispatched', 'taskCompleted', 'taskFailed', 'taskStalled', 'sessionLaunched', 'checkpointCreated', 'lastActivityAt', 'recentFailures'] as const
1189
+ if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
1190
+ if (!isEvidenceIdentifier(value.meshId)) return false
1191
+ for (const k of ['totalEntries', 'taskDispatched', 'taskCompleted', 'taskFailed', 'taskStalled', 'sessionLaunched', 'checkpointCreated', 'recentFailures'] as const) {
1192
+ if (!isNonNegativeInt(value[k])) return false
1193
+ }
1194
+ if (value.lastActivityAt !== null && typeof value.lastActivityAt !== 'string') return false
1195
+ return true
1196
+ }
1197
+
1198
+ export function isLedgerQueryResponse(value: unknown): value is LedgerQueryResponse {
1199
+ if (!isRecord(value) || !hasOnlyKeys(value, ['entries', 'summary'])) return false
1200
+ if (!Array.isArray(value.entries) || !value.entries.every(isLedgerQueryEntryWire)) return false
1201
+ if (value.summary !== undefined && !isLedgerQuerySummaryWire(value.summary)) return false
1202
+ return true
1203
+ }
1204
+
1205
+ export function decodeLedgerQueryResponse(value: unknown): LedgerQueryResponse | null {
1206
+ return isLedgerQueryResponse(value) ? value : null
1207
+ }
1208
+
1209
+ // ─── C-W9a: local records, queue composites, active work ────────────────────
1210
+ //
1211
+ // Wiring-unification Phase C, workstream C-W9a (the 2026-09-24 14:00 stamp
1212
+ // "Left for C-W9"): the event ledger and its JSONL mirror are gone, and every
1213
+ // mcp-server call that still reached the daemon's `mesh-runtime.db` in-process
1214
+ // — record appends, queue mutations, the queue/active-work reads that fed
1215
+ // `buildMeshActiveWork`, recovery hints — moves behind the commands below,
1216
+ // executed in the daemon that owns the store.
1217
+ //
1218
+ // CONTENT BOUNDARY: like `mission_upsert`'s `goal`, `note_upsert`'s `text` and
1219
+ // `ledger_query`'s entry payloads, several fields here are free text or
1220
+ // unbounded JSON (a task message, a MAGI synthesis, a queue row with its
1221
+ // message, an active-work record with its task summary). This is fine for the
1222
+ // same reason: local IPC between the mcp-server and the daemon on the
1223
+ // operator's own machine — never a cross-machine or server hop. Those fields
1224
+ // are documented as passthroughs and validated only for shape (object/array),
1225
+ // exactly like `LedgerQueryEntryWire.payload`. Nothing here is ever published
1226
+ // to a topic: `record_local`'s topic leg is the daemon's `mesh.record`
1227
+ // allow-list projection, which drops free text by construction.
1228
+
1229
+ function isRecordArray(value: unknown): value is readonly Record<string, unknown>[] {
1230
+ return Array.isArray(value) && value.every(isRecord)
1231
+ }
1232
+
1233
+ function isOptionalRecord(value: unknown): boolean {
1234
+ return value === undefined || isRecord(value)
1235
+ }
1236
+
1237
+ function isOptionalBoolean(value: unknown): boolean {
1238
+ return value === undefined || typeof value === 'boolean'
1239
+ }
1240
+
1241
+ function isOptionalString(value: unknown): boolean {
1242
+ return value === undefined || typeof value === 'string'
1243
+ }
1244
+
1245
+ /** A queue row as the daemon's `getQueue` returns it — a JSON passthrough (see the section note). */
1246
+ export type QueueEntryWire = Record<string, unknown> & { id: string; status: string }
1247
+
1248
+ function isQueueEntryWire(value: unknown): value is QueueEntryWire {
1249
+ return isRecord(value) && isEvidenceIdentifier(value.id) && typeof value.status === 'string'
1250
+ }
1251
+
1252
+ // ── record_local ──
1253
+ //
1254
+ // `meshRecord(meshId, kind, scalars, { local: true })` over IPC: the scalar
1255
+ // projection goes to `mesh.<id>.events` (content-free), the FULL payload lands
1256
+ // in the daemon's `mesh_local_records` (local-only). Replaces every mcp-server
1257
+ // `appendLedgerEntry` (dispatch / MAGI question+synthesis / checkpoint message /
1258
+ // reconcile records). `payload` is free-form JSON (see the section note).
1259
+
1260
+ export interface RecordLocalRequest {
1261
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1262
+ meshId: string
1263
+ /** A `MeshLedgerKind` (daemon-core); an identifier here (mesh-shared cannot import that union). */
1264
+ kind: string
1265
+ nodeId?: string
1266
+ sessionId?: string
1267
+ providerType?: string
1268
+ taskId?: string
1269
+ payload: Record<string, unknown>
1270
+ }
1271
+
1272
+ export interface RecordLocalResponse {
1273
+ eventId: string
1274
+ timestamp: string
1275
+ storedLocally: boolean
1276
+ published: boolean
1277
+ }
1278
+
1279
+ export function isRecordLocalRequest(value: unknown): value is RecordLocalRequest {
1280
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'kind', 'nodeId', 'sessionId', 'providerType', 'taskId', 'payload'])
1281
+ && value.v === TURN_IPC_PROTOCOL_VERSION
1282
+ && isEvidenceIdentifier(value.meshId)
1283
+ && isEvidenceIdentifier(value.kind)
1284
+ && isOptionalId(value.nodeId) && isOptionalId(value.sessionId) && isOptionalId(value.providerType) && isOptionalId(value.taskId)
1285
+ && isRecord(value.payload)
1286
+ }
1287
+
1288
+ export function decodeRecordLocalRequest(value: unknown): RecordLocalRequest | null {
1289
+ return isRecordLocalRequest(value) ? value : null
1290
+ }
1291
+
1292
+ export function isRecordLocalResponse(value: unknown): value is RecordLocalResponse {
1293
+ return isRecord(value) && hasOnlyKeys(value, ['eventId', 'timestamp', 'storedLocally', 'published'])
1294
+ && isEvidenceIdentifier(value.eventId) && typeof value.timestamp === 'string'
1295
+ && typeof value.storedLocally === 'boolean' && typeof value.published === 'boolean'
1296
+ }
1297
+
1298
+ export function decodeRecordLocalResponse(value: unknown): RecordLocalResponse | null {
1299
+ return isRecordLocalResponse(value) ? value : null
1300
+ }
1301
+
1302
+ // ── queue_query ──
1303
+
1304
+ export interface QueueQueryRequest {
1305
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1306
+ meshId: string
1307
+ /** Status filter (queue statuses); omitted = every row. */
1308
+ statuses?: readonly string[]
1309
+ /** One row by id (returns 0 or 1 entries). */
1310
+ taskId?: string
1311
+ /** Project for a VIEW surface: the persisted input envelope → `inputSummary` (summarizeQueueEntryInputForView). */
1312
+ view?: boolean
1313
+ /**
1314
+ * Also return the per-status row counts of the WHOLE mesh queue (columns only,
1315
+ * no payload parse) — lets a view read only the rows it shows yet report
1316
+ * mesh-wide counts. An older daemon rejects the key.
1317
+ */
1318
+ withCounts?: boolean
1319
+ /** With `withCounts`: also count terminal rows whose `updated_at` is older than this age. */
1320
+ historicalOlderThanMs?: number
1321
+ /**
1322
+ * Also return `dependencyHeads`: id/status/blockedReason/cancelReason of every
1323
+ * row the returned entries list in `dependsOn` that is not itself returned —
1324
+ * enough to annotate dependency state without reading the whole queue.
1325
+ */
1326
+ withDependencyHeads?: boolean
1327
+ }
1328
+
1329
+ /** A dependency row reduced to the fields dependency-state annotation reads. */
1330
+ export interface QueueDependencyHeadWire {
1331
+ id: string
1332
+ status: string
1333
+ blockedReason?: string
1334
+ cancelReason?: string
1335
+ }
1336
+
1337
+ export interface QueueQueryResponse {
1338
+ entries: readonly QueueEntryWire[]
1339
+ /** `withCounts`: row count per status over the whole mesh queue. */
1340
+ counts?: Record<string, number>
1341
+ /** `withCounts` + `historicalOlderThanMs`: terminal rows older than that age. */
1342
+ oldHistoricalCount?: number
1343
+ /** `withDependencyHeads`. */
1344
+ dependencyHeads?: readonly QueueDependencyHeadWire[]
1345
+ }
1346
+
1347
+ export function isQueueQueryRequest(value: unknown): value is QueueQueryRequest {
1348
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'statuses', 'taskId', 'view', 'withCounts', 'historicalOlderThanMs', 'withDependencyHeads'])) return false
1349
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
1350
+ if (value.statuses !== undefined && (!Array.isArray(value.statuses) || !value.statuses.every(isEvidenceIdentifier))) return false
1351
+ if (value.historicalOlderThanMs !== undefined && !isNonNegativeInt(value.historicalOlderThanMs)) return false
1352
+ return isOptionalId(value.taskId) && isOptionalBoolean(value.view)
1353
+ && isOptionalBoolean(value.withCounts) && isOptionalBoolean(value.withDependencyHeads)
1354
+ }
1355
+
1356
+ export function decodeQueueQueryRequest(value: unknown): QueueQueryRequest | null {
1357
+ return isQueueQueryRequest(value) ? value : null
1358
+ }
1359
+
1360
+ function isQueueDependencyHeadWire(value: unknown): value is QueueDependencyHeadWire {
1361
+ return isRecord(value) && hasOnlyKeys(value, ['id', 'status', 'blockedReason', 'cancelReason'])
1362
+ && typeof value.id === 'string' && typeof value.status === 'string'
1363
+ && (value.blockedReason === undefined || typeof value.blockedReason === 'string')
1364
+ && (value.cancelReason === undefined || typeof value.cancelReason === 'string')
1365
+ }
1366
+
1367
+ export function isQueueQueryResponse(value: unknown): value is QueueQueryResponse {
1368
+ if (!isRecord(value) || !hasOnlyKeys(value, ['entries', 'counts', 'oldHistoricalCount', 'dependencyHeads'])) return false
1369
+ if (!Array.isArray(value.entries) || !value.entries.every(isQueueEntryWire)) return false
1370
+ if (value.counts !== undefined && (!isRecord(value.counts) || !Object.values(value.counts).every(isNonNegativeInt))) return false
1371
+ if (value.oldHistoricalCount !== undefined && !isNonNegativeInt(value.oldHistoricalCount)) return false
1372
+ if (value.dependencyHeads !== undefined && (!Array.isArray(value.dependencyHeads) || !value.dependencyHeads.every(isQueueDependencyHeadWire))) return false
1373
+ return true
1374
+ }
1375
+
1376
+ export function decodeQueueQueryResponse(value: unknown): QueueQueryResponse | null {
1377
+ return isQueueQueryResponse(value) ? value : null
1378
+ }
1379
+
1380
+ // ── queue_enqueue ──
1381
+ //
1382
+ // `enqueueTask(meshId, message, options)` + (when `decision` is given) the
1383
+ // single-surface `recordSingleEnqueueDecision` for the new task id, in the
1384
+ // daemon. `options` is `MeshEnqueueTaskOptions` (daemon-core) as a JSON
1385
+ // passthrough; the daemon's enqueue guards (message, task-mode, difficulty,
1386
+ // dependency cycle) run unchanged and a refusal comes back as the error.
1387
+
1388
+ export interface QueueEnqueueRequest {
1389
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1390
+ meshId: string
1391
+ /** Free text (the task message — see the section note). */
1392
+ message: string
1393
+ options?: Record<string, unknown>
1394
+ /** `recordSingleEnqueueDecision` args minus `taskId` (the daemon fills it). */
1395
+ decision?: Record<string, unknown>
1396
+ }
1397
+
1398
+ export interface QueueEnqueueResponse {
1399
+ entry: QueueEntryWire
1400
+ }
1401
+
1402
+ export function isQueueEnqueueRequest(value: unknown): value is QueueEnqueueRequest {
1403
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'message', 'options', 'decision'])
1404
+ && value.v === TURN_IPC_PROTOCOL_VERSION && isEvidenceIdentifier(value.meshId)
1405
+ && typeof value.message === 'string'
1406
+ && isOptionalRecord(value.options) && isOptionalRecord(value.decision)
1407
+ }
1408
+
1409
+ export function decodeQueueEnqueueRequest(value: unknown): QueueEnqueueRequest | null {
1410
+ return isQueueEnqueueRequest(value) ? value : null
1411
+ }
1412
+
1413
+ export function isQueueEnqueueResponse(value: unknown): value is QueueEnqueueResponse {
1414
+ return isRecord(value) && hasOnlyKeys(value, ['entry']) && isQueueEntryWire(value.entry)
1415
+ }
1416
+
1417
+ export function decodeQueueEnqueueResponse(value: unknown): QueueEnqueueResponse | null {
1418
+ return isQueueEnqueueResponse(value) ? value : null
1419
+ }
1420
+
1421
+ // ── queue_enqueue_graph ──
1422
+ //
1423
+ // The atomic batch enqueue, both paths, with its audit trail — in the daemon:
1424
+ // - `mode: 'compat'` → `enqueueTaskGraph(meshId, specs)`;
1425
+ // - `mode: 'graph'` → `commitMeshGraphPlan(plan)` + `recordGraphEnqueueCommitted`.
1426
+ // A failure records `recordGraphEnqueueRolledBack` (graph) or
1427
+ // `recordGraphEnqueueValidationFailed` (compat) from the catch — the daemon
1428
+ // writes the audit AFTER the failed transaction, exactly as design :752-753
1429
+ // requires — and answers `{ ok: false, refusalCode?, message, extra? }` (a
1430
+ // domain refusal is a RESULT here, not a transport error, so the caller keeps
1431
+ // its code/extra for the tool response). NOT `code`/`error`: those two keys
1432
+ // belong to the command envelope (`{ success, error, code }`) the client
1433
+ // unwraps, so a result must never reuse them.
1434
+
1435
+ export interface QueueEnqueueGraphRequest {
1436
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1437
+ meshId: string
1438
+ mode: 'compat' | 'graph'
1439
+ /** compat: `MeshTaskGraphEntrySpec[]` (JSON passthrough). */
1440
+ specs?: readonly Record<string, unknown>[]
1441
+ /** graph: `commitMeshGraphPlan` input minus `meshId` (JSON passthrough). */
1442
+ plan?: Record<string, unknown>
1443
+ /** Audit context: batchId / missionId / coordinatorSessionId / onDependencyFailure / orchestrationDecision / taskCount. */
1444
+ audit?: Record<string, unknown>
1445
+ }
1446
+
1447
+ export type QueueEnqueueGraphResponse =
1448
+ | { ok: true; tasks: readonly QueueEntryWire[]; graph?: Record<string, unknown> }
1449
+ | { ok: false; refusalCode?: string; message: string; extra?: Record<string, unknown> }
1450
+
1451
+ export function isQueueEnqueueGraphRequest(value: unknown): value is QueueEnqueueGraphRequest {
1452
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'mode', 'specs', 'plan', 'audit'])) return false
1453
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
1454
+ if (value.mode === 'compat') return isRecordArray(value.specs) && value.plan === undefined && isOptionalRecord(value.audit)
1455
+ if (value.mode === 'graph') return isRecord(value.plan) && value.specs === undefined && isOptionalRecord(value.audit)
1456
+ return false
1457
+ }
1458
+
1459
+ export function decodeQueueEnqueueGraphRequest(value: unknown): QueueEnqueueGraphRequest | null {
1460
+ return isQueueEnqueueGraphRequest(value) ? value : null
1461
+ }
1462
+
1463
+ export function isQueueEnqueueGraphResponse(value: unknown): value is QueueEnqueueGraphResponse {
1464
+ if (!isRecord(value)) return false
1465
+ if (value.ok === true) {
1466
+ return hasOnlyKeys(value, ['ok', 'tasks', 'graph']) && Array.isArray(value.tasks) && value.tasks.every(isQueueEntryWire)
1467
+ && isOptionalRecord(value.graph)
1468
+ }
1469
+ if (value.ok === false) {
1470
+ return hasOnlyKeys(value, ['ok', 'refusalCode', 'message', 'extra']) && typeof value.message === 'string'
1471
+ && isOptionalString(value.refusalCode) && isOptionalRecord(value.extra)
1472
+ }
1473
+ return false
1474
+ }
1475
+
1476
+ export function decodeQueueEnqueueGraphResponse(value: unknown): QueueEnqueueGraphResponse | null {
1477
+ return isQueueEnqueueGraphResponse(value) ? value : null
1478
+ }
1479
+
1480
+ // ── queue_cancel ──
1481
+
1482
+ export interface QueueCancelRequest {
1483
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1484
+ meshId: string
1485
+ taskId: string
1486
+ /** Operator reason (free text, local IPC). */
1487
+ reason?: string
1488
+ }
1489
+
1490
+ export interface QueueCancelResponse {
1491
+ /** The cancelled row, or null when no such task. */
1492
+ task: QueueEntryWire | null
1493
+ /** The row as it was BEFORE the cancel (its assignment is what a caller stops). */
1494
+ before: QueueEntryWire | null
1495
+ }
1496
+
1497
+ export function isQueueCancelRequest(value: unknown): value is QueueCancelRequest {
1498
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'taskId', 'reason'])
1499
+ && value.v === TURN_IPC_PROTOCOL_VERSION && isEvidenceIdentifier(value.meshId) && isEvidenceIdentifier(value.taskId)
1500
+ && isOptionalString(value.reason)
1501
+ }
1502
+
1503
+ export function decodeQueueCancelRequest(value: unknown): QueueCancelRequest | null {
1504
+ return isQueueCancelRequest(value) ? value : null
1505
+ }
1506
+
1507
+ export function isQueueCancelResponse(value: unknown): value is QueueCancelResponse {
1508
+ return isRecord(value) && hasOnlyKeys(value, ['task', 'before'])
1509
+ && (value.task === null || isQueueEntryWire(value.task))
1510
+ && (value.before === null || isQueueEntryWire(value.before))
1511
+ }
1512
+
1513
+ export function decodeQueueCancelResponse(value: unknown): QueueCancelResponse | null {
1514
+ return isQueueCancelResponse(value) ? value : null
1515
+ }
1516
+
1517
+ // ── queue_requeue ──
1518
+
1519
+ export interface QueueRequeueRequest {
1520
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1521
+ meshId: string
1522
+ taskId: string
1523
+ /** `requeueTask` options (reason / targetNodeId / targetSessionId / clear* / force / message / maxRetries / notBefore), JSON passthrough. */
1524
+ options?: Record<string, unknown>
1525
+ }
1526
+
1527
+ export interface QueueRequeueResponse {
1528
+ task: QueueEntryWire | null
1529
+ }
1530
+
1531
+ export function isQueueRequeueRequest(value: unknown): value is QueueRequeueRequest {
1532
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'taskId', 'options'])
1533
+ && value.v === TURN_IPC_PROTOCOL_VERSION && isEvidenceIdentifier(value.meshId) && isEvidenceIdentifier(value.taskId)
1534
+ && isOptionalRecord(value.options)
1535
+ }
1536
+
1537
+ export function decodeQueueRequeueRequest(value: unknown): QueueRequeueRequest | null {
1538
+ return isQueueRequeueRequest(value) ? value : null
1539
+ }
1540
+
1541
+ export function isQueueRequeueResponse(value: unknown): value is QueueRequeueResponse {
1542
+ return isRecord(value) && hasOnlyKeys(value, ['task']) && (value.task === null || isQueueEntryWire(value.task))
1543
+ }
1544
+
1545
+ export function decodeQueueRequeueResponse(value: unknown): QueueRequeueResponse | null {
1546
+ return isQueueRequeueResponse(value) ? value : null
1547
+ }
1548
+
1549
+ // ── direct_dispatch_record ──
1550
+ //
1551
+ // The post-dispatch bookkeeping of `mesh_send_task`'s direct arms, in the
1552
+ // daemon: `recordDirectDispatchTask` (materialise the queue row, stamped with
1553
+ // the already-open `mesh_direct` attempt) and `recordDirectDispatchDecision`
1554
+ // (GRAPH-MEASUREMENT-DIRECT). Each step is best-effort and reported separately
1555
+ // — a dispatch that already happened must never fail on its bookkeeping.
1556
+
1557
+ export interface DirectDispatchRecordRequest {
1558
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1559
+ meshId: string
1560
+ taskId: string
1561
+ /** Free text (the authored task message — the materialised row keeps it). */
1562
+ message: string
1563
+ /** `recordDirectDispatchTask` options minus `id` (JSON passthrough). */
1564
+ task?: Record<string, unknown>
1565
+ /** `recordDirectDispatchDecision` args minus `taskId` (JSON passthrough). */
1566
+ decision?: Record<string, unknown>
1567
+ }
1568
+
1569
+ export interface DirectDispatchRecordResponse {
1570
+ taskRecorded: boolean
1571
+ decisionRecorded: boolean
1572
+ }
1573
+
1574
+ export function isDirectDispatchRecordRequest(value: unknown): value is DirectDispatchRecordRequest {
1575
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'taskId', 'message', 'task', 'decision'])
1576
+ && value.v === TURN_IPC_PROTOCOL_VERSION && isEvidenceIdentifier(value.meshId) && isEvidenceIdentifier(value.taskId)
1577
+ && typeof value.message === 'string' && isOptionalRecord(value.task) && isOptionalRecord(value.decision)
1578
+ }
1579
+
1580
+ export function decodeDirectDispatchRecordRequest(value: unknown): DirectDispatchRecordRequest | null {
1581
+ return isDirectDispatchRecordRequest(value) ? value : null
1582
+ }
1583
+
1584
+ export function isDirectDispatchRecordResponse(value: unknown): value is DirectDispatchRecordResponse {
1585
+ return isRecord(value) && hasOnlyKeys(value, ['taskRecorded', 'decisionRecorded'])
1586
+ && typeof value.taskRecorded === 'boolean' && typeof value.decisionRecorded === 'boolean'
1587
+ }
1588
+
1589
+ export function decodeDirectDispatchRecordResponse(value: unknown): DirectDispatchRecordResponse | null {
1590
+ return isDirectDispatchRecordResponse(value) ? value : null
1591
+ }
1592
+
1593
+ // ── graph_audit_record ──
1594
+ //
1595
+ // The coordinator-gate / node-patch provenance records the `mesh_graph_*`
1596
+ // tools write after a gate action (design :740-750), written by the daemon's
1597
+ // allow-listed recorders (mesh-graph-provenance.ts) — the mcp-server never
1598
+ // builds the record payload itself. `fields` is the recorder's argument object.
1599
+
1600
+ export const GRAPH_AUDIT_EVENTS = ['gate_claimed', 'gate_released', 'gate_abandoned', 'node_patched'] as const
1601
+ export type GraphAuditEvent = typeof GRAPH_AUDIT_EVENTS[number]
1602
+ export const isGraphAuditEvent = makeGuard(GRAPH_AUDIT_EVENTS)
1603
+
1604
+ export interface GraphAuditRecordRequest {
1605
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1606
+ meshId: string
1607
+ event: GraphAuditEvent
1608
+ fields: Record<string, unknown>
1609
+ }
1610
+
1611
+ export interface GraphAuditRecordResponse {
1612
+ recorded: boolean
1613
+ }
1614
+
1615
+ export function isGraphAuditRecordRequest(value: unknown): value is GraphAuditRecordRequest {
1616
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'event', 'fields'])
1617
+ && value.v === TURN_IPC_PROTOCOL_VERSION && isEvidenceIdentifier(value.meshId)
1618
+ && isGraphAuditEvent(value.event) && isRecord(value.fields)
1619
+ }
1620
+
1621
+ export function decodeGraphAuditRecordRequest(value: unknown): GraphAuditRecordRequest | null {
1622
+ return isGraphAuditRecordRequest(value) ? value : null
1623
+ }
1624
+
1625
+ export function isGraphAuditRecordResponse(value: unknown): value is GraphAuditRecordResponse {
1626
+ return isRecord(value) && hasOnlyKeys(value, ['recorded']) && typeof value.recorded === 'boolean'
1627
+ }
1628
+
1629
+ export function decodeGraphAuditRecordResponse(value: unknown): GraphAuditRecordResponse | null {
1630
+ return isGraphAuditRecordResponse(value) ? value : null
1631
+ }
1632
+
1633
+ // ── active_work_query ──
1634
+ //
1635
+ // The active-work view, COMPUTED in the daemon: it reads the queue, the open
1636
+ // direct dispatches (`mesh_direct` attempts) and its local records (+ turn
1637
+ // outcomes), and runs `buildMeshActiveWork` over them with the caller's live
1638
+ // node probe results. `includeInputs` also returns the records + dispatches it
1639
+ // used (the transcript-reconcile pass and the stale-direct prune read them);
1640
+ // `compute: false` returns only those inputs. `includeSchedulingRuntime` adds
1641
+ // `buildMeshSchedulingRuntime(mesh, queue)` for the caller's `mesh` snapshot.
1642
+ // `queue` lets a caller substitute its own annotated queue view.
1643
+
1644
+ export interface ActiveWorkQueryRequest {
1645
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1646
+ meshId: string
1647
+ /** Live node probe results (`buildMeshActiveWork`'s `nodes`), JSON passthrough. */
1648
+ nodes?: readonly Record<string, unknown>[]
1649
+ /** Substitute queue rows (default: the daemon's `getQueue`). */
1650
+ queue?: readonly Record<string, unknown>[]
1651
+ /** Record tail fed to active work (default 200). */
1652
+ recordTail?: number
1653
+ includeTerminalDirect?: boolean
1654
+ /** Default true. */
1655
+ compute?: boolean
1656
+ includeInputs?: boolean
1657
+ includeSummary?: boolean
1658
+ includeSchedulingRuntime?: boolean
1659
+ /**
1660
+ * The caller's mesh snapshot for the scheduling runtime, JSON passthrough.
1661
+ * Optional since the daemon resolves its own mesh record when it is absent;
1662
+ * an older daemon rejects `includeSchedulingRuntime` without it (the caller's
1663
+ * feature-detect signal to resend with the mesh).
1664
+ */
1665
+ mesh?: Record<string, unknown>
1666
+ }
1667
+
1668
+ export interface ActiveWorkQueryResponse {
1669
+ /** `MeshActiveWorkEvidence` (daemon-core), JSON passthrough; absent when `compute: false`. */
1670
+ activeWork?: Record<string, unknown>
1671
+ records?: readonly Record<string, unknown>[]
1672
+ directDispatches?: readonly Record<string, unknown>[]
1673
+ /** `MeshLedgerSummary`-shaped record summary. */
1674
+ summary?: Record<string, unknown>
1675
+ schedulingRuntime?: Record<string, unknown>
1676
+ }
1677
+
1678
+ export function isActiveWorkQueryRequest(value: unknown): value is ActiveWorkQueryRequest {
1679
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'nodes', 'queue', 'recordTail', 'includeTerminalDirect', 'compute', 'includeInputs', 'includeSummary', 'includeSchedulingRuntime', 'mesh'])) return false
1680
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
1681
+ if (value.nodes !== undefined && !isRecordArray(value.nodes)) return false
1682
+ if (value.queue !== undefined && !isRecordArray(value.queue)) return false
1683
+ if (value.recordTail !== undefined && !isNonNegativeInt(value.recordTail)) return false
1684
+ return isOptionalBoolean(value.includeTerminalDirect) && isOptionalBoolean(value.compute) && isOptionalBoolean(value.includeInputs)
1685
+ && isOptionalBoolean(value.includeSummary) && isOptionalBoolean(value.includeSchedulingRuntime) && isOptionalRecord(value.mesh)
1686
+ }
1687
+
1688
+ export function decodeActiveWorkQueryRequest(value: unknown): ActiveWorkQueryRequest | null {
1689
+ return isActiveWorkQueryRequest(value) ? value : null
1690
+ }
1691
+
1692
+ export function isActiveWorkQueryResponse(value: unknown): value is ActiveWorkQueryResponse {
1693
+ if (!isRecord(value) || !hasOnlyKeys(value, ['activeWork', 'records', 'directDispatches', 'summary', 'schedulingRuntime'])) return false
1694
+ if (value.records !== undefined && !isRecordArray(value.records)) return false
1695
+ if (value.directDispatches !== undefined && !isRecordArray(value.directDispatches)) return false
1696
+ return isOptionalRecord(value.activeWork) && isOptionalRecord(value.summary) && isOptionalRecord(value.schedulingRuntime)
1697
+ }
1698
+
1699
+ export function decodeActiveWorkQueryResponse(value: unknown): ActiveWorkQueryResponse | null {
1700
+ return isActiveWorkQueryResponse(value) ? value : null
1701
+ }
1702
+
1703
+ // ── recovery_context_query ──
1704
+ //
1705
+ // `getSessionRecoveryContext` (daemon-core `mesh-local-records.ts`) in the
1706
+ // daemon. `lastTaskMessage` / `advice` are free text (local IPC).
1707
+
1708
+ export interface RecoveryContextQueryRequest {
1709
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1710
+ meshId: string
1711
+ nodeId?: string
1712
+ sessionId?: string
1713
+ maxRetries?: number
1714
+ /**
1715
+ * Batch: one context per node id, computed over ONE record read (answers
1716
+ * `contexts`). Exclusive with `nodeId`/`sessionId`. An older daemon rejects it.
1717
+ */
1718
+ nodeIds?: readonly string[]
1719
+ }
1720
+
1721
+ export interface RecoveryContextQueryResponse {
1722
+ /** Single mode (nodeId/sessionId). */
1723
+ context?: Record<string, unknown>
1724
+ /** Batch mode (`nodeIds`): context per node id. */
1725
+ contexts?: Record<string, Record<string, unknown>>
1726
+ }
1727
+
1728
+ export function isRecoveryContextQueryRequest(value: unknown): value is RecoveryContextQueryRequest {
1729
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'nodeId', 'sessionId', 'maxRetries', 'nodeIds'])) return false
1730
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
1731
+ if (!isOptionalId(value.nodeId) || !isOptionalId(value.sessionId)) return false
1732
+ if (value.maxRetries !== undefined && !isNonNegativeInt(value.maxRetries)) return false
1733
+ if (value.nodeIds !== undefined) {
1734
+ if (!Array.isArray(value.nodeIds) || value.nodeIds.length === 0 || !value.nodeIds.every(isEvidenceIdentifier)) return false
1735
+ return value.nodeId === undefined && value.sessionId === undefined
1736
+ }
1737
+ return value.nodeId !== undefined || value.sessionId !== undefined
1738
+ }
1739
+
1740
+ export function decodeRecoveryContextQueryRequest(value: unknown): RecoveryContextQueryRequest | null {
1741
+ return isRecoveryContextQueryRequest(value) ? value : null
1742
+ }
1743
+
1744
+ function isRecoveryContextWire(value: unknown): boolean {
1745
+ return isRecord(value) && typeof value.consecutiveNodeFailures === 'number'
1746
+ }
1747
+
1748
+ export function isRecoveryContextQueryResponse(value: unknown): value is RecoveryContextQueryResponse {
1749
+ if (!isRecord(value) || !hasOnlyKeys(value, ['context', 'contexts'])) return false
1750
+ if (value.context === undefined && value.contexts === undefined) return false
1751
+ if (value.context !== undefined && !isRecoveryContextWire(value.context)) return false
1752
+ if (value.contexts !== undefined && (!isRecord(value.contexts) || !Object.values(value.contexts).every(isRecoveryContextWire))) return false
1753
+ return true
1754
+ }
1755
+
1756
+ export function decodeRecoveryContextQueryResponse(value: unknown): RecoveryContextQueryResponse | null {
1757
+ return isRecoveryContextQueryResponse(value) ? value : null
1758
+ }
1759
+
1760
+ // ─── graph_gate_claim / graph_gate_release / graph_gate_abandon / graph_node_patch / graph_view_query ──
1761
+ //
1762
+ // Wiring-unification Phase C, workstream C-W9c (2026-09-24 19:00 stamp
1763
+ // "mcp-server graph gates/plan/patch ... still call daemon-core in-process").
1764
+ //
1765
+ // `mesh-tools-graph.ts`'s five graph-orchestration tools (the gate claim
1766
+ // / release / abandon verbs, now `mesh_graph_gate`, `mesh_graph_node_patch`, `mesh_graph_view`) called
1767
+ // daemon-core's MeshRuntimeStore-backed graph module in-process
1768
+ // (`claimMeshGraphGate` / `releaseMeshGraphGate` / `abandonMeshGraphGate` /
1769
+ // `patchGraphNodeAndRetry` / `buildMeshGraphViews`, all in `mesh-graph-gates.ts` /
1770
+ // `mesh-graph-transition-runner.ts` / `mesh-graph-view.ts`), plus the read-only
1771
+ // git-based `collectGateConvergenceEvidence` probe. These five commands move
1772
+ // every one of those calls into the daemon that owns the graph rows.
1773
+ //
1774
+ // CONTENT BOUNDARY: a gate/node-patch result is exposed to the CALLER as
1775
+ // identifiers, enums, counts and a `result`/`evidence`/`patches` passthrough
1776
+ // the coordinator itself supplied on the request (release/patch echo back only
1777
+ // what they were given) — never anything the daemon derives from chat content.
1778
+ // `MeshGraphGateRow` / `MeshGraphView` are large, evolving daemon-core shapes;
1779
+ // rather than duplicate their structure here (the same risk `active_work_query`'s
1780
+ // `activeWork` avoided), the gate/graph payload is a JSON passthrough
1781
+ // (`Record<string, unknown>`), exactly like `ActiveWorkQueryResponse.activeWork`.
1782
+
1783
+ export interface GraphGateClaimRequest {
1784
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1785
+ meshId: string
1786
+ gateId: string
1787
+ coordinatorSessionId: string
1788
+ leaseSeconds?: number
1789
+ extendDeadlineSeconds?: number
1790
+ /** Also collect `collectGateConvergenceEvidence` on a successful claim (mesh_graph_gate action=claim's own behavior). */
1791
+ probeConvergenceEvidence?: boolean
1792
+ }
1793
+
1794
+ export interface GraphGateClaimResponse {
1795
+ claimed: boolean
1796
+ /** gate_not_found / gate_not_eligible / gate_not_awaiting / gate_lease_held / gate_claim_race / gate_terminal:<state> / gate_not_claimable, when `claimed: false`. */
1797
+ reason?: string
1798
+ /** `MeshGraphGateRow`, JSON passthrough; present whenever the core found the gate. */
1799
+ gate?: Record<string, unknown>
1800
+ leaseGeneration?: number
1801
+ fencingToken?: string
1802
+ leaseExpiresAt?: string
1803
+ deadlineAt?: string
1804
+ ambiguousExternalOutcome?: boolean
1805
+ previousLeaseOwnerSessionId?: string
1806
+ /** `GateConvergenceEvidence`, JSON passthrough; present only when requested and found. */
1807
+ convergenceEvidence?: Record<string, unknown>
1808
+ }
1809
+
1810
+ export function isGraphGateClaimRequest(value: unknown): value is GraphGateClaimRequest {
1811
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'gateId', 'coordinatorSessionId', 'leaseSeconds', 'extendDeadlineSeconds', 'probeConvergenceEvidence'])) return false
1812
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.gateId) || !isEvidenceIdentifier(value.coordinatorSessionId)) return false
1813
+ if (value.leaseSeconds !== undefined && !isFiniteNumber(value.leaseSeconds)) return false
1814
+ if (value.extendDeadlineSeconds !== undefined && !isFiniteNumber(value.extendDeadlineSeconds)) return false
1815
+ return isOptionalBoolean(value.probeConvergenceEvidence)
1816
+ }
1817
+
1818
+ export function decodeGraphGateClaimRequest(value: unknown): GraphGateClaimRequest | null {
1819
+ return isGraphGateClaimRequest(value) ? value : null
1820
+ }
1821
+
1822
+ export function isGraphGateClaimResponse(value: unknown): value is GraphGateClaimResponse {
1823
+ if (!isRecord(value) || !hasOnlyKeys(value, ['claimed', 'reason', 'gate', 'leaseGeneration', 'fencingToken', 'leaseExpiresAt', 'deadlineAt', 'ambiguousExternalOutcome', 'previousLeaseOwnerSessionId', 'convergenceEvidence'])) return false
1824
+ if (typeof value.claimed !== 'boolean') return false
1825
+ return isOptionalRecord(value.gate) && isOptionalRecord(value.convergenceEvidence) && isOptionalBoolean(value.ambiguousExternalOutcome)
1826
+ }
1827
+
1828
+ export function decodeGraphGateClaimResponse(value: unknown): GraphGateClaimResponse | null {
1829
+ return isGraphGateClaimResponse(value) ? value : null
1830
+ }
1831
+
1832
+ export interface GraphGateReleaseRequest {
1833
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1834
+ meshId: string
1835
+ gateId: string
1836
+ fencingToken: string
1837
+ leaseGeneration: number
1838
+ idempotencyKey: string
1839
+ outcome: string
1840
+ /** Action-specific structured result/evidence the CALLER supplied — JSON passthrough, echoed back unmodified. */
1841
+ result?: unknown
1842
+ evidence?: unknown
1843
+ patches?: readonly { node: string, baseSpecPatch: Record<string, unknown> }[]
1844
+ }
1845
+
1846
+ // `releaseMeshGraphGate` THROWS for every rejection (its whole transaction must
1847
+ // roll back — mesh-graph-gates.ts file header). The daemon handler catches
1848
+ // that and reports it as a RESULT, not an IPC-level failure (the same
1849
+ // success:true-carries-a-refusal shape `queue_enqueue_graph` already uses) —
1850
+ // `mesh_graph_gate` action=release's tool layer classifies `refusalCode` by prefix
1851
+ // exactly as it did the in-process thrown message.
1852
+ export type GraphGateReleaseResponse =
1853
+ | {
1854
+ released: true
1855
+ duplicate: boolean
1856
+ gate?: Record<string, unknown>
1857
+ materializedNodeIds: readonly string[]
1858
+ downstreamNodeCount?: number
1859
+ graphCompleted?: boolean
1860
+ }
1861
+ | { released: false, refusalCode?: string, message: string }
1862
+
1863
+ export function isGraphGateReleaseRequest(value: unknown): value is GraphGateReleaseRequest {
1864
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'gateId', 'fencingToken', 'leaseGeneration', 'idempotencyKey', 'outcome', 'result', 'evidence', 'patches'])) return false
1865
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.gateId)) return false
1866
+ if (typeof value.fencingToken !== 'string' || !value.fencingToken) return false
1867
+ if (!isFiniteNumber(value.leaseGeneration)) return false
1868
+ if (typeof value.idempotencyKey !== 'string' || !value.idempotencyKey) return false
1869
+ if (typeof value.outcome !== 'string' || !value.outcome) return false
1870
+ if (value.patches !== undefined) {
1871
+ if (!Array.isArray(value.patches)) return false
1872
+ for (const p of value.patches) {
1873
+ if (!isRecord(p) || typeof p.node !== 'string' || !p.node || !isRecord(p.baseSpecPatch)) return false
1874
+ }
1875
+ }
1876
+ return true
1877
+ }
1878
+
1879
+ export function decodeGraphGateReleaseRequest(value: unknown): GraphGateReleaseRequest | null {
1880
+ return isGraphGateReleaseRequest(value) ? value : null
1881
+ }
1882
+
1883
+ export function isGraphGateReleaseResponse(value: unknown): value is GraphGateReleaseResponse {
1884
+ if (!isRecord(value)) return false
1885
+ if (value.released === true) {
1886
+ if (!hasOnlyKeys(value, ['released', 'duplicate', 'gate', 'materializedNodeIds', 'downstreamNodeCount', 'graphCompleted'])) return false
1887
+ if (typeof value.duplicate !== 'boolean') return false
1888
+ if (!Array.isArray(value.materializedNodeIds) || !value.materializedNodeIds.every((n) => typeof n === 'string')) return false
1889
+ if (value.downstreamNodeCount !== undefined && !isNonNegativeInt(value.downstreamNodeCount)) return false
1890
+ return isOptionalRecord(value.gate) && isOptionalBoolean(value.graphCompleted)
1891
+ }
1892
+ if (value.released === false) {
1893
+ return hasOnlyKeys(value, ['released', 'refusalCode', 'message']) && typeof value.message === 'string' && isOptionalString(value.refusalCode)
1894
+ }
1895
+ return false
1896
+ }
1897
+
1898
+ export function decodeGraphGateReleaseResponse(value: unknown): GraphGateReleaseResponse | null {
1899
+ return isGraphGateReleaseResponse(value) ? value : null
1900
+ }
1901
+
1902
+ export interface GraphGateAbandonRequest {
1903
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1904
+ meshId: string
1905
+ gateId: string
1906
+ reason: string
1907
+ coordinatorSessionId?: string
1908
+ force?: boolean
1909
+ }
1910
+
1911
+ export interface GraphGateAbandonResponse {
1912
+ abandoned: boolean
1913
+ /** gate_not_found / gate_lease_held / gate_abandon_race / gate_terminal:<state> / gate_not_abandonable, when `abandoned: false`. */
1914
+ reason?: string
1915
+ gate?: Record<string, unknown>
1916
+ cancelledNodeIds: readonly string[]
1917
+ cancelledTaskIds: readonly string[]
1918
+ graphStatus?: string
1919
+ }
1920
+
1921
+ export function isGraphGateAbandonRequest(value: unknown): value is GraphGateAbandonRequest {
1922
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'gateId', 'reason', 'coordinatorSessionId', 'force'])) return false
1923
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.gateId)) return false
1924
+ if (typeof value.reason !== 'string' || !value.reason) return false
1925
+ if (value.coordinatorSessionId !== undefined && !isEvidenceIdentifier(value.coordinatorSessionId)) return false
1926
+ return isOptionalBoolean(value.force)
1927
+ }
1928
+
1929
+ export function decodeGraphGateAbandonRequest(value: unknown): GraphGateAbandonRequest | null {
1930
+ return isGraphGateAbandonRequest(value) ? value : null
1931
+ }
1932
+
1933
+ export function isGraphGateAbandonResponse(value: unknown): value is GraphGateAbandonResponse {
1934
+ if (!isRecord(value) || !hasOnlyKeys(value, ['abandoned', 'reason', 'gate', 'cancelledNodeIds', 'cancelledTaskIds', 'graphStatus'])) return false
1935
+ if (typeof value.abandoned !== 'boolean') return false
1936
+ if (!Array.isArray(value.cancelledNodeIds) || !value.cancelledNodeIds.every((n) => typeof n === 'string')) return false
1937
+ if (!Array.isArray(value.cancelledTaskIds) || !value.cancelledTaskIds.every((n) => typeof n === 'string')) return false
1938
+ return isOptionalRecord(value.gate)
1939
+ }
1940
+
1941
+ export function decodeGraphGateAbandonResponse(value: unknown): GraphGateAbandonResponse | null {
1942
+ return isGraphGateAbandonResponse(value) ? value : null
1943
+ }
1944
+
1945
+ export interface GraphNodePatchRequest {
1946
+ v: typeof TURN_IPC_PROTOCOL_VERSION
1947
+ meshId: string
1948
+ node: string
1949
+ graphId?: string
1950
+ /** Only {@link MESH_NODE_PATCH_KEYS}-shaped keys are permitted; the daemon enforces the allow-list. */
1951
+ baseSpecPatch: Record<string, unknown>
1952
+ }
1953
+
1954
+ // `patchGraphNodeAndRetry` THROWS for every rejection (mesh-graph-transition-runner.ts) —
1955
+ // same released:false treatment as `GraphGateReleaseResponse` above.
1956
+ export type GraphNodePatchResponse =
1957
+ | {
1958
+ patched: true
1959
+ graphId: string
1960
+ nodeId: string
1961
+ ref?: string
1962
+ queueTaskId?: string
1963
+ materializationVersion: number
1964
+ state: string
1965
+ /** `SettleOutcome['kind']` — materialized / deferred / skipped / error. */
1966
+ outcomeKind: string
1967
+ /** Present when `outcomeKind === 'skipped'`. */
1968
+ skippedReason?: string
1969
+ blockedReason?: string
1970
+ }
1971
+ | { patched: false, refusalCode?: string, message: string }
1972
+
1973
+ export function isGraphNodePatchRequest(value: unknown): value is GraphNodePatchRequest {
1974
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'node', 'graphId', 'baseSpecPatch'])) return false
1975
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
1976
+ if (typeof value.node !== 'string' || !value.node) return false
1977
+ if (value.graphId !== undefined && !isEvidenceIdentifier(value.graphId)) return false
1978
+ return isRecord(value.baseSpecPatch) && Object.keys(value.baseSpecPatch).length > 0
1979
+ }
1980
+
1981
+ export function decodeGraphNodePatchRequest(value: unknown): GraphNodePatchRequest | null {
1982
+ return isGraphNodePatchRequest(value) ? value : null
1983
+ }
1984
+
1985
+ export function isGraphNodePatchResponse(value: unknown): value is GraphNodePatchResponse {
1986
+ if (!isRecord(value)) return false
1987
+ if (value.patched === true) {
1988
+ if (!hasOnlyKeys(value, ['patched', 'graphId', 'nodeId', 'ref', 'queueTaskId', 'materializationVersion', 'state', 'outcomeKind', 'skippedReason', 'blockedReason'])) return false
1989
+ if (!isEvidenceIdentifier(value.graphId) || !isEvidenceIdentifier(value.nodeId)) return false
1990
+ if (!isNonNegativeInt(value.materializationVersion)) return false
1991
+ return typeof value.state === 'string' && typeof value.outcomeKind === 'string'
1992
+ }
1993
+ if (value.patched === false) {
1994
+ return hasOnlyKeys(value, ['patched', 'refusalCode', 'message']) && typeof value.message === 'string' && isOptionalString(value.refusalCode)
1995
+ }
1996
+ return false
1997
+ }
1998
+
1999
+ export function decodeGraphNodePatchResponse(value: unknown): GraphNodePatchResponse | null {
2000
+ return isGraphNodePatchResponse(value) ? value : null
2001
+ }
2002
+
2003
+ export interface GraphViewQueryRequest {
2004
+ v: typeof TURN_IPC_PROTOCOL_VERSION
2005
+ meshId: string
2006
+ graphId?: string
2007
+ batchId?: string
2008
+ /** Default true: only graphs that still need attention. */
2009
+ activeOnly?: boolean
2010
+ limit?: number
2011
+ /** Attach `collectGateConvergenceEvidence` to the first few waiting gates (mesh_graph_view's G4 opt-in probe). */
2012
+ probeGateEvidence?: boolean
2013
+ }
2014
+
2015
+ export interface GraphViewQueryResponse {
2016
+ /** `MeshGraphView[]`, JSON passthrough — see this section's content-boundary note. */
2017
+ graphs: readonly Record<string, unknown>[]
2018
+ }
2019
+
2020
+ export function isGraphViewQueryRequest(value: unknown): value is GraphViewQueryRequest {
2021
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'graphId', 'batchId', 'activeOnly', 'limit', 'probeGateEvidence'])) return false
2022
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
2023
+ if (value.graphId !== undefined && !isEvidenceIdentifier(value.graphId)) return false
2024
+ if (value.batchId !== undefined && !isEvidenceIdentifier(value.batchId)) return false
2025
+ if (value.limit !== undefined && !isNonNegativeInt(value.limit)) return false
2026
+ return isOptionalBoolean(value.activeOnly) && isOptionalBoolean(value.probeGateEvidence)
2027
+ }
2028
+
2029
+ export function decodeGraphViewQueryRequest(value: unknown): GraphViewQueryRequest | null {
2030
+ return isGraphViewQueryRequest(value) ? value : null
2031
+ }
2032
+
2033
+ export function isGraphViewQueryResponse(value: unknown): value is GraphViewQueryResponse {
2034
+ return isRecord(value) && hasOnlyKeys(value, ['graphs']) && isRecordArray(value.graphs)
2035
+ }
2036
+
2037
+ export function decodeGraphViewQueryResponse(value: unknown): GraphViewQueryResponse | null {
2038
+ return isGraphViewQueryResponse(value) ? value : null
2039
+ }
2040
+
2041
+ // ─── task_stats_query ───────────────────────────────────────────────────────
2042
+ //
2043
+ // C-W9c: replaces the in-process `computeMeshTaskStats` / `computeMeshMissionStats`
2044
+ // (daemon-core `mesh-task-stats.ts`) — both scan the daemon's queue + local
2045
+ // records, so both move behind one command. Exactly one of `taskIds` /
2046
+ // `missionId` selects the per-task mode; `missionId` alone with `rollup: true`
2047
+ // additionally returns the mission-level aggregate.
2048
+
2049
+ export interface TaskStatsQueryRequest {
2050
+ v: typeof TURN_IPC_PROTOCOL_VERSION
2051
+ meshId: string
2052
+ taskIds?: readonly string[]
2053
+ missionId?: string
2054
+ tail?: number
2055
+ /** Also compute `computeMeshMissionStats` — requires `missionId`. */
2056
+ rollup?: boolean
2057
+ /**
2058
+ * Batch rollups: `computeMeshMissionStats` for every id, over ONE queue read and
2059
+ * ONE record read (answers `missions`, `tasks` empty). Exclusive with
2060
+ * `taskIds`/`missionId`. An older daemon rejects the key.
2061
+ */
2062
+ missionIds?: readonly string[]
2063
+ }
2064
+
2065
+ export interface TaskStatsQueryResponse {
2066
+ /** `MeshTaskStats[]`, JSON passthrough. */
2067
+ tasks: readonly Record<string, unknown>[]
2068
+ /** `MeshMissionStats`, JSON passthrough; present only when `rollup: true` was requested and satisfiable. */
2069
+ mission?: Record<string, unknown>
2070
+ /** Batch mode: `MeshMissionStats` per requested mission id, JSON passthrough. */
2071
+ missions?: Record<string, Record<string, unknown>>
2072
+ }
2073
+
2074
+ export function isTaskStatsQueryRequest(value: unknown): value is TaskStatsQueryRequest {
2075
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'taskIds', 'missionId', 'tail', 'rollup', 'missionIds'])) return false
2076
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
2077
+ if (value.taskIds !== undefined) {
2078
+ if (!Array.isArray(value.taskIds) || value.taskIds.length === 0 || !value.taskIds.every(isEvidenceIdentifier)) return false
2079
+ }
2080
+ if (value.missionId !== undefined && !isEvidenceIdentifier(value.missionId)) return false
2081
+ if (value.tail !== undefined && !isNonNegativeInt(value.tail)) return false
2082
+ if (value.missionIds !== undefined) {
2083
+ if (!Array.isArray(value.missionIds) || value.missionIds.length === 0 || !value.missionIds.every(isEvidenceIdentifier)) return false
2084
+ if (value.taskIds !== undefined || value.missionId !== undefined) return false
2085
+ }
2086
+ if (value.rollup === true && value.missionId === undefined) return false
2087
+ return isOptionalBoolean(value.rollup)
2088
+ }
2089
+
2090
+ export function decodeTaskStatsQueryRequest(value: unknown): TaskStatsQueryRequest | null {
2091
+ return isTaskStatsQueryRequest(value) ? value : null
2092
+ }
2093
+
2094
+ export function isTaskStatsQueryResponse(value: unknown): value is TaskStatsQueryResponse {
2095
+ return isRecord(value) && hasOnlyKeys(value, ['tasks', 'mission', 'missions']) && isRecordArray(value.tasks) && isOptionalRecord(value.mission)
2096
+ && (value.missions === undefined || (isRecord(value.missions) && Object.values(value.missions).every(isRecord)))
2097
+ }
2098
+
2099
+ export function decodeTaskStatsQueryResponse(value: unknown): TaskStatsQueryResponse | null {
2100
+ return isTaskStatsQueryResponse(value) ? value : null
2101
+ }
2102
+
2103
+ // ─── prune_stale_direct ─────────────────────────────────────────────────────
2104
+ //
2105
+ // C-W9c: replaces the in-process `pruneStaleDirectDispatches` call
2106
+ // (`mesh_cleanup_sessions` mode=prune_stale_direct, `mesh-tools-session.ts`). Unlike the other
2107
+ // C-W9 commands this one does NOT take its inputs (queue/records/direct
2108
+ // dispatches) on the wire — the daemon already reads them itself
2109
+ // (`getQueue`/`readLocalRecords`/`getActiveDirectDispatches`), so the request
2110
+ // carries only the decision knobs. `closeDispatches` (the C-W8 `turn_cancel`
2111
+ // callback) also runs daemon-side now, since it is the same process.
2112
+
2113
+ export interface PruneStaleDirectRequest {
2114
+ v: typeof TURN_IPC_PROTOCOL_VERSION
2115
+ meshId: string
2116
+ execute?: boolean
2117
+ includeTerminal?: boolean
2118
+ source?: string
2119
+ }
2120
+
2121
+ export interface PruneStaleDirectResponse {
2122
+ mode: 'execute' | 'dry_run'
2123
+ includeTerminal: boolean
2124
+ candidateCount: number
2125
+ /** `MeshActiveWorkRecord[]`, JSON passthrough. */
2126
+ prunable: readonly Record<string, unknown>[]
2127
+ prunedCount: number
2128
+ preservedUnacknowledged: readonly Record<string, unknown>[]
2129
+ preservedLedgerOnly: readonly Record<string, unknown>[]
2130
+ preservedNotOrphan: readonly Record<string, unknown>[]
2131
+ }
2132
+
2133
+ export function isPruneStaleDirectRequest(value: unknown): value is PruneStaleDirectRequest {
2134
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'execute', 'includeTerminal', 'source'])) return false
2135
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId)) return false
2136
+ if (value.source !== undefined && typeof value.source !== 'string') return false
2137
+ return isOptionalBoolean(value.execute) && isOptionalBoolean(value.includeTerminal)
2138
+ }
2139
+
2140
+ export function decodePruneStaleDirectRequest(value: unknown): PruneStaleDirectRequest | null {
2141
+ return isPruneStaleDirectRequest(value) ? value : null
2142
+ }
2143
+
2144
+ export function isPruneStaleDirectResponse(value: unknown): value is PruneStaleDirectResponse {
2145
+ if (!isRecord(value) || !hasOnlyKeys(value, ['mode', 'includeTerminal', 'candidateCount', 'prunable', 'prunedCount', 'preservedUnacknowledged', 'preservedLedgerOnly', 'preservedNotOrphan'])) return false
2146
+ if (value.mode !== 'execute' && value.mode !== 'dry_run') return false
2147
+ if (typeof value.includeTerminal !== 'boolean') return false
2148
+ if (!isNonNegativeInt(value.candidateCount) || !isNonNegativeInt(value.prunedCount)) return false
2149
+ return isRecordArray(value.prunable) && isRecordArray(value.preservedUnacknowledged)
2150
+ && isRecordArray(value.preservedLedgerOnly) && isRecordArray(value.preservedNotOrphan)
2151
+ }
2152
+
2153
+ export function decodePruneStaleDirectResponse(value: unknown): PruneStaleDirectResponse | null {
2154
+ return isPruneStaleDirectResponse(value) ? value : null
2155
+ }
2156
+
2157
+ // ─── orphaned_pin_notify ────────────────────────────────────────────────────
2158
+ //
2159
+ // C-W9c: replaces the in-process `notifyCoordinatorOfOrphanedPins` call
2160
+ // (`mesh_queue_cancel` tool, `mesh-tools-queue.ts`, CANCEL-ORPHANS-PINNED-TASK).
2161
+ // The core reads the daemon's live queue (`getQueue`) and, when it finds
2162
+ // orphans, calls `notifyMeshCoordinator` to page the coordinator — both are
2163
+ // daemon-only concerns, so the whole call moves, not just its queue read.
2164
+ // `title` is a task-message-derived label (first line, truncated) — free text,
2165
+ // hence local IPC only, same rationale as `record_local`'s payload.
2166
+
2167
+ export interface OrphanedPinNotifyRequest {
2168
+ v: typeof TURN_IPC_PROTOCOL_VERSION
2169
+ meshId: string
2170
+ stoppedSessionId: string
2171
+ excludeTaskId?: string
2172
+ cause?: string
2173
+ nodeId?: string
2174
+ coordinatorSessionId?: string
2175
+ }
2176
+
2177
+ export interface OrphanedPinnedTaskWire {
2178
+ taskId: string
2179
+ title: string
2180
+ targetSessionId: string
2181
+ targetNodeId?: string
2182
+ missionId?: string
2183
+ }
2184
+
2185
+ export interface OrphanedPinNotifyResponse {
2186
+ orphans: readonly OrphanedPinnedTaskWire[]
2187
+ }
2188
+
2189
+ function isOrphanedPinnedTaskWire(value: unknown): value is OrphanedPinnedTaskWire {
2190
+ if (!isRecord(value) || !hasOnlyKeys(value, ['taskId', 'title', 'targetSessionId', 'targetNodeId', 'missionId'])) return false
2191
+ if (!isEvidenceIdentifier(value.taskId) || !isEvidenceIdentifier(value.targetSessionId)) return false
2192
+ if (typeof value.title !== 'string') return false
2193
+ if (value.targetNodeId !== undefined && !isEvidenceIdentifier(value.targetNodeId)) return false
2194
+ return value.missionId === undefined || isEvidenceIdentifier(value.missionId)
2195
+ }
2196
+
2197
+ export function isOrphanedPinNotifyRequest(value: unknown): value is OrphanedPinNotifyRequest {
2198
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'stoppedSessionId', 'excludeTaskId', 'cause', 'nodeId', 'coordinatorSessionId'])) return false
2199
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.meshId) || !isEvidenceIdentifier(value.stoppedSessionId)) return false
2200
+ if (value.excludeTaskId !== undefined && !isEvidenceIdentifier(value.excludeTaskId)) return false
2201
+ if (value.cause !== undefined && typeof value.cause !== 'string') return false
2202
+ if (value.nodeId !== undefined && !isEvidenceIdentifier(value.nodeId)) return false
2203
+ return value.coordinatorSessionId === undefined || isEvidenceIdentifier(value.coordinatorSessionId)
2204
+ }
2205
+
2206
+ export function decodeOrphanedPinNotifyRequest(value: unknown): OrphanedPinNotifyRequest | null {
2207
+ return isOrphanedPinNotifyRequest(value) ? value : null
2208
+ }
2209
+
2210
+ export function isOrphanedPinNotifyResponse(value: unknown): value is OrphanedPinNotifyResponse {
2211
+ return isRecord(value) && hasOnlyKeys(value, ['orphans']) && Array.isArray(value.orphans) && value.orphans.every(isOrphanedPinnedTaskWire)
2212
+ }
2213
+
2214
+ export function decodeOrphanedPinNotifyResponse(value: unknown): OrphanedPinNotifyResponse | null {
2215
+ return isOrphanedPinNotifyResponse(value) ? value : null
2216
+ }
2217
+
2218
+ // ─── command registry ───────────────────────────────────────────────────────
2219
+
2220
+ /** The complete, closed set of IPC command names this contract defines. */
2221
+ export const TURN_IPC_COMMANDS = [
2222
+ 'turn_observe',
2223
+ 'mesh_record',
2224
+ 'turn_cancel',
2225
+ 'operator_status',
2226
+ 'turn_query',
2227
+ 'mesh_index_query',
2228
+ 'mission_upsert',
2229
+ 'mission_query',
2230
+ 'note_upsert',
2231
+ 'note_forget',
2232
+ // C-W9b additions (2026-09-24 14:00 stamp "C-W9"):
2233
+ 'tool_call_record',
2234
+ 'ledger_query',
2235
+ 'mission_list_query',
2236
+ // C-W9a additions (the event ledger retired; the queue behind IPC):
2237
+ 'record_local',
2238
+ 'queue_query',
2239
+ 'queue_enqueue',
2240
+ 'queue_enqueue_graph',
2241
+ 'queue_cancel',
2242
+ 'queue_requeue',
2243
+ 'direct_dispatch_record',
2244
+ 'graph_audit_record',
2245
+ 'active_work_query',
2246
+ 'recovery_context_query',
2247
+ // C-W9c additions (2026-09-24 19:00 stamp — the last mcp-server in-process
2248
+ // daemon-core paths: graph gates/plan/patch, mission reads (MAGI), task/
2249
+ // mission stats, orphaned-pin helpers, one prune audit):
2250
+ 'graph_gate_claim',
2251
+ 'graph_gate_release',
2252
+ 'graph_gate_abandon',
2253
+ 'graph_node_patch',
2254
+ 'graph_view_query',
2255
+ 'task_stats_query',
2256
+ 'prune_stale_direct',
2257
+ 'orphaned_pin_notify',
2258
+ ] as const
2259
+ export type TurnIpcCommand = typeof TURN_IPC_COMMANDS[number]
2260
+ export const isTurnIpcCommand = makeGuard(TURN_IPC_COMMANDS)
2261
+
2262
+ /** Request type keyed by command name — for a generically-typed dispatcher on either end. */
2263
+ export interface TurnIpcRequestByCommand {
2264
+ turn_observe: TurnObserveRequest
2265
+ mesh_record: MeshRecordRequest
2266
+ turn_cancel: TurnCancelRequest
2267
+ operator_status: OperatorStatusRequest
2268
+ turn_query: TurnQueryRequest
2269
+ mesh_index_query: MeshIndexQueryRequest
2270
+ mission_upsert: MissionUpsertRequest
2271
+ mission_query: MissionQueryRequest
2272
+ note_upsert: NoteUpsertRequest
2273
+ note_forget: NoteForgetRequest
2274
+ tool_call_record: ToolCallRecordRequest
2275
+ ledger_query: LedgerQueryRequest
2276
+ mission_list_query: MissionListQueryRequest
2277
+ record_local: RecordLocalRequest
2278
+ queue_query: QueueQueryRequest
2279
+ queue_enqueue: QueueEnqueueRequest
2280
+ queue_enqueue_graph: QueueEnqueueGraphRequest
2281
+ queue_cancel: QueueCancelRequest
2282
+ queue_requeue: QueueRequeueRequest
2283
+ direct_dispatch_record: DirectDispatchRecordRequest
2284
+ graph_audit_record: GraphAuditRecordRequest
2285
+ active_work_query: ActiveWorkQueryRequest
2286
+ recovery_context_query: RecoveryContextQueryRequest
2287
+ graph_gate_claim: GraphGateClaimRequest
2288
+ graph_gate_release: GraphGateReleaseRequest
2289
+ graph_gate_abandon: GraphGateAbandonRequest
2290
+ graph_node_patch: GraphNodePatchRequest
2291
+ graph_view_query: GraphViewQueryRequest
2292
+ task_stats_query: TaskStatsQueryRequest
2293
+ prune_stale_direct: PruneStaleDirectRequest
2294
+ orphaned_pin_notify: OrphanedPinNotifyRequest
2295
+ }
2296
+
2297
+ /** Response type keyed by command name — for a generically-typed dispatcher on either end. */
2298
+ export interface TurnIpcResponseByCommand {
2299
+ turn_observe: TurnObserveResponse
2300
+ mesh_record: MeshRecordResponse
2301
+ turn_cancel: TurnCancelResponse
2302
+ operator_status: OperatorStatusResponse
2303
+ turn_query: TurnQueryResponse
2304
+ mission_upsert: MissionUpsertResponse
2305
+ mission_query: MissionQueryResponse
2306
+ mesh_index_query: MeshIndexQueryResponse
2307
+ note_upsert: NoteUpsertResponse
2308
+ note_forget: NoteForgetResponse
2309
+ tool_call_record: ToolCallRecordResponse
2310
+ ledger_query: LedgerQueryResponse
2311
+ mission_list_query: MissionListQueryResponse
2312
+ record_local: RecordLocalResponse
2313
+ queue_query: QueueQueryResponse
2314
+ queue_enqueue: QueueEnqueueResponse
2315
+ queue_enqueue_graph: QueueEnqueueGraphResponse
2316
+ queue_cancel: QueueCancelResponse
2317
+ queue_requeue: QueueRequeueResponse
2318
+ direct_dispatch_record: DirectDispatchRecordResponse
2319
+ graph_audit_record: GraphAuditRecordResponse
2320
+ active_work_query: ActiveWorkQueryResponse
2321
+ recovery_context_query: RecoveryContextQueryResponse
2322
+ graph_gate_claim: GraphGateClaimResponse
2323
+ graph_gate_release: GraphGateReleaseResponse
2324
+ graph_gate_abandon: GraphGateAbandonResponse
2325
+ graph_node_patch: GraphNodePatchResponse
2326
+ graph_view_query: GraphViewQueryResponse
2327
+ task_stats_query: TaskStatsQueryResponse
2328
+ prune_stale_direct: PruneStaleDirectResponse
2329
+ orphaned_pin_notify: OrphanedPinNotifyResponse
2330
+ }
2331
+