@team-semicolon/semicolony-cli 4.18.125 → 4.18.127

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,19 @@
1
+ -- Compatibility marker for the Gateway/Dashboard MCP catalog rollout.
2
+ -- The canonical schema is owned and applied by the SEMO Ops operator runner.
3
+ DO $block$
4
+ BEGIN
5
+ IF NOT EXISTS (
6
+ SELECT 1 FROM semo_ops.schema_migrations
7
+ WHERE version = '222_mcp_catalog_control_plane'
8
+ ) OR NOT EXISTS (
9
+ SELECT 1 FROM semo_ops.schema_migrations
10
+ WHERE version = '223_mcp_cohort_releases'
11
+ ) THEN
12
+ RAISE EXCEPTION 'SEMO Ops MCP catalog migrations 222 and 223 must be applied first';
13
+ END IF;
14
+ IF to_regprocedure('semo_ops.mcp_catalog_for_installation(uuid)') IS NULL
15
+ OR to_regprocedure('semo_ops.mcp_report_runtime_status(uuid,text,uuid,text,text,text,text,timestamptz)') IS NULL THEN
16
+ RAISE EXCEPTION 'SEMO Ops MCP Gateway function contract is missing';
17
+ END IF;
18
+ END;
19
+ $block$;
@@ -0,0 +1,388 @@
1
+ -- 225_meeting_action_history_preservation.sql
2
+ -- Source baseline: Semicolony 58771bc9b75a0ed473c1c838199b48e075192c13.
3
+ -- 2026-09-14 audit reproduced migration 204 deleting completed follow-ups and
4
+ -- their ON DELETE CASCADE event histories when a meeting was re-recorded.
5
+ -- Preserve existing tasks and append only new descriptions. Retain proposed
6
+ -- changes in meetings.metadata.meeting_import.preserved_action_items for review.
7
+ -- Signature and ACL stay unchanged; the runner owns the transaction.
8
+ -- Validation: scripts/verify-meeting-action-preservation.sql on disposable PG.
9
+ -- Rollback: restore the 15-argument function from migration 204 (no data DDL).
10
+
11
+ CREATE OR REPLACE FUNCTION semicolony.record_meeting(
12
+ p_title text,
13
+ p_meeting_date date,
14
+ p_meeting_type text,
15
+ p_adhoc_subtype text,
16
+ p_visibility text,
17
+ p_target_domain text,
18
+ p_attendees jsonb,
19
+ p_record_body text,
20
+ p_transcript text,
21
+ p_action_items jsonb,
22
+ p_record_source text,
23
+ p_worker_id text,
24
+ p_device_id text,
25
+ p_discussion_url text DEFAULT NULL,
26
+ p_discussion_number integer DEFAULT NULL
27
+ )
28
+ RETURNS TABLE(meeting_id uuid, kb_id bigint, action_item_ids uuid[], duplicate boolean)
29
+ LANGUAGE plpgsql
30
+ SECURITY DEFINER
31
+ SET search_path = pg_catalog
32
+ AS $function$
33
+ DECLARE
34
+ v_meeting_id uuid;
35
+ v_kb_id bigint;
36
+ v_existing uuid;
37
+ v_norm text;
38
+ v_sensitivity text;
39
+ v_sub_key text;
40
+ v_item jsonb;
41
+ v_item_id uuid;
42
+ v_preserved jsonb;
43
+ v_description text;
44
+ v_priority text;
45
+ v_project text;
46
+ v_candidate text;
47
+ v_assignee_kind text;
48
+ v_assignee_member text;
49
+ v_assignee_agent text;
50
+ v_external_contact text;
51
+ v_deadline date;
52
+ -- Y1: fallbacks are recorded as data, not only as a log line. v_fallbacks
53
+ -- accumulates per item and lands in that item's own metadata; v_skipped
54
+ -- accumulates items that produced no row at all and lands on the meeting.
55
+ v_fallbacks jsonb;
56
+ v_skipped jsonb;
57
+ BEGIN
58
+ IF p_title IS NULL OR length(btrim(p_title)) < 3 THEN
59
+ RAISE EXCEPTION 'title must be at least 3 characters';
60
+ END IF;
61
+ IF p_meeting_date IS NULL THEN
62
+ RAISE EXCEPTION 'meeting_date is required';
63
+ END IF;
64
+ IF p_visibility IS NULL OR p_visibility NOT IN ('team', 'restricted') THEN
65
+ RAISE EXCEPTION 'visibility must be team or restricted, got %', p_visibility;
66
+ END IF;
67
+ IF p_record_source IS NULL
68
+ OR p_record_source NOT IN ('slack-worker', 'operator', 'backfill-discussion') THEN
69
+ RAISE EXCEPTION 'invalid record_source %', p_record_source;
70
+ END IF;
71
+ IF p_record_body IS NULL OR length(btrim(p_record_body)) = 0 THEN
72
+ RAISE EXCEPTION 'record_body is required';
73
+ END IF;
74
+
75
+ v_norm := lower(regexp_replace(btrim(p_title), '\s+', ' ', 'g'));
76
+ v_sensitivity := CASE WHEN p_visibility = 'restricted' THEN 'restricted' ELSE 'internal' END;
77
+
78
+ -- Serialize all writers for one normalized meeting, including first insert.
79
+ PERFORM pg_advisory_xact_lock(hashtextextended(
80
+ 'meeting:' || p_meeting_date::text || ':' || v_norm, 0));
81
+
82
+ SELECT m.meeting_id INTO v_existing
83
+ FROM semicolony.meetings m
84
+ WHERE m.meeting_date = p_meeting_date
85
+ AND lower(regexp_replace(m.title, '\s+', ' ', 'g')) = v_norm;
86
+
87
+ -- Discussion provenance from 202_meeting_record_discussion_provenance,
88
+ -- unchanged: COALESCE on update so a re-record that omits the link does not
89
+ -- erase one an earlier submission established, and both columns written on
90
+ -- insert.
91
+ IF v_existing IS NOT NULL THEN
92
+ UPDATE semicolony.meetings m
93
+ SET record_body = COALESCE(p_record_body, m.record_body),
94
+ mapped_transcript = COALESCE(p_transcript, m.mapped_transcript),
95
+ visibility = p_visibility,
96
+ record_source = p_record_source,
97
+ target_domain = COALESCE(p_target_domain, m.target_domain),
98
+ attendees = COALESCE(p_attendees, m.attendees),
99
+ discussion_url = COALESCE(p_discussion_url, m.discussion_url),
100
+ discussion_number = COALESCE(p_discussion_number, m.discussion_number),
101
+ updated_at = now()
102
+ WHERE m.meeting_id = v_existing;
103
+ v_meeting_id := v_existing;
104
+ ELSE
105
+ INSERT INTO semicolony.meetings (
106
+ meeting_id, title, meeting_type, adhoc_subtype, meeting_date, attendees,
107
+ visibility, record_body, mapped_transcript, record_source, target_domain,
108
+ transcription_status, speaker_map, discussion_url, discussion_number,
109
+ created_at, updated_at
110
+ ) VALUES (
111
+ gen_random_uuid(), btrim(p_title), p_meeting_type, p_adhoc_subtype, p_meeting_date,
112
+ COALESCE(p_attendees, '[]'::jsonb), p_visibility, p_record_body,
113
+ p_transcript, p_record_source, p_target_domain,
114
+ 'completed', '{}'::jsonb, p_discussion_url, p_discussion_number, now(), now()
115
+ ) RETURNING meetings.meeting_id INTO v_meeting_id;
116
+ END IF;
117
+
118
+ v_sub_key := to_char(p_meeting_date, 'YYYY-MM-DD') || '-' || left(v_norm, 60);
119
+
120
+ INSERT INTO semicolony.knowledge_base (
121
+ domain, key, sub_key, content, metadata, created_by, created_at, updated_at
122
+ ) VALUES (
123
+ COALESCE(p_target_domain, 'semicolon'),
124
+ 'meeting',
125
+ v_sub_key,
126
+ p_record_body,
127
+ jsonb_build_object(
128
+ 'classification', jsonb_build_object('sensitivity', v_sensitivity),
129
+ 'meeting_id', v_meeting_id,
130
+ 'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
131
+ 'record_source', p_record_source
132
+ ),
133
+ COALESCE(p_worker_id, 'meeting-worker'),
134
+ now(), now()
135
+ )
136
+ ON CONFLICT (domain, key, sub_key) DO UPDATE
137
+ SET content = EXCLUDED.content,
138
+ metadata = semicolony.knowledge_base.metadata || EXCLUDED.metadata,
139
+ updated_at = now()
140
+ RETURNING semicolony.knowledge_base.kb_id INTO v_kb_id;
141
+
142
+ -- Re-extraction is additive. The action ledger owns execution state, manual
143
+ -- corrections and audit history; a transcript refresh cannot replace it.
144
+ -- A matching description is conservative identity within one meeting.
145
+ -- Preserve changed proposals for review instead of overwriting assignments,
146
+ -- deadlines or completion. Deliberate edits use the action-item API.
147
+ IF p_action_items IS NOT NULL AND jsonb_typeof(p_action_items) = 'array'
148
+ AND jsonb_array_length(p_action_items) > 0 THEN
149
+ v_skipped := '[]'::jsonb;
150
+ v_preserved := '[]'::jsonb;
151
+
152
+ FOR v_item IN
153
+ SELECT item FROM jsonb_array_elements(p_action_items) AS item
154
+ LOOP
155
+ v_description := btrim(COALESCE(v_item ->> 'description', ''));
156
+ IF char_length(v_description) < 5 THEN
157
+ RAISE WARNING 'record_meeting: skipping meeting % action item, description shorter than 5 characters (%)',
158
+ v_meeting_id, v_description;
159
+ -- Y1: the RAISE alone is not evidence. Neither meeting-service.ts nor
160
+ -- meetings.ts subscribes to pg's `notice` event, so a WARNING is
161
+ -- visible only to someone attached with psql by hand. This item
162
+ -- produces no row to carry its own record, so the whole submitted
163
+ -- item is preserved on the meeting instead — an operator can then see
164
+ -- that the meeting's follow-up list is incomplete, and what was lost.
165
+ v_skipped := v_skipped || jsonb_build_array(jsonb_build_object(
166
+ 'reason', 'description_shorter_than_5_characters',
167
+ 'description', v_item ->> 'description',
168
+ 'owner', v_item ->> 'owner',
169
+ 'target', v_item ->> 'target',
170
+ 'assignee', v_item ->> 'assignee',
171
+ 'deadline', v_item ->> 'deadline',
172
+ 'priority', v_item ->> 'priority'
173
+ ));
174
+ CONTINUE;
175
+ END IF;
176
+
177
+ -- Y1: reset per item. Anything appended here lands in the created row's
178
+ -- metadata under meeting_import.fallbacks, so the row itself answers
179
+ -- "why is this filed under semo-ops / due then / unassigned?".
180
+ v_fallbacks := '[]'::jsonb;
181
+
182
+ v_item_id := NULL;
183
+ SELECT a.action_item_id INTO v_item_id
184
+ FROM semicolony.action_items a
185
+ WHERE a.meeting_id = v_meeting_id
186
+ AND lower(regexp_replace(btrim(a.description), '\s+', ' ', 'g')) =
187
+ lower(regexp_replace(v_description, '\s+', ' ', 'g'))
188
+ ORDER BY a.created_at, a.action_item_id
189
+ LIMIT 1;
190
+ IF v_item_id IS NOT NULL THEN
191
+ v_preserved := v_preserved || jsonb_build_array(jsonb_build_object(
192
+ 'action_item_id', v_item_id, 'submitted', v_item,
193
+ 'reason', 'existing_followup_preserved'));
194
+ CONTINUE;
195
+ END IF;
196
+
197
+ v_priority := COALESCE(NULLIF(btrim(v_item ->> 'priority'), ''), 'normal');
198
+ IF v_priority NOT IN ('low', 'normal', 'high', 'urgent') THEN
199
+ RAISE WARNING 'record_meeting: meeting % action item priority % is not one of low/normal/high/urgent, recording as normal',
200
+ v_meeting_id, v_priority;
201
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
202
+ 'field', 'priority',
203
+ 'reason', 'not_in_low_normal_high_urgent',
204
+ 'substituted', 'normal',
205
+ 'original', v_priority
206
+ ));
207
+ v_priority := 'normal';
208
+ END IF;
209
+
210
+ v_project := NULL;
211
+ FOREACH v_candidate IN ARRAY ARRAY[
212
+ NULLIF(btrim(v_item ->> 'target'), ''),
213
+ NULLIF(btrim(v_item ->> 'owner'), ''),
214
+ NULLIF(btrim(p_target_domain), '')
215
+ ]
216
+ LOOP
217
+ CONTINUE WHEN v_candidate IS NULL;
218
+ IF EXISTS (SELECT 1 FROM semicolony.ontology o
219
+ WHERE o.domain = v_candidate AND o.entity_type = 'service') THEN
220
+ v_project := v_candidate;
221
+ EXIT;
222
+ END IF;
223
+ END LOOP;
224
+ -- Y1: record the substitution before it is applied, while the rejected
225
+ -- candidates are still distinguishable from a deliberate 'semo-ops'.
226
+ IF v_project IS NULL THEN
227
+ RAISE WARNING 'record_meeting: meeting % action item names no entity_type=service domain (target %, owner %, meeting target %), filing under semo-ops',
228
+ v_meeting_id, v_item ->> 'target', v_item ->> 'owner', p_target_domain;
229
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
230
+ 'field', 'project_domain',
231
+ 'reason', 'no_service_domain_among_candidates',
232
+ 'substituted', 'semo-ops',
233
+ 'original', jsonb_build_object(
234
+ 'target', v_item ->> 'target',
235
+ 'owner', v_item ->> 'owner',
236
+ 'meeting_target_domain', p_target_domain
237
+ )
238
+ ));
239
+ END IF;
240
+ v_project := COALESCE(v_project, 'semo-ops');
241
+
242
+ v_assignee_kind := 'unassigned';
243
+ v_assignee_member := NULL;
244
+ v_assignee_agent := NULL;
245
+ FOREACH v_candidate IN ARRAY ARRAY[
246
+ NULLIF(btrim(v_item ->> 'assignee'), ''),
247
+ NULLIF(btrim(v_item ->> 'owner'), '')
248
+ ]
249
+ LOOP
250
+ CONTINUE WHEN v_candidate IS NULL;
251
+ IF EXISTS (SELECT 1 FROM semicolony.team_members tm WHERE tm.domain = v_candidate) THEN
252
+ v_assignee_kind := 'team-member';
253
+ v_assignee_member := v_candidate;
254
+ EXIT;
255
+ ELSIF EXISTS (SELECT 1 FROM semicolony.bot_status bs WHERE bs.bot_id = v_candidate) THEN
256
+ v_assignee_kind := 'agent';
257
+ v_assignee_agent := v_candidate;
258
+ EXIT;
259
+ END IF;
260
+ END LOOP;
261
+
262
+ v_external_contact := CASE WHEN v_assignee_kind = 'unassigned'
263
+ THEN NULLIF(btrim(v_item ->> 'assignee'), '')
264
+ ELSE NULL END;
265
+
266
+ -- Y1: only a *supplied* name that failed to resolve is a fallback. An
267
+ -- item that named nobody is simply unassigned, not a substitution, and
268
+ -- recording it as one would bury the real cases in noise.
269
+ IF v_assignee_kind = 'unassigned'
270
+ AND (NULLIF(btrim(v_item ->> 'assignee'), '') IS NOT NULL
271
+ OR NULLIF(btrim(v_item ->> 'owner'), '') IS NOT NULL) THEN
272
+ RAISE WARNING 'record_meeting: meeting % action item assignee (assignee %, owner %) is neither a team member nor an agent, leaving it unassigned',
273
+ v_meeting_id, v_item ->> 'assignee', v_item ->> 'owner';
274
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
275
+ 'field', 'assignee',
276
+ 'reason', 'not_a_team_member_or_agent',
277
+ 'substituted', 'unassigned',
278
+ 'original', jsonb_build_object(
279
+ 'assignee', v_item ->> 'assignee',
280
+ 'owner', v_item ->> 'owner'
281
+ ),
282
+ 'external_contact', v_external_contact
283
+ ));
284
+ END IF;
285
+
286
+ v_deadline := COALESCE(
287
+ NULLIF(btrim(v_item ->> 'deadline'), '')::date,
288
+ semicolony.action_item_default_deadline(v_priority)
289
+ );
290
+
291
+ -- Y1: the COALESCE above is deliberately left byte-identical (its shape
292
+ -- is pinned by the migration test); the fallback is detected separately
293
+ -- on the same condition rather than by restructuring it.
294
+ IF NULLIF(btrim(v_item ->> 'deadline'), '') IS NULL THEN
295
+ RAISE WARNING 'record_meeting: meeting % action item has no deadline, defaulting to % for priority %',
296
+ v_meeting_id, v_deadline, v_priority;
297
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
298
+ 'field', 'deadline',
299
+ 'reason', 'absent',
300
+ 'substituted', to_char(v_deadline, 'YYYY-MM-DD'),
301
+ 'original', v_item ->> 'deadline',
302
+ 'derived_from_priority', v_priority
303
+ ));
304
+ END IF;
305
+
306
+ v_item_id := semicolony.create_action_item(
307
+ p_description => v_description,
308
+ p_project_domain => v_project,
309
+ p_deadline => v_deadline,
310
+ p_assignee_kind => v_assignee_kind,
311
+ p_assignee_member => v_assignee_member,
312
+ p_assignee_agent => v_assignee_agent,
313
+ p_assignee_device => NULL,
314
+ p_priority => v_priority,
315
+ p_source_kind => 'meeting',
316
+ p_source_ref => v_meeting_id::text,
317
+ p_related_url => NULL,
318
+ p_external_contact => v_external_contact,
319
+ p_metadata => jsonb_build_object(
320
+ 'meeting_id', v_meeting_id,
321
+ 'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
322
+ 'record_source', p_record_source,
323
+ 'worker_id', p_worker_id,
324
+ 'device_id', p_device_id,
325
+ 'raw_owner', v_item ->> 'owner',
326
+ 'raw_target', v_item ->> 'target',
327
+ 'raw_assignee', v_item ->> 'assignee',
328
+ -- Y1: always present, empty array when the
329
+ -- item mapped cleanly, so a consumer can
330
+ -- filter on jsonb_array_length() without
331
+ -- having to distinguish absent from empty.
332
+ 'meeting_import', jsonb_build_object(
333
+ 'fallbacks', v_fallbacks
334
+ )
335
+ ),
336
+ p_origin_surface => 'system',
337
+ p_origin_actor_kind => NULL,
338
+ p_origin_member => NULL,
339
+ p_origin_agent => NULL,
340
+ p_origin_device => NULL,
341
+ p_origin_harness => NULL,
342
+ p_origin_session_key => NULL
343
+ );
344
+
345
+ -- The typed relation 198 added. create_action_item deliberately has no
346
+ -- meeting_id parameter: meetings are one source among nine and the
347
+ -- function's parameter list is already twenty wide, so the linkage is
348
+ -- stamped here, in the same transaction, rather than widening every
349
+ -- caller's signature for one of them.
350
+ UPDATE semicolony.action_items
351
+ SET meeting_id = v_meeting_id
352
+ WHERE action_item_id = v_item_id;
353
+ END LOOP;
354
+
355
+ -- Y1: publish the skip list on the meeting, inside the same IF that owns
356
+ -- the delete-and-recreate. `||` replaces the whole meeting_import key
357
+ -- rather than merging into it, so a re-record with the descriptions fixed
358
+ -- writes back an empty array instead of leaving a stale accusation on a
359
+ -- meeting whose follow-up list is now complete — the evidence follows the
360
+ -- same idempotency rule as the items it describes. Keeping it inside the
361
+ -- IF also means an empty submission leaves both the items and their
362
+ -- evidence untouched, which is the behaviour 199 defined and verification
363
+ -- confirmed.
364
+ UPDATE semicolony.meetings m
365
+ SET metadata = COALESCE(m.metadata, '{}'::jsonb)
366
+ || jsonb_build_object('meeting_import', jsonb_build_object(
367
+ 'preserved_action_items', v_preserved,
368
+ 'preserved_count', jsonb_array_length(v_preserved),
369
+ 'skipped_action_items', v_skipped,
370
+ 'skipped_count', jsonb_array_length(v_skipped),
371
+ 'submitted_count', jsonb_array_length(p_action_items),
372
+ 'recorded_at', now(),
373
+ 'record_source', p_record_source
374
+ )),
375
+ updated_at = now()
376
+ WHERE m.meeting_id = v_meeting_id;
377
+ END IF;
378
+
379
+ RETURN QUERY
380
+ SELECT v_meeting_id,
381
+ v_kb_id,
382
+ COALESCE(ARRAY(SELECT a.action_item_id
383
+ FROM semicolony.action_items a
384
+ WHERE a.meeting_id = v_meeting_id
385
+ ORDER BY a.created_at), '{}'::uuid[]),
386
+ (v_existing IS NOT NULL);
387
+ END;
388
+ $function$;