@adhdev/mesh-shared 1.0.60-rc.8 → 1.0.60

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 (67) hide show
  1. package/dist/brain-routing.d.ts +6 -8
  2. package/dist/git-diff.d.ts +21 -0
  3. package/dist/index.d.ts +12 -1
  4. package/dist/index.js +2876 -77
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +2625 -72
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/interpolation.d.ts +0 -9
  9. package/dist/json.d.ts +4 -0
  10. package/dist/keyed-doc-delta.d.ts +105 -0
  11. package/dist/mesh-tool-names.d.ts +31 -2
  12. package/dist/mesh-vocabulary.d.ts +66 -0
  13. package/dist/mission-brief.d.ts +62 -0
  14. package/dist/outbound-message.d.ts +201 -0
  15. package/dist/path-ownership.d.ts +101 -0
  16. package/dist/protocol/daemon-server.d.ts +356 -0
  17. package/dist/protocol/dashboard-daemon-p2p.d.ts +213 -0
  18. package/dist/protocol/dashboard-server.d.ts +240 -0
  19. package/dist/protocol/dashboard-wire-version.d.ts +21 -0
  20. package/dist/protocol/envelope.d.ts +55 -0
  21. package/dist/protocol/index.d.ts +16 -0
  22. package/dist/session-launch.d.ts +105 -0
  23. package/dist/session-status.d.ts +75 -0
  24. package/dist/slot-proposal.d.ts +2 -28
  25. package/dist/turn-evidence.d.ts +434 -0
  26. package/dist/turn-ipc-guards.d.ts +18 -0
  27. package/dist/turn-ipc-mission.d.ts +250 -0
  28. package/dist/turn-ipc-queue.d.ts +196 -0
  29. package/dist/turn-ipc-stats.d.ts +72 -0
  30. package/dist/turn-ipc-turn.d.ts +176 -0
  31. package/dist/turn-ipc.d.ts +90 -0
  32. package/dist/types.d.ts +11 -0
  33. package/dist/worker-protocol.d.ts +69 -0
  34. package/dist/ws-protocol.d.ts +69 -41
  35. package/package.json +1 -1
  36. package/src/brain-routing.ts +9 -11
  37. package/src/git-diff.ts +48 -0
  38. package/src/index.ts +12 -1
  39. package/src/interpolation.ts +0 -18
  40. package/src/json.ts +10 -0
  41. package/src/keyed-doc-delta.ts +413 -0
  42. package/src/mesh-tool-names.ts +66 -25
  43. package/src/mesh-vocabulary.ts +94 -0
  44. package/src/mission-brief.ts +153 -0
  45. package/src/outbound-message.ts +319 -0
  46. package/src/path-ownership.ts +271 -0
  47. package/src/protocol/daemon-server.ts +510 -0
  48. package/src/protocol/dashboard-daemon-p2p.ts +273 -0
  49. package/src/protocol/dashboard-server.ts +340 -0
  50. package/src/protocol/dashboard-wire-version.ts +27 -0
  51. package/src/protocol/envelope.ts +75 -0
  52. package/src/protocol/index.ts +17 -0
  53. package/src/session-launch.ts +245 -0
  54. package/src/session-status.ts +164 -0
  55. package/src/slot-proposal.ts +2 -48
  56. package/src/turn-evidence.ts +596 -0
  57. package/src/turn-ipc-guards.ts +61 -0
  58. package/src/turn-ipc-mission.ts +647 -0
  59. package/src/turn-ipc-queue.ts +493 -0
  60. package/src/turn-ipc-stats.ts +193 -0
  61. package/src/turn-ipc-turn.ts +453 -0
  62. package/src/turn-ipc.ts +137 -0
  63. package/src/types.ts +8 -0
  64. package/src/worker-protocol.ts +164 -0
  65. package/src/ws-protocol.ts +98 -93
  66. package/dist/magi.d.ts +0 -281
  67. package/src/magi.ts +0 -310
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
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Keyed document delta — THE diff/fold engine behind every keyed dashboard
3
+ * lane: `daemon.metadata` (the daemon's state, sessions keyed by id) and
4
+ * `mesh.status` (the coordinator's mesh view, nodes / queue tasks / missions
5
+ * keyed) — data-path audit 2026-09-29 P1-4 (one engine, one spec per lane).
6
+ *
7
+ * A document is plain top-level fields, optional SPLIT objects (an object whose
8
+ * fields are diffed one by one, e.g. daemon.metadata's `status`), and named
9
+ * keyed COLLECTIONS (arrays of rows identified by an id field, possibly nested
10
+ * one object deep, e.g. `queue.tasks` / `status.sessions`). The daemon digests
11
+ * every body it would have sent, diffs it against the digest of the last body
12
+ * DELIVERED to a subscription, and sends only the difference: changed fields,
13
+ * and per collection the rows whose content changed (changed fields only), the
14
+ * fields a row lost, and the rows that disappeared. An unchanged document
15
+ * diffs to `null` — zero bytes on the wire. The dashboard folds each delta
16
+ * into its held snapshot; the fold of every delta since a snapshot equals the
17
+ * latest snapshot apart from `ignore`d fields (pinned by keyed-doc-delta.test.ts).
18
+ *
19
+ * Volatile fields (a build stamp, live process counters, a row's per-build
20
+ * stamps via `stableRow`) are not a change by themselves; when something else
21
+ * changed they ride along.
22
+ *
23
+ * Pure leaf: plain objects in, plain objects out; no transport, no clock.
24
+ */
25
+
26
+ export interface KeyedDocSpec {
27
+ /** Collection path (`nodes`, `queue.tasks`, `status.sessions`) → the id field of its rows. */
28
+ collections: Readonly<Record<string, string>>
29
+ /** Top-level objects whose own fields are diffed one by one (the rest of the object is not re-sent). */
30
+ splitObjects?: readonly string[]
31
+ /** Field paths (`daemonId`, `status.timestamp`) that are never diffed — envelope identity / clock. */
32
+ ignore?: readonly string[]
33
+ /** Field paths (top-level or inside a split object) that change on every build without meaning anything. */
34
+ volatileTopLevel?: readonly string[]
35
+ /**
36
+ * The part of a collection row that decides whether it CHANGED (drop the
37
+ * per-build stamps inside it). A row whose stable view is unchanged sends
38
+ * nothing; a changed row sends every field whose value differs, stamps
39
+ * included. Must not mutate `row`.
40
+ */
41
+ stableRow?: (collection: string, row: Record<string, unknown>) => Record<string, unknown>
42
+ }
43
+
44
+ type Row = Record<string, unknown>
45
+
46
+ interface RowDigest {
47
+ readonly value: Row
48
+ /** The row as compared (stableRow view). */
49
+ readonly stable: Row
50
+ readonly sig: string
51
+ fieldSigs?: Map<string, string>
52
+ }
53
+
54
+ interface CollectionDigest {
55
+ readonly rows: ReadonlyMap<string, RowDigest>
56
+ readonly order: readonly string[]
57
+ }
58
+
59
+ export interface KeyedDocDigest {
60
+ /** Field signatures by path (`field`, or `object.field` for a split object). */
61
+ readonly top: ReadonlyMap<string, string>
62
+ readonly topValues: Readonly<Record<string, unknown>>
63
+ readonly collections: ReadonlyMap<string, CollectionDigest>
64
+ readonly volatile: ReadonlySet<string>
65
+ }
66
+
67
+ export interface KeyedCollectionDelta {
68
+ /** New rows (whole) and changed rows (id + changed fields). */
69
+ upsert?: Row[]
70
+ /** Fields a row lost, by row id. */
71
+ unsetFields?: Record<string, string[]>
72
+ removed?: string[]
73
+ order?: string[]
74
+ /** The collection is no longer present in the document at all. */
75
+ absent?: true
76
+ }
77
+
78
+ export interface KeyedDocDelta {
79
+ set?: Record<string, unknown>
80
+ unset?: string[]
81
+ /** Per split object: its changed / lost fields. */
82
+ objects?: Record<string, { set?: Record<string, unknown>; unset?: string[] }>
83
+ collections?: Record<string, KeyedCollectionDelta>
84
+ }
85
+
86
+ function sig(value: unknown): string | undefined {
87
+ if (value === undefined) return undefined
88
+ try {
89
+ return JSON.stringify(value)
90
+ } catch {
91
+ return undefined
92
+ }
93
+ }
94
+
95
+ function isRecord(value: unknown): value is Record<string, unknown> {
96
+ return !!value && typeof value === 'object' && !Array.isArray(value)
97
+ }
98
+
99
+ /** Read the array at `path` (`a` or `a.b`) — undefined when absent. */
100
+ function readPath(doc: Record<string, unknown>, path: string): unknown {
101
+ const [head, tail] = splitPath(path)
102
+ if (!tail) return doc[head]
103
+ const parent = doc[head]
104
+ return isRecord(parent) ? parent[tail] : undefined
105
+ }
106
+
107
+ function splitPath(path: string): [string, string | undefined] {
108
+ const dot = path.indexOf('.')
109
+ return dot < 0 ? [path, undefined] : [path.slice(0, dot), path.slice(dot + 1)]
110
+ }
111
+
112
+ /**
113
+ * The document minus its collections (a nested collection is removed from a
114
+ * shallow copy of its parent, so the parent's other fields stay top-level data).
115
+ */
116
+ function stripCollections(doc: Record<string, unknown>, spec: KeyedDocSpec): Record<string, unknown> {
117
+ const out: Record<string, unknown> = { ...doc }
118
+ for (const path of Object.keys(spec.collections)) {
119
+ const [head, tail] = splitPath(path)
120
+ if (!tail) {
121
+ delete out[head]
122
+ continue
123
+ }
124
+ const parent = out[head]
125
+ if (!isRecord(parent) || !(tail in parent)) continue
126
+ const copy = { ...parent }
127
+ delete copy[tail]
128
+ out[head] = copy
129
+ }
130
+ return out
131
+ }
132
+
133
+ function fieldSigsOf(digest: RowDigest): Map<string, string> {
134
+ if (!digest.fieldSigs) {
135
+ const sigs = new Map<string, string>()
136
+ for (const [key, value] of Object.entries(digest.stable)) {
137
+ const s = sig(value)
138
+ if (s !== undefined) sigs.set(key, s)
139
+ }
140
+ digest.fieldSigs = sigs
141
+ }
142
+ return digest.fieldSigs
143
+ }
144
+
145
+ /** Digest one document. Cheap to hold: values are referenced, not copied. */
146
+ export function digestKeyedDoc(doc: Record<string, unknown>, spec: KeyedDocSpec): KeyedDocDigest {
147
+ const top = new Map<string, string>()
148
+ const topValues: Record<string, unknown> = {}
149
+ const ignore = new Set(spec.ignore ?? [])
150
+ const split = new Set(spec.splitObjects ?? [])
151
+ const add = (path: string, value: unknown) => {
152
+ if (ignore.has(path)) return
153
+ const s = sig(value)
154
+ if (s === undefined) return
155
+ top.set(path, s)
156
+ topValues[path] = value
157
+ }
158
+ for (const [key, value] of Object.entries(stripCollections(doc, spec))) {
159
+ if (split.has(key) && isRecord(value)) {
160
+ for (const [field, fieldValue] of Object.entries(value)) add(`${key}.${field}`, fieldValue)
161
+ continue
162
+ }
163
+ add(key, value)
164
+ }
165
+ const collections = new Map<string, CollectionDigest>()
166
+ for (const [path, idField] of Object.entries(spec.collections)) {
167
+ const list = readPath(doc, path)
168
+ if (!Array.isArray(list)) continue
169
+ const rows = new Map<string, RowDigest>()
170
+ const order: string[] = []
171
+ for (const row of list) {
172
+ if (!isRecord(row)) continue
173
+ const id = row[idField]
174
+ if (typeof id !== 'string' || !id || rows.has(id)) continue
175
+ const stable = spec.stableRow ? spec.stableRow(path, row) : row
176
+ rows.set(id, { value: row, stable, sig: sig(stable) ?? '' })
177
+ order.push(id)
178
+ }
179
+ collections.set(path, { rows, order })
180
+ }
181
+ return { top, topValues, collections, volatile: new Set(spec.volatileTopLevel ?? []) }
182
+ }
183
+
184
+ function sameOrder(a: readonly string[], b: readonly string[]): boolean {
185
+ if (a.length !== b.length) return false
186
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
187
+ return true
188
+ }
189
+
190
+ function diffCollection(prev: CollectionDigest | undefined, next: CollectionDigest | undefined, idField: string): KeyedCollectionDelta | null {
191
+ if (!next) return prev ? { absent: true } : null
192
+ const delta: KeyedCollectionDelta = {}
193
+ const prevRows = prev?.rows ?? new Map<string, RowDigest>()
194
+ const nextRows = next?.rows ?? new Map<string, RowDigest>()
195
+ for (const [id, nextRow] of nextRows) {
196
+ const prevRow = prevRows.get(id)
197
+ if (!prevRow) {
198
+ ;(delta.upsert ??= []).push(nextRow.value)
199
+ continue
200
+ }
201
+ if (prevRow.sig === nextRow.sig) continue
202
+ const prevSigs = fieldSigsOf(prevRow)
203
+ const nextSigs = fieldSigsOf(nextRow)
204
+ const change: Row = { [idField]: id }
205
+ let changed = false
206
+ for (const [key, s] of nextSigs) {
207
+ if (key === idField || prevSigs.get(key) === s) continue
208
+ change[key] = nextRow.value[key]
209
+ changed = true
210
+ }
211
+ // A real change carries the row's volatile fields (dropped by stableRow) along.
212
+ if (changed) {
213
+ for (const [key, value] of Object.entries(nextRow.value)) {
214
+ if (key in nextRow.stable || key in change) continue
215
+ if (sig(prevRow.value[key]) !== sig(value)) change[key] = value
216
+ }
217
+ }
218
+ const gone: string[] = []
219
+ for (const key of prevSigs.keys()) if (!nextSigs.has(key) && !(key in nextRow.value)) gone.push(key)
220
+ if (changed) (delta.upsert ??= []).push(change)
221
+ if (gone.length > 0) (delta.unsetFields ??= {})[id] = gone
222
+ }
223
+ for (const id of prevRows.keys()) {
224
+ if (!nextRows.has(id)) (delta.removed ??= []).push(id)
225
+ }
226
+ if (!sameOrder(prev?.order ?? [], next?.order ?? [])) delta.order = [...(next?.order ?? [])]
227
+ return Object.keys(delta).length > 0 ? delta : null
228
+ }
229
+
230
+ /**
231
+ * The difference `prev → next`, or `null` when nothing a consumer can observe
232
+ * changed (volatile top-level fields alone are not a change).
233
+ */
234
+ export function diffKeyedDoc(prev: KeyedDocDigest, next: KeyedDocDigest, spec: KeyedDocSpec): KeyedDocDelta | null {
235
+ const delta: KeyedDocDelta = {}
236
+ let meaningful = false
237
+ let set: Record<string, unknown> | undefined
238
+ let unset: string[] | undefined
239
+ const split = spec.splitObjects ?? []
240
+ const objectOf = (path: string): [string, string] | null => {
241
+ for (const object of split) {
242
+ if (path.startsWith(`${object}.`)) return [object, path.slice(object.length + 1)]
243
+ }
244
+ return null
245
+ }
246
+ const objects: NonNullable<KeyedDocDelta['objects']> = {}
247
+ for (const [path, s] of next.top) {
248
+ if (prev.top.get(path) === s) continue
249
+ const inObject = objectOf(path)
250
+ if (inObject) ((objects[inObject[0]] ??= {}).set ??= {})[inObject[1]] = next.topValues[path]
251
+ else (set ??= {})[path] = next.topValues[path]
252
+ if (!next.volatile.has(path)) meaningful = true
253
+ }
254
+ for (const path of prev.top.keys()) {
255
+ if (next.top.has(path)) continue
256
+ const inObject = objectOf(path)
257
+ if (inObject) ((objects[inObject[0]] ??= {}).unset ??= []).push(inObject[1])
258
+ else (unset ??= []).push(path)
259
+ if (!next.volatile.has(path)) meaningful = true
260
+ }
261
+ for (const [path, idField] of Object.entries(spec.collections)) {
262
+ const change = diffCollection(prev.collections.get(path), next.collections.get(path), idField)
263
+ if (!change) continue
264
+ ;(delta.collections ??= {})[path] = change
265
+ meaningful = true
266
+ }
267
+ if (!meaningful) return null
268
+ if (set) delta.set = set
269
+ if (unset) delta.unset = unset
270
+ if (Object.keys(objects).length > 0) delta.objects = objects
271
+ return delta
272
+ }
273
+
274
+ function foldCollection(held: unknown, change: KeyedCollectionDelta, idField: string): Row[] {
275
+ const byId = new Map<string, Row>()
276
+ const heldOrder: string[] = []
277
+ for (const row of Array.isArray(held) ? held : []) {
278
+ if (!isRecord(row) || typeof row[idField] !== 'string') continue
279
+ byId.set(row[idField] as string, row)
280
+ heldOrder.push(row[idField] as string)
281
+ }
282
+ for (const id of change.removed ?? []) byId.delete(id)
283
+ for (const row of change.upsert ?? []) {
284
+ const id = row[idField]
285
+ if (typeof id !== 'string') continue
286
+ const base = byId.get(id)
287
+ byId.set(id, base ? { ...base, ...row } : { ...row })
288
+ }
289
+ for (const [id, keys] of Object.entries(change.unsetFields ?? {})) {
290
+ const base = byId.get(id)
291
+ if (!base) continue
292
+ const trimmed = { ...base }
293
+ for (const key of keys) delete trimmed[key]
294
+ byId.set(id, trimmed)
295
+ }
296
+ const order = change.order ?? heldOrder
297
+ const out: Row[] = []
298
+ const placed = new Set<string>()
299
+ for (const id of order) {
300
+ const row = byId.get(id)
301
+ if (!row || placed.has(id)) continue
302
+ out.push(row)
303
+ placed.add(id)
304
+ }
305
+ for (const [id, row] of byId) if (!placed.has(id)) out.push(row)
306
+ return out
307
+ }
308
+
309
+ /** Rebuild the full document from a held one plus a delta. Never mutates `held`. */
310
+ export function foldKeyedDoc<T extends Record<string, unknown>>(held: T, delta: KeyedDocDelta, spec: KeyedDocSpec): T {
311
+ // Collections are re-inserted below from the held ones, so the plain fields
312
+ // are folded on the stripped view (a nested parent keeps its other fields).
313
+ const heldCollections = new Map<string, unknown>()
314
+ for (const path of Object.keys(spec.collections)) heldCollections.set(path, readPath(held, path))
315
+ const next: Record<string, unknown> = stripCollections(held, spec)
316
+ for (const key of delta.unset ?? []) delete next[key]
317
+ Object.assign(next, delta.set ?? {})
318
+ for (const [object, change] of Object.entries(delta.objects ?? {})) {
319
+ const folded: Record<string, unknown> = isRecord(next[object]) ? { ...(next[object] as Record<string, unknown>) } : {}
320
+ for (const key of change.unset ?? []) delete folded[key]
321
+ Object.assign(folded, change.set ?? {})
322
+ next[object] = folded
323
+ }
324
+ for (const [path, idField] of Object.entries(spec.collections)) {
325
+ const change = delta.collections?.[path]
326
+ const heldList = heldCollections.get(path)
327
+ if (change?.absent) {
328
+ const [head, tail] = splitPath(path)
329
+ if (!tail) delete next[head]
330
+ continue
331
+ }
332
+ if (!change && heldList === undefined) continue
333
+ const list = change ? foldCollection(heldList, change, idField) : heldList
334
+ const [head, tail] = splitPath(path)
335
+ if (!tail) {
336
+ next[head] = list
337
+ continue
338
+ }
339
+ const parent = isRecord(next[head]) ? { ...(next[head] as Record<string, unknown>) } : {}
340
+ parent[tail] = list
341
+ next[head] = parent
342
+ }
343
+ return next as T
344
+ }
345
+
346
+ /**
347
+ * The `daemon.metadata` document (the snapshot body minus its envelope):
348
+ * `status` is split field by field, its sessions keyed by `id`. `daemonId` is
349
+ * envelope identity and `status.timestamp` the build clock — never diffed (the
350
+ * fold re-stamps them from the frame); a session's `lastUpdated` build stamp is
351
+ * not a change by itself.
352
+ */
353
+ export const DAEMON_METADATA_DOC_SPEC: KeyedDocSpec = {
354
+ collections: { 'status.sessions': 'id' },
355
+ splitObjects: ['status'],
356
+ ignore: ['daemonId', 'status.timestamp'],
357
+ stableRow: (_collection, row) => {
358
+ if (!('lastUpdated' in row)) return row
359
+ const { lastUpdated: _stamp, ...stable } = row
360
+ return stable
361
+ },
362
+ }
363
+
364
+ /**
365
+ * The `mesh.status` document: nodes keyed by `nodeId`, queue tasks and missions
366
+ * by `id`. `refreshedAt` and `sourceOfTruth` (its aggregate-cache age /
367
+ * returnedAt) are per-call stamps and the counters are live process tallies —
368
+ * none of them is a change by itself.
369
+ */
370
+ export const MESH_STATUS_DOC_SPEC: KeyedDocSpec = {
371
+ collections: { nodes: 'nodeId', 'queue.tasks': 'id', missions: 'id' },
372
+ volatileTopLevel: [
373
+ 'refreshedAt', 'sourceOfTruth',
374
+ 'turnPresentationCounters', 'meshProtocolV2Counters', 'pendingRetentionCounters', 'meshProtocolMetrics',
375
+ ],
376
+ stableRow: (collection, row) => (collection === 'nodes' ? stableMeshNodeRow(row) : row),
377
+ }
378
+
379
+ function without(value: unknown, keys: readonly string[]): unknown {
380
+ if (!isRecord(value)) return value
381
+ const out = { ...value }
382
+ for (const key of keys) delete out[key]
383
+ return out
384
+ }
385
+
386
+ /**
387
+ * A mesh node minus the stamps an aggregate rebuild re-derives from the clock
388
+ * (last-seen / updated / freshness age, the local read's check time, a local
389
+ * facts bundle's reportedAt). A REMOTE node's `gitObservation.observedAt` is
390
+ * real news (the member's own check time — the dashboard's "aged" hint) and
391
+ * is compared; the coordinator's own / local checkout's is its read time.
392
+ */
393
+ function stableMeshNodeRow(row: Record<string, unknown>): Record<string, unknown> {
394
+ const out: Record<string, unknown> = { ...row }
395
+ delete out.lastSeenAt
396
+ delete out.updatedAt
397
+ delete out.last_seen_at
398
+ delete out.updated_at
399
+ if ('dataFreshness' in out) out.dataFreshness = without(out.dataFreshness, ['ageMs', 'lastProbeAt', 'staleness'])
400
+ if ('connection' in out) out.connection = without(out.connection, ['lastStateChangeAt'])
401
+ if ('git' in out) {
402
+ const git = without(out.git, ['lastCheckedAt'])
403
+ // "No submodules" is the same fact whether a read listed none or skipped the scan.
404
+ if (isRecord(git) && Array.isArray(git.submodules) && git.submodules.length === 0) delete git.submodules
405
+ out.git = git
406
+ }
407
+ if ('nodeFacts' in out) out.nodeFacts = without(out.nodeFacts, ['reportedAt'])
408
+ const observation = out.gitObservation
409
+ if (isRecord(observation) && (observation.source === 'self' || observation.source === 'local')) {
410
+ out.gitObservation = without(observation, ['observedAt'])
411
+ }
412
+ return out
413
+ }
@@ -22,16 +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_claim',
32
- 'mesh_graph_gate_release',
33
- 'mesh_graph_gate_abandon',
34
- 'mesh_graph_node_patch',
35
29
  'mesh_queue_cancel',
36
30
  'mesh_queue_requeue',
37
31
  'mesh_send_task',
@@ -48,7 +42,6 @@ export const CANONICAL_MESH_TOOL_NAMES = [
48
42
  'mesh_approve',
49
43
  'mesh_answer_question',
50
44
  'mesh_list_pending_approvals',
51
- 'mesh_plan_onboarding',
52
45
  'mesh_create',
53
46
  'mesh_add_node',
54
47
  'mesh_clone_node',
@@ -56,35 +49,83 @@ export const CANONICAL_MESH_TOOL_NAMES = [
56
49
  'mesh_cleanup_worktree_nodes',
57
50
  'mesh_refine_node',
58
51
  'mesh_refine_batch',
59
- 'mesh_refine_config',
60
- 'mesh_change_impact_config',
52
+ 'mesh_config',
61
53
  'mesh_init',
62
- 'mesh_reinit',
63
- 'mesh_write_mesh_json_config',
64
54
  'mesh_refine_plan',
65
55
  'mesh_cleanup_sessions',
66
- 'mesh_prune_stale_direct',
67
56
  'mesh_task_history',
68
57
  'mesh_ledger_query',
69
- 'mesh_record_note',
70
- 'mesh_forget_note',
58
+ 'mesh_note',
71
59
  'mesh_reconcile_ledger',
72
- 'mesh_requeue_held_events',
73
60
  'mesh_mission_upsert',
74
61
  'mesh_mission_list',
75
62
  'mesh_review_inbox',
76
- 'mesh_magi_review',
77
- 'mesh_magi_collect',
78
- 'mesh_magi_kind_panel_set',
79
- 'mesh_magi_kind_panel_list',
80
- 'mesh_node_slots_set',
81
- 'mesh_node_slots_list',
82
- 'mesh_node_slots_propose',
83
- 'mesh_coordinator_prompt_append_get',
84
- 'mesh_coordinator_prompt_append_set',
63
+ 'mesh_node_slots',
64
+ 'mesh_coordinator_prompt_append',
85
65
  ] as const;
86
66
 
87
67
  export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
88
68
 
89
69
  /** The count the `NN tools` barrel doc comments and consistency test assert against. */
90
70
  export const CANONICAL_MESH_TOOL_COUNT = CANONICAL_MESH_TOOL_NAMES.length;
71
+
72
+ /**
73
+ * Tool names retired by the 2026-09-26 tool-surface consolidation (60 → 48).
74
+ * Every capability behind them is still reachable: each retired name maps to the
75
+ * merged tool plus the discriminator argument that selects the same behaviour.
76
+ *
77
+ * mcp-server answers a call to a retired name with an error naming the
78
+ * replacement (it does NOT forward silently — a coordinator running an old
79
+ * prompt must learn the new spelling), and the daemon-core coordinator prompt
80
+ * test asserts none of these names is advertised anywhere in the prompt.
81
+ *
82
+ * `args` is the discriminator to add; every other argument keeps its name.
83
+ * `note` carries any extra guidance when an argument moved in a rename.
84
+ */
85
+ export const RETIRED_MESH_TOOLS: Readonly<Record<string, {
86
+ readonly tool: CanonicalMeshToolName;
87
+ readonly args: Readonly<Record<string, string>>;
88
+ readonly note?: string;
89
+ }>> = {
90
+ mesh_node_slots_set: { tool: 'mesh_node_slots', args: { action: 'set' } },
91
+ mesh_node_slots_list: { tool: 'mesh_node_slots', args: { action: 'list' } },
92
+ mesh_node_slots_propose: { tool: 'mesh_node_slots', args: { action: 'propose' } },
93
+ mesh_coordinator_prompt_append_get: { tool: 'mesh_coordinator_prompt_append', args: { action: 'get' } },
94
+ mesh_coordinator_prompt_append_set: { tool: 'mesh_coordinator_prompt_append', args: { action: 'set' } },
95
+ mesh_record_note: { tool: 'mesh_note', args: { action: 'record' } },
96
+ mesh_forget_note: { tool: 'mesh_note', args: { action: 'forget' } },
97
+ mesh_reinit: { tool: 'mesh_init', args: { mode: 'reinit' } },
98
+ mesh_refine_config: { tool: 'mesh_config', args: { kind: 'refine' } },
99
+ mesh_change_impact_config: { tool: 'mesh_config', args: { kind: 'change_impact' } },
100
+ mesh_write_mesh_json_config: { tool: 'mesh_config', args: { kind: 'mesh_json' } },
101
+ mesh_plan_onboarding: { tool: 'mesh_create', args: { mode: 'plan' } },
102
+ mesh_prune_stale_direct: { tool: 'mesh_cleanup_sessions', args: { mode: 'prune_stale_direct' } },
103
+ };
104
+
105
+ /**
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.
121
+ */
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
+ }
126
+ const entry = Object.prototype.hasOwnProperty.call(RETIRED_MESH_TOOLS, name) ? RETIRED_MESH_TOOLS[name] : undefined;
127
+ if (!entry) return null;
128
+ const discriminator = Object.entries(entry.args).map(([k, v]) => `${k}: "${v}"`).join(', ');
129
+ return `Tool "${name}" was retired (merged into ${entry.tool}). Call ${entry.tool} with ${discriminator} and the same other arguments.`
130
+ + (entry.note ? ` ${entry.note}` : '');
131
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * mesh-vocabulary — the ONE declaration of every mesh enum.
3
+ *
4
+ * Wiring-unification Phase A3 (docs/design/2026-09-23-wiring-unification.md).
5
+ *
6
+ * Before this file `MeshTaskStatus` was declared twice in daemon-core with
7
+ * different members (one carried `in_progress`, which no task row ever held),
8
+ * a third time in `repo-mesh-types.ts`, and loosened to `| string` in web-core;
9
+ * the MCP tool schemas hand-copied the task-mode list six times. Every enum a
10
+ * mesh tool, ledger row, queue entry or dashboard view branches on is declared
11
+ * here once, as a `const` tuple, and the type is derived from it. JSON-schema
12
+ * producers use `enumOf()` so the published schema cannot drift from the code.
13
+ *
14
+ * `MeshTaskDifficulty` / `MESH_TASK_DIFFICULTIES` already live in
15
+ * ./brain-routing.ts and are re-exported here for one-stop discovery.
16
+ */
17
+
18
+ export { MESH_TASK_DIFFICULTIES, type MeshTaskDifficulty } from './brain-routing'
19
+
20
+ export const MESH_TASK_STATUSES = ['pending', 'assigned', 'completed', 'failed', 'cancelled'] as const
21
+ export type MeshTaskStatus = typeof MESH_TASK_STATUSES[number]
22
+
23
+ export const MESH_TERMINAL_TASK_STATUSES = ['completed', 'failed', 'cancelled'] as const
24
+ export type MeshTerminalTaskStatus = typeof MESH_TERMINAL_TASK_STATUSES[number]
25
+
26
+ export const MESH_TASK_MODES = ['code_change', 'validation', 'live_debug_readonly', 'launch_app', 'convergence'] as const
27
+ export type MeshTaskMode = typeof MESH_TASK_MODES[number]
28
+
29
+ export const MESH_TASK_PRIORITIES = ['low', 'normal', 'high'] as const
30
+ export type MeshTaskPriority = typeof MESH_TASK_PRIORITIES[number]
31
+
32
+ export const MESH_THINKING_LEVELS = ['low', 'medium', 'high'] as const
33
+ export type MeshThinkingLevel = typeof MESH_THINKING_LEVELS[number]
34
+
35
+ /**
36
+ * How a message is delivered to a session that may be busy.
37
+ *
38
+ * 'when_idle' — DEFAULT. Never disturbs a running turn; a busy session's task
39
+ * is queued and auto-delivered on its next idle transition.
40
+ * 'interrupt' — Abort the in-flight turn (the provider's own stop control),
41
+ * then deliver once the session settles to idle. The name is
42
+ * deliberately blunt: the unfinished work is LOST. `immediate`
43
+ * was rejected as a name because it reads like a gentle overlay.
44
+ */
45
+ export const MESH_DELIVERY_MODES = ['when_idle', 'interrupt'] as const
46
+ export type MeshDeliveryMode = typeof MESH_DELIVERY_MODES[number]
47
+
48
+ /**
49
+ * What to do with a node's sessions when the node is removed. Declared here so daemon-core (`RepoMeshSessionCleanupMode`), the
50
+ * web dashboard picker and the MCP schemas all read one list.
51
+ */
52
+ export const MESH_SESSION_CLEANUP_MODES = ['preserve', 'stop', 'delete_stopped', 'stop_and_delete'] as const
53
+ export type MeshSessionCleanupMode = typeof MESH_SESSION_CLEANUP_MODES[number]
54
+
55
+ /** Outcome a delegated worker may report through `report_completion`. */
56
+ export const WORKER_REPORT_OUTCOMES = ['completed', 'blocked', 'failed'] as const
57
+ export type WorkerReportOutcome = typeof WORKER_REPORT_OUTCOMES[number]
58
+
59
+ /** Final branch state a worker reports (see coordinator convergence rules). */
60
+ export const WORKER_BRANCH_STATES = [
61
+ 'merged_to_main',
62
+ 'pushed_feature_branch_needs_merge',
63
+ 'blocked_review',
64
+ 'cleanup_candidate',
65
+ 'not_mergeable',
66
+ ] as const
67
+ export type WorkerBranchState = typeof WORKER_BRANCH_STATES[number]
68
+
69
+ function makeGuard<T extends readonly string[]>(values: T): (value: unknown) => value is T[number] {
70
+ const set: ReadonlySet<string> = new Set(values)
71
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
72
+ }
73
+
74
+ export const isMeshTaskStatus = makeGuard(MESH_TASK_STATUSES)
75
+ export const isMeshTerminalTaskStatus = makeGuard(MESH_TERMINAL_TASK_STATUSES)
76
+ export const isMeshTaskMode = makeGuard(MESH_TASK_MODES)
77
+ export const isMeshTaskPriority = makeGuard(MESH_TASK_PRIORITIES)
78
+ export const isMeshDeliveryMode = makeGuard(MESH_DELIVERY_MODES)
79
+ export const isWorkerReportOutcome = makeGuard(WORKER_REPORT_OUTCOMES)
80
+
81
+ /**
82
+ * JSON-schema fragment for a string enum, derived from the tuple. Use this in
83
+ * every MCP tool schema instead of spelling the member list again.
84
+ */
85
+ export function enumOf<T extends readonly string[]>(
86
+ values: T,
87
+ description?: string,
88
+ ): { type: 'string'; enum: T[number][]; description?: string } {
89
+ return {
90
+ type: 'string',
91
+ enum: [...values] as T[number][],
92
+ ...(description ? { description } : {}),
93
+ }
94
+ }