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

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 (53) hide show
  1. package/dist/brain-routing.d.ts +2 -2
  2. package/dist/index.d.ts +11 -0
  3. package/dist/index.js +3156 -48
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2827 -45
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/interpolation.d.ts +0 -9
  8. package/dist/keyed-doc-delta.d.ts +105 -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 +67 -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 +379 -0
  16. package/dist/protocol/dashboard-daemon-p2p.d.ts +213 -0
  17. package/dist/protocol/dashboard-server.d.ts +240 -0
  18. package/dist/protocol/dashboard-wire-version.d.ts +21 -0
  19. package/dist/protocol/envelope.d.ts +55 -0
  20. package/dist/protocol/index.d.ts +16 -0
  21. package/dist/session-launch.d.ts +113 -0
  22. package/dist/session-status.d.ts +75 -0
  23. package/dist/slot-proposal.d.ts +1 -1
  24. package/dist/turn-evidence.d.ts +434 -0
  25. package/dist/turn-ipc.d.ts +1040 -0
  26. package/dist/types.d.ts +11 -0
  27. package/dist/worker-protocol.d.ts +69 -0
  28. package/dist/ws-protocol.d.ts +69 -41
  29. package/package.json +1 -1
  30. package/src/brain-routing.ts +3 -3
  31. package/src/index.ts +11 -0
  32. package/src/interpolation.ts +0 -18
  33. package/src/keyed-doc-delta.ts +413 -0
  34. package/src/magi.ts +1 -1
  35. package/src/mesh-tool-names.ts +65 -19
  36. package/src/mesh-vocabulary.ts +95 -0
  37. package/src/mission-brief.ts +153 -0
  38. package/src/outbound-message.ts +320 -0
  39. package/src/path-ownership.ts +271 -0
  40. package/src/protocol/daemon-server.ts +541 -0
  41. package/src/protocol/dashboard-daemon-p2p.ts +273 -0
  42. package/src/protocol/dashboard-server.ts +340 -0
  43. package/src/protocol/dashboard-wire-version.ts +27 -0
  44. package/src/protocol/envelope.ts +75 -0
  45. package/src/protocol/index.ts +17 -0
  46. package/src/session-launch.ts +247 -0
  47. package/src/session-status.ts +164 -0
  48. package/src/slot-proposal.ts +1 -1
  49. package/src/turn-evidence.ts +596 -0
  50. package/src/turn-ipc.ts +2330 -0
  51. package/src/types.ts +8 -0
  52. package/src/worker-protocol.ts +164 -0
  53. package/src/ws-protocol.ts +98 -93
