@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/mesh-shared",
3
- "version": "1.0.60-rc.72",
3
+ "version": "1.0.60-rc.74",
4
4
  "description": "ADHDev mesh-shared — pure mesh/git status normalizers shared by daemon-core and web-core",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -5,10 +5,8 @@
5
5
  * per-difficulty "brain" preset resolves that into a concrete provider / model /
6
6
  * thinking-level for the launched session. The goal is token economy: an `easy`
7
7
  * task runs on a cheaper model at low reasoning effort; a `difficult` task gets a
8
- * stronger model at high effort. This is a separate axis from MAGI's review kinds
9
- * (rca/design/…) — MAGI fans out review replicas; this picks the single brain that
10
- * *executes* a task — but it reuses the same slot shape (provider/model) and the
11
- * same machine-local storage (`~/.adhdev/meshes.json`).
8
+ * stronger model at high effort. This picks the single brain that *executes* a
9
+ * task, stored machine-local (`~/.adhdev/meshes.json`).
12
10
  */
13
11
 
14
12
  /** The fixed difficulty axis the coordinator classifies a task into. */
@@ -33,8 +31,8 @@ export interface BrainSlot {
33
31
 
34
32
  /**
35
33
  * Per-difficulty brain bindings for ONE mesh, stored machine-local in
36
- * `~/.adhdev/meshes.json` under that mesh's entry (`meshes[].difficultyBrains`,
37
- * sibling of `magiKindPanels`). The scope is per mesh: two meshes on the same
34
+ * `~/.adhdev/meshes.json` under that mesh's entry (`meshes[].difficultyBrains`).
35
+ * The scope is per mesh: two meshes on the same
38
36
  * machine hold independent presets, so one can pin `difficult` to a cheaper model
39
37
  * without changing what any other mesh runs. (It formerly sat at the config root
40
38
  * keyed by difficulty alone — and since this map picks the MODEL a task runs on,
@@ -118,8 +116,8 @@ export function normalizeDifficultyBrainMap(raw: unknown): DifficultyBrainMap {
118
116
  // capability slots. Each slot bundles what used to be scattered across
119
117
  // providerPriority (order), a per-provider maxParallel cap, and the
120
118
  // the owning mesh's difficultyBrains (difficulty → model/thinking). Slot order =
121
- // preference. This single profile is the source of truth for task routing, MAGI
122
- // fan-out, and orchestrator-proposed edits.
119
+ // preference. This single profile is the source of truth for task routing and
120
+ // orchestrator-proposed edits.
123
121
  // ─────────────────────────────────────────────────────────────────────────────
124
122
 
125
123
  /**
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Projection of a daemon `git_diff_file` answer into the per-file row the MCP
3
+ * `git_diff` tool and the cloud git-diff shortcut both return: the diff text
4
+ * capped at `maxLines` (with a visible truncation marker), plus the file's
5
+ * change metadata from the diff summary.
6
+ */
7
+
8
+ import { readRecord } from './json'
9
+
10
+ export interface GitFileDiffRow {
11
+ path: string
12
+ old_path?: string | null
13
+ status?: string
14
+ diff: string
15
+ truncated: boolean
16
+ binary: boolean
17
+ error?: string
18
+ }
19
+
20
+ /** Cap diff text at `maxLines`; a truncated diff ends with a `... (truncated)` marker line. */
21
+ function truncateDiffText(text: string, maxLines: number): { diff: string; truncated: boolean } {
22
+ const lines = text.split('\n')
23
+ const truncated = lines.length > maxLines
24
+ return {
25
+ diff: truncated ? lines.slice(0, maxLines).join('\n') + '\n... (truncated)' : text,
26
+ truncated,
27
+ }
28
+ }
29
+
30
+ /** One changed file's row from its `git_diff_file` answer (either `{ diff: {...} }` or the bare diff record). */
31
+ export function projectFileDiffRow(
32
+ file: { path: string; oldPath?: string | null; status?: string },
33
+ rawResult: unknown,
34
+ maxLines: number,
35
+ ): GitFileDiffRow {
36
+ const raw = readRecord(rawResult)
37
+ const d = readRecord(raw.diff ?? rawResult)
38
+ const text = typeof d.diff === 'string' ? d.diff : ''
39
+ const { diff, truncated } = truncateDiffText(text, maxLines)
40
+ return {
41
+ path: file.path,
42
+ old_path: file.oldPath ?? null,
43
+ status: file.status ?? 'M',
44
+ diff,
45
+ truncated,
46
+ binary: typeof d.binary === 'boolean' ? d.binary : false,
47
+ }
48
+ }
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ export * from './node-facts'
19
19
  export * from './workspace-normalize'
20
20
  export * from './daemon-normalize'
21
21
  export * from './git-summarize'
22
- export * from './magi'
22
+ export * from './git-diff'
23
23
  export * from './brain-routing'
24
24
  export * from './slot-proposal'
25
25
  export * from './interpolation'
package/src/json.ts CHANGED
@@ -14,6 +14,11 @@ export function readRecord(value: unknown): JsonRecord {
14
14
  return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
15
15
  }
16
16
 
17
+ /** A plain-object record, or undefined — for callers that must tell "absent" from "empty". */
18
+ export function readOptionalRecord(value: unknown): JsonRecord | undefined {
19
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : undefined
20
+ }
21
+
17
22
  export function readString(...values: unknown[]): string | undefined {
18
23
  for (const value of values) {
19
24
  if (typeof value !== 'string') continue
@@ -23,6 +28,11 @@ export function readString(...values: unknown[]): string | undefined {
23
28
  return undefined
24
29
  }
25
30
 
31
+ /** The first non-blank trimmed string, or '' — readString for callers that want a plain string. */
32
+ export function readText(...values: unknown[]): string {
33
+ return readString(...values) ?? ''
34
+ }
35
+
26
36
  export function readNumber(...values: unknown[]): number | undefined {
27
37
  for (const value of values) {
28
38
  if (typeof value === 'number' && Number.isFinite(value)) return value
@@ -22,14 +22,10 @@ export const CANONICAL_MESH_TOOL_NAMES = [
22
22
  'mesh_status',
23
23
  'mesh_route_preview',
24
24
  'mesh_list_nodes',
25
- // GRAPH-ORCHESTRATION Phase F — batch before task, mirroring ALL_MESH_TOOLS.
25
+ // Batch before task, mirroring ALL_MESH_TOOLS.
26
26
  'mesh_enqueue_batch',
27
27
  'mesh_enqueue_task',
28
28
  'mesh_view_queue',
29
- // GRAPH-ORCHESTRATION Phase E — the coordinator gate + graph view surface.
30
- 'mesh_graph_view',
31
- 'mesh_graph_gate',
32
- 'mesh_graph_node_patch',
33
29
  'mesh_queue_cancel',
34
30
  'mesh_queue_requeue',
35
31
  'mesh_send_task',
@@ -64,9 +60,6 @@ export const CANONICAL_MESH_TOOL_NAMES = [
64
60
  'mesh_mission_upsert',
65
61
  'mesh_mission_list',
66
62
  'mesh_review_inbox',
67
- 'mesh_magi_review',
68
- 'mesh_magi_collect',
69
- 'mesh_magi_kind_panel',
70
63
  'mesh_node_slots',
71
64
  'mesh_coordinator_prompt_append',
72
65
  ] as const;
@@ -87,29 +80,16 @@ export const CANONICAL_MESH_TOOL_COUNT = CANONICAL_MESH_TOOL_NAMES.length;
87
80
  * test asserts none of these names is advertised anywhere in the prompt.
88
81
  *
89
82
  * `args` is the discriminator to add; every other argument keeps its name.
90
- * `note` covers the one rename where an argument moved (the old
91
- * `mesh_graph_gate_claim({ extend_seconds })` extend-only mode).
83
+ * `note` carries any extra guidance when an argument moved in a rename.
92
84
  */
93
85
  export const RETIRED_MESH_TOOLS: Readonly<Record<string, {
94
86
  readonly tool: CanonicalMeshToolName;
95
87
  readonly args: Readonly<Record<string, string>>;
96
88
  readonly note?: string;
97
89
  }>> = {
98
- mesh_graph_gate_claim: {
99
- tool: 'mesh_graph_gate',
100
- args: { action: 'claim' },
101
- note: 'The extend-only form (claim with extend_seconds) is now action: "extend" with the same extend_seconds.',
102
- },
103
- mesh_graph_gate_release: { tool: 'mesh_graph_gate', args: { action: 'release' } },
104
- mesh_graph_gate_abandon: { tool: 'mesh_graph_gate', args: { action: 'abandon' } },
105
- // Never a published tool (it is the daemon command behind the extend verb),
106
- // but an older gate-expiry notice told coordinators to call it by this name.
107
- mesh_graph_gate_extend: { tool: 'mesh_graph_gate', args: { action: 'extend' } },
108
90
  mesh_node_slots_set: { tool: 'mesh_node_slots', args: { action: 'set' } },
109
91
  mesh_node_slots_list: { tool: 'mesh_node_slots', args: { action: 'list' } },
110
92
  mesh_node_slots_propose: { tool: 'mesh_node_slots', args: { action: 'propose' } },
111
- mesh_magi_kind_panel_set: { tool: 'mesh_magi_kind_panel', args: { action: 'set' } },
112
- mesh_magi_kind_panel_list: { tool: 'mesh_magi_kind_panel', args: { action: 'list' } },
113
93
  mesh_coordinator_prompt_append_get: { tool: 'mesh_coordinator_prompt_append', args: { action: 'get' } },
114
94
  mesh_coordinator_prompt_append_set: { tool: 'mesh_coordinator_prompt_append', args: { action: 'set' } },
115
95
  mesh_record_note: { tool: 'mesh_note', args: { action: 'record' } },
@@ -123,11 +103,26 @@ export const RETIRED_MESH_TOOLS: Readonly<Record<string, {
123
103
  };
124
104
 
125
105
  /**
126
- * The error text for a call to a retired tool name, or null when `name` is not
127
- * retired. One wording shared by every MCP mode so the redirect reads the same
128
- * wherever an old prompt calls it.
106
+ * Tool names removed outright (no merged replacement), mapped to the guidance a
107
+ * coordinator on an old prompt needs to do the same job with the remaining tools.
108
+ */
109
+ export const REMOVED_MESH_TOOLS: Readonly<Record<string, string>> = {
110
+ mesh_magi_review: 'For a multi-perspective review, send the same question to 2-3 workers on different providers with mesh_send_task, wait for their report_completion, then synthesize the answers yourself.',
111
+ mesh_magi_collect: 'Worker answers arrive as report_completion events; synthesize them yourself.',
112
+ mesh_magi_kind_panel: 'There are no review panels any more; pick the workers per review with mesh_send_task.',
113
+ mesh_magi_kind_panel_set: 'There are no review panels any more; pick the workers per review with mesh_send_task.',
114
+ mesh_magi_kind_panel_list: 'There are no review panels any more; pick the workers per review with mesh_send_task.',
115
+ };
116
+
117
+ /**
118
+ * The error text for a call to a retired or removed tool name, or null when
119
+ * `name` is neither. One wording shared by every MCP mode so the redirect reads
120
+ * the same wherever an old prompt calls it.
129
121
  */
130
122
  export function retiredMeshToolError(name: string): string | null {
123
+ if (Object.prototype.hasOwnProperty.call(REMOVED_MESH_TOOLS, name)) {
124
+ return `Tool "${name}" was removed. ${REMOVED_MESH_TOOLS[name]}`;
125
+ }
131
126
  const entry = Object.prototype.hasOwnProperty.call(RETIRED_MESH_TOOLS, name) ? RETIRED_MESH_TOOLS[name] : undefined;
132
127
  if (!entry) return null;
133
128
  const discriminator = Object.entries(entry.args).map(([k, v]) => `${k}: "${v}"`).join(', ');
@@ -46,8 +46,7 @@ export const MESH_DELIVERY_MODES = ['when_idle', 'interrupt'] as const
46
46
  export type MeshDeliveryMode = typeof MESH_DELIVERY_MODES[number]
47
47
 
48
48
  /**
49
- * What to do with a node's sessions when the node is removed or a MAGI fan-out
50
- * settles. Declared here so daemon-core (`RepoMeshSessionCleanupMode`), the
49
+ * What to do with a node's sessions when the node is removed. Declared here so daemon-core (`RepoMeshSessionCleanupMode`), the
51
50
  * web dashboard picker and the MCP schemas all read one list.
52
51
  */
53
52
  export const MESH_SESSION_CLEANUP_MODES = ['preserve', 'stop', 'delete_stopped', 'stop_and_delete'] as const
@@ -167,9 +167,8 @@ export type SendRefusal =
167
167
  * a turn is generating (`send_now`). Written, not yet answered;
168
168
  * `interrupt` — the turn in flight was stopped first, then this body was
169
169
  * written (or re-parked) as a new turn;
170
- * `acp` — handed to an ACP agent's `session/prompt`.
171
170
  */
172
- export type SubmitRoute = 'pty' | 'agent_queue' | 'interrupt' | 'acp'
171
+ export type SubmitRoute = 'pty' | 'agent_queue' | 'interrupt'
173
172
 
174
173
  /** The stop key pressed by an `interrupt` submit (logs / operator display). */
175
174
  export interface SubmitInterruptDetail {
@@ -195,8 +195,6 @@ export type AuthOkWirePayload = {
195
195
  machineNickname: string | null
196
196
  seqscribeFleetSecret?: string
197
197
  seqscribeFleetSecretVersion?: number
198
- /** Beacon board seed for cold start (advisory, may be absent). */
199
- beaconSeed?: { reports: unknown[]; truncated: number }
200
198
  limits: AuthOkPlanLimits
201
199
  }
202
200
 
@@ -251,12 +249,6 @@ export type DaemonErrorWirePayload = {
251
249
  detail?: unknown
252
250
  }
253
251
 
254
- // ─── Beacon (seqscribe vector board, design §7.1) ──────────────────────────
255
-
256
- export type BeaconVectorsWirePayload =
257
- | { op: 'put'; report: unknown }
258
- | { op: 'get'; topics: string[]; requestId: string }
259
-
260
252
  // ─── P2P signaling (dashboard ↔ daemon through the server) ─────────────────
261
253
 
262
254
  export const P2P_SIGNAL_TYPES = ['p2p_ready', 'p2p_offer', 'p2p_answer', 'p2p_ice'] as const
@@ -308,7 +300,6 @@ export const DAEMON_TO_SERVER_TYPES = [
308
300
  'command_result',
309
301
  'error',
310
302
  'agent_event',
311
- 'beacon_vectors',
312
303
  'daemon_mesh_command',
313
304
  'p2p_offer',
314
305
  'p2p_ice',
@@ -324,7 +315,6 @@ export type CommandResultMsg = Envelope<'command_result', CommandResultWirePaylo
324
315
  export type DaemonErrorMsg = Envelope<'error', DaemonErrorWirePayload>
325
316
  /** Agent-level event forwarded verbatim to the `agent:status` webhook (throttled server-side). Dynamic by design. */
326
317
  export type AgentEventMsg = Envelope<'agent_event', CommandPayload>
327
- export type BeaconVectorsMsg = Envelope<'beacon_vectors', BeaconVectorsWirePayload>
328
318
  export type DaemonP2POfferMsg = Envelope<'p2p_offer', P2POfferWirePayload>
329
319
  export type DaemonP2PIceMsg = Envelope<'p2p_ice', P2PIceWirePayload>
330
320
  /**
@@ -349,7 +339,6 @@ export type DaemonToServerMsg =
349
339
  | CommandResultMsg
350
340
  | DaemonErrorMsg
351
341
  | AgentEventMsg
352
- | BeaconVectorsMsg
353
342
  | DaemonMeshCommandMsg
354
343
  | DaemonP2POfferMsg
355
344
  | DaemonP2PIceMsg
@@ -398,7 +387,6 @@ export const SERVER_TO_DAEMON_CONTROL_TYPES = [
398
387
  'command',
399
388
  'agent_command',
400
389
  'resolve_action',
401
- 'beacon_vectors_result',
402
390
  'daemon_mesh_result',
403
391
  'p2p_ready',
404
392
  'p2p_offer',
@@ -428,15 +416,6 @@ export type ServerAgentCommandMsg = Envelope<'agent_command', { action: string }
428
416
  export type ServerResolveActionMsg = Envelope<'resolve_action', CommandPayload> & ServerRelayFields
429
417
  export type ServerP2PSignalMsg = Envelope<P2PSignalType, RelayedP2PSignalWirePayload> & ServerRelayFields
430
418
  export type ServerMeshP2PSignalMsg = Envelope<MeshP2PSignalType, MeshP2PSignalWirePayload> & ServerRelayFields
431
- /** Reply to a `beacon_vectors` GET, correlated by `requestId` (top-level, no payload). A PUT gets no reply. */
432
- export type BeaconVectorsResultMsg = {
433
- type: 'beacon_vectors_result'
434
- requestId: string
435
- success: boolean
436
- reports?: unknown[]
437
- truncated?: number
438
- error?: string
439
- }
440
419
  /** Correlated ack/nack of a `daemon_mesh_command` (top-level, no payload). */
441
420
  export type DaemonMeshResultMsg = {
442
421
  type: 'daemon_mesh_result'
@@ -457,7 +436,6 @@ export type ServerToDaemonControlMsg =
457
436
  | ServerCommandMsg
458
437
  | ServerAgentCommandMsg
459
438
  | ServerResolveActionMsg
460
- | BeaconVectorsResultMsg
461
439
  | DaemonMeshResultMsg
462
440
  | ServerP2PSignalMsg
463
441
  | ServerMeshP2PSignalMsg
@@ -491,7 +469,7 @@ export function isServerDirectCommandMsg(msg: ServerToDaemonMsg): msg is ServerD
491
469
  }
492
470
 
493
471
  /** Members of the control set that carry a `payload` object. */
494
- export type ServerToDaemonPayloadType = Exclude<ServerToDaemonControlMsg, BeaconVectorsResultMsg | DaemonMeshResultMsg>['type']
472
+ export type ServerToDaemonPayloadType = Exclude<ServerToDaemonControlMsg, DaemonMeshResultMsg>['type']
495
473
  export type ServerToDaemonPayloadOf<T extends ServerToDaemonPayloadType> = Extract<ServerToDaemonControlMsg, { type: T }>['payload']
496
474
 
497
475
  /** Command types a daemon must never receive as a direct command: they are daemon→server frames or reserved envelope words. */
@@ -504,7 +482,7 @@ export function isDaemonDirectCommandType(value: unknown): value is DaemonDirect
504
482
  /**
505
483
  * Parse boundary for the daemon: is `raw` a frame this link accepts from the server?
506
484
  * Control members are checked for their discriminant and envelope shape; the
507
- * two payload-less replies need their `requestId`; any other well-formed
485
+ * payload-less `daemon_mesh_result` reply needs its `requestId`; any other well-formed
508
486
  * command name becomes a `ServerDirectCommandMsg`.
509
487
  */
510
488
  export function decodeServerToDaemon(raw: unknown): ServerToDaemonMsg | null {
@@ -513,18 +491,9 @@ export function decodeServerToDaemon(raw: unknown): ServerToDaemonMsg | null {
513
491
  ...readEnvelopeFields(raw),
514
492
  ...(isNonEmptyString(raw.source) ? { source: raw.source } : {}),
515
493
  }
516
- if (raw.type === 'beacon_vectors_result' || raw.type === 'daemon_mesh_result') {
494
+ if (raw.type === 'daemon_mesh_result') {
517
495
  if (!isNonEmptyString(raw.requestId)) return null
518
496
  const base = { requestId: raw.requestId, success: raw.success === true }
519
- if (raw.type === 'beacon_vectors_result') {
520
- return {
521
- type: 'beacon_vectors_result',
522
- ...base,
523
- ...(Array.isArray(raw.reports) ? { reports: raw.reports } : {}),
524
- ...(typeof raw.truncated === 'number' ? { truncated: raw.truncated } : {}),
525
- ...(typeof raw.error === 'string' ? { error: raw.error } : {}),
526
- }
527
- }
528
497
  return {
529
498
  type: 'daemon_mesh_result',
530
499
  ...base,
@@ -28,6 +28,8 @@
28
28
  * only `SessionLaunchRecord.launchedBy` records that the session came back via
29
29
  * restore.
30
30
  */
31
+ import { readString } from './json'
32
+
31
33
  export const MODEL_AXIS_SOURCES = [
32
34
  /** Picked explicitly for this launch (dashboard dropdown, coordinator dialog). */
33
35
  'user',
@@ -173,10 +175,6 @@ export function sanitizeModelIdentifier(value: unknown): string | undefined {
173
175
  return MODEL_IDENTIFIER_PATTERN.test(trimmed) ? trimmed : undefined
174
176
  }
175
177
 
176
- function readString(value: unknown): string | undefined {
177
- return typeof value === 'string' && value.trim() ? value.trim() : undefined
178
- }
179
-
180
178
  function readFiniteNumber(value: unknown): number | undefined {
181
179
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
182
180
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLI auto-detect → capability-slot / MAGI-panel PROPOSAL generator.
2
+ * CLI auto-detect → capability-slot PROPOSAL generator.
3
3
  *
4
4
  * Detection of installed CLI providers already exists per node (the status
5
5
  * snapshot's `availableProviders`), and applying a slot profile already exists
@@ -31,7 +31,6 @@ import {
31
31
  type MeshTaskDifficulty,
32
32
  type NodeCapabilitySlot,
33
33
  } from './brain-routing'
34
- import type { MagiSlot } from './magi'
35
34
 
36
35
  /**
37
36
  * One provider's seeded slot recipe. A provider may map to MORE THAN ONE slot
@@ -280,48 +279,3 @@ export function buildSlotProposal(
280
279
  destructive: droppedSlots.length > 0,
281
280
  }
282
281
  }
283
-
284
- /**
285
- * Build a MAGI panel proposal from the same detections.
286
- *
287
- * ─── Deliberately narrow ──────────────────────────────────────────────────────
288
- *
289
- * MAGI's value is provider INDEPENDENCE: replicas from different providers
290
- * (ideally different machines) answering the same question, so agreement means
291
- * something. Detection tells us which providers exist — that is exactly enough
292
- * to propose one panel of distinct providers, and no more.
293
- *
294
- * What detection does NOT tell us is which provider suits which review KIND
295
- * (rca vs design vs claim_audit). Nothing in any manifest grades a provider for
296
- * root-cause analysis over design review, and inventing a per-kind assignment
297
- * would fabricate a rationale that does not exist. So this proposes ONE panel of
298
- * the detected providers and leaves the kind binding to the operator; the caller
299
- * decides which `task_kind` to bind it to via the existing dry-run tool.
300
- *
301
- * Ordering follows {@link CLI_SLOT_RECIPES} insertion order (recipe-known
302
- * providers first, in table order), so the panel leads with the providers whose
303
- * suitability is actually attested.
304
- */
305
- export function buildMagiPanelProposal(
306
- detected: readonly DetectedCliProvider[],
307
- opts: { nodeId?: string; maxSlots?: number } = {},
308
- ): MagiSlot[] {
309
- const providers = dedupeDetected(detected ?? [])
310
- const tableOrder = Object.keys(CLI_SLOT_RECIPES)
311
- const rank = (type: string): number => {
312
- const i = tableOrder.indexOf(type)
313
- return i === -1 ? Number.MAX_SAFE_INTEGER : i
314
- }
315
- const ordered = [...providers].sort((a, b) => rank(a.type) - rank(b.type))
316
- const limit = Number.isFinite(opts.maxSlots) && (opts.maxSlots as number) > 0
317
- ? Math.floor(opts.maxSlots as number)
318
- : ordered.length
319
-
320
- return ordered.slice(0, limit).map((p): MagiSlot => ({
321
- ...(opts.nodeId ? { nodeId: opts.nodeId } : {}),
322
- provider: p.type,
323
- // A model is intentionally NOT pinned: the panel's job is cross-provider
324
- // independence, and pinning models here would silently couple the panel
325
- // to this table's cost assumptions rather than to review quality.
326
- }))
327
- }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * turn-ipc shared runtime-guard primitives (the protocol version stamp and the
3
+ * small structural checks every command decoder composes). Part of the turn-ipc
4
+ * wire contract (./turn-ipc.ts).
5
+ */
6
+
7
+ import { isEvidenceIdentifier } from './turn-evidence';
8
+ import { isRecord } from './protocol/envelope';
9
+
10
+ // ─── shared primitives ─────────────────────────────────────────────────────
11
+
12
+ /** Every request in this file is versioned so a responder can reject a shape it predates. */
13
+ export const TURN_IPC_PROTOCOL_VERSION = 1 as const
14
+
15
+ export function isFiniteNumber(value: unknown): value is number {
16
+ return typeof value === 'number' && Number.isFinite(value)
17
+ }
18
+
19
+ export function isNonNegativeInt(value: unknown): value is number {
20
+ return Number.isSafeInteger(value) && (value as number) >= 0
21
+ }
22
+
23
+ export function isOptionalId(value: unknown): boolean {
24
+ return value === undefined || isEvidenceIdentifier(value)
25
+ }
26
+
27
+ export function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
28
+ for (const key of Object.keys(value)) {
29
+ if (!allowed.includes(key)) return false
30
+ }
31
+ return true
32
+ }
33
+
34
+ export function makeGuard<T extends readonly string[]>(values: T): (value: unknown) => value is T[number] {
35
+ const set: ReadonlySet<string> = new Set(values)
36
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
37
+ }
38
+
39
+ export function isStringArray(value: unknown): value is readonly string[] {
40
+ return Array.isArray(value) && value.every((v) => typeof v === 'string')
41
+ }
42
+
43
+ export function isOptionalShortString(value: unknown, max = 512): boolean {
44
+ return value === undefined || (typeof value === 'string' && value.trim().length > 0 && value.length <= max)
45
+ }
46
+
47
+ export function isRecordArray(value: unknown): value is readonly Record<string, unknown>[] {
48
+ return Array.isArray(value) && value.every(isRecord)
49
+ }
50
+
51
+ export function isOptionalRecord(value: unknown): boolean {
52
+ return value === undefined || isRecord(value)
53
+ }
54
+
55
+ export function isOptionalBoolean(value: unknown): boolean {
56
+ return value === undefined || typeof value === 'boolean'
57
+ }
58
+
59
+ export function isOptionalString(value: unknown): boolean {
60
+ return value === undefined || typeof value === 'string'
61
+ }