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

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 (51) hide show
  1. package/dist/brain-routing.d.ts +2 -2
  2. package/dist/index.d.ts +12 -0
  3. package/dist/index.js +3264 -35
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2931 -35
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/keyed-doc-delta.d.ts +83 -0
  8. package/dist/keyed-status-delta.d.ts +76 -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 +70 -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 +381 -0
  16. package/dist/protocol/dashboard-daemon-p2p.d.ts +216 -0
  17. package/dist/protocol/dashboard-server.d.ts +240 -0
  18. package/dist/protocol/envelope.d.ts +55 -0
  19. package/dist/protocol/index.d.ts +15 -0
  20. package/dist/session-launch.d.ts +113 -0
  21. package/dist/session-status.d.ts +61 -0
  22. package/dist/slot-proposal.d.ts +1 -1
  23. package/dist/turn-evidence.d.ts +438 -0
  24. package/dist/turn-ipc.d.ts +1040 -0
  25. package/dist/types.d.ts +11 -0
  26. package/dist/worker-protocol.d.ts +69 -0
  27. package/dist/ws-protocol.d.ts +71 -41
  28. package/package.json +1 -1
  29. package/src/brain-routing.ts +3 -3
  30. package/src/index.ts +12 -0
  31. package/src/keyed-doc-delta.ts +348 -0
  32. package/src/keyed-status-delta.ts +239 -0
  33. package/src/magi.ts +1 -1
  34. package/src/mesh-tool-names.ts +65 -19
  35. package/src/mesh-vocabulary.ts +98 -0
  36. package/src/mission-brief.ts +153 -0
  37. package/src/outbound-message.ts +320 -0
  38. package/src/path-ownership.ts +271 -0
  39. package/src/protocol/daemon-server.ts +545 -0
  40. package/src/protocol/dashboard-daemon-p2p.ts +280 -0
  41. package/src/protocol/dashboard-server.ts +340 -0
  42. package/src/protocol/envelope.ts +75 -0
  43. package/src/protocol/index.ts +16 -0
  44. package/src/session-launch.ts +247 -0
  45. package/src/session-status.ts +140 -0
  46. package/src/slot-proposal.ts +1 -1
  47. package/src/turn-evidence.ts +602 -0
  48. package/src/turn-ipc.ts +2331 -0
  49. package/src/types.ts +8 -0
  50. package/src/worker-protocol.ts +164 -0
  51. package/src/ws-protocol.ts +102 -93
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Keyed status delta — the diff/fold pair behind the `daemon.metadata` lane
3
+ * (the ONE daemon→dashboard state lane; data-path audit 2026-09-29 P0-3).
4
+ *
5
+ * The daemon digests every body it would have sent, diffs it against the
6
+ * digest of the last body DELIVERED to a subscription, and sends only the
7
+ * difference: changed top-level fields, changed daemon-level `status` fields,
8
+ * and per-session changed fields keyed by session id, with removals explicit.
9
+ * An unchanged state diffs to `null` — zero bytes on the wire. The dashboard
10
+ * folds each delta into its held snapshot; the fold of every delta since a
11
+ * snapshot equals the latest snapshot (pinned by keyed-status-delta.test.ts).
12
+ *
13
+ * Pure leaf: plain objects in, plain objects out; no transport, no clock.
14
+ */
15
+
16
+ /** A session row: identified by `id`, everything else is a field. */
17
+ export interface KeyedStatusSession {
18
+ id: string
19
+ [field: string]: unknown
20
+ }
21
+
22
+ /** The snapshot body being diffed: top-level fields plus a `status` with a keyed session list. */
23
+ export interface KeyedStatusBody {
24
+ status: { sessions: KeyedStatusSession[]; [field: string]: unknown }
25
+ [field: string]: unknown
26
+ }
27
+
28
+ export interface KeyedStatusDigestOptions {
29
+ /** Top-level keys that are never diffed (envelope identity: daemonId, …). */
30
+ ignoreTopLevel?: readonly string[]
31
+ /** `status` keys that are never diffed (in addition to `sessions`). */
32
+ ignoreStatus?: readonly string[]
33
+ /**
34
+ * Session fields that change on every build without meaning anything
35
+ * (a build-time `lastUpdated` stamp). A change to ONLY these fields is not
36
+ * a change; when something else changed they ride along.
37
+ */
38
+ volatileSessionFields?: readonly string[]
39
+ }
40
+
41
+ interface SessionDigest {
42
+ readonly value: KeyedStatusSession
43
+ /** Signature of every non-volatile field (fast unchanged check). */
44
+ readonly sig: string
45
+ /** Per-field signatures, computed lazily — only for sessions that changed. */
46
+ fieldSigs?: Map<string, string>
47
+ }
48
+
49
+ export interface KeyedStatusDigest {
50
+ readonly top: ReadonlyMap<string, string>
51
+ readonly topValues: Readonly<Record<string, unknown>>
52
+ readonly status: ReadonlyMap<string, string>
53
+ readonly statusValues: Readonly<Record<string, unknown>>
54
+ readonly sessions: ReadonlyMap<string, SessionDigest>
55
+ readonly order: readonly string[]
56
+ readonly volatile: ReadonlySet<string>
57
+ }
58
+
59
+ export interface KeyedStatusDelta {
60
+ set?: Record<string, unknown>
61
+ unset?: string[]
62
+ statusSet?: Record<string, unknown>
63
+ statusUnset?: string[]
64
+ sessions?: KeyedStatusSession[]
65
+ sessionUnset?: Record<string, string[]>
66
+ removedSessionIds?: string[]
67
+ sessionOrder?: string[]
68
+ }
69
+
70
+ function sig(value: unknown): string | undefined {
71
+ if (value === undefined) return undefined
72
+ try {
73
+ return JSON.stringify(value)
74
+ } catch {
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ function digestFields(
80
+ source: Record<string, unknown>,
81
+ ignore: ReadonlySet<string>,
82
+ ): { sigs: Map<string, string>; values: Record<string, unknown> } {
83
+ const sigs = new Map<string, string>()
84
+ const values: Record<string, unknown> = {}
85
+ for (const [key, value] of Object.entries(source)) {
86
+ if (ignore.has(key)) continue
87
+ const s = sig(value)
88
+ if (s === undefined) continue
89
+ sigs.set(key, s)
90
+ values[key] = value
91
+ }
92
+ return { sigs, values }
93
+ }
94
+
95
+ function sessionSignature(session: KeyedStatusSession, volatile: ReadonlySet<string>): string {
96
+ if (volatile.size === 0) return sig(session) ?? ''
97
+ const stable: Record<string, unknown> = {}
98
+ for (const [key, value] of Object.entries(session)) {
99
+ if (!volatile.has(key)) stable[key] = value
100
+ }
101
+ return sig(stable) ?? ''
102
+ }
103
+
104
+ function fieldSigsOf(digest: SessionDigest): Map<string, string> {
105
+ if (!digest.fieldSigs) {
106
+ const sigs = new Map<string, string>()
107
+ for (const [key, value] of Object.entries(digest.value)) {
108
+ const s = sig(value)
109
+ if (s !== undefined) sigs.set(key, s)
110
+ }
111
+ digest.fieldSigs = sigs
112
+ }
113
+ return digest.fieldSigs
114
+ }
115
+
116
+ /** Digest one body. Cheap to hold: values are referenced, not copied. */
117
+ export function digestKeyedStatus(body: KeyedStatusBody, options: KeyedStatusDigestOptions = {}): KeyedStatusDigest {
118
+ const volatile = new Set(options.volatileSessionFields ?? [])
119
+ const top = digestFields(body as Record<string, unknown>, new Set(['status', ...(options.ignoreTopLevel ?? [])]))
120
+ const status = digestFields(body.status, new Set(['sessions', ...(options.ignoreStatus ?? [])]))
121
+ const sessions = new Map<string, SessionDigest>()
122
+ const order: string[] = []
123
+ for (const session of Array.isArray(body.status?.sessions) ? body.status.sessions : []) {
124
+ if (!session || typeof session.id !== 'string' || !session.id) continue
125
+ if (sessions.has(session.id)) continue
126
+ sessions.set(session.id, { value: session, sig: sessionSignature(session, volatile) })
127
+ order.push(session.id)
128
+ }
129
+ return { top: top.sigs, topValues: top.values, status: status.sigs, statusValues: status.values, sessions, order, volatile }
130
+ }
131
+
132
+ function diffFieldMaps(
133
+ prev: ReadonlyMap<string, string>,
134
+ next: ReadonlyMap<string, string>,
135
+ nextValues: Readonly<Record<string, unknown>>,
136
+ ): { set?: Record<string, unknown>; unset?: string[] } {
137
+ let set: Record<string, unknown> | undefined
138
+ let unset: string[] | undefined
139
+ for (const [key, s] of next) {
140
+ if (prev.get(key) === s) continue
141
+ ;(set ??= {})[key] = nextValues[key]
142
+ }
143
+ for (const key of prev.keys()) {
144
+ if (!next.has(key)) (unset ??= []).push(key)
145
+ }
146
+ return { ...(set ? { set } : {}), ...(unset ? { unset } : {}) }
147
+ }
148
+
149
+ function sameOrder(a: readonly string[], b: readonly string[]): boolean {
150
+ if (a.length !== b.length) return false
151
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
152
+ return true
153
+ }
154
+
155
+ /**
156
+ * The difference `prev → next`, or `null` when nothing a consumer can observe
157
+ * changed. `prev === null` is not accepted here — a subscription with no
158
+ * delivered baseline gets a snapshot, not a delta.
159
+ */
160
+ export function diffKeyedStatus(prev: KeyedStatusDigest, next: KeyedStatusDigest): KeyedStatusDelta | null {
161
+ const delta: KeyedStatusDelta = {}
162
+ const top = diffFieldMaps(prev.top, next.top, next.topValues)
163
+ if (top.set) delta.set = top.set
164
+ if (top.unset) delta.unset = top.unset
165
+ const status = diffFieldMaps(prev.status, next.status, next.statusValues)
166
+ if (status.set) delta.statusSet = status.set
167
+ if (status.unset) delta.statusUnset = status.unset
168
+
169
+ for (const [id, nextSession] of next.sessions) {
170
+ const prevSession = prev.sessions.get(id)
171
+ if (!prevSession) {
172
+ ;(delta.sessions ??= []).push(nextSession.value)
173
+ continue
174
+ }
175
+ if (prevSession.sig === nextSession.sig) continue
176
+ const prevSigs = fieldSigsOf(prevSession)
177
+ const nextSigs = fieldSigsOf(nextSession)
178
+ const change: KeyedStatusSession = { id }
179
+ let changed = false
180
+ for (const [key, s] of nextSigs) {
181
+ if (key === 'id' || prevSigs.get(key) === s) continue
182
+ change[key] = nextSession.value[key]
183
+ changed = true
184
+ }
185
+ const gone: string[] = []
186
+ for (const key of prevSigs.keys()) {
187
+ if (!nextSigs.has(key)) gone.push(key)
188
+ }
189
+ if (changed) (delta.sessions ??= []).push(change)
190
+ if (gone.length > 0) (delta.sessionUnset ??= {})[id] = gone
191
+ }
192
+ for (const id of prev.sessions.keys()) {
193
+ if (!next.sessions.has(id)) (delta.removedSessionIds ??= []).push(id)
194
+ }
195
+ if (!sameOrder(prev.order, next.order)) delta.sessionOrder = [...next.order]
196
+
197
+ return Object.keys(delta).length > 0 ? delta : null
198
+ }
199
+
200
+ /** Rebuild the full body from a held one plus a delta. Never mutates `held`. */
201
+ export function foldKeyedStatus<T extends KeyedStatusBody>(held: T, delta: KeyedStatusDelta): T {
202
+ const next: Record<string, unknown> = { ...held }
203
+ for (const key of delta.unset ?? []) delete next[key]
204
+ Object.assign(next, delta.set ?? {})
205
+
206
+ const status: Record<string, unknown> = { ...held.status }
207
+ for (const key of delta.statusUnset ?? []) delete status[key]
208
+ Object.assign(status, delta.statusSet ?? {})
209
+
210
+ const byId = new Map<string, KeyedStatusSession>()
211
+ for (const session of held.status.sessions ?? []) byId.set(session.id, session)
212
+ for (const id of delta.removedSessionIds ?? []) byId.delete(id)
213
+ for (const change of delta.sessions ?? []) {
214
+ const base = byId.get(change.id)
215
+ byId.set(change.id, base ? { ...base, ...change } : { ...change })
216
+ }
217
+ for (const [id, keys] of Object.entries(delta.sessionUnset ?? {})) {
218
+ const base = byId.get(id)
219
+ if (!base) continue
220
+ const trimmed = { ...base }
221
+ for (const key of keys) delete trimmed[key]
222
+ byId.set(id, trimmed)
223
+ }
224
+ const order = delta.sessionOrder ?? (held.status.sessions ?? []).map((session) => session.id)
225
+ const sessions: KeyedStatusSession[] = []
226
+ const placed = new Set<string>()
227
+ for (const id of order) {
228
+ const session = byId.get(id)
229
+ if (!session || placed.has(id)) continue
230
+ sessions.push(session)
231
+ placed.add(id)
232
+ }
233
+ for (const [id, session] of byId) {
234
+ if (!placed.has(id)) sessions.push(session)
235
+ }
236
+ status.sessions = sessions
237
+ next.status = status
238
+ return next as T
239
+ }
package/src/magi.ts CHANGED
@@ -59,7 +59,7 @@
59
59
  * already pinned the (node, provider) target, so any difficulty would be inert at best
60
60
  * and would fight the panel's own slot selection at worst. To change how hard a
61
61
  * replica thinks or how much parallelism a node grants, edit that NODE's capability
62
- * slots (`mesh_node_slots_set`); it is a different axis, on purpose.
62
+ * slots (`mesh_node_slots` action "set"); it is a different axis, on purpose.
63
63
  *
64
64
  * Unknown keys are dropped rather than rejected, so slots written by another version
65
65
  * stay readable. Write paths pair the normalizer with
@@ -28,9 +28,7 @@ export const CANONICAL_MESH_TOOL_NAMES = [
28
28
  'mesh_view_queue',
29
29
  // GRAPH-ORCHESTRATION Phase E — the coordinator gate + graph view surface.
30
30
  'mesh_graph_view',
31
- 'mesh_graph_gate_claim',
32
- 'mesh_graph_gate_release',
33
- 'mesh_graph_gate_abandon',
31
+ 'mesh_graph_gate',
34
32
  'mesh_graph_node_patch',
35
33
  'mesh_queue_cancel',
36
34
  'mesh_queue_requeue',
@@ -48,7 +46,6 @@ export const CANONICAL_MESH_TOOL_NAMES = [
48
46
  'mesh_approve',
49
47
  'mesh_answer_question',
50
48
  'mesh_list_pending_approvals',
51
- 'mesh_plan_onboarding',
52
49
  'mesh_create',
53
50
  'mesh_add_node',
54
51
  'mesh_clone_node',
@@ -56,35 +53,84 @@ export const CANONICAL_MESH_TOOL_NAMES = [
56
53
  'mesh_cleanup_worktree_nodes',
57
54
  'mesh_refine_node',
58
55
  'mesh_refine_batch',
59
- 'mesh_refine_config',
60
- 'mesh_change_impact_config',
56
+ 'mesh_config',
61
57
  'mesh_init',
62
- 'mesh_reinit',
63
- 'mesh_write_mesh_json_config',
64
58
  'mesh_refine_plan',
65
59
  'mesh_cleanup_sessions',
66
- 'mesh_prune_stale_direct',
67
60
  'mesh_task_history',
68
61
  'mesh_ledger_query',
69
- 'mesh_record_note',
70
- 'mesh_forget_note',
62
+ 'mesh_note',
71
63
  'mesh_reconcile_ledger',
72
- 'mesh_requeue_held_events',
73
64
  'mesh_mission_upsert',
74
65
  'mesh_mission_list',
75
66
  'mesh_review_inbox',
76
67
  'mesh_magi_review',
77
68
  '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',
69
+ 'mesh_magi_kind_panel',
70
+ 'mesh_node_slots',
71
+ 'mesh_coordinator_prompt_append',
85
72
  ] as const;
86
73
 
87
74
  export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
88
75
 
89
76
  /** The count the `NN tools` barrel doc comments and consistency test assert against. */
90
77
  export const CANONICAL_MESH_TOOL_COUNT = CANONICAL_MESH_TOOL_NAMES.length;
78
+
79
+ /**
80
+ * Tool names retired by the 2026-09-26 tool-surface consolidation (60 → 48).
81
+ * Every capability behind them is still reachable: each retired name maps to the
82
+ * merged tool plus the discriminator argument that selects the same behaviour.
83
+ *
84
+ * mcp-server answers a call to a retired name with an error naming the
85
+ * replacement (it does NOT forward silently — a coordinator running an old
86
+ * prompt must learn the new spelling), and the daemon-core coordinator prompt
87
+ * test asserts none of these names is advertised anywhere in the prompt.
88
+ *
89
+ * `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).
92
+ */
93
+ export const RETIRED_MESH_TOOLS: Readonly<Record<string, {
94
+ readonly tool: CanonicalMeshToolName;
95
+ readonly args: Readonly<Record<string, string>>;
96
+ readonly note?: string;
97
+ }>> = {
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
+ mesh_node_slots_set: { tool: 'mesh_node_slots', args: { action: 'set' } },
109
+ mesh_node_slots_list: { tool: 'mesh_node_slots', args: { action: 'list' } },
110
+ 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
+ mesh_coordinator_prompt_append_get: { tool: 'mesh_coordinator_prompt_append', args: { action: 'get' } },
114
+ mesh_coordinator_prompt_append_set: { tool: 'mesh_coordinator_prompt_append', args: { action: 'set' } },
115
+ mesh_record_note: { tool: 'mesh_note', args: { action: 'record' } },
116
+ mesh_forget_note: { tool: 'mesh_note', args: { action: 'forget' } },
117
+ mesh_reinit: { tool: 'mesh_init', args: { mode: 'reinit' } },
118
+ mesh_refine_config: { tool: 'mesh_config', args: { kind: 'refine' } },
119
+ mesh_change_impact_config: { tool: 'mesh_config', args: { kind: 'change_impact' } },
120
+ mesh_write_mesh_json_config: { tool: 'mesh_config', args: { kind: 'mesh_json' } },
121
+ mesh_plan_onboarding: { tool: 'mesh_create', args: { mode: 'plan' } },
122
+ mesh_prune_stale_direct: { tool: 'mesh_cleanup_sessions', args: { mode: 'prune_stale_direct' } },
123
+ };
124
+
125
+ /**
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.
129
+ */
130
+ export function retiredMeshToolError(name: string): string | null {
131
+ const entry = Object.prototype.hasOwnProperty.call(RETIRED_MESH_TOOLS, name) ? RETIRED_MESH_TOOLS[name] : undefined;
132
+ if (!entry) return null;
133
+ const discriminator = Object.entries(entry.args).map(([k, v]) => `${k}: "${v}"`).join(', ');
134
+ return `Tool "${name}" was retired (merged into ${entry.tool}). Call ${entry.tool} with ${discriminator} and the same other arguments.`
135
+ + (entry.note ? ` ${entry.note}` : '');
136
+ }
@@ -0,0 +1,98 @@
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 or a MAGI fan-out
50
+ * settles. Declared here so daemon-core (`RepoMeshSessionCleanupMode`), the
51
+ * web dashboard picker and the MCP schemas all read one list.
52
+ */
53
+ export const MESH_SESSION_CLEANUP_MODES = ['preserve', 'stop', 'delete_stopped', 'stop_and_delete'] as const
54
+ export type MeshSessionCleanupMode = typeof MESH_SESSION_CLEANUP_MODES[number]
55
+
56
+ /** Outcome a delegated worker may report through `report_completion`. */
57
+ export const WORKER_REPORT_OUTCOMES = ['completed', 'blocked', 'failed'] as const
58
+ export type WorkerReportOutcome = typeof WORKER_REPORT_OUTCOMES[number]
59
+
60
+ /** Final branch state a worker reports (see coordinator convergence rules). */
61
+ export const WORKER_BRANCH_STATES = [
62
+ 'merged_to_main',
63
+ 'pushed_feature_branch_needs_merge',
64
+ 'blocked_review',
65
+ 'cleanup_candidate',
66
+ 'not_mergeable',
67
+ ] as const
68
+ export type WorkerBranchState = typeof WORKER_BRANCH_STATES[number]
69
+
70
+ function makeGuard<T extends readonly string[]>(values: T): (value: unknown) => value is T[number] {
71
+ const set: ReadonlySet<string> = new Set(values)
72
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
73
+ }
74
+
75
+ export const isMeshTaskStatus = makeGuard(MESH_TASK_STATUSES)
76
+ export const isMeshTerminalTaskStatus = makeGuard(MESH_TERMINAL_TASK_STATUSES)
77
+ export const isMeshTaskMode = makeGuard(MESH_TASK_MODES)
78
+ export const isMeshTaskPriority = makeGuard(MESH_TASK_PRIORITIES)
79
+ export const isMeshThinkingLevel = makeGuard(MESH_THINKING_LEVELS)
80
+ export const isMeshDeliveryMode = makeGuard(MESH_DELIVERY_MODES)
81
+ export const isMeshSessionCleanupMode = makeGuard(MESH_SESSION_CLEANUP_MODES)
82
+ export const isWorkerReportOutcome = makeGuard(WORKER_REPORT_OUTCOMES)
83
+ export const isWorkerBranchState = makeGuard(WORKER_BRANCH_STATES)
84
+
85
+ /**
86
+ * JSON-schema fragment for a string enum, derived from the tuple. Use this in
87
+ * every MCP tool schema instead of spelling the member list again.
88
+ */
89
+ export function enumOf<T extends readonly string[]>(
90
+ values: T,
91
+ description?: string,
92
+ ): { type: 'string'; enum: T[number][]; description?: string } {
93
+ return {
94
+ type: 'string',
95
+ enum: [...values] as T[number][],
96
+ ...(description ? { description } : {}),
97
+ }
98
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * mission-brief — the goal/constraints/done-criteria/handoff a coordinator
3
+ * attaches to a mission at `mesh_mission_upsert` time, and the deterministic
4
+ * rendering of that brief into a dispatched task's worker-protocol footer
5
+ * (wiring-unification Phase H2, `docs/design/2026-09-23-wiring-unification.md`
6
+ * §7c).
7
+ *
8
+ * `mesh_mission_upsert` today carries only a free-text `goal` string — no
9
+ * structured constraints, done-criteria or handoff notes, and nothing from a
10
+ * mission ever reaches a dispatched worker's task body. This module is the
11
+ * pure, dependency-free core: a normalized `MissionBrief` shape with length
12
+ * caps (mirrors `report_completion.summary`'s "prefer specifics" discipline —
13
+ * a brief is meant to be read by a worker mid-task, not skimmed by a human),
14
+ * and a deterministic Markdown renderer capped at 1,200 chars so a mission
15
+ * with many/long fields cannot blow a dispatched task's token budget.
16
+ *
17
+ * This module does NOT decide where the rendered block is inserted (that is
18
+ * `worker-protocol.ts`'s `renderWorkerProtocolFooter`, which places it above
19
+ * the marker line — see that file), and does NOT touch storage (H2's proposal
20
+ * keeps `mesh_missions` as the row source; this is the shape callers upsert
21
+ * into/read out of that row's `goal`/new `brief` columns).
22
+ */
23
+
24
+ /** Per-field caps. Generous enough for real content, small enough that four
25
+ * arrays of them cannot silently balloon a rendered block past the render cap. */
26
+ const GOAL_MAX = 500
27
+ const LIST_ITEM_MAX = 200
28
+ const LIST_MAX_ITEMS = 12
29
+
30
+ /** Total rendered-block cap (design doc H2 "bounded payload" discipline, mirrored from `listMeshMissionsForTool`). */
31
+ export const MISSION_BRIEF_RENDER_MAX_CHARS = 1200
32
+
33
+ export interface MissionBrief {
34
+ /** What this mission is trying to accomplish. Required — an empty brief is not meaningful. */
35
+ goal: string
36
+ /** Hard constraints a worker must respect (e.g. "do not touch daemon-core", "no npm install"). */
37
+ constraints?: string[]
38
+ /** How to know the mission is actually done. */
39
+ doneCriteria?: string[]
40
+ /** Standing notes for whoever picks up mission work next. */
41
+ handoffNotes?: string[]
42
+ /** Paths this mission's tasks collectively own (H1 integration point — surfaced, not enforced here). */
43
+ ownedPaths?: string[]
44
+ }
45
+
46
+ export interface NormalizeMissionBriefResult {
47
+ /** `null` when the input had no usable `goal` — callers should treat this as "no brief", not an empty one. */
48
+ readonly brief: MissionBrief | null
49
+ /** What was trimmed/dropped, for a caller that wants to warn (never thrown). */
50
+ readonly truncated: ReadonlyArray<{ field: string; reason: 'field_too_long' | 'list_too_long' | 'item_too_long' }>
51
+ }
52
+
53
+ function truncateString(value: string, max: number): string {
54
+ if (value.length <= max) return value
55
+ return `${value.slice(0, max - 1).trimEnd()}…`
56
+ }
57
+
58
+ function normalizeStringList(
59
+ value: unknown,
60
+ field: string,
61
+ truncated: Array<{ field: string; reason: 'field_too_long' | 'list_too_long' | 'item_too_long' }>,
62
+ ): string[] | undefined {
63
+ if (value === undefined || value === null) return undefined
64
+ if (!Array.isArray(value)) return undefined
65
+ const items: string[] = []
66
+ let itemTruncated = false
67
+ for (const raw of value) {
68
+ if (typeof raw !== 'string') continue
69
+ const trimmed = raw.trim()
70
+ if (!trimmed) continue
71
+ if (trimmed.length > LIST_ITEM_MAX) itemTruncated = true
72
+ items.push(truncateString(trimmed, LIST_ITEM_MAX))
73
+ }
74
+ if (itemTruncated) truncated.push({ field, reason: 'item_too_long' })
75
+ if (items.length > LIST_MAX_ITEMS) {
76
+ truncated.push({ field, reason: 'list_too_long' })
77
+ return items.slice(0, LIST_MAX_ITEMS)
78
+ }
79
+ return items.length > 0 ? items : undefined
80
+ }
81
+
82
+ /**
83
+ * Normalize a caller-supplied brief. Never throws. Returns `brief: null` when
84
+ * there is no non-empty `goal` — the caller (e.g. `mesh_mission_upsert`'s
85
+ * handler) decides whether that means "reject" or "no brief attached", this
86
+ * module only normalizes shape.
87
+ */
88
+ export function normalizeMissionBrief(input: unknown): NormalizeMissionBriefResult {
89
+ const truncated: Array<{ field: string; reason: 'field_too_long' | 'list_too_long' | 'item_too_long' }> = []
90
+ if (!input || typeof input !== 'object') {
91
+ return { brief: null, truncated }
92
+ }
93
+ const raw = input as Record<string, unknown>
94
+
95
+ const rawGoal = typeof raw.goal === 'string' ? raw.goal.trim() : ''
96
+ if (!rawGoal) {
97
+ return { brief: null, truncated }
98
+ }
99
+ if (rawGoal.length > GOAL_MAX) truncated.push({ field: 'goal', reason: 'field_too_long' })
100
+ const goal = truncateString(rawGoal, GOAL_MAX)
101
+
102
+ const constraints = normalizeStringList(raw.constraints, 'constraints', truncated)
103
+ const doneCriteria = normalizeStringList(raw.doneCriteria ?? raw.done_criteria, 'doneCriteria', truncated)
104
+ const handoffNotes = normalizeStringList(raw.handoffNotes ?? raw.handoff_notes, 'handoffNotes', truncated)
105
+ const ownedPaths = normalizeStringList(raw.ownedPaths ?? raw.owned_paths, 'ownedPaths', truncated)
106
+
107
+ const brief: MissionBrief = { goal }
108
+ if (constraints) brief.constraints = constraints
109
+ if (doneCriteria) brief.doneCriteria = doneCriteria
110
+ if (handoffNotes) brief.handoffNotes = handoffNotes
111
+ if (ownedPaths) brief.ownedPaths = ownedPaths
112
+
113
+ return { brief, truncated }
114
+ }
115
+
116
+ function renderList(heading: string, items: readonly string[] | undefined): string[] {
117
+ if (!items || items.length === 0) return []
118
+ return [`${heading}:`, ...items.map(item => `- ${item}`)]
119
+ }
120
+
121
+ /**
122
+ * Deterministic Markdown rendering of a brief, capped at
123
+ * `MISSION_BRIEF_RENDER_MAX_CHARS`. Same field order every call (goal,
124
+ * constraints, done criteria, owned paths, handoff notes) so a snapshot test
125
+ * or a worker reading two renders of the same brief sees no drift. If the
126
+ * assembled text exceeds the cap, trailing sections are dropped whole (never
127
+ * mid-line) until it fits, and a final `(brief truncated)` marker is appended
128
+ * — a worker should never receive a block cut off mid-sentence.
129
+ */
130
+ export function renderMissionBriefBlock(brief: MissionBrief): string {
131
+ const sections: string[] = [`Mission goal: ${brief.goal}`]
132
+ sections.push(...renderList('Constraints', brief.constraints))
133
+ sections.push(...renderList('Done when', brief.doneCriteria))
134
+ sections.push(...renderList('Owned paths', brief.ownedPaths))
135
+ sections.push(...renderList('Handoff notes', brief.handoffNotes))
136
+
137
+ const full = sections.join('\n')
138
+ if (full.length <= MISSION_BRIEF_RENDER_MAX_CHARS) return full
139
+
140
+ // Drop whole lines from the end until it fits, then append the marker
141
+ // (itself budgeted into the cap so the final string never exceeds it).
142
+ const marker = '\n(brief truncated)'
143
+ const budget = MISSION_BRIEF_RENDER_MAX_CHARS - marker.length
144
+ const lines = full.split('\n')
145
+ let acc = ''
146
+ for (const line of lines) {
147
+ const next = acc ? `${acc}\n${line}` : line
148
+ if (next.length > budget) break
149
+ acc = next
150
+ }
151
+ if (!acc) acc = truncateString(lines[0] ?? '', Math.max(budget, 0))
152
+ return `${acc}${marker}`
153
+ }