@@ -0,0 +1,596 @@
1
+ /**
2
+ * turn-evidence — the ONE vocabulary for "what happened to a turn".
3
+ *
4
+ * Wiring-unification Phase C1 (docs/design/2026-09-23-wiring-unification.md §5).
5
+ *
6
+ * Every producer that today decides "done" on its own (completion flush, stall
7
+ * rescue, reconcile synthesis, MCP probes, worker report tools, …) instead
8
+ * submits one of these evidence records to the turn ledger, whose pure reducer
9
+ * is the only authority for a turn's state. Worker daemons and the MCP server
10
+ * build them, so the types live here (a dependency-free leaf).
11
+ *
12
+ * CONTENT-FREE BY CONSTRUCTION
13
+ * ----------------------------
14
+ * Evidence crosses machines (seqscribe `mesh.<id>.events` replication) and must
15
+ * never carry anything a user or an agent wrote. Every field is one of: an
16
+ * identifier, a closed enum, a boolean, a number, or a `SummaryRef` — a pointer
17
+ * `{topic, writer, seq}` to a content-class topic entry (`mesh.<id>.handoff`)
18
+ * where the text actually lives. `TURN_EVIDENCE_FIELD_SPECS` declares the class
19
+ * of every field of every member (the compiler forces it to stay total and
20
+ * exact), the runtime guard validates against it, and a test walks it to prove
21
+ * no free-text field exists.
22
+ */
23
+
24
+ import { WORKER_BRANCH_STATES, WORKER_REPORT_OUTCOMES, type WorkerBranchState, type WorkerReportOutcome } from './mesh-vocabulary'
25
+ import { SESSION_STATUSES, type SessionStatus } from './session-status'
26
+ import { SEND_REFUSAL_REASONS, type SendRefusal } from './outbound-message'
27
+
28
+ // ─── closed vocabularies ─────────────────────────────────────────────────
29
+
30
+ /** Protocol version stamped on every `mesh.<id>.events` entry written by the turn ledger. */
31
+ export const MESH_TOPIC_PROTOCOL_VERSION = 2 as const
32
+
33
+ export const EVIDENCE_SOURCE_IDS = [
34
+ 'fsm_edge', 'short_gen_inline', 'completion_flush_genuine', 'completion_flush_weak',
35
+ 'monitor_final_summary', 'pre_cleanup_transcript', 'stall_transcript', 'startup_grace_collapse',
36
+ 'acp_prompt_response', 'cdp_state', 'approval_gate', 'modal_button',
37
+ 'mesh_stall_watchdog', 'status_monitor', 'pty_exit', 'provider_error',
38
+ 'coordinator_probe', 'mcp_probe', 'worker_tool', 'dispatch', 'input_service',
39
+ 'operator', 'intentional_cleanup', 'scheduler', 'git_probe', 'session_registry',
40
+ ] as const
41
+ export type EvidenceSourceId = typeof EVIDENCE_SOURCE_IDS[number]
42
+
43
+ export const TURN_SCOPES = ['mesh_queue', 'mesh_direct', 'plain'] as const
44
+ export type TurnScope = typeof TURN_SCOPES[number]
45
+
46
+ /**
47
+ * Refusals (D1 `SendRefusal`, owned by ./outbound-message) that mean the target
48
+ * can never take this dispatch, so the attempt is reclaimed (R3). Every other
49
+ * refusal is retried by the sender and only recorded.
50
+ */
51
+ export const RECLAIMING_SEND_REFUSALS = ['session_exited', 'no_target', 'unsupported_input'] as const satisfies readonly SendRefusal[]
52
+ export type ReclaimingSendRefusal = typeof RECLAIMING_SEND_REFUSALS[number]
53
+
54
+ export const DELIVERY_OUTCOMES = ['delivered', 'queued'] as const
55
+ export type DeliveryOutcome = typeof DELIVERY_OUTCOMES[number]
56
+ export const DELIVERY_VIAS = ['local', 'p2p'] as const
57
+ export type DeliveryVia = typeof DELIVERY_VIAS[number]
58
+
59
+ export const DISPATCH_FAILURE_REASONS = ['worker_absent', 'transport_error', 'spawn_failed', 'rejected_by_worker', 'timeout'] as const
60
+ export type DispatchFailureReason = typeof DISPATCH_FAILURE_REASONS[number]
61
+
62
+ /**
63
+ * Normalize a worker's own refusal code (e.g. `session_busy_with_task`,
64
+ * `mesh_sender_not_on_roster`, `provider_quota_exhausted`) into the shape
65
+ * `dispatch_failed.refusalCode` accepts: lowercase ascii + underscore, capped
66
+ * at 64 chars. Returns undefined for anything that yields no usable token, so
67
+ * a malformed/foreign code degrades to "no refusalCode" rather than smuggling
68
+ * arbitrary text through what must stay a closed-enum-like identifier slot
69
+ * (see the field's doc comment — this evidence replicates cross-machine).
70
+ */
71
+ export function sanitizeRefusalCode(value: unknown): string | undefined {
72
+ if (typeof value !== 'string') return undefined
73
+ const normalized = value.trim().toLowerCase().replace(/[^a-z_]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '')
74
+ if (!normalized) return undefined
75
+ return normalized.slice(0, 64)
76
+ }
77
+
78
+ export const CONSUME_PROFILES = ['default', 'native_source'] as const
79
+ export type ConsumeProfile = typeof CONSUME_PROFILES[number]
80
+
81
+ export const SESSION_REBOUND_REASONS = ['restart', 'live_holder'] as const
82
+ export type SessionReboundReason = typeof SESSION_REBOUND_REASONS[number]
83
+
84
+ export const SUSPENSION_MODALS = ['approval', 'choice'] as const
85
+ export type SuspensionModal = typeof SUSPENSION_MODALS[number]
86
+ export const SUSPENSION_RESOLUTIONS = ['approved', 'rejected', 'answered'] as const
87
+ export type SuspensionResolution = typeof SUSPENSION_RESOLUTIONS[number]
88
+ export const SUSPENSION_RESOLUTION_VIAS = ['modal_button', 'auto_approve', 'prompt_answer'] as const
89
+ export type SuspensionResolutionVia = typeof SUSPENSION_RESOLUTION_VIAS[number]
90
+
91
+ export const TURN_END_STRENGTHS = ['genuine', 'weak'] as const
92
+ export type TurnEndStrength = typeof TURN_END_STRENGTHS[number]
93
+
94
+ /** Why a provider-side weak end was released (was free text `blockReason`). */
95
+ export const TURN_END_BLOCK_REASONS = [
96
+ 'missing_final_assistant', 'finalization_timeout', 'terminal_block_hard_cap', 'decoupled_completion',
97
+ ] as const
98
+ export type TurnEndBlockReason = typeof TURN_END_BLOCK_REASONS[number]
99
+
100
+ export const NATIVE_TURN_OUTCOMES = ['completed', 'aborted'] as const
101
+ export type NativeTurnOutcome = typeof NATIVE_TURN_OUTCOMES[number]
102
+
103
+ export const LIVENESS_RESULTS = ['alive', 'unknown', 'read_failed', 'dead'] as const
104
+ export type LivenessResult = typeof LIVENESS_RESULTS[number]
105
+
106
+ export const PROVIDER_FAILURES = ['auth_failed', 'billing_failed'] as const
107
+ export type ProviderFailure = typeof PROVIDER_FAILURES[number]
108
+
109
+ /**
110
+ * `daemon_restart` (wiring-unification follow-up, design §5): a boot-time
111
+ * reconciliation observes this for a plain attempt whose session is gone
112
+ * after a restart — the daemon never saw the session's own exit, so it is
113
+ * not `provider_error`/`adapter_error`/etc.; it is the daemon itself
114
+ * reporting "this session no longer exists".
115
+ */
116
+ export const SESSION_ERROR_REASONS = ['provider_error', 'adapter_error', 'spawn_failed', 'auth_failed', 'billing_failed', 'unknown', 'daemon_restart'] as const
117
+ export type SessionErrorReason = typeof SESSION_ERROR_REASONS[number]
118
+
119
+ export const CANCEL_REASONS = ['operator_cancel', 'intentional_cleanup', 'task_removed', 'mission_abandoned', 'superseded'] as const
120
+ export type CancelReason = typeof CANCEL_REASONS[number]
121
+
122
+ export const OPERATOR_STATUSES = ['completed', 'failed'] as const
123
+ export type OperatorStatus = typeof OPERATOR_STATUSES[number]
124
+ export const OPERATOR_STATUS_REASONS = ['operator_update', 'refine_terminal', 'validation_terminal'] as const
125
+ export type OperatorStatusReason = typeof OPERATOR_STATUS_REASONS[number]
126
+
127
+ export const COORDINATOR_ACK_OUTCOMES = ['delivered', 'queued', 'duplicate'] as const
128
+ export type CoordinatorAckOutcome = typeof COORDINATOR_ACK_OUTCOMES[number]
129
+
130
+ /** Observed status as a no-progress monitor saw it (canonical session status or `unknown`). */
131
+ export const NO_PROGRESS_OBSERVED_STATUSES = [...SESSION_STATUSES, 'unknown'] as const
132
+ export type NoProgressObservedStatus = SessionStatus | 'unknown'
133
+
134
+ /**
135
+ * Reasons a turn hold exists. Holds are attempt-scoped only (C2: delivery-side
136
+ * waits are consumer deferral on the `turn.deliver` cursor, not hold rows).
137
+ */
138
+ export const HOLD_REASONS = [
139
+ 'await_delivery', 'await_consume', 'await_turn', 'liveness', 'hard_ceiling',
140
+ 'live_pending', 'transcript_quiet', 'weak_candidate', 'suspension_before_consumed',
141
+ // A genuine FSM end of a mesh turn whose worker holds a live worker-MCP bind:
142
+ // the structured report is the primary evidence (design §F2), so the idle edge
143
+ // only opens this hold (R9r) — a report commits (R17), a new busy edge cancels
144
+ // it as a false idle (R12r), expiry commits weak (R13r).
145
+ 'await_report',
146
+ // A worker report recorded while the session is still generating (R17g):
147
+ // the report is the verdict, the idle edge is only awaited as corroboration
148
+ // — a turn_end commits the report (R9t), expiry commits it anyway (R13t) so
149
+ // a session whose FSM never shows the idle edge again does not wait for
150
+ // liveness / hard_ceiling.
151
+ 'await_end',
152
+ ] as const
153
+ export type HoldReason = typeof HOLD_REASONS[number]
154
+
155
+ export const TURN_OUTCOMES = ['completed', 'failed', 'cancelled'] as const
156
+ export type TurnOutcome = typeof TURN_OUTCOMES[number]
157
+
158
+ export const COMMIT_STRENGTHS = ['genuine', 'weak', 'tool_report', 'operator'] as const
159
+ export type CommitStrength = typeof COMMIT_STRENGTHS[number]
160
+
161
+ /** Every terminal or reclaim reason the ledger writes. Closed: no free-text reasons. */
162
+ export const TURN_REASONS = [
163
+ // commits
164
+ 'turn_end', 'weak_end_confirmed', 'transcript_final', 'worker_reported',
165
+ 'finalization_timeout_no_response', 'session_error', 'provider_auth_failed', 'provider_billing_failed',
166
+ 'operator_cancel', 'intentional_cleanup', 'task_removed', 'mission_abandoned', 'superseded',
167
+ 'operator_update', 'refine_terminal', 'validation_terminal',
168
+ 'hard_ceiling', 'reclaim_budget_exhausted', 'hollow_max_retries', 'migration_orphan',
169
+ // reclaims (generation + 1)
170
+ 'dispatch_refused_session_exited', 'dispatch_refused_no_target', 'dispatch_refused_unsupported_input', 'dispatch_failed',
171
+ 'session_exit', 'session_exit_before_turn', 'session_dead', 'hollow_completion',
172
+ 'assigned_stranded_dispatch_unconfirmed', 'delivered_not_consumed_redrive', 'delivered_no_turn_deadline',
173
+ ] as const
174
+ export type TurnReason = typeof TURN_REASONS[number]
175
+
176
+ /**
177
+ * What a `turn.notify` entry tells a coordinator.
178
+ *
179
+ * `late_completion` (C1 R27, owner revision 2026-09-23): a superseded
180
+ * generation finished while the current one was already running; the
181
+ * coordinator decides whether to salvage it (the entry carries the g−1
182
+ * summary as its append `ref`). `mesh_event`: a non-turn coordinator notice
183
+ * (refine terminal, worktree bootstrap, …) whose text lives only in the
184
+ * owner's local `turn_events.payload_json` and is rendered at deliver time.
185
+ */
186
+ export const NOTIFY_KINDS = [
187
+ 'completed', 'failed', 'cancelled', 'stopped', 'approval', 'choice', 'approval_resolved',
188
+ 'candidate', 'no_progress', 'progress', 'late_completion', 'mesh_event',
189
+ ] as const
190
+ export type NotifyKind = typeof NOTIFY_KINDS[number]
191
+
192
+ // ─── shared shapes ───────────────────────────────────────────────────────
193
+
194
+ export interface TurnAttemptRef { attemptId: string; generation: number }
195
+
196
+ /**
197
+ * Pointer to text that lives in a content-class topic entry (`mesh.<id>.handoff`).
198
+ * Text never travels inside evidence; the deliver side renders it from the ref.
199
+ */
200
+ export interface SummaryRef { topic: string; writer: string; seq: number }
201
+
202
+ /** Live-state observations that make a transcript end premature (admission input). */
203
+ export interface LiveTurnPending { modal: boolean; adapterPending: boolean; trailingTool: boolean; newestActivityAt?: number }
204
+
205
+ /** Structural copy of daemon-core's `CoordinatorIdentity` (this package cannot import daemon-core). */
206
+ export interface EvidenceCoordinatorIdentity { daemonId: string; coordinatorRunId: string; sessionId?: string }
207
+
208
+ export interface NativeTurnMarkerRef { outcome: NativeTurnOutcome; turnId?: string }
209
+
210
+ export interface EvidenceEnvelope {
211
+ /** Stable id of this observation; dedupe key across republish/replication. */
212
+ eventId: string
213
+ /** Producer clock, epoch ms. */
214
+ at: number
215
+ source: EvidenceSourceId
216
+ /** The session that produced / is the subject of the observation. */
217
+ sessionId: string
218
+ attemptRef?: TurnAttemptRef
219
+ /** Only when attemptRef is absent (legacy relay / MCP probe). */
220
+ taskId?: string
221
+ /** Daemon id that observed it. */
222
+ observedBy: string
223
+ }
224
+
225
+ /** Kind-specific bodies. `TurnEvidence = EvidenceEnvelope & TurnEvidenceBody`. */
226
+ export type TurnEvidenceBody =
227
+ // ── dispatch / delivery ──
228
+ | { kind: 'dispatch_accepted'; scope: TurnScope; messageId: string; meshId?: string; nodeId?: string; providerType?: string
229
+ attemptNo?: number; dispatchNonce?: number; consumeProfile?: ConsumeProfile; maxTaskRetries?: number
230
+ coordinator?: EvidenceCoordinatorIdentity }
231
+ | { kind: 'delivered'; messageId: string; outcome: DeliveryOutcome; via: DeliveryVia }
232
+ | { kind: 'delivery_refused'; messageId: string; reason: SendRefusal }
233
+ /**
234
+ * `refusalCode` (wiring-unification live-gap fix, 2026-09-25): the worker's own
235
+ * refusal code for an application-level `{success:false}` answer (e.g.
236
+ * `session_busy_with_task`, `mesh_sender_not_on_roster`,
237
+ * `mesh_node_bootstrap_pending`, `provider_quota_exhausted`) — optional and
238
+ * ONLY ever populated for `reason: 'rejected_by_worker'`. Deliberately an `id`
239
+ * (short enum-like token), never free text: this evidence replicates across
240
+ * machines over `mesh.<id>.events` (CONTENT-FREE BY CONSTRUCTION above), so a
241
+ * human-readable detail string must NOT travel here — it stays local (owner
242
+ * WARN log, MCP tool JSON response). Producers sanitize to `[a-z_]{1,64}`
243
+ * before emitting so a stray free-text code can never smuggle prose through
244
+ * this slot.
245
+ */
246
+ | { kind: 'dispatch_failed'; workerAbsent: boolean; reason: DispatchFailureReason; refusalCode?: string }
247
+ | { kind: 'duplicate_dispatch_refusal'; holderSessionId: string; holderAttemptId?: string }
248
+ | { kind: 'session_rebound'; toSessionId: string; reason: SessionReboundReason }
249
+ // ── worker turn lifecycle ──
250
+ | { kind: 'turn_started'; retro: boolean }
251
+ | { kind: 'suspension'; modal: SuspensionModal; modalKey?: string }
252
+ | { kind: 'suspension_resolved'; resolution: SuspensionResolution; via: SuspensionResolutionVia }
253
+ | { kind: 'turn_end'; strength: TurnEndStrength; afterFinalizationTimeout?: boolean; hollow?: boolean; summary?: SummaryRef
254
+ blockReason?: TurnEndBlockReason; releasedByHardCap?: boolean; nativeOutcome?: NativeTurnOutcome; live?: LiveTurnPending
255
+ /**
256
+ * Stamped (true only) by the WORKER's daemon when the session holds a live
257
+ * worker-MCP session bind, i.e. the worker can call report_completion. The
258
+ * owner then treats a genuine end as a report-awaiting candidate (R9r), not
259
+ * a commit. Absent = no reporting surface = today's genuine-end commit.
260
+ */
261
+ reportExpected?: boolean }
262
+ | { kind: 'transcript_final'; selfAttributing: boolean; nativeRead: boolean; nativeMarker?: NativeTurnMarkerRef
263
+ live: LiveTurnPending; summary?: SummaryRef; messageAt?: number }
264
+ | { kind: 'transcript_activity'; newestActivityAt: number }
265
+ | { kind: 'no_progress'; stalledMs: number; observedStatus: NoProgressObservedStatus; finalAssistantPresent: boolean }
266
+ | { kind: 'liveness'; result: LivenessResult }
267
+ | { kind: 'process_exit'; exitCode: number | null; providerFailure?: ProviderFailure }
268
+ | { kind: 'session_error'; reason: SessionErrorReason }
269
+ // ── worker MCP (F2) ──
270
+ /**
271
+ * `summary` is the handoff-topic pointer when the report's text was published;
272
+ * a report the owner daemon accepted itself carries its text in the evidence
273
+ * row's local envelope instead (never published), so the pointer is optional.
274
+ */
275
+ | { kind: 'worker_report'; outcome: WorkerReportOutcome; summary?: SummaryRef; hasHandoffNotes: boolean
276
+ branchState?: WorkerBranchState; touchedFileCount?: number }
277
+ | { kind: 'worker_progress'; note?: SummaryRef }
278
+ // ── coordinator / operator ──
279
+ | { kind: 'git_side_effect'; dirty: boolean; commitsSinceDispatch: number; attributable: boolean }
280
+ | { kind: 'cancel'; reason: CancelReason }
281
+ | { kind: 'operator_status'; status: OperatorStatus; reason: OperatorStatusReason }
282
+ | { kind: 'coordinator_ack'; notify: NotifyKind; outcome: CoordinatorAckOutcome }
283
+ // ── scheduler-generated ──
284
+ | { kind: 'hold_expired'; holdId: string; reason: HoldReason }
285
+
286
+ export type TurnEvidence = EvidenceEnvelope & TurnEvidenceBody
287
+ export type TurnEvidenceKind = TurnEvidenceBody['kind']
288
+ export type TurnEvidenceOf<K extends TurnEvidenceKind> = EvidenceEnvelope & Extract<TurnEvidenceBody, { kind: K }>
289
+
290
+ // ─── field-class registry (drives the guard and the content-free test) ────
291
+
292
+ /**
293
+ * The class of every declared field. There is deliberately NO free-text class:
294
+ * `id` strings are length-bounded and whitespace-free, so prose cannot be
295
+ * smuggled through an identifier slot either.
296
+ */
297
+ export type EvidenceFieldSpec =
298
+ | { t: 'id'; optional?: true }
299
+ | { t: 'enum'; values: readonly string[]; optional?: true }
300
+ | { t: 'bool'; optional?: true }
301
+ | { t: 'int'; optional?: true; nullable?: true }
302
+ | { t: 'ms'; optional?: true }
303
+ | { t: 'summary_ref'; optional?: true }
304
+ | { t: 'attempt_ref'; optional?: true }
305
+ | { t: 'live'; optional?: true }
306
+ | { t: 'native_marker'; optional?: true }
307
+ | { t: 'coordinator'; optional?: true }
308
+
309
+ type FieldsOf<K extends TurnEvidenceKind> = Exclude<keyof Extract<TurnEvidenceBody, { kind: K }>, 'kind'>
310
+ /** Exact per-kind registry: a missing or extra field fails to compile. */
311
+ export type TurnEvidenceFieldSpecs = { readonly [K in TurnEvidenceKind]: { readonly [F in FieldsOf<K>]-?: EvidenceFieldSpec } }
312
+
313
+ const id = { t: 'id' } as const
314
+ const idOpt = { t: 'id', optional: true } as const
315
+ const bool = { t: 'bool' } as const
316
+ const boolOpt = { t: 'bool', optional: true } as const
317
+ const ms = { t: 'ms' } as const
318
+ const msOpt = { t: 'ms', optional: true } as const
319
+ const intOpt = { t: 'int', optional: true } as const
320
+ const en = (values: readonly string[]) => ({ t: 'enum', values }) as const
321
+ const enOpt = (values: readonly string[]) => ({ t: 'enum', values, optional: true }) as const
322
+
323
+ export const EVIDENCE_ENVELOPE_FIELD_SPECS: { readonly [F in keyof EvidenceEnvelope]-?: EvidenceFieldSpec } = {
324
+ eventId: id,
325
+ at: ms,
326
+ source: en(EVIDENCE_SOURCE_IDS),
327
+ sessionId: id,
328
+ attemptRef: { t: 'attempt_ref', optional: true },
329
+ taskId: idOpt,
330
+ observedBy: id,
331
+ }
332
+
333
+ export const TURN_EVIDENCE_FIELD_SPECS: TurnEvidenceFieldSpecs = {
334
+ dispatch_accepted: {
335
+ scope: en(TURN_SCOPES), messageId: id, meshId: idOpt, nodeId: idOpt, providerType: idOpt,
336
+ attemptNo: intOpt, dispatchNonce: intOpt, consumeProfile: enOpt(CONSUME_PROFILES), maxTaskRetries: intOpt,
337
+ coordinator: { t: 'coordinator', optional: true },
338
+ },
339
+ delivered: { messageId: id, outcome: en(DELIVERY_OUTCOMES), via: en(DELIVERY_VIAS) },
340
+ delivery_refused: { messageId: id, reason: en(SEND_REFUSAL_REASONS) },
341
+ dispatch_failed: { workerAbsent: bool, reason: en(DISPATCH_FAILURE_REASONS), refusalCode: idOpt },
342
+ duplicate_dispatch_refusal: { holderSessionId: id, holderAttemptId: idOpt },
343
+ session_rebound: { toSessionId: id, reason: en(SESSION_REBOUND_REASONS) },
344
+ turn_started: { retro: bool },
345
+ suspension: { modal: en(SUSPENSION_MODALS), modalKey: idOpt },
346
+ suspension_resolved: { resolution: en(SUSPENSION_RESOLUTIONS), via: en(SUSPENSION_RESOLUTION_VIAS) },
347
+ turn_end: {
348
+ strength: en(TURN_END_STRENGTHS), afterFinalizationTimeout: boolOpt, hollow: boolOpt,
349
+ summary: { t: 'summary_ref', optional: true }, blockReason: enOpt(TURN_END_BLOCK_REASONS),
350
+ releasedByHardCap: boolOpt, nativeOutcome: enOpt(NATIVE_TURN_OUTCOMES), live: { t: 'live', optional: true },
351
+ reportExpected: boolOpt,
352
+ },
353
+ transcript_final: {
354
+ selfAttributing: bool, nativeRead: bool, nativeMarker: { t: 'native_marker', optional: true },
355
+ live: { t: 'live' }, summary: { t: 'summary_ref', optional: true }, messageAt: msOpt,
356
+ },
357
+ transcript_activity: { newestActivityAt: ms },
358
+ no_progress: { stalledMs: ms, observedStatus: en(NO_PROGRESS_OBSERVED_STATUSES), finalAssistantPresent: bool },
359
+ liveness: { result: en(LIVENESS_RESULTS) },
360
+ process_exit: { exitCode: { t: 'int', nullable: true }, providerFailure: enOpt(PROVIDER_FAILURES) },
361
+ session_error: { reason: en(SESSION_ERROR_REASONS) },
362
+ worker_report: {
363
+ outcome: en(WORKER_REPORT_OUTCOMES), summary: { t: 'summary_ref', optional: true }, hasHandoffNotes: bool,
364
+ branchState: enOpt(WORKER_BRANCH_STATES), touchedFileCount: intOpt,
365
+ },
366
+ worker_progress: { note: { t: 'summary_ref', optional: true } },
367
+ git_side_effect: { dirty: bool, commitsSinceDispatch: { t: 'int' }, attributable: bool },
368
+ cancel: { reason: en(CANCEL_REASONS) },
369
+ operator_status: { status: en(OPERATOR_STATUSES), reason: en(OPERATOR_STATUS_REASONS) },
370
+ coordinator_ack: { notify: en(NOTIFY_KINDS), outcome: en(COORDINATOR_ACK_OUTCOMES) },
371
+ hold_expired: { holdId: id, reason: en(HOLD_REASONS) },
372
+ }
373
+
374
+ export const TURN_EVIDENCE_KINDS = Object.keys(TURN_EVIDENCE_FIELD_SPECS) as readonly TurnEvidenceKind[]
375
+
376
+ /** Evidence kinds that, on their own, can end a turn (the ones R18/R19/R27 reason about). */
377
+ export const TERMINAL_CLASS_EVIDENCE_KINDS = [
378
+ 'turn_end', 'transcript_final', 'no_progress', 'worker_report', 'session_error', 'process_exit', 'cancel', 'operator_status',
379
+ ] as const satisfies readonly TurnEvidenceKind[]
380
+
381
+ // ─── guards ──────────────────────────────────────────────────────────────
382
+
383
+ /** Identifier shape: 1..256 chars, no whitespace or control characters. */
384
+ const IDENTIFIER_RE = /^[^\s\u0000-\u001f\u007f]{1,256}$/
385
+
386
+ function makeGuard<T extends readonly string[]>(values: T): (value: unknown) => value is T[number] {
387
+ const set: ReadonlySet<string> = new Set(values)
388
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
389
+ }
390
+
391
+ export const isTurnEvidenceKind = makeGuard(TURN_EVIDENCE_KINDS as readonly TurnEvidenceKind[])
392
+ export const isTurnReason = makeGuard(TURN_REASONS)
393
+ export const isNotifyKind = makeGuard(NOTIFY_KINDS)
394
+ export const isTurnOutcome = makeGuard(TURN_OUTCOMES)
395
+ export const isCommitStrength = makeGuard(COMMIT_STRENGTHS)
396
+
397
+ function isRecord(value: unknown): value is Record<string, unknown> {
398
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
399
+ }
400
+
401
+ export function isEvidenceIdentifier(value: unknown): value is string {
402
+ return typeof value === 'string' && IDENTIFIER_RE.test(value)
403
+ }
404
+
405
+ function isFiniteNumber(value: unknown): value is number {
406
+ return typeof value === 'number' && Number.isFinite(value)
407
+ }
408
+
409
+ export function isSummaryRef(value: unknown): value is SummaryRef {
410
+ return isRecord(value)
411
+ && hasOnlyKeys(value, ['topic', 'writer', 'seq'])
412
+ && isEvidenceIdentifier(value.topic)
413
+ && isEvidenceIdentifier(value.writer)
414
+ && Number.isSafeInteger(value.seq) && (value.seq as number) >= 0
415
+ }
416
+
417
+ export function isTurnAttemptRef(value: unknown): value is TurnAttemptRef {
418
+ return isRecord(value)
419
+ && hasOnlyKeys(value, ['attemptId', 'generation'])
420
+ && isEvidenceIdentifier(value.attemptId)
421
+ && Number.isSafeInteger(value.generation) && (value.generation as number) >= 0
422
+ }
423
+
424
+ function isLiveTurnPending(value: unknown): value is LiveTurnPending {
425
+ return isRecord(value)
426
+ && hasOnlyKeys(value, ['modal', 'adapterPending', 'trailingTool', 'newestActivityAt'])
427
+ && typeof value.modal === 'boolean'
428
+ && typeof value.adapterPending === 'boolean'
429
+ && typeof value.trailingTool === 'boolean'
430
+ && (value.newestActivityAt === undefined || isFiniteNumber(value.newestActivityAt))
431
+ }
432
+
433
+ function isNativeMarkerRef(value: unknown): value is NativeTurnMarkerRef {
434
+ return isRecord(value)
435
+ && hasOnlyKeys(value, ['outcome', 'turnId'])
436
+ && (NATIVE_TURN_OUTCOMES as readonly string[]).includes(value.outcome as string)
437
+ && (value.turnId === undefined || isEvidenceIdentifier(value.turnId))
438
+ }
439
+
440
+ function isCoordinatorIdentity(value: unknown): value is EvidenceCoordinatorIdentity {
441
+ return isRecord(value)
442
+ && hasOnlyKeys(value, ['daemonId', 'coordinatorRunId', 'sessionId'])
443
+ && isEvidenceIdentifier(value.daemonId)
444
+ && isEvidenceIdentifier(value.coordinatorRunId)
445
+ && (value.sessionId === undefined || isEvidenceIdentifier(value.sessionId))
446
+ }
447
+
448
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
449
+ for (const key of Object.keys(value)) {
450
+ if (!allowed.includes(key)) return false
451
+ }
452
+ return true
453
+ }
454
+
455
+ function fieldMatches(spec: EvidenceFieldSpec, value: unknown): boolean {
456
+ if (value === undefined) return spec.optional === true
457
+ switch (spec.t) {
458
+ case 'id': return isEvidenceIdentifier(value)
459
+ case 'enum': return typeof value === 'string' && spec.values.includes(value)
460
+ case 'bool': return typeof value === 'boolean'
461
+ case 'int': return (value === null && spec.nullable === true) || Number.isSafeInteger(value)
462
+ case 'ms': return isFiniteNumber(value) && value >= 0
463
+ case 'summary_ref': return isSummaryRef(value)
464
+ case 'attempt_ref': return isTurnAttemptRef(value)
465
+ case 'live': return isLiveTurnPending(value)
466
+ case 'native_marker': return isNativeMarkerRef(value)
467
+ case 'coordinator': return isCoordinatorIdentity(value)
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Strict structural guard: known kind, every declared field of the right class,
473
+ * and NO undeclared field (an unknown key is how free text would sneak in).
474
+ */
475
+ export function isTurnEvidence(value: unknown): value is TurnEvidence {
476
+ if (!isRecord(value) || !isTurnEvidenceKind(value.kind)) return false
477
+ const bodySpecs = TURN_EVIDENCE_FIELD_SPECS[value.kind] as Record<string, EvidenceFieldSpec>
478
+ const envelopeSpecs = EVIDENCE_ENVELOPE_FIELD_SPECS as Record<string, EvidenceFieldSpec>
479
+ for (const key of Object.keys(value)) {
480
+ if (key === 'kind') continue
481
+ if (!(key in bodySpecs) && !(key in envelopeSpecs)) return false
482
+ }
483
+ for (const [key, spec] of Object.entries(envelopeSpecs)) {
484
+ if (!fieldMatches(spec, value[key])) return false
485
+ }
486
+ for (const [key, spec] of Object.entries(bodySpecs)) {
487
+ if (!fieldMatches(spec, value[key])) return false
488
+ }
489
+ return true
490
+ }
491
+
492
+
493
+ // ─── mesh.<id>.events topic entries (C2) ─────────────────────────────────
494
+
495
+ export const TURN_EVIDENCE_FLAGS = ['hollow', 'afterFinalizationTimeout', 'nativeMarker', 'selfAttributing'] as const
496
+ export type TurnEvidenceFlag = typeof TURN_EVIDENCE_FLAGS[number]
497
+
498
+ /** Scalar-only payload for `mesh.record` (the existing allow-list projection's output). */
499
+ export type ProjectedScalars = Readonly<Record<string, string | number | boolean | null>>
500
+
501
+ export const MESH_TOPIC_ENTRY_KINDS = ['turn.evidence', 'turn.committed', 'turn.notify', 'mesh.record'] as const
502
+ export type MeshTopicEntryKind = typeof MESH_TOPIC_ENTRY_KINDS[number]
503
+
504
+ export type MeshTopicEntry = { v: typeof MESH_TOPIC_PROTOCOL_VERSION; eventId: string; at: number } & (
505
+ | { k: 'turn.evidence'; attemptId: string; generation: number; ownerDaemonId: string; taskId?: string; sessionId: string
506
+ ev: TurnEvidenceKind; strength?: TurnEndStrength; flags?: Partial<Record<TurnEvidenceFlag, boolean>>
507
+ /** The full (content-free) evidence, so a foreign `turn.ingest` can re-observe it losslessly. */
508
+ evidence?: TurnEvidence }
509
+ | { k: 'turn.committed'; attemptId: string; generation: number; taskId?: string; outcome: TurnOutcome; strength: CommitStrength; reason: TurnReason }
510
+ | { k: 'turn.notify'; attemptId?: string; notify: NotifyKind; targetDaemonId: string; targetSessionId?: string; taskId?: string }
511
+ | { k: 'mesh.record'; ledgerKind: string; nodeId?: string; sessionId?: string; taskId?: string; payload: ProjectedScalars }
512
+ )
513
+
514
+
515
+ function isOptionalId(value: unknown): boolean {
516
+ return value === undefined || isEvidenceIdentifier(value)
517
+ }
518
+
519
+ function isGeneration(value: unknown): boolean {
520
+ return Number.isSafeInteger(value) && (value as number) >= 0
521
+ }
522
+
523
+ function isProjectedScalars(value: unknown): value is ProjectedScalars {
524
+ if (!isRecord(value)) return false
525
+ for (const v of Object.values(value)) {
526
+ if (!(v === null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string')) return false
527
+ }
528
+ return true
529
+ }
530
+
531
+ export function isMeshTopicEntry(value: unknown): value is MeshTopicEntry {
532
+ if (!isRecord(value)) return false
533
+ if (value.v !== MESH_TOPIC_PROTOCOL_VERSION || !isEvidenceIdentifier(value.eventId) || !isFiniteNumber(value.at)) return false
534
+ switch (value.k) {
535
+ case 'turn.evidence': {
536
+ if (!isEvidenceIdentifier(value.attemptId) || !isGeneration(value.generation)) return false
537
+ if (!isEvidenceIdentifier(value.ownerDaemonId) || !isEvidenceIdentifier(value.sessionId) || !isOptionalId(value.taskId)) return false
538
+ if (!isTurnEvidenceKind(value.ev)) return false
539
+ if (value.strength !== undefined && !(TURN_END_STRENGTHS as readonly string[]).includes(value.strength as string)) return false
540
+ if (value.flags !== undefined) {
541
+ if (!isRecord(value.flags)) return false
542
+ for (const [flag, on] of Object.entries(value.flags)) {
543
+ if (!(TURN_EVIDENCE_FLAGS as readonly string[]).includes(flag) || typeof on !== 'boolean') return false
544
+ }
545
+ }
546
+ if (value.evidence !== undefined && !(isTurnEvidence(value.evidence) && value.evidence.kind === value.ev)) return false
547
+ return true
548
+ }
549
+ case 'turn.committed':
550
+ return isEvidenceIdentifier(value.attemptId) && isGeneration(value.generation) && isOptionalId(value.taskId)
551
+ && isTurnOutcome(value.outcome) && isCommitStrength(value.strength) && isTurnReason(value.reason)
552
+ case 'turn.notify':
553
+ return isOptionalId(value.attemptId) && isNotifyKind(value.notify) && isEvidenceIdentifier(value.targetDaemonId)
554
+ && isOptionalId(value.targetSessionId) && isOptionalId(value.taskId)
555
+ case 'mesh.record':
556
+ return isEvidenceIdentifier(value.ledgerKind) && isOptionalId(value.nodeId) && isOptionalId(value.sessionId)
557
+ && isOptionalId(value.taskId) && isProjectedScalars(value.payload)
558
+ default:
559
+ return false
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Project evidence onto its `turn.evidence` topic entry. Pure; the attempt
565
+ * identity comes from the resolved attempt (the evidence may lack attemptRef).
566
+ */
567
+ export function projectTurnEvidenceEntry(
568
+ evidence: TurnEvidence,
569
+ attempt: { attemptId: string; generation: number; ownerDaemonId: string },
570
+ ): Extract<MeshTopicEntry, { k: 'turn.evidence' }> {
571
+ const flags: Partial<Record<TurnEvidenceFlag, boolean>> = {}
572
+ if (evidence.kind === 'turn_end') {
573
+ if (evidence.hollow) flags.hollow = true
574
+ if (evidence.afterFinalizationTimeout) flags.afterFinalizationTimeout = true
575
+ }
576
+ if (evidence.kind === 'transcript_final') {
577
+ if (evidence.nativeMarker) flags.nativeMarker = true
578
+ if (evidence.selfAttributing) flags.selfAttributing = true
579
+ }
580
+ const taskId = evidence.taskId
581
+ return {
582
+ v: MESH_TOPIC_PROTOCOL_VERSION,
583
+ eventId: evidence.eventId,
584
+ at: evidence.at,
585
+ k: 'turn.evidence',
586
+ attemptId: attempt.attemptId,
587
+ generation: attempt.generation,
588
+ ownerDaemonId: attempt.ownerDaemonId,
589
+ ...(taskId ? { taskId } : {}),
590
+ sessionId: evidence.sessionId,
591
+ ev: evidence.kind,
592
+ ...(evidence.kind === 'turn_end' ? { strength: evidence.strength } : {}),
593
+ ...(Object.keys(flags).length > 0 ? { flags } : {}),
594
+ evidence,
595
+ }
596
+ }