@adhdev/mesh-shared 1.0.82-rc.34 → 1.0.82-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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * mission-items — structured mission records: the item kinds a mission carries
3
+ * (remaining work, live checks, owner decisions, evidence), their normalization
4
+ * and limits, dedupe-key derivation and the close outcome enum
5
+ * (docs/design/2026-10-28-structured-missions-and-owner-decisions.md §4, §5).
6
+ *
7
+ * Philosophy (owner 2026-10-28, design §0): NEVER reject for size or shape —
8
+ * over-long text is truncated, extra refs are dropped, unusable items are
9
+ * reported per-item. The only hard rule lives elsewhere (the daemon): a
10
+ * coordinator or worker can never ANSWER a decision, so `answer`/`delivery`
11
+ * are stripped from every caller-supplied `decision` here.
12
+ *
13
+ * Dependency-free leaf: pure functions over plain data, no storage.
14
+ */
15
+
16
+ export const MISSION_ITEM_KINDS = ['remaining', 'live_check', 'decision', 'evidence'] as const
17
+ export type MissionItemKind = typeof MISSION_ITEM_KINDS[number]
18
+
19
+ export const MISSION_ITEM_STATES = ['open', 'done', 'dropped', 'moved', 'answered', 'expired', 'withdrawn', 'superseded'] as const
20
+ export type MissionItemState = typeof MISSION_ITEM_STATES[number]
21
+
22
+ /** States a coordinator/worker may SET through items_update, per kind. `answered`/`expired` are daemon-owned. */
23
+ export const COORDINATOR_SETTABLE_ITEM_STATES: Readonly<Record<MissionItemKind, readonly MissionItemState[]>> = {
24
+ remaining: ['open', 'done', 'dropped'],
25
+ live_check: ['open', 'done', 'dropped'],
26
+ evidence: ['open', 'done', 'dropped'],
27
+ decision: ['open', 'withdrawn', 'superseded'],
28
+ }
29
+
30
+ export const MISSION_OUTCOMES = ['merged', 'verified_live', 'superseded', 'not_needed', 'owner_abandoned', 'unspecified'] as const
31
+ export type MissionOutcome = typeof MISSION_OUTCOMES[number]
32
+ export function isMissionOutcome(value: unknown): value is MissionOutcome {
33
+ return typeof value === 'string' && (MISSION_OUTCOMES as readonly string[]).includes(value)
34
+ }
35
+
36
+ export const MISSION_REF_KINDS = ['commit', 'branch', 'pr', 'task', 'artifact', 'mission', 'url'] as const
37
+ export type MissionRefKind = typeof MISSION_REF_KINDS[number]
38
+
39
+ export const MISSION_LIMITS = {
40
+ summary: 300,
41
+ outcomeReason: 500,
42
+ itemText: 300,
43
+ machine: 80,
44
+ refs: 8,
45
+ refValue: 300,
46
+ refRepo: 120,
47
+ refNote: 200,
48
+ dedupeKey: 400,
49
+ question: 300,
50
+ context: 600,
51
+ options: 5,
52
+ optionLabel: 60,
53
+ optionDetail: 200,
54
+ /** Open items per mission before the oldest `remaining` fold into a "N more" summary item. */
55
+ openItemsPerMission: 200,
56
+ /** One event payload (serialized). */
57
+ eventPayloadBytes: 16 * 1024,
58
+ /** items_add / items_update entries honoured per call (extra entries are reported, not applied). */
59
+ itemsPerCall: 50,
60
+ } as const
61
+
62
+ export const MISSION_DECISION_OPTION_IDS = ['a', 'b', 'c', 'd', 'e'] as const
63
+ export const MISSION_DECISION_URGENCIES = ['normal', 'blocking'] as const
64
+ export type MissionDecisionUrgency = typeof MISSION_DECISION_URGENCIES[number]
65
+ export const MISSION_DECISION_ON_DUE = ['remind', 'apply_recommended'] as const
66
+ export type MissionDecisionOnDue = typeof MISSION_DECISION_ON_DUE[number]
67
+
68
+ export interface MissionItemRef {
69
+ kind: MissionRefKind
70
+ repo?: string
71
+ value: string
72
+ note?: string
73
+ }
74
+
75
+ export interface MissionDecisionOption {
76
+ id: string
77
+ label: string
78
+ detail?: string
79
+ }
80
+
81
+ /** Who/where an answer came from. Written only by the daemon's (owner-side) answer path — never from tool input. */
82
+ export interface MissionDecisionAnswer {
83
+ optionId?: string
84
+ text?: string
85
+ by: 'owner' | 'auto_recommended'
86
+ via: 'dashboard' | 'assistant' | 'push' | 'cli' | 'auto'
87
+ interpretedFrom?: string
88
+ at: string
89
+ ingress?: string
90
+ }
91
+
92
+ export interface MissionDecisionDelivery {
93
+ messageId: string
94
+ /**
95
+ * `queued`: the coordinator's input queue accepted it but has not typed it yet — the daemon
96
+ * confirms it against the send receipt (a daemon restart drops that queue). Daemon-written.
97
+ */
98
+ state: 'pending' | 'queued' | 'sent' | 'acked'
99
+ lastAttemptAt?: string
100
+ /** Coordinator session + funnel messageId of a `queued` delivery (receipt lookup). Daemon-written. */
101
+ sessionId?: string
102
+ wireMessageId?: string
103
+ /** Failed send attempts of this revision (retry backoff). Daemon-written. */
104
+ attempts?: number
105
+ /** What the coordinator is told: a first answer, a changed answer, or an undone one. Daemon-written. */
106
+ kind?: 'answered' | 'changed' | 'undone'
107
+ }
108
+
109
+ export interface MissionDecision {
110
+ question: string
111
+ context?: string
112
+ options: MissionDecisionOption[]
113
+ recommended?: string
114
+ allowFreeText: boolean
115
+ dueAt?: string
116
+ onDue: MissionDecisionOnDue
117
+ urgency: MissionDecisionUrgency
118
+ answer?: MissionDecisionAnswer
119
+ delivery?: MissionDecisionDelivery
120
+ /**
121
+ * Coordinator-prompt watermark (bundle B): ISO time the coordinator last "saw" this item's
122
+ * answer. An answered decision is shown in the coordinator prompt while this is absent or
123
+ * older than `answer.at` (a changed answer re-surfaces). Written only by the daemon.
124
+ */
125
+ promptAckedAt?: string
126
+ /** Counts answer writes (answer / change / undo); delivery messageIds are `decision:<itemId>:r<n>`. Daemon-written. */
127
+ answerRevision?: number
128
+ /** ISO time an `onDue: remind` crossing was recorded (once per dueAt). Daemon-written. */
129
+ remindedAt?: string
130
+ }
131
+
132
+ export interface NormalizedMissionItem {
133
+ kind: MissionItemKind
134
+ text: string
135
+ machine?: string
136
+ refs: MissionItemRef[]
137
+ decision?: MissionDecision
138
+ dedupeKey: string
139
+ /** Fields that were truncated/dropped — informational, never an error. */
140
+ truncated: string[]
141
+ }
142
+
143
+ export function truncateMissionText(value: string, max: number): string {
144
+ if (value.length <= max) return value
145
+ return `${value.slice(0, max - 1).trimEnd()}…`
146
+ }
147
+
148
+ function str(value: unknown): string {
149
+ return typeof value === 'string' ? value.trim() : ''
150
+ }
151
+
152
+ function collapse(value: string): string {
153
+ return value.replace(/\s+/g, ' ').trim()
154
+ }
155
+
156
+ export function normalizeMissionSummary(value: unknown): { value: string; truncated: boolean } | null {
157
+ if (typeof value !== 'string') return null
158
+ const text = collapse(value)
159
+ return { value: truncateMissionText(text, MISSION_LIMITS.summary), truncated: text.length > MISSION_LIMITS.summary }
160
+ }
161
+
162
+ export function normalizeOutcomeReason(value: unknown): { value: string; truncated: boolean } | null {
163
+ if (typeof value !== 'string') return null
164
+ const text = value.trim()
165
+ return { value: truncateMissionText(text, MISSION_LIMITS.outcomeReason), truncated: text.length > MISSION_LIMITS.outcomeReason }
166
+ }
167
+
168
+ /** Refs: ≤8, `{kind, repo?, value, note?}`. Unusable entries are dropped; the 9th and later are dropped. */
169
+ export function normalizeMissionRefs(value: unknown): { refs: MissionItemRef[]; truncated: boolean } {
170
+ if (!Array.isArray(value)) return { refs: [], truncated: false }
171
+ const refs: MissionItemRef[] = []
172
+ let truncated = false
173
+ for (const raw of value) {
174
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
175
+ const r = raw as Record<string, unknown>
176
+ const kind = str(r.kind)
177
+ const refValue = str(r.value)
178
+ if (!(MISSION_REF_KINDS as readonly string[]).includes(kind) || !refValue) continue
179
+ if (refs.length >= MISSION_LIMITS.refs) { truncated = true; break }
180
+ const repo = str(r.repo)
181
+ const note = str(r.note)
182
+ refs.push({
183
+ kind: kind as MissionRefKind,
184
+ ...(repo ? { repo: truncateMissionText(repo, MISSION_LIMITS.refRepo) } : {}),
185
+ value: truncateMissionText(refValue, MISSION_LIMITS.refValue),
186
+ ...(note ? { note: truncateMissionText(note, MISSION_LIMITS.refNote) } : {}),
187
+ })
188
+ }
189
+ return { refs, truncated }
190
+ }
191
+
192
+ function normalizeOptions(value: unknown, truncated: string[]): MissionDecisionOption[] {
193
+ if (!Array.isArray(value)) return []
194
+ const out: MissionDecisionOption[] = []
195
+ for (const raw of value) {
196
+ if (typeof raw === 'string' && raw.trim()) {
197
+ if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
198
+ out.push({ id: MISSION_DECISION_OPTION_IDS[out.length], label: truncateMissionText(raw.trim(), MISSION_LIMITS.optionLabel) })
199
+ continue
200
+ }
201
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
202
+ const o = raw as Record<string, unknown>
203
+ const label = collapse(str(o.label))
204
+ if (!label) continue
205
+ if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
206
+ const detail = str(o.detail)
207
+ // Ids are positional ('a'..'e') so a caller cannot collide or invent ids.
208
+ out.push({
209
+ id: MISSION_DECISION_OPTION_IDS[out.length],
210
+ label: truncateMissionText(label, MISSION_LIMITS.optionLabel),
211
+ ...(detail ? { detail: truncateMissionText(detail, MISSION_LIMITS.optionDetail) } : {}),
212
+ })
213
+ }
214
+ return out
215
+ }
216
+
217
+ function normalizeDueAt(value: unknown): string | undefined {
218
+ if (typeof value !== 'string' || !value.trim()) return undefined
219
+ const ms = Date.parse(value)
220
+ return Number.isNaN(ms) ? undefined : new Date(ms).toISOString()
221
+ }
222
+
223
+ /**
224
+ * Caller-supplied decision → stored shape. `answer`/`delivery` (and any other
225
+ * unknown key) are DROPPED: a coordinator/worker cannot pre-answer its own
226
+ * question. `recommended` must name one of the options, else it is dropped.
227
+ */
228
+ export function normalizeDecisionInput(raw: Record<string, unknown>, truncated: string[]): MissionDecision | null {
229
+ const questionSource = collapse(str(raw.question) || str(raw.text))
230
+ if (!questionSource) return null
231
+ if (questionSource.length > MISSION_LIMITS.question) truncated.push('question')
232
+ const question = truncateMissionText(questionSource, MISSION_LIMITS.question)
233
+ const contextSource = str(raw.context)
234
+ if (contextSource.length > MISSION_LIMITS.context) truncated.push('context')
235
+ const options = normalizeOptions(raw.options, truncated)
236
+ const recommendedRaw = str(raw.recommended)
237
+ const recommended = recommendedRaw && options.some(o => o.id === recommendedRaw)
238
+ ? recommendedRaw
239
+ // Also accept the option label (coordinators naturally write "recommended: Option A").
240
+ : options.find(o => recommendedRaw && o.label.toLowerCase() === recommendedRaw.toLowerCase())?.id
241
+ const dueAt = normalizeDueAt(raw.dueAt ?? raw.due_at)
242
+ const onDueRaw = str(raw.onDue ?? raw.on_due)
243
+ const urgencyRaw = str(raw.urgency)
244
+ const allowFreeTextRaw = raw.allowFreeText ?? raw.allow_free_text
245
+ return {
246
+ question,
247
+ ...(contextSource ? { context: truncateMissionText(contextSource, MISSION_LIMITS.context) } : {}),
248
+ options,
249
+ ...(recommended ? { recommended } : {}),
250
+ allowFreeText: typeof allowFreeTextRaw === 'boolean' ? allowFreeTextRaw : true,
251
+ ...(dueAt ? { dueAt } : {}),
252
+ // apply_recommended without a recommended option has nothing to apply — fall back to remind.
253
+ onDue: onDueRaw === 'apply_recommended' && recommended ? 'apply_recommended' : 'remind',
254
+ urgency: urgencyRaw === 'blocking' ? 'blocking' : 'normal',
255
+ }
256
+ }
257
+
258
+ /** Normalized text for dedupe: lower-case, whitespace-collapsed, trailing punctuation trimmed. */
259
+ export function normalizeForDedupe(text: string): string {
260
+ return collapse(text).toLowerCase().replace(/[\s.!?。]+$/u, '')
261
+ }
262
+
263
+ export function deriveItemDedupeKey(kind: MissionItemKind, parts: { text?: string; machine?: string; refs?: readonly MissionItemRef[]; question?: string; explicit?: string }): string {
264
+ const explicit = str(parts.explicit)
265
+ if (explicit) return truncateMissionText(`k:${explicit}`, MISSION_LIMITS.dedupeKey)
266
+ if (kind === 'decision') return truncateMissionText(`decision:${normalizeForDedupe(parts.question ?? parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
267
+ if (kind === 'evidence') {
268
+ const ref = parts.refs?.[0]
269
+ if (ref) return truncateMissionText(`evidence:${ref.kind}:${ref.repo ?? ''}:${ref.value}`, MISSION_LIMITS.dedupeKey)
270
+ }
271
+ const machine = parts.machine ? `@${normalizeForDedupe(parts.machine)}` : ''
272
+ return truncateMissionText(`${kind}${machine}:${normalizeForDedupe(parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
273
+ }
274
+
275
+ /**
276
+ * Normalize one caller-supplied `items_add[]` entry. Returns `null` + `error`
277
+ * only when nothing usable remains (no text/question) — everything else is
278
+ * clamped, never rejected.
279
+ */
280
+ export function normalizeMissionItemInput(raw: unknown): { item: NormalizedMissionItem } | { error: string } {
281
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'item must be an object' }
282
+ const r = raw as Record<string, unknown>
283
+ const kind = str(r.kind) || 'remaining'
284
+ if (!(MISSION_ITEM_KINDS as readonly string[]).includes(kind)) {
285
+ return { error: `invalid kind '${kind}' (valid: ${MISSION_ITEM_KINDS.join(', ')})` }
286
+ }
287
+ const truncated: string[] = []
288
+ const { refs, truncated: refsTruncated } = normalizeMissionRefs(r.refs)
289
+ if (refsTruncated) truncated.push('refs')
290
+ const machineRaw = collapse(str(r.machine))
291
+ const machine = machineRaw ? truncateMissionText(machineRaw, MISSION_LIMITS.machine) : undefined
292
+ const explicitKey = str(r.dedupe_key ?? r.dedupeKey)
293
+ if (kind === 'decision') {
294
+ const decisionRaw = (r.decision && typeof r.decision === 'object' && !Array.isArray(r.decision) ? r.decision : r) as Record<string, unknown>
295
+ const decision = normalizeDecisionInput({ ...decisionRaw, question: decisionRaw.question ?? r.question ?? r.text }, truncated)
296
+ if (!decision) return { error: 'decision item needs a question' }
297
+ return {
298
+ item: {
299
+ kind,
300
+ text: decision.question,
301
+ ...(machine ? { machine } : {}),
302
+ refs,
303
+ decision,
304
+ dedupeKey: deriveItemDedupeKey('decision', { question: decision.question, explicit: explicitKey }),
305
+ truncated,
306
+ },
307
+ }
308
+ }
309
+ const textSource = collapse(str(r.text))
310
+ if (!textSource && !(kind === 'evidence' && refs.length > 0)) return { error: `${kind} item needs text` }
311
+ if (textSource.length > MISSION_LIMITS.itemText) truncated.push('text')
312
+ const text = truncateMissionText(textSource || `${refs[0].kind}: ${refs[0].value}`, MISSION_LIMITS.itemText)
313
+ return {
314
+ item: {
315
+ kind: kind as MissionItemKind,
316
+ text,
317
+ ...(machine ? { machine } : {}),
318
+ refs,
319
+ dedupeKey: deriveItemDedupeKey(kind as MissionItemKind, { text, machine, refs, explicit: explicitKey }),
320
+ truncated,
321
+ },
322
+ }
323
+ }
324
+
325
+ export interface MissionItemUpdateInput {
326
+ id: string
327
+ state?: MissionItemState
328
+ text?: string
329
+ machine?: string | null
330
+ refs?: MissionItemRef[]
331
+ }
332
+
333
+ /** Normalize one `items_update[]` entry (id required). The kind-specific state rule is applied by the daemon, which knows the stored kind. */
334
+ export function normalizeMissionItemUpdate(raw: unknown): { update: MissionItemUpdateInput; truncated: string[] } | { error: string } {
335
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'update must be an object' }
336
+ const r = raw as Record<string, unknown>
337
+ const id = str(r.id ?? r.item_id ?? r.itemId)
338
+ if (!id) return { error: 'update needs an item id' }
339
+ const truncated: string[] = []
340
+ const update: MissionItemUpdateInput = { id }
341
+ if (r.state !== undefined) {
342
+ const state = str(r.state)
343
+ if (!(MISSION_ITEM_STATES as readonly string[]).includes(state)) return { error: `invalid state '${state}'` }
344
+ update.state = state as MissionItemState
345
+ }
346
+ if (typeof r.text === 'string') {
347
+ const text = collapse(r.text)
348
+ if (text.length > MISSION_LIMITS.itemText) truncated.push('text')
349
+ if (text) update.text = truncateMissionText(text, MISSION_LIMITS.itemText)
350
+ }
351
+ if (r.machine === null) update.machine = null
352
+ else if (typeof r.machine === 'string') {
353
+ const machine = collapse(r.machine)
354
+ update.machine = machine ? truncateMissionText(machine, MISSION_LIMITS.machine) : null
355
+ }
356
+ if (r.refs !== undefined) {
357
+ const { refs, truncated: t } = normalizeMissionRefs(r.refs)
358
+ if (t) truncated.push('refs')
359
+ update.refs = refs
360
+ }
361
+ return { update, truncated }
362
+ }
363
+
364
+ /** Cap a serialized event payload at {@link MISSION_LIMITS.eventPayloadBytes}; over-size payloads become a stub that says so. */
365
+ export function boundEventPayload(payload: Record<string, unknown>): string {
366
+ let json = JSON.stringify(payload)
367
+ if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
368
+ // Shrink the largest string fields first (the previous goal text is the usual offender).
369
+ const shrunk: Record<string, unknown> = {}
370
+ for (const [k, v] of Object.entries(payload)) {
371
+ shrunk[k] = typeof v === 'string' && v.length > 4000 ? `${v.slice(0, 4000)}…` : v
372
+ }
373
+ shrunk.payloadTruncated = true
374
+ json = JSON.stringify(shrunk)
375
+ if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
376
+ return JSON.stringify({ payloadTruncated: true, keys: Object.keys(payload).slice(0, 20) })
377
+ }
378
+
379
+ /** The carry-over mission's fixed title (design §5.1). */
380
+ export const CARRY_OVER_MISSION_TITLE = 'Carry-over — open items from closed missions'
381
+ /** The title earlier builds stored; existing DBs still hold it, so it keeps matching (no rename). */
382
+ export const LEGACY_CARRY_OVER_MISSION_TITLE = '이월 — 닫힌 미션에서 넘어온 항목'
383
+ /** Canonical goal text of a newly created carry-over mission. */
384
+ export const CARRY_OVER_MISSION_GOAL = 'Open items carried over from closed missions, and decisions requested without a mission. Managed automatically — do not close.'
385
+ /**
386
+ * THE one predicate for "is this the auto carry-over mission". The daemon's `is_carry_over`
387
+ * column (`isCarryOver`) is the source of truth; wire views that cannot carry the flag (older
388
+ * strict decoders reject unknown keys) fall back to the fixed title. Use this everywhere.
389
+ */
390
+ export function isCarryOverMissionRecord(m: { isCarryOver?: boolean; title?: unknown } | null | undefined): boolean {
391
+ return !!m && (m.isCarryOver === true || isCarryOverMissionTitle(m.title))
392
+ }
393
+ /** The carry-over mission's title is fixed (the daemon ignores renames), so a title match identifies it on wire views that carry no flag. */
394
+ export function isCarryOverMissionTitle(title: unknown): boolean {
395
+ if (typeof title !== 'string') return false
396
+ const t = title.trim()
397
+ return t === CARRY_OVER_MISSION_TITLE || t === LEGACY_CARRY_OVER_MISSION_TITLE
398
+ }
399
+
400
+ /** Prefix of the owner-confirmation decision a coordinator's `owner_abandoned` close creates. */
401
+ export const OWNER_ABANDON_CONFIRM_KEY_PREFIX = 'owner-abandon-confirm:'
402
+
403
+ export const OWNER_ABANDON_CONFIRM_QUESTION = "The coordinator closed this as 'owner abandoned' — correct?"
404
+ export const OWNER_ABANDON_CONFIRM_OPTIONS = ['Correct', 'Reopen'] as const
405
+
406
+ /** worker `report_completion.decisions_needed` cap. */
407
+ export const WORKER_DECISIONS_NEEDED_MAX = 3
408
+ /** worker `report_completion.commits` cap and shape. */
409
+ export const WORKER_COMMITS_MAX = 20
410
+ const SHA_RE = /^[0-9a-f]{7,64}$/i
411
+ export function normalizeReportCommits(value: unknown): string[] | null {
412
+ if (!Array.isArray(value)) return null
413
+ const out: string[] = []
414
+ for (const v of value) {
415
+ if (typeof v !== 'string') return null
416
+ const sha = v.trim()
417
+ if (!SHA_RE.test(sha)) return null
418
+ if (!out.includes(sha.toLowerCase())) out.push(sha.toLowerCase())
419
+ }
420
+ return out
421
+ }
@@ -52,6 +52,9 @@ export const DAEMON_STATUS_EVENT_NAMES = [
52
52
  'agent:generating_completed',
53
53
  'agent:stopped',
54
54
  'monitor:no_progress',
55
+ // An owner decision (structured missions) needs the owner. Server-bound for the push only;
56
+ // the dashboard learns about decisions over P2P (`mission_decisions_list`).
57
+ 'agent:decision_needed',
55
58
  ] as const
56
59
  export type DaemonStatusEventName = typeof DAEMON_STATUS_EVENT_NAMES[number]
57
60
  export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
@@ -62,6 +65,27 @@ export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
62
65
  * (`modalMessage`/`modalButtons`, CLAUDE.md "Push notification exception").
63
66
  * Mirror of daemon-core `DaemonStatusEventPayload`.
64
67
  */
68
+ /**
69
+ * Owner-decision push payload (design §9, owner decision D1 2026-10-28). The ONLY mission text that
70
+ * reaches the server: the question (<=80 chars) and up to 3 option labels (<=24 chars). Never
71
+ * `context`, free-text answers, mission goal/summary or chat content.
72
+ */
73
+ export type DecisionPushWire = {
74
+ meshId: string
75
+ missionId: string
76
+ itemId: string
77
+ question: string
78
+ options: Array<{ id: string; label: string }>
79
+ recommended?: string
80
+ urgency: 'normal' | 'blocking'
81
+ dueAt?: string
82
+ /** `new` = freshly asked; `remind` = a blocking decision crossed its due time. */
83
+ reason: 'new' | 'remind'
84
+ }
85
+ export const DECISION_PUSH_QUESTION_MAX = 80
86
+ export const DECISION_PUSH_OPTION_LABEL_MAX = 24
87
+ export const DECISION_PUSH_OPTIONS_MAX = 3
88
+
65
89
  export type DaemonStatusEventWirePayload = {
66
90
  event: DaemonStatusEventName
67
91
  timestamp: number
@@ -78,6 +102,8 @@ export type DaemonStatusEventWirePayload = {
78
102
  waitKind?: 'external'
79
103
  surfaceHidden?: boolean
80
104
  muted?: boolean
105
+ /** Present only on `agent:decision_needed`. */
106
+ decision?: DecisionPushWire
81
107
  }
82
108
 
83
109
  // ─── Status report (mirror of daemon-core `CloudStatusReportPayload`) ──────
@@ -85,9 +85,43 @@ export interface MissionUpsertRequest {
85
85
  * this wire guard only checks SHAPE, not that decision.
86
86
  */
87
87
  brief?: MissionBriefWire | null
88
- }
89
-
90
- export interface MeshMissionRecordWire {
88
+ /**
89
+ * Structured-missions bundle A (design 2026-10-28 §5.1). Every key below is optional
90
+ * and ABSENT on a legacy call. `structured: true` is the caller's capability flag: the
91
+ * daemon only puts the new keys on the response (mission summary/outcome/version,
92
+ * `result`) when it is set, so an older mcp-server that never sends it keeps decoding
93
+ * byte-identical responses (mixed-version fleets, design §13.1).
94
+ */
95
+ structured?: boolean
96
+ summary?: string
97
+ outcome?: string
98
+ outcomeReason?: string
99
+ /** Mission id open items move to when this call closes the mission (default: the carry-over mission). */
100
+ carryOverTo?: string
101
+ /** Route to the mesh's auto-created carry-over mission instead of `id`/`title` (decision requests without a mission). `title` may then be empty. */
102
+ toCarryOver?: boolean
103
+ itemsAdd?: readonly Record<string, unknown>[]
104
+ itemsUpdate?: readonly Record<string, unknown>[]
105
+ }
106
+
107
+ export interface MeshMissionOpenItemCountsWire {
108
+ remaining: number
109
+ live_check: number
110
+ decision: number
111
+ evidence: number
112
+ }
113
+
114
+ /** Structured-missions fields (only with `structured: true` on the request). */
115
+ export interface MeshMissionStructuredWire {
116
+ summary?: string
117
+ outcome?: string
118
+ outcomeReason?: string
119
+ version?: number
120
+ carryOver?: boolean
121
+ openItems?: MeshMissionOpenItemCountsWire
122
+ }
123
+
124
+ export interface MeshMissionRecordWire extends MeshMissionStructuredWire {
91
125
  id: string
92
126
  meshId: string
93
127
  title: string
@@ -99,10 +133,30 @@ export interface MeshMissionRecordWire {
99
133
 
100
134
  export interface MissionUpsertResponse {
101
135
  mission: MeshMissionRecordWire
136
+ /** Structured calls only: per-item results, movedItems/movedTo, restoredItems, ownerConfirmationItemId, warnings. */
137
+ result?: Record<string, unknown>
138
+ }
139
+
140
+ const STRUCTURED_MISSION_KEYS = ['summary', 'outcome', 'outcomeReason', 'version', 'carryOver', 'openItems'] as const
141
+
142
+ function isOpenItemCountsWire(value: unknown): value is MeshMissionOpenItemCountsWire {
143
+ if (!isRecord(value) || !hasOnlyKeys(value, ['remaining', 'live_check', 'decision', 'evidence'])) return false
144
+ return (['remaining', 'live_check', 'decision', 'evidence'] as const).every((k) => isNonNegativeInt(value[k]))
145
+ }
146
+
147
+ function isStructuredMissionFields(value: Record<string, unknown>): boolean {
148
+ for (const k of ['summary', 'outcome', 'outcomeReason'] as const) {
149
+ if (value[k] !== undefined && typeof value[k] !== 'string') return false
150
+ }
151
+ if (value.version !== undefined && !isNonNegativeInt(value.version)) return false
152
+ if (value.carryOver !== undefined && typeof value.carryOver !== 'boolean') return false
153
+ if (value.openItems !== undefined && !isOpenItemCountsWire(value.openItems)) return false
154
+ return true
102
155
  }
103
156
 
104
157
  function isMeshMissionRecordWire(value: unknown): value is MeshMissionRecordWire {
105
- if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'meshId', 'title', 'goal', 'status', 'brief'])) return false
158
+ if (!isRecord(value) || !hasOnlyKeys(value, ['id', 'meshId', 'title', 'goal', 'status', 'brief', ...STRUCTURED_MISSION_KEYS])) return false
159
+ if (!isStructuredMissionFields(value)) return false
106
160
  if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
107
161
  if (typeof value.title !== 'string' || typeof value.goal !== 'string') return false
108
162
  if (!isMeshMissionStatusValue(value.status)) return false
@@ -111,11 +165,24 @@ function isMeshMissionRecordWire(value: unknown): value is MeshMissionRecordWire
111
165
  }
112
166
 
113
167
  function isMissionUpsertRequest(value: unknown): value is MissionUpsertRequest {
114
- if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'id', 'title', 'goal', 'status', 'brief'])) return false
168
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'id', 'title', 'goal', 'status', 'brief', 'structured', 'summary', 'outcome', 'outcomeReason', 'carryOverTo', 'toCarryOver', 'itemsAdd', 'itemsUpdate'])) return false
115
169
  if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
