@adhdev/mesh-shared 1.0.60-rc.33 → 1.0.60-rc.35

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 (39) hide show
  1. package/dist/index.d.ts +10 -0
  2. package/dist/index.js +2742 -17
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +2420 -17
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh-tool-names.d.ts +2 -2
  7. package/dist/mesh-vocabulary.d.ts +70 -0
  8. package/dist/mission-brief.d.ts +62 -0
  9. package/dist/outbound-message.d.ts +202 -0
  10. package/dist/path-ownership.d.ts +101 -0
  11. package/dist/protocol/daemon-server.d.ts +388 -0
  12. package/dist/protocol/dashboard-daemon-p2p.d.ts +212 -0
  13. package/dist/protocol/dashboard-server.d.ts +240 -0
  14. package/dist/protocol/envelope.d.ts +55 -0
  15. package/dist/protocol/index.d.ts +15 -0
  16. package/dist/session-launch.d.ts +113 -0
  17. package/dist/session-status.d.ts +61 -0
  18. package/dist/turn-evidence.d.ts +393 -0
  19. package/dist/turn-ipc.d.ts +974 -0
  20. package/dist/worker-protocol.d.ts +69 -0
  21. package/dist/ws-protocol.d.ts +26 -43
  22. package/package.json +1 -1
  23. package/src/index.ts +10 -0
  24. package/src/mesh-tool-names.ts +0 -1
  25. package/src/mesh-vocabulary.ts +98 -0
  26. package/src/mission-brief.ts +153 -0
  27. package/src/outbound-message.ts +320 -0
  28. package/src/path-ownership.ts +271 -0
  29. package/src/protocol/daemon-server.ts +552 -0
  30. package/src/protocol/dashboard-daemon-p2p.ts +272 -0
  31. package/src/protocol/dashboard-server.ts +340 -0
  32. package/src/protocol/envelope.ts +75 -0
  33. package/src/protocol/index.ts +16 -0
  34. package/src/session-launch.ts +247 -0
  35. package/src/session-status.ts +140 -0
  36. package/src/turn-evidence.ts +542 -0
  37. package/src/turn-ipc.ts +2231 -0
  38. package/src/worker-protocol.ts +142 -0
  39. package/src/ws-protocol.ts +41 -95
