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

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