116
170
  if (!isEvidenceIdentifier(value.meshId)) return false
117
171
  if (value.id !== undefined && !isEvidenceIdentifier(value.id)) return false
118
- if (typeof value.title !== 'string' || value.title.trim().length === 0) return false
172
+ if (typeof value.title !== 'string') return false
173
+ // An empty title is allowed only when the daemon can supply one: an existing mission (id) or the carry-over mission.
174
+ if (value.title.trim().length === 0 && value.toCarryOver !== true && value.id === undefined) return false
175
+ if (value.structured !== undefined && typeof value.structured !== 'boolean') return false
176
+ if (value.toCarryOver !== undefined && typeof value.toCarryOver !== 'boolean') return false
177
+ for (const k of ['summary', 'outcome', 'outcomeReason'] as const) {
178
+ if (value[k] !== undefined && typeof value[k] !== 'string') return false
179
+ }
180
+ if (value.carryOverTo !== undefined && !isEvidenceIdentifier(value.carryOverTo)) return false
181
+ for (const k of ['itemsAdd', 'itemsUpdate'] as const) {
182
+ const list = value[k]
183
+ // Bounded only to keep the IPC message sane; per-entry shape/size is clamped (never rejected) by the daemon.
184
+ if (list !== undefined && (!Array.isArray(list) || list.length > 500 || !list.every(isRecord))) return false
185
+ }
119
186
  if (value.goal !== undefined && typeof value.goal !== 'string') return false
120
187
  if (value.status !== undefined && !isMeshMissionStatusValue(value.status)) return false
121
188
  if (value.brief !== undefined && value.brief !== null && !isMissionBriefWire(value.brief)) return false
@@ -127,7 +194,8 @@ export function decodeMissionUpsertRequest(value: unknown): MissionUpsertRequest
127
194
  }
128
195
 
129
196
  export function isMissionUpsertResponse(value: unknown): value is MissionUpsertResponse {
130
- return isRecord(value) && hasOnlyKeys(value, ['mission']) && isMeshMissionRecordWire(value.mission)
197
+ return isRecord(value) && hasOnlyKeys(value, ['mission', 'result']) && isMeshMissionRecordWire(value.mission)
198
+ && (value.result === undefined || isRecord(value.result))
131
199
  }
132
200
 
133
201
  export function decodeMissionUpsertResponse(value: unknown): MissionUpsertResponse | null {
@@ -207,6 +275,12 @@ export interface MissionListQueryRequest {
207
275
  * option except `historyIdLimit` is ignored. An older daemon rejects the key.
208
276
  */
209
277
  meshStatusView?: 'compact' | 'verbose'
278
+ /** Structured-missions capability flag (see MissionUpsertRequest.structured): rows then carry summary/outcome/openItems/carryOver/version. */
279
+ structured?: boolean
280
+ /** Structured only: attach each mission's items. */
281
+ items?: boolean
282
+ /** Structured only: attach each mission's change history (bounded tail). */
283
+ history?: boolean
210
284
  }
211
285
 
212
286
  export interface MeshMissionTaskAggregateWire {
@@ -248,6 +322,15 @@ export interface MissionListSummaryVerboseWire {
248
322
  createdAt?: string
249
323
  updatedAt?: string
250
324
  closeCandidateEmittedAt?: string | null
325
+ /** Structured lists only. */
326
+ summary?: string
327
+ outcome?: string
328
+ outcomeReason?: string
329
+ version?: number
330
+ carryOver?: boolean
331
+ openItems?: MeshMissionOpenItemCountsWire
332
+ items?: readonly Record<string, unknown>[]
333
+ history?: readonly Record<string, unknown>[]
251
334
  }
252
335
 
253
336
  /** Compact (default) mission row. `goalPreview`/`goalTruncated` present, `goal` absent. */
@@ -266,6 +349,15 @@ export interface MissionListSummarySlimWire {
266
349
  createdAt?: string
267
350
  updatedAt?: string
268
351
  closeCandidateEmittedAt?: string | null
352
+ /** Structured lists only. */
353
+ summary?: string
354
+ outcome?: string
355
+ outcomeReason?: string
356
+ version?: number
357
+ carryOver?: boolean
358
+ openItems?: MeshMissionOpenItemCountsWire
359
+ items?: readonly Record<string, unknown>[]
360
+ history?: readonly Record<string, unknown>[]
269
361
  }
270
362
 
271
363
  export type MissionListSummaryWire = MissionListSummaryVerboseWire | MissionListSummarySlimWire
@@ -286,7 +378,10 @@ export interface MissionListQueryResponse {
286
378
  }
287
379
 
288
380
  function isMissionListQueryRequest(value: unknown): value is MissionListQueryRequest {
289
- if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'statuses', 'verbose', 'withStats', 'limit', 'historyIdLimit', 'meshStatusView'])) return false
381
+ if (!isRecord(value) || !hasOnlyKeys(value, ['v', 'meshId', 'statuses', 'verbose', 'withStats', 'limit', 'historyIdLimit', 'meshStatusView', 'structured', 'items', 'history'])) return false
382
+ for (const k of ['structured', 'items', 'history'] as const) {
383
+ if (value[k] !== undefined && typeof value[k] !== 'boolean') return false
384
+ }
290
385
  if (value.meshStatusView !== undefined && value.meshStatusView !== 'compact' && value.meshStatusView !== 'verbose') return false
291
386
  if (value.v !== TURN_IPC_PROTOCOL_VERSION) return false
292
387
  if (!isEvidenceIdentifier(value.meshId)) return false
@@ -332,7 +427,7 @@ function isMissionListSummaryWire(value: unknown): value is MissionListSummaryWi
332
427
  const hasGoal = 'goal' in value
333
428
  const hasPreview = 'goalPreview' in value && 'goalTruncated' in value
334
429
  if (hasGoal === hasPreview) return false // exactly one of the two shapes
335
- const baseKeys = ['id', 'meshId', 'title', 'status', 'tasks', 'stats', 'brief', 'createdAt', 'updatedAt', 'closeCandidateEmittedAt'] as const
430
+ const baseKeys = ['id', 'meshId', 'title', 'status', 'tasks', 'stats', 'brief', 'createdAt', 'updatedAt', 'closeCandidateEmittedAt', ...STRUCTURED_MISSION_KEYS, 'items', 'history'] as const
336
431
  const allowed = hasGoal ? [...baseKeys, 'goal'] : [...baseKeys, 'goalPreview', 'goalTruncated']
337
432
  if (!hasOnlyKeys(value, allowed)) return false
338
433
  if (!isEvidenceIdentifier(value.id) || !isEvidenceIdentifier(value.meshId)) return false
@@ -346,6 +441,10 @@ function isMissionListSummaryWire(value: unknown): value is MissionListSummaryWi
346
441
  if (value.createdAt !== undefined && typeof value.createdAt !== 'string') return false
347
442
  if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string') return false
348
443
  if (value.closeCandidateEmittedAt !== undefined && value.closeCandidateEmittedAt !== null && typeof value.closeCandidateEmittedAt !== 'string') return false
444
+ if (!isStructuredMissionFields(value)) return false
445
+ for (const k of ['items', 'history'] as const) {
446
+ if (value[k] !== undefined && (!Array.isArray(value[k]) || !(value[k] as unknown[]).every(isRecord))) return false
447
+ }
349
448
  return true
350
449
  }
351
450
 
@@ -43,6 +43,10 @@ export const WORKER_TOOLS = [
43
43
  'driver_list',
44
44
  'driver_describe',
45
45
  'driver_invoke',
46
+ 'driver_scaffold',
47
+ 'driver_validate',
48
+ 'driver_export',
49
+ 'driver_install',
46
50
  'artifact_register',
47
51
  'artifact_list',
48
52
  'artifact_read',