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

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.
@@ -0,0 +1,647 @@
1
+ /**
2
+ * turn-ipc — mission, note, tool-call and ledger commands: mission_upsert,
3
+ * mission_query, mission_list_query, note_upsert, note_forget, tool_call_record,
4
+ * ledger_query. Part of the turn-ipc wire contract (./turn-ipc.ts).
5
+ */
6
+
7
+ import {
8
+ makeGuard,
9
+ hasOnlyKeys,
10
+ isStringArray,
11
+ TURN_IPC_PROTOCOL_VERSION,
12
+ isNonNegativeInt,
13
+ isOptionalShortString,
14
+ isOptionalId,
15
+ } from './turn-ipc-guards';
16
+ import { isRecord } from './protocol/envelope';
17
+ import { isEvidenceIdentifier } from './turn-evidence';
18
+
19
+ // ─── mission_upsert / mission_query ────────────────────────────────────────
20
+ //
21
+ // Decision (2026-09-23, design doc §5 C2 update): missions and graph gates
22
+ // have no home in the original six commands — `upsertMeshMission`/
23
+ // `listMeshMissionsForTool` write/read structured state with a free-text
24
+ // `goal`/`title` that cannot fit `mesh_record`'s scalar `ProjectedScalars`
25
+ // allow-list (§ "MESH_RECORD_PAYLOAD_KEYS" above has no text field, on
26
+ // purpose). Two more commands, not a widened allow-list:
27
+ //
28
+ // - mission_upsert — create/update a mission (mesh-missions.ts
29
+ // `upsertMeshMission`'s shape).
30
+ // - mission_query — list missions for a mesh, optionally filtered by
31
+ // status (`getMeshMissions`'s shape).
32
+ //
33
+ // WHY FREE TEXT IS FINE HERE, UNLIKE mesh_record's payload: this is LOCAL
34
+ // IPC between mcp-server and the daemon that owns the mission table — both
35
+ // processes run on the operator's own machine. The content boundary this
36
+ // program protects is "the SERVER never receives chat content" (CLAUDE.md);
37
+ // IPC between two local processes was never inside that boundary, the same
38
+ // way a direct in-process function call wasn't. Cross-machine mission text
39
+ // (a worker learning about a mission a coordinator defined) travels as a
40
+ // `mesh.<id>.handoff` ref per C10-1's precedent — mission_upsert/query never
41
+ // themselves cross a machine boundary.
42
+
43
+ export const MESH_MISSION_STATUSES = ['active', 'paused', 'completed', 'abandoned'] as const
44
+ export type MeshMissionStatusValue = typeof MESH_MISSION_STATUSES[number]
45
+ export const isMeshMissionStatusValue = makeGuard(MESH_MISSION_STATUSES)
46
+
47
+ // H2 (mission brief, wiring-unification Phase H — docs/design/2026-09-23-wiring-
48
+ // unification.md §7c): free text, same "local IPC is inside the boundary" rationale
49
+ // as `goal` above (section note). `MissionBriefWire` is a structural mirror of
50
+ // mesh-shared's own `MissionBrief` (mission-brief.ts) rather than importing it —
51
+ // this file's guards are all hand-rolled structural checks, and importing the type
52
+ // only (not the class) would still couple this wire contract to that module's
53
+ // internal shape; a mirror keeps the two independently reviewable, same as every
54
+ // other wire type in this file.
55
+ export interface MissionBriefWire {
56
+ goal: string
57
+ constraints?: readonly string[]
58
+ doneCriteria?: readonly string[]
59
+ handoffNotes?: readonly string[]
60
+ ownedPaths?: readonly string[]
61
+ }
62
+
63
+ function isMissionBriefWire(value: unknown): value is MissionBriefWire {
64
+ if (!isRecord(value) || !hasOnlyKeys(value, ['goal', 'constraints', 'doneCriteria', 'handoffNotes', 'ownedPaths'])) return false
65
+ if (typeof value.goal !== 'string' || value.goal.trim().length === 0) return false
66
+ for (const k of ['constraints', 'doneCriteria', 'handoffNotes', 'ownedPaths'] as const) {
67
+ if (value[k] !== undefined && !isStringArray(value[k])) return false
68
+ }
69
+ return true
70
+ }
71
+
72
+ export interface MissionUpsertRequest {
73
+ v: typeof TURN_IPC_PROTOCOL_VERSION
74
+ meshId: string
75
+ /** Omitted = create a new mission; present = update (must resolve to an existing mission — see mesh-missions.ts MISSION-UPSERT-SILENT-CREATE). */
76
+ id?: string
77
+ title: string
78
+ /** Free text — see file-header note on why this is fine over local IPC. */
79
+ goal?: string
80
+ status?: MeshMissionStatusValue
81
+ /**
82
+ * H2: `undefined` = do not touch the stored brief. `null` = explicitly clear it.
83
+ * A present object with no usable `goal` is treated by the daemon's own
84
+ * `normalizeMissionBrief` as "no brief" (see mesh-missions.ts upsertMeshMission) —
85
+ * this wire guard only checks SHAPE, not that decision.
86
+ */
87
+ brief?: MissionBriefWire | null
88
+ }
89
+
90
+ export interface MeshMissionRecordWire {
91
+ id: string
92
+ meshId: string
93
+ title: string
94
+ goal: string
95
+ status: MeshMissionStatusValue
96
+ /** H2: absent = no brief attached to this mission. */
97
+ brief?: MissionBriefWire
98
+ }
99
+
100
+ export interface MissionUpsertResponse {
101
+ mission: MeshMissionRecordWire
102
+ }
103
+
104
+ function isMeshMissionRecordWire(value: unknown): value is MeshMissionRecordWire {
105
+ if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'meshId', 'title', 'goal', 'status', 'brief'])) return false
106
+ if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
107
+ if (typeof value.title !== 'string' || typeof value.goal !== 'string') return false
108
+ if (!isMeshMissionStatusValue(value.status)) return false
109
+ if (value.brief !== undefined && !isMissionBriefWire(value.brief)) return false
110
+ return true
111
+ }
112
+
113
+ function isMissionUpsertRequest(value: unknown): value is MissionUpsertRequest {
114
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'id', 'title', 'goal', 'status', 'brief'])) return false
115
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
116
+ if (!isEvidenceIdentifier(value.meshId)) return false
117
+ if (value.id !== undefined && !isEvidenceIdentifier(value.id)) return false
118
+ if (typeof value.title !== 'string' || value.title.trim().length === 0) return false
119
+ if (value.goal !== undefined && typeof value.goal !== 'string') return false
120
+ if (value.status !== undefined && !isMeshMissionStatusValue(value.status)) return false
121
+ if (value.brief !== undefined && value.brief !== null && !isMissionBriefWire(value.brief)) return false
122
+ return true
123
+ }
124
+
125
+ export function decodeMissionUpsertRequest(value: unknown): MissionUpsertRequest | null {
126
+ return isMissionUpsertRequest(value) ? value : null
127
+ }
128
+
129
+ export function isMissionUpsertResponse(value: unknown): value is MissionUpsertResponse {
130
+ return isRecord(value) && hasOnlyKeys(value, ['mission']) && isMeshMissionRecordWire(value.mission)
131
+ }
132
+
133
+ export function decodeMissionUpsertResponse(value: unknown): MissionUpsertResponse | null {
134
+ return isMissionUpsertResponse(value) ? value : null
135
+ }
136
+
137
+ export interface MissionQueryRequest {
138
+ v: typeof TURN_IPC_PROTOCOL_VERSION
139
+ meshId: string
140
+ /** Omitted = every status. */
141
+ statuses?: readonly MeshMissionStatusValue[]
142
+ /** Single-mission lookup (mesh-missions.ts `getMeshMission`'s shape) — mutually exclusive with `statuses`. */
143
+ id?: string
144
+ }
145
+
146
+ export interface MissionQueryResponse {
147
+ missions: readonly MeshMissionRecordWire[]
148
+ }
149
+
150
+ function isMissionQueryRequest(value: unknown): value is MissionQueryRequest {
151
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'statuses', 'id'])) return false
152
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
153
+ if (!isEvidenceIdentifier(value.meshId)) return false
154
+ if (value.id !== undefined && !isEvidenceIdentifier(value.id)) return false
155
+ if (value.statuses !== undefined) {
156
+ if (!Array.isArray(value.statuses) || value.statuses.length === 0) return false
157
+ if (!value.statuses.every(isMeshMissionStatusValue)) return false
158
+ }
159
+ return true
160
+ }
161
+
162
+ export function decodeMissionQueryRequest(value: unknown): MissionQueryRequest | null {
163
+ return isMissionQueryRequest(value) ? value : null
164
+ }
165
+
166
+ function isMissionQueryResponse(value: unknown): value is MissionQueryResponse {
167
+ return isRecord(value) && hasOnlyKeys(value, ['missions'])
168
+ && Array.isArray(value.missions) && value.missions.every(isMeshMissionRecordWire)
169
+ }
170
+
171
+ export function decodeMissionQueryResponse(value: unknown): MissionQueryResponse | null {
172
+ return isMissionQueryResponse(value) ? value : null
173
+ }
174
+
175
+ // ─── mission_list_query ─────────────────────────────────────────────────────
176
+ //
177
+ // Wiring-unification Phase C, workstream C-W9b.
178
+ //
179
+ // `mesh-tools-mission.ts`'s own file-header note (2026-09-24, C-W6) flagged
180
+ // this exact gap: `mission_query`'s landed response is `{missions:
181
+ // MeshMissionRecordWire[]}` only — no `verbose`/`withStats`/
182
+ // `limit`/`truncated`/`overflowIds`/`historyFold`, all of which
183
+ // `listMeshMissionsForTool` (daemon-core `mesh-missions.ts`) computes for the
184
+ // `mesh_mission_list` tool. Rather than widen `mission_query` itself (an
185
+ // already-landed, already-tested contract other callers rely on for its
186
+ // narrow shape), this is an ADDITIVE sibling command carrying
187
+ // `listMeshMissionsForTool`'s full output shape — request/response fields
188
+ // mirror its options/`MeshMissionListResult` one-for-one (see
189
+ // mesh-missions.ts). `goal`/`goalPreview` are free text, same local-IPC
190
+ // rationale as `mission_upsert`'s `goal` above.
191
+
192
+ export interface MissionListQueryRequest {
193
+ v: typeof TURN_IPC_PROTOCOL_VERSION
194
+ meshId: string
195
+ statuses?: readonly MeshMissionStatusValue[]
196
+ verbose?: boolean
197
+ withStats?: boolean
198
+ limit?: number
199
+ historyIdLimit?: number
200
+ /**
201
+ * The mesh_status mission projection, computed in the daemon (the MCP no longer
202
+ * opens the daemon's store for it): 'compact' = `getMeshStatusMissionsCompact`
203
+ * (live missions goal-elided + folded history), 'verbose' =
204
+ * `getMeshStatusMissionSummaries({verbose: true})` (live + capped history, full
205
+ * goal, no stats — stats ride one batched `task_stats_query`). Rows then also
206
+ * carry `createdAt`/`updatedAt`/`closeCandidateEmittedAt`. When set, every other
207
+ * option except `historyIdLimit` is ignored. An older daemon rejects the key.
208
+ */
209
+ meshStatusView?: 'compact' | 'verbose'
210
+ }
211
+
212
+ export interface MeshMissionTaskAggregateWire {
213
+ total: number
214
+ pending: number
215
+ assigned: number
216
+ completed: number
217
+ failed: number
218
+ cancelled: number
219
+ blocked: number
220
+ lastActivityAt: string | null
221
+ }
222
+
223
+ export interface MeshMissionStatsWire {
224
+ missionId: string
225
+ taskCount: number
226
+ completed: number
227
+ failed: number
228
+ totalDurationMs: number
229
+ wallClockMs: number | null
230
+ retries: number
231
+ incompleteTaskIds: readonly string[]
232
+ }
233
+
234
+ /** Verbose (full-goal) mission row. `goal` present, `goalPreview`/`goalTruncated` absent. */
235
+ export interface MissionListSummaryVerboseWire {
236
+ id: string
237
+ meshId: string
238
+ title: string
239
+ goal: string
240
+ status: MeshMissionStatusValue
241
+ tasks: MeshMissionTaskAggregateWire
242
+ stats?: MeshMissionStatsWire
243
+ /** H2: absent = no brief attached. */
244
+ brief?: MissionBriefWire
245
+ /** `meshStatusView` rows only. */
246
+ createdAt?: string
247
+ updatedAt?: string
248
+ closeCandidateEmittedAt?: string | null
249
+ }
250
+
251
+ /** Compact (default) mission row. `goalPreview`/`goalTruncated` present, `goal` absent. */
252
+ export interface MissionListSummarySlimWire {
253
+ id: string
254
+ meshId: string
255
+ title: string
256
+ goalPreview: string
257
+ goalTruncated: boolean
258
+ status: MeshMissionStatusValue
259
+ tasks: MeshMissionTaskAggregateWire
260
+ stats?: MeshMissionStatsWire
261
+ /** H2: absent = no brief attached. */
262
+ brief?: MissionBriefWire
263
+ /** `meshStatusView` rows only. */
264
+ createdAt?: string
265
+ updatedAt?: string
266
+ closeCandidateEmittedAt?: string | null
267
+ }
268
+
269
+ export type MissionListSummaryWire = MissionListSummaryVerboseWire | MissionListSummarySlimWire
270
+
271
+ export interface MeshMissionHistoryFoldWire {
272
+ count: number
273
+ byStatus: Record<string, number>
274
+ missionIds: readonly string[]
275
+ note: string
276
+ }
277
+
278
+ export interface MissionListQueryResponse {
279
+ missions: readonly MissionListSummaryWire[]
280
+ historyFold: MeshMissionHistoryFoldWire | null
281
+ truncated: boolean
282
+ matched: number
283
+ overflowIds?: readonly string[]
284
+ }
285
+
286
+ function isMissionListQueryRequest(value: unknown): value is MissionListQueryRequest {
287
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'statuses', 'verbose', 'withStats', 'limit', 'historyIdLimit', 'meshStatusView'])) return false
288
+ if (value.meshStatusView !== undefined && value.meshStatusView !== 'compact' && value.meshStatusView !== 'verbose') return false
289
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
290
+ if (!isEvidenceIdentifier(value.meshId)) return false
291
+ if (value.statuses !== undefined) {
292
+ if (!Array.isArray(value.statuses) || value.statuses.length === 0) return false
293
+ if (!value.statuses.every(isMeshMissionStatusValue)) return false
294
+ }
295
+ if (value.verbose !== undefined && typeof value.verbose !== 'boolean') return false
296
+ if (value.withStats !== undefined && typeof value.withStats !== 'boolean') return false
297
+ if (value.limit !== undefined && !isNonNegativeInt(value.limit)) return false
298
+ if (value.historyIdLimit !== undefined && !isNonNegativeInt(value.historyIdLimit)) return false
299
+ return true
300
+ }
301
+
302
+ export function decodeMissionListQueryRequest(value: unknown): MissionListQueryRequest | null {
303
+ return isMissionListQueryRequest(value) ? value : null
304
+ }
305
+
306
+ function isMeshMissionTaskAggregateWire(value: unknown): value is MeshMissionTaskAggregateWire {
307
+ const keys = ['total', 'pending', 'assigned', 'completed', 'failed', 'cancelled', 'blocked', 'lastActivityAt'] as const
308
+ if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
309
+ for (const k of ['total', 'pending', 'assigned', 'completed', 'failed', 'cancelled', 'blocked'] as const) {
310
+ if (!isNonNegativeInt(value[k])) return false
311
+ }
312
+ return value.lastActivityAt === null || typeof value.lastActivityAt === 'string'
313
+ }
314
+
315
+ function isMeshMissionStatsWire(value: unknown): value is MeshMissionStatsWire {
316
+ const keys = ['missionId', 'taskCount', 'completed', 'failed', 'totalDurationMs', 'wallClockMs', 'retries', 'incompleteTaskIds'] as const
317
+ if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
318
+ if (!isEvidenceIdentifier(value.missionId)) return false
319
+ for (const k of ['taskCount', 'completed', 'failed', 'totalDurationMs', 'retries'] as const) {
320
+ if (!isNonNegativeInt(value[k])) return false
321
+ }
322
+ if (value.wallClockMs !== null && !isNonNegativeInt(value.wallClockMs)) return false
323
+ if (!Array.isArray(value.incompleteTaskIds) || !value.incompleteTaskIds.every(isEvidenceIdentifier)) return false
324
+ return true
325
+ }
326
+
327
+ function isMissionListSummaryWire(value: unknown): value is MissionListSummaryWire {
328
+ if (!isRecord(value)) return false
329
+ const hasGoal = 'goal' in value
330
+ const hasPreview = 'goalPreview' in value && 'goalTruncated' in value
331
+ if (hasGoal === hasPreview) return false // exactly one of the two shapes
332
+ const baseKeys = ['id', 'meshId', 'title', 'status', 'tasks', 'stats', 'brief', 'createdAt', 'updatedAt', 'closeCandidateEmittedAt'] as const
333
+ const allowed = hasGoal ? [...baseKeys, 'goal'] : [...baseKeys, 'goalPreview', 'goalTruncated']
334
+ if (!hasOnlyKeys(value, allowed)) return false
335
+ if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
336
+ if (typeof value.title !== 'string') return false
337
+ if (!isMeshMissionStatusValue(value.status)) return false
338
+ if (!isMeshMissionTaskAggregateWire(value.tasks)) return false
339
+ if (value.stats !== undefined && !isMeshMissionStatsWire(value.stats)) return false
340
+ if (value.brief !== undefined && !isMissionBriefWire(value.brief)) return false
341
+ if (hasGoal && typeof value.goal !== 'string') return false
342
+ if (hasPreview && (typeof value.goalPreview !== 'string' || typeof value.goalTruncated !== 'boolean')) return false
343
+ if (value.createdAt !== undefined && typeof value.createdAt !== 'string') return false
344
+ if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string') return false
345
+ if (value.closeCandidateEmittedAt !== undefined && value.closeCandidateEmittedAt !== null && typeof value.closeCandidateEmittedAt !== 'string') return false
346
+ return true
347
+ }
348
+
349
+ function isMeshMissionHistoryFoldWire(value: unknown): value is MeshMissionHistoryFoldWire {
350
+ if (!isRecord(value) || !hasOnlyKeys(value, ['count', 'byStatus', 'missionIds', 'note'])) return false
351
+ if (!isNonNegativeInt(value.count)) return false
352
+ if (!isRecord(value.byStatus) || !Object.values(value.byStatus).every((n) => isNonNegativeInt(n))) return false
353
+ if (!Array.isArray(value.missionIds) || !value.missionIds.every(isEvidenceIdentifier)) return false
354
+ return typeof value.note === 'string'
355
+ }
356
+
357
+ function isMissionListQueryResponse(value: unknown): value is MissionListQueryResponse {
358
+ if (!isRecord(value) || !hasOnlyKeys(value, ['missions', 'historyFold', 'truncated', 'matched', 'overflowIds'])) return false
359
+ if (!Array.isArray(value.missions) || !value.missions.every(isMissionListSummaryWire)) return false
360
+ if (value.historyFold !== null && !isMeshMissionHistoryFoldWire(value.historyFold)) return false
361
+ if (typeof value.truncated !== 'boolean') return false
362
+ if (!isNonNegativeInt(value.matched)) return false
363
+ if (value.overflowIds !== undefined && (!Array.isArray(value.overflowIds) || !value.overflowIds.every(isEvidenceIdentifier))) return false
364
+ return true
365
+ }
366
+
367
+ export function decodeMissionListQueryResponse(value: unknown): MissionListQueryResponse | null {
368
+ return isMissionListQueryResponse(value) ? value : null
369
+ }
370
+
371
+ // ─── note_upsert / note_forget ──────────────────────────────────────────────
372
+ //
373
+ // Decision (2026-09-24, C-W6b; implemented C-W8): coordinator operating notes
374
+ // get their own two local-IPC commands. A note's text is coordinator-authored
375
+ // free text, so — exactly like mission_upsert's `goal` — it cannot ride
376
+ // `mesh_record`'s scalar allow-list, and it never needs to: notes live in the
377
+ // owning daemon's `mesh_operating_notes` table (local-only; never on
378
+ // `mesh.<id>.events`). The same "local IPC is inside the boundary" rationale as
379
+ // the mission commands above applies.
380
+
381
+ export const OPERATING_NOTE_CATEGORIES = ['provider_quirk', 'pattern_to_avoid', 'recovery_lesson'] as const
382
+ export type OperatingNoteCategory = typeof OPERATING_NOTE_CATEGORIES[number]
383
+ const isOperatingNoteCategory = makeGuard(OPERATING_NOTE_CATEGORIES)
384
+
385
+ export interface NoteUpsertRequest {
386
+ v: typeof TURN_IPC_PROTOCOL_VERSION
387
+ meshId: string
388
+ /** Free text (local IPC — see the section note). */
389
+ text: string
390
+ category?: OperatingNoteCategory
391
+ pinned?: boolean
392
+ /** Explicit expiry, ISO-8601. */
393
+ expiresAt?: string
394
+ /** Note id (or subject key) this note supersedes. */
395
+ supersedes?: string
396
+ subjectKey?: string
397
+ /** Best-effort identity of the recording coordinator (session id / daemon id / host). */
398
+ sourceCoordinator?: string
399
+ }
400
+
401
+ export interface NoteUpsertResponse {
402
+ noteId: string
403
+ /** True when the same text was already among the recent live notes (no new note). */
404
+ deduped: boolean
405
+ createdAt: string
406
+ }
407
+
408
+ function isNoteUpsertRequest(value: unknown): value is NoteUpsertRequest {
409
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'text', 'category', 'pinned', 'expiresAt', 'supersedes', 'subjectKey', 'sourceCoordinator'])) return false
410
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
411
+ if (!isEvidenceIdentifier(value.meshId)) return false
412
+ if (typeof value.text !== 'string' || value.text.trim().length === 0) return false
413
+ if (value.category !== undefined && !isOperatingNoteCategory(value.category)) return false
414
+ if (value.pinned !== undefined && typeof value.pinned !== 'boolean') return false
415
+ if (value.expiresAt !== undefined && (typeof value.expiresAt !== 'string' || Number.isNaN(Date.parse(value.expiresAt)))) return false
416
+ return isOptionalShortString(value.supersedes) && isOptionalShortString(value.subjectKey) && isOptionalShortString(value.sourceCoordinator)
417
+ }
418
+
419
+ export function decodeNoteUpsertRequest(value: unknown): NoteUpsertRequest | null {
420
+ return isNoteUpsertRequest(value) ? value : null
421
+ }
422
+
423
+ function isNoteUpsertResponse(value: unknown): value is NoteUpsertResponse {
424
+ return isRecord(value) && hasOnlyKeys(value, ['noteId', 'deduped', 'createdAt'])
425
+ && isEvidenceIdentifier(value.noteId) && typeof value.deduped === 'boolean' && typeof value.createdAt === 'string'
426
+ }
427
+
428
+ export function decodeNoteUpsertResponse(value: unknown): NoteUpsertResponse | null {
429
+ return isNoteUpsertResponse(value) ? value : null
430
+ }
431
+
432
+ export interface NoteForgetRequest {
433
+ v: typeof TURN_IPC_PROTOCOL_VERSION
434
+ meshId: string
435
+ /** At least one of `noteId` / `text` (a text forget retracts every note with that exact text, and any recorded later). */
436
+ noteId?: string
437
+ text?: string
438
+ reason?: string
439
+ }
440
+
441
+ export interface NoteForgetResponse {
442
+ /** Live notes this forget hid. */
443
+ matched: number
444
+ tombstoneId: string
445
+ }
446
+
447
+ function isNoteForgetRequest(value: unknown): value is NoteForgetRequest {
448
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'noteId', 'text', 'reason'])) return false
449
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
450
+ if (!isEvidenceIdentifier(value.meshId)) return false
451
+ if (value.noteId !== undefined && !isEvidenceIdentifier(value.noteId)) return false
452
+ if (value.text !== undefined && (typeof value.text !== 'string' || value.text.trim().length === 0)) return false
453
+ if (value.noteId === undefined && value.text === undefined) return false
454
+ return value.reason === undefined || typeof value.reason === 'string'
455
+ }
456
+
457
+ export function decodeNoteForgetRequest(value: unknown): NoteForgetRequest | null {
458
+ return isNoteForgetRequest(value) ? value : null
459
+ }
460
+
461
+ function isNoteForgetResponse(value: unknown): value is NoteForgetResponse {
462
+ return isRecord(value) && hasOnlyKeys(value, ['matched', 'tombstoneId'])
463
+ && typeof value.matched === 'number' && Number.isInteger(value.matched) && value.matched >= 0
464
+ && isEvidenceIdentifier(value.tombstoneId)
465
+ }
466
+
467
+ export function decodeNoteForgetResponse(value: unknown): NoteForgetResponse | null {
468
+ return isNoteForgetResponse(value) ? value : null
469
+ }
470
+
471
+ // ─── tool_call_record ───────────────────────────────────────────────────────
472
+ //
473
+ // Wiring-unification Phase C, workstream C-W9b
474
+ // (docs/design/2026-09-23-wiring-unification.md §5 C2 "MCP server" paragraph,
475
+ // 2026-09-24 14:00 stamp "C-W9").
476
+ //
477
+ // Replaces `recordMeshToolCall`/`recordMeshCoordinatorToolCall`
478
+ // (`daemon-core/src/mesh/mesh-direct-dispatch.ts`) — an in-process,
479
+ // per-mesh-tool-call rate-limit/advisory bump mcp-server called directly
480
+ // against its own store handle. Every field is an identifier, a closed enum,
481
+ // a boolean or a counter — squarely inside `mesh_record`'s content boundary,
482
+ // but this is its own command (not a `mesh_record` payload) because it is a
483
+ // COUNTER MUTATION with a computed return value (`rateLimitExceeded`,
484
+ // `callsInWindow`, `advisory`), not an append-only scalar record.
485
+
486
+ export const TOOL_CALLER_ROLES = ['coordinator', 'unknown'] as const
487
+ export type ToolCallerRole = typeof TOOL_CALLER_ROLES[number]
488
+ const isToolCallerRole = makeGuard(TOOL_CALLER_ROLES)
489
+
490
+ export interface ToolCallRecordRequest {
491
+ v: typeof TURN_IPC_PROTOCOL_VERSION
492
+ meshId: string
493
+ /** The mesh tool name, e.g. `mesh_status` (identifier, not free text — always a fixed tool-name string). */
494
+ tool: string
495
+ sessionId?: string
496
+ callerRole: ToolCallerRole
497
+ }
498
+
499
+ export interface ToolCallRecordResponse {
500
+ rateLimitExceeded: boolean
501
+ callsInWindow: number
502
+ /** Short machine-oriented advisory string, or null. Never free text describing content. */
503
+ advisory: string | null
504
+ }
505
+
506
+ function isToolCallRecordRequest(value: unknown): value is ToolCallRecordRequest {
507
+ return isRecord(value) && hasOnlyKeys(value, ['v', 'meshId', 'tool', 'sessionId', 'callerRole'])
508
+ && value.v === TURN_IPC_PROTOCOL_VERSION
509
+ && isEvidenceIdentifier(value.meshId)
510
+ && isEvidenceIdentifier(value.tool)
511
+ && isOptionalId(value.sessionId)
512
+ && isToolCallerRole(value.callerRole)
513
+ }
514
+
515
+ export function decodeToolCallRecordRequest(value: unknown): ToolCallRecordRequest | null {
516
+ return isToolCallRecordRequest(value) ? value : null
517
+ }
518
+
519
+ function isToolCallRecordResponse(value: unknown): value is ToolCallRecordResponse {
520
+ return isRecord(value) && hasOnlyKeys(value, ['rateLimitExceeded', 'callsInWindow', 'advisory'])
521
+ && typeof value.rateLimitExceeded === 'boolean'
522
+ && isNonNegativeInt(value.callsInWindow)
523
+ && (value.advisory === null || typeof value.advisory === 'string')
524
+ }
525
+
526
+ export function decodeToolCallRecordResponse(value: unknown): ToolCallRecordResponse | null {
527
+ return isToolCallRecordResponse(value) ? value : null
528
+ }
529
+
530
+ // ─── ledger_query ───────────────────────────────────────────────────────────
531
+ //
532
+ // Wiring-unification Phase C, workstream C-W9b.
533
+ //
534
+ // Replaces the remaining bare `readLedgerEntries(meshId, opts)` /
535
+ // `getLedgerSummary(meshId)` in-process reads that `turn_query` cannot serve
536
+ // (§5 C2's `mesh-tools-mission.ts` file-header note, 2026-09-24: "general
537
+ // ledger browsing by arbitrary `kind`/`since`/`node` filters —
538
+ // `turn_query`'s shape... has no free-form kind-list or node filter, so this
539
+ // is a real API mismatch, not a mechanical rename"). `turn_query` stays
540
+ // scoped to the turn-ledger's own attempt/event rows; `ledger_query` is the
541
+ // general record browse surface (C-W9a: the daemon's `mesh_local_records` plus
542
+ // the turn ledger's task outcomes, `readLocalRecords`) `meshTaskHistory` /
543
+ // `meshLedgerQuery` need.
544
+ //
545
+ // CONTENT BOUNDARY: `MeshLedgerEntry.payload` (daemon-core `mesh-ledger.ts`)
546
+ // is `Record<string, unknown>` — genuinely unbounded JSON (a graph plan, a free-text checkpoint message), NOT constrained to
547
+ // `mesh_record`'s scalar allow-list. This is fine for the SAME reason
548
+ // `mission_upsert`'s `goal` and `note_upsert`'s `text` are fine (see that
549
+ // section's note above): this is local IPC between mcp-server and the
550
+ // daemon that owns the ledger, both on the operator's own machine — never a
551
+ // cross-machine or server hop. `entryPayload` below is therefore an
552
+ // intentionally-unvalidated `Record<string, unknown>` passthrough, exactly
553
+ // like `MissionUpsertRequest.goal` is unvalidated free text.
554
+
555
+ export interface LedgerQueryRequest {
556
+ v: typeof TURN_IPC_PROTOCOL_VERSION
557
+ meshId: string
558
+ /** One or more ledger `kind` strings (daemon-core `MeshLedgerKind`; mesh-shared cannot import that union, so these are identifiers here). Omitted = every kind. */
559
+ kind?: readonly string[]
560
+ /** ISO-8601 or epoch-ms-as-string — passed through to `readLedgerEntries`'s `since` (parsed via `new Date()`). */
561
+ since?: string
562
+ /** Filter to entries whose nodeId is equivalent to this daemon id (matched via `daemonIdsEquivalent` daemon-side, not raw `===`). */
563
+ node?: string
564
+ tail?: number
565
+ /** 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). */
566
+ includeSummary?: boolean
567
+ }
568
+
569
+ export interface LedgerQueryEntryWire {
570
+ id: string
571
+ meshId: string
572
+ timestamp: string
573
+ kind: string
574
+ nodeId?: string
575
+ sessionId?: string
576
+ providerType?: string
577
+ taskId?: string
578
+ /** Unbounded JSON passthrough — see file section's CONTENT BOUNDARY note. */
579
+ payload: Record<string, unknown>
580
+ }
581
+
582
+ export interface LedgerQuerySummaryWire {
583
+ meshId: string
584
+ totalEntries: number
585
+ taskDispatched: number
586
+ taskCompleted: number
587
+ taskFailed: number
588
+ taskStalled: number
589
+ sessionLaunched: number
590
+ checkpointCreated: number
591
+ lastActivityAt: string | null
592
+ recentFailures: number
593
+ }
594
+
595
+ export interface LedgerQueryResponse {
596
+ entries: readonly LedgerQueryEntryWire[]
597
+ summary?: LedgerQuerySummaryWire
598
+ }
599
+
600
+ function isLedgerQueryRequest(value: unknown): value is LedgerQueryRequest {
601
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'kind', 'since', 'node', 'tail', 'includeSummary'])) return false
602
+ if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
603
+ if (!isEvidenceIdentifier(value.meshId)) return false
604
+ if (value.kind !== undefined && (!Array.isArray(value.kind) || value.kind.length === 0 || !value.kind.every(isEvidenceIdentifier))) return false
605
+ if (value.since !== undefined && typeof value.since !== 'string') return false
606
+ if (!isOptionalId(value.node)) return false
607
+ if (value.tail !== undefined && !isNonNegativeInt(value.tail)) return false
608
+ if (value.includeSummary !== undefined && typeof value.includeSummary !== 'boolean') return false
609
+ return true
610
+ }
611
+
612
+ export function decodeLedgerQueryRequest(value: unknown): LedgerQueryRequest | null {
613
+ return isLedgerQueryRequest(value) ? value : null
614
+ }
615
+
616
+ const LEDGER_QUERY_ENTRY_KEYS = ['id', 'meshId', 'timestamp', 'kind', 'nodeId', 'sessionId', 'providerType', 'taskId', 'payload'] as const
617
+
618
+ function isLedgerQueryEntryWire(value: unknown): value is LedgerQueryEntryWire {
619
+ if (!isRecord(value) || !hasOnlyKeys(value, LEDGER_QUERY_ENTRY_KEYS)) return false
620
+ if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
621
+ if (typeof value.timestamp !== 'string' || typeof value.kind !== 'string') return false
622
+ if (!isOptionalId(value.nodeId) || !isOptionalId(value.sessionId) || !isOptionalId(value.providerType) || !isOptionalId(value.taskId)) return false
623
+ if (!isRecord(value.payload)) return false
624
+ return true
625
+ }
626
+
627
+ function isLedgerQuerySummaryWire(value: unknown): value is LedgerQuerySummaryWire {
628
+ const keys = ['meshId', 'totalEntries', 'taskDispatched', 'taskCompleted', 'taskFailed', 'taskStalled', 'sessionLaunched', 'checkpointCreated', 'lastActivityAt', 'recentFailures'] as const
629
+ if (!isRecord(value) || !hasOnlyKeys(value, keys)) return false
630
+ if (!isEvidenceIdentifier(value.meshId)) return false
631
+ for (const k of ['totalEntries', 'taskDispatched', 'taskCompleted', 'taskFailed', 'taskStalled', 'sessionLaunched', 'checkpointCreated', 'recentFailures'] as const) {
632
+ if (!isNonNegativeInt(value[k])) return false
633
+ }
634
+ if (value.lastActivityAt !== null && typeof value.lastActivityAt !== 'string') return false
635
+ return true
636
+ }
637
+
638
+ function isLedgerQueryResponse(value: unknown): value is LedgerQueryResponse {
639
+ if (!isRecord(value) || !hasOnlyKeys(value, ['entries', 'summary'])) return false
640
+ if (!Array.isArray(value.entries) || !value.entries.every(isLedgerQueryEntryWire)) return false
641
+ if (value.summary !== undefined && !isLedgerQuerySummaryWire(value.summary)) return false
642
+ return true
643
+ }
644
+
645
+ export function decodeLedgerQueryResponse(value: unknown): LedgerQueryResponse | null {
646
+ return isLedgerQueryResponse(value) ? value : null
647
+ }