@@ -0,0 +1,69 @@
1
+ /**
2
+ * worker-protocol — what a delegated worker is told, and what it may call.
3
+ *
4
+ * Wiring-unification Phase F1 (docs/design/2026-09-23-wiring-unification.md §7b).
5
+ *
6
+ * Measured 2026-09-23 on the live preview ledger: 635 worker attempts in 14
7
+ * days, 3 `report_completion` calls. The worker MCP was delivered to every
8
+ * provider and never used, because nothing ever told the worker it existed —
9
+ * the dispatched task body carried no instruction and the coordinator prompt
10
+ * did not mention the worker tools at all. The only text a worker saw about
11
+ * its tools was the tool description.
12
+ *
13
+ * This module is the single source for BOTH sides of that contract:
14
+ * - `WORKER_TOOLS` — exactly what `adhdev mcp --mode worker` advertises
15
+ * (a parity test in mcp-server pins ListTools to this tuple);
16
+ * - `renderWorkerProtocolFooter()` — the block appended to EVERY dispatched
17
+ * task body, so a worker learns the protocol from the task itself;
18
+ * - `renderCoordinatorWorkerSection()` — the matching paragraph the
19
+ * coordinator prompt carries, so the coordinator expects structured reports
20
+ * and stops polling for them.
21
+ *
22
+ * Wiring-unification Phase H2 adds an optional mission-brief block: when a
23
+ * dispatched task belongs to a mission that carries a `MissionBrief`
24
+ * (`./mission-brief.ts`), `renderWorkerProtocolFooter` renders it ABOVE the
25
+ * `WORKER_PROTOCOL_FOOTER_MARKER` line. This is deliberate placement, not an
26
+ * accident of insertion order: `hasWorkerProtocolFooter` / `stripWorkerProtocolFooter`
27
+ * both key off the marker line alone, so a body with a brief block strips
28
+ * identically to one without — the brief is authored content from the
29
+ * coordinator's point of view (like the task message itself), never part of
30
+ * the protocol contract the marker demarcates.
31
+ */
32
+ import { type MissionBrief } from './mission-brief';
33
+ export declare const WORKER_TOOLS: readonly ["report_completion", "progress_update", "peer_context_pull", "git_status", "git_log", "git_diff"];
34
+ export type WorkerTool = typeof WORKER_TOOLS[number];
35
+ export declare function isWorkerTool(value: unknown): value is WorkerTool;
36
+ /** Stable marker line so a body is never footered twice and tests can find it. */
37
+ export declare const WORKER_PROTOCOL_FOOTER_MARKER = "--- adhdev worker protocol ---";
38
+ export interface WorkerProtocolFooterInput {
39
+ taskId?: string;
40
+ taskMode?: string;
41
+ difficulty?: string;
42
+ readonly?: boolean;
43
+ /** Number of handoff notes enclosed above the footer, when any. */
44
+ enclosedHandoffNotes?: number;
45
+ /** H2: the owning mission's brief, when this task belongs to one. Rendered above the marker line — see module doc. */
46
+ missionBrief?: MissionBrief;
47
+ }
48
+ export declare function hasWorkerProtocolFooter(body: string): boolean;
49
+ /**
50
+ * The protocol block appended to a dispatched task. Written for the model that
51
+ * receives it: imperative, short, and explicit about what is NOT available so
52
+ * the worker does not go looking for coordinator tools.
53
+ */
54
+ export declare function renderWorkerProtocolFooter(input?: WorkerProtocolFooterInput): string;
55
+ /**
56
+ * The authored text of a dispatched body: everything before the footer marker.
57
+ * Ledger descriptors, replay fixtures and tests compare authored text, never
58
+ * the protocol block.
59
+ */
60
+ export declare function stripWorkerProtocolFooter(body: string): string;
61
+ /**
62
+ * Append the footer to a body that does not carry one yet. Idempotent.
63
+ */
64
+ export declare function appendWorkerProtocolFooter(body: string, input?: WorkerProtocolFooterInput): string;
65
+ /**
66
+ * The paragraph the COORDINATOR prompt carries about its workers. Kept next to
67
+ * the footer so the two halves of the protocol cannot drift apart.
68
+ */
69
+ export declare function renderCoordinatorWorkerSection(): string;
@@ -1,53 +1,36 @@
1
1
  /**
2
- * ws-protocol — shared string-literal unions for the daemon↔server WS surface
3
- * and the daemon↔dashboard P2P DataChannel surface.
2
+ * ws-protocol — name-only aliases for the daemon↔server WS surface and the
3
+ * daemon↔dashboard P2P DataChannel surface, plus the `auth_ok.payload.limits`
4
+ * contract.
4
5
  *
5
- * Fragmentation audit: these message types existed as a TypeScript union in
6
- * exactly ONE package (the proprietary daemon-cloud's server-connection.ts),
7
- * which is a leaf CONSUMER — the other two participants (the Workers server
8
- * and OSS daemon-core, which is the primary `status_report` producer) matched
9
- * bare string literals by hand. Renaming `auth_ok` server-side would compile
10
- * everywhere and leave the daemon reconnecting forever. Pure literals, zero
11
- * runtime deps — the textbook mesh-shared leaf.
12
- *
13
- * SCOPE HONESTY: this file declares the OSS-visible protocol surface. The
14
- * proprietary repo's server-connection.ts remains the authority for the full
15
- * ServerToDaemon command set; it should adopt these unions and extend them
16
- * (`ServerToDaemonMsg | <proprietary extras>`) rather than re-declaring the
17
- * shared members. Members here are the ones OSS daemon-core itself produces
18
- * or matches.
19
- */
20
- /** Messages the daemon sends UP to the Workers server over the WS bridge. */
21
- export type DaemonToServerWsMsg = 'auth' | 'status_report' | 'status_heartbeat' | 'status_event' | 'command_result' | 'error' | 'agent_event' | 'log'
22
- /**
23
- * seqscribe Beacon vectors (design §7.1). ONE daemon-initiated frame carries
24
- * both directions — `op: 'put'` stores this node's content-free vector
25
- * report, `op: 'get'` asks for the board — because the server has no way to
26
- * wake itself: `DaemonConnectionDO` has neither an alarm nor a timer, and
27
- * adding one would hit the most request-quota-pressured axis in the system.
28
- */
29
- | 'beacon_vectors';
30
- /** Server→daemon control messages the OSS engine reacts to. */
31
- export type ServerToDaemonWsMsg = 'auth_ok' | 'auth_error' | 'machine_evicted' | 'force_disconnect' | 'token_revoked' | 'version_mismatch' | 'force_update_required' | 'command' | 'agent_command' | 'resolve_action'
32
- /**
33
- * Reply to a `beacon_vectors` GET, correlated by `requestId`. A PUT is
34
- * fire-and-forget and gets no reply at all — the beacon is advisory, so a
35
- * lost report costs one debounce cycle of prediction accuracy and nothing
36
- * else, which is not worth an ack round trip.
6
+ * Wiring-unification Phase A2: the typed frame unions now live in ./protocol
7
+ * (one file per link, payloads included). This file keeps the symbols that
8
+ * pre-dated them — `DaemonToServerWsMsg`, `ServerToDaemonWsMsg`,
9
+ * `P2PSignalingWsMsg`, `DashboardP2PMessageKind` and the two name arrays —
10
+ * DERIVED from the protocol unions rather than re-declared, so they cannot
11
+ * drift again. Before this derivation the list here said `offer|answer|ice`
12
+ * while the wire said `p2p_offer|p2p_answer|p2p_ice`, declared a `log` frame
13
+ * nothing sent or handled, omitted `daemon_mesh_command` (sent by a raw
14
+ * `ws.send`), and listed 12 of the ~27 P2P kinds — and nothing imported the
15
+ * drifted members, so nothing noticed.
37
16
  */
38
- | 'beacon_vectors_result';
39
- /** P2P signaling relayed through the server WS. */
40
- export type P2PSignalingWsMsg = 'p2p_ready' | 'offer' | 'answer' | 'ice' | 'mesh_p2p_ready' | 'mesh_p2p_offer' | 'mesh_p2p_answer' | 'mesh_p2p_ice';
17
+ import { type DaemonToServerType, type ServerToDaemonControlType, type P2PSignalType, type MeshP2PSignalType, type DashboardToDaemonP2PType, type DaemonToDashboardP2PType } from './protocol';
18
+ /** Messages the daemon sends UP to the Workers server over the WS bridge (see protocol/daemon-server.ts). */
19
+ export type DaemonToServerWsMsg = DaemonToServerType;
41
20
  /**
42
- * Dashboard↔daemon P2P DataChannel JSON message kinds. Previously matched as
43
- * hand-synced literals on both ends with NO shared symbol anywhere —
44
- * `p2p_evicted` had exactly two occurrences repo-wide (emit + handle).
21
+ * Server→daemon CONTROL messages (see protocol/daemon-server.ts). The server
22
+ * additionally relays an open command namespace as the frame `type`
23
+ * (`ServerDirectCommandMsg`), which is deliberately not a member here.
45
24
  */
46
- export type DashboardP2PMessageKind = 'ping' | 'pong' | 'status_report' | 'status_event' | 'p2p_evicted' | 'command' | 'command_result' | 'command_result_chunk' | 'screenshot_start' | 'screenshot_stop' | 'pty_input' | 'pty_resize';
25
+ export type ServerToDaemonWsMsg = ServerToDaemonControlType;
26
+ /** P2P signaling relayed through the server WS — the `p2p_`/`mesh_p2p_` spellings the wire actually uses. */
27
+ export type P2PSignalingWsMsg = P2PSignalType | MeshP2PSignalType;
28
+ /** Every dashboard↔daemon P2P DataChannel JSON kind, both directions (see protocol/dashboard-daemon-p2p.ts). */
29
+ export type DashboardP2PMessageKind = DashboardToDaemonP2PType | DaemonToDashboardP2PType;
47
30
  export declare const DAEMON_TO_SERVER_WS_MSGS: readonly DaemonToServerWsMsg[];
48
31
  export declare const SERVER_TO_DAEMON_WS_MSGS: readonly ServerToDaemonWsMsg[];
49
- export declare function isDaemonToServerWsMsg(value: unknown): value is DaemonToServerWsMsg;
50
- export declare function isServerToDaemonWsMsg(value: unknown): value is ServerToDaemonWsMsg;
32
+ export declare const isDaemonToServerWsMsg: (value: unknown) => value is DaemonToServerWsMsg;
33
+ export declare const isServerToDaemonWsMsg: (value: unknown) => value is ServerToDaemonWsMsg;
51
34
  /**
52
35
  * `auth_ok.payload.limits` — the plan-limit contract the server sends DOWN to
53
36
  * the daemon for client-side enforcement.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/mesh-shared",
3
- "version": "1.0.60-rc.33",
3
+ "version": "1.0.60-rc.35",
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",
package/src/index.ts CHANGED
@@ -29,3 +29,13 @@ export * from './rpc-chunking'
29
29
  export * from './semver-compare'
30
30
  export * from './ws-protocol'
31
31
  export * from './interactive-prompt-constants'
32
+ export * from './session-status'
33
+ export * from './mesh-vocabulary'
34
+ export * from './outbound-message'
35
+ export * from './worker-protocol'
36
+ export * from './protocol'
37
+ export * from './turn-evidence'
38
+ export * from './session-launch'
39
+ export * from './turn-ipc'
40
+ export * from './path-ownership'
41
+ export * from './mission-brief'
@@ -69,7 +69,6 @@ export const CANONICAL_MESH_TOOL_NAMES = [
69
69
  'mesh_record_note',
70
70
  'mesh_forget_note',
71
71
  'mesh_reconcile_ledger',
72
- 'mesh_requeue_held_events',
73
72
  'mesh_mission_upsert',
74
73
  'mesh_mission_list',
75
74
  'mesh_review_inbox',
@@ -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
+ }