@adhdev/mesh-shared 1.0.82-rc.4 → 1.0.82-rc.41

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 (65) hide show
  1. package/dist/assistant-protocol.d.ts +161 -6
  2. package/dist/decision-push-relay.d.ts +20 -0
  3. package/dist/index.d.ts +11 -0
  4. package/dist/index.js +1197 -118
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1107 -118
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/launch-defaults.d.ts +196 -0
  9. package/dist/mesh-tool-names.d.ts +2 -2
  10. package/dist/mesh-vocabulary.d.ts +13 -4
  11. package/dist/mission-git-state.d.ts +83 -0
  12. package/dist/mission-items.d.ts +202 -0
  13. package/dist/node-facts.d.ts +75 -1
  14. package/dist/outbound-message.d.ts +9 -2
  15. package/dist/path-ownership.d.ts +1 -1
  16. package/dist/protocol/daemon-server.d.ts +42 -2
  17. package/dist/protocol/dashboard-daemon-p2p.d.ts +1 -1
  18. package/dist/protocol/dashboard-server.d.ts +20 -2
  19. package/dist/provider-approval.d.ts +36 -0
  20. package/dist/provider-question.d.ts +19 -0
  21. package/dist/quota-freshness.d.ts +65 -0
  22. package/dist/relay-reprobe.d.ts +52 -0
  23. package/dist/release.d.ts +90 -0
  24. package/dist/role-launch.d.ts +11 -0
  25. package/dist/session-launch.d.ts +10 -22
  26. package/dist/slot-proposal.d.ts +14 -0
  27. package/dist/task-source-phase.d.ts +75 -0
  28. package/dist/turn-evidence.d.ts +29 -13
  29. package/dist/turn-ipc-mission.d.ts +68 -1
  30. package/dist/turn-ipc-queue.d.ts +19 -0
  31. package/dist/turn-ipc.d.ts +2 -2
  32. package/dist/types.d.ts +1 -0
  33. package/dist/worker-protocol.d.ts +1 -1
  34. package/package.json +1 -1
  35. package/src/assistant-protocol.ts +302 -6
  36. package/src/daemon-normalize.ts +7 -5
  37. package/src/decision-push-relay.ts +23 -0
  38. package/src/index.ts +12 -0
  39. package/src/keyed-doc-delta.ts +9 -1
  40. package/src/launch-defaults.ts +170 -0
  41. package/src/mesh-tool-names.ts +16 -0
  42. package/src/mesh-vocabulary.ts +12 -2
  43. package/src/mission-git-state.ts +112 -0
  44. package/src/mission-items.ts +421 -0
  45. package/src/node-facts.ts +70 -1
  46. package/src/outbound-message.ts +19 -0
  47. package/src/path-ownership.ts +4 -6
  48. package/src/protocol/daemon-server.ts +37 -2
  49. package/src/protocol/dashboard-server.ts +13 -2
  50. package/src/provider-approval.ts +59 -0
  51. package/src/provider-question.ts +31 -0
  52. package/src/quota-freshness.ts +139 -0
  53. package/src/relay-reprobe.ts +117 -0
  54. package/src/release.ts +121 -0
  55. package/src/role-launch.ts +51 -0
  56. package/src/session-launch.ts +17 -1
  57. package/src/slot-proposal.ts +61 -2
  58. package/src/task-source-phase.ts +61 -0
  59. package/src/turn-evidence.ts +27 -20
  60. package/src/turn-ipc-mission.ts +121 -11
  61. package/src/turn-ipc-queue.ts +29 -1
  62. package/src/turn-ipc-turn.ts +1 -1
  63. package/src/turn-ipc.ts +1 -0
  64. package/src/types.ts +1 -0
  65. package/src/worker-protocol.ts +15 -0
@@ -27,7 +27,9 @@ export const CANONICAL_MESH_TOOL_NAMES = [
27
27
  'mesh_enqueue_task',
28
28
  'mesh_view_queue',
29
29
  'mesh_queue_cancel',
30
+ 'mesh_direct_cancel',
30
31
  'mesh_queue_requeue',
32
+ 'mesh_queue_revise',
31
33
  'mesh_send_task',
32
34
  'mesh_read_chat',
33
35
  'mesh_read_debug',
@@ -44,8 +46,11 @@ export const CANONICAL_MESH_TOOL_NAMES = [
44
46
  'mesh_list_pending_approvals',
45
47
  'mesh_create',
46
48
  'mesh_add_node',
49
+ 'mesh_reattach_source_parent',
50
+ 'mesh_attach_remote',
47
51
  'mesh_clone_node',
48
52
  'mesh_remove_node',
53
+ 'mesh_retry_node_bootstrap',
49
54
  'mesh_cleanup_worktree_nodes',
50
55
  'mesh_refine_node',
51
56
  'mesh_refine_batch',
@@ -62,8 +67,19 @@ export const CANONICAL_MESH_TOOL_NAMES = [
62
67
  'mesh_reconcile_ledger',
63
68
  'mesh_mission_upsert',
64
69
  'mesh_mission_list',
70
+ 'mesh_mission_git_eval',
71
+ 'mesh_release_upsert',
72
+ 'mesh_release_scope',
73
+ 'mesh_release_list',
74
+ 'mesh_release_record_deploy',
75
+ 'mesh_release_changelog_draft',
65
76
  'mesh_review_inbox',
66
77
  'mesh_node_slots',
78
+ 'driver_list',
79
+ 'driver_describe',
80
+ 'artifact_list',
81
+ 'artifact_read',
82
+ 'artifact_register',
67
83
  ] as const;
68
84
 
69
85
  export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
@@ -17,10 +17,12 @@
17
17
 
18
18
  export { MESH_TASK_DIFFICULTIES, type MeshTaskDifficulty } from './brain-routing'
19
19
 
20
- export const MESH_TASK_STATUSES = ['pending', 'assigned', 'completed', 'failed', 'cancelled'] as const
20
+ /** `blocked` ends a worker attempt pending external follow-up; only completed satisfies dependencies.
21
+ * Branch convergence (including blocked_review) remains an independent axis. */
22
+ export const MESH_TASK_STATUSES = ['pending', 'assigned', 'completed', 'blocked', 'failed', 'cancelled'] as const
21
23
  export type MeshTaskStatus = typeof MESH_TASK_STATUSES[number]
22
24
 
23
- export const MESH_TERMINAL_TASK_STATUSES = ['completed', 'failed', 'cancelled'] as const
25
+ export const MESH_TERMINAL_TASK_STATUSES = ['completed', 'blocked', 'failed', 'cancelled'] as const
24
26
  export type MeshTerminalTaskStatus = typeof MESH_TERMINAL_TASK_STATUSES[number]
25
27
 
26
28
  export const MESH_TASK_MODES = ['code_change', 'validation', 'live_debug_readonly', 'launch_app', 'convergence'] as const
@@ -56,6 +58,14 @@ export type MeshSessionCleanupMode = typeof MESH_SESSION_CLEANUP_MODES[number]
56
58
  export const WORKER_REPORT_OUTCOMES = ['completed', 'blocked', 'failed'] as const
57
59
  export type WorkerReportOutcome = typeof WORKER_REPORT_OUTCOMES[number]
58
60
 
61
+ /**
62
+ * Hard cap on a `report_completion` summary (characters, after trim). Over-limit
63
+ * summaries are REJECTED, never truncated. Declared once so the daemon validator
64
+ * and the mcp-server's pre-send check (which keeps an over-limit report out of the
65
+ * durable outbox) cannot drift apart.
66
+ */
67
+ export const WORKER_REPORT_SUMMARY_MAX_CHARS = 8_000
68
+
59
69
  /** Final branch state a worker reports (see coordinator convergence rules). */
60
70
  export const WORKER_BRANCH_STATES = [
61
71
  'merged_to_main',
@@ -0,0 +1,112 @@
1
+ /**
2
+ * mission-git-state — types and the pure aggregation for "did this mission's
3
+ * referenced commits/branches land on the default branch?"
4
+ * (docs/design/2026-11-01-mission-git-refs.md §3).
5
+ *
6
+ * The facts come from the daemon opening git objects itself (never from
7
+ * commit-message text, branch-name similarity or worker prose). `landed` means
8
+ * ONLY "the referenced changes are on the base"; it never implies live
9
+ * acceptance, mission completion or that remaining items are done, and it is
10
+ * never used to change a mission's status.
11
+ *
12
+ * Dependency-free leaf: plain data in, plain data out.
13
+ */
14
+
15
+ /** Per-ref verdict. `unknown` is never folded into `not_contained` nor promoted to `contained_*`. */
16
+ export const MISSION_REF_GIT_STATUSES = ['contained_ancestor', 'contained_patch_equivalent', 'not_contained', 'unknown'] as const
17
+ export type MissionRefGitStatus = typeof MISSION_REF_GIT_STATUSES[number]
18
+
19
+ /** How a contained verdict was reached. */
20
+ export const MISSION_REF_GIT_METHODS = ['ancestry', 'cherry', 'residual_patch'] as const
21
+ export type MissionRefGitMethod = typeof MISSION_REF_GIT_METHODS[number]
22
+
23
+ /** Mission-level projection. */
24
+ export const MISSION_GIT_STATES = ['no_refs', 'landed', 'partial', 'not_landed', 'unknown'] as const
25
+ export type MissionGitStateValue = typeof MISSION_GIT_STATES[number]
26
+
27
+ export function isMissionRefContained(status: MissionRefGitStatus): boolean {
28
+ return status === 'contained_ancestor' || status === 'contained_patch_equivalent'
29
+ }
30
+
31
+ /** Why a ref is `unknown` (detail.unknownReason). Free-form strings are tolerated on read. */
32
+ export type MissionRefUnknownReason =
33
+ | 'object_missing'
34
+ | 'shallow'
35
+ | 'undeterminable'
36
+ | 'base_missing'
37
+ | 'branch_not_found'
38
+ | 'base_branch_ref'
39
+ | 'gitlink_undeterminable'
40
+ | 'merge_tree_failed'
41
+ | 'node_offline'
42
+ | 'no_repo'
43
+ | 'budget'
44
+ | 'not_a_git_ref'
45
+
46
+ export interface MissionRefGitDetail {
47
+ reasonCode?: string
48
+ unknownReason?: MissionRefUnknownReason | string
49
+ /** `modified_since_landing_possible`: main later touched the same lines — shown as a hint, not a verdict. */
50
+ note?: string
51
+ nodeId?: string
52
+ residualPatchId?: string
53
+ ahead?: number
54
+ behind?: number
55
+ }
56
+
57
+ /** One stored per-ref evaluation (a row of `mesh_mission_ref_evals`). */
58
+ export interface MissionRefGitEval {
59
+ itemId: string
60
+ /** `kind:repo:value` (normalized, lower-case SHA). */
61
+ refKey: string
62
+ kind: 'commit' | 'branch'
63
+ value: string
64
+ repo?: string
65
+ /** Branch refs: the SHA the name resolved to at first evaluation (re-evaluation keeps using it). */
66
+ pinnedSha?: string
67
+ baseRef: string
68
+ baseSha?: string
69
+ status: MissionRefGitStatus
70
+ method?: MissionRefGitMethod
71
+ detail?: MissionRefGitDetail
72
+ evaluatedAt: string
73
+ }
74
+
75
+ export interface MissionGitState {
76
+ missionId: string
77
+ state: MissionGitStateValue
78
+ /** `partial` only: some refs were `unknown` (not just `not_contained`). */
79
+ partialUnknown: boolean
80
+ counts: { total: number; contained: number; notContained: number; unknown: number }
81
+ /** Base the newest evaluation compared against. */
82
+ baseRef?: string
83
+ baseSha?: string
84
+ /** Oldest `evaluatedAt` among the refs (the freshness floor). */
85
+ evaluatedAt?: string
86
+ /** True when no row exists yet / the rows are older than the TTL — a refresh was (or can be) triggered. */
87
+ stale: boolean
88
+ refs: MissionRefGitEval[]
89
+ }
90
+
91
+ /**
92
+ * Aggregate per-ref statuses (design §3.3):
93
+ * - none → `no_refs`; all contained → `landed`; some contained → `partial`
94
+ * (`partialUnknown` when any of the rest is `unknown`);
95
+ * - none contained: all `not_contained` → `not_landed`, otherwise `unknown`.
96
+ */
97
+ export function aggregateMissionGitState(statuses: readonly MissionRefGitStatus[]): {
98
+ state: MissionGitStateValue
99
+ partialUnknown: boolean
100
+ counts: MissionGitState['counts']
101
+ } {
102
+ const total = statuses.length
103
+ const contained = statuses.filter(isMissionRefContained).length
104
+ const notContained = statuses.filter(s => s === 'not_contained').length
105
+ const unknown = statuses.filter(s => s === 'unknown').length
106
+ const counts = { total, contained, notContained, unknown }
107
+ if (total === 0) return { state: 'no_refs', partialUnknown: false, counts }
108
+ if (contained === total) return { state: 'landed', partialUnknown: false, counts }
109
+ if (contained > 0) return { state: 'partial', partialUnknown: unknown > 0, counts }
110
+ if (notContained === total) return { state: 'not_landed', partialUnknown: false, counts }
111
+ return { state: 'unknown', partialUnknown: false, counts }
112
+ }
@@ -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', 'failed', '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`/`failed` (owner live-check verdict) 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
+ }