@team-semicolon/semicolony-cli 4.18.121 → 4.18.123

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,50 @@
1
+ -- 218_feedback_loop_state.sql
2
+ --
3
+ -- 루프의 상태를 hermes 의 ledger.json 에서 DB 로 옮긴다. 키는 액션아이템 id 가 아니라
4
+ -- KB 좌표(slug)다.
5
+ --
6
+ -- 2026-08-12 의 203_action_item_redesign_cutover.sql 이 action_items 를
7
+ -- action_items_archive_20260812 로 얼릴 때, 그 안에 열려 있던 피드백 액션아이템 477건이
8
+ -- 함께 얼었고 이월되지 않았다. 루프는 현행 테이블만 읽으므로 KB 의 open 피드백 779건 중
9
+ -- 473건이 보이지 않게 됐고, 원장 217건 중 185건이 아카이브 테이블을 가리키는 죽은 참조가
10
+ -- 됐다(2026-09-03 실측). 파생 테이블의 surrogate id 를 상태 키로 삼은 것이 원인이다.
11
+ -- slug 는 KB 좌표라 테이블 재설계가 건드릴 수 없다.
12
+ --
13
+ -- KB metadata 에 넣지 않는 이유: 런타임 부기(재시도 횟수·스누즈)와 지식(판정·재발 카운터)을
14
+ -- 섞으면 부기 때문에 KB 버전이 오른다.
15
+
16
+ CREATE TABLE IF NOT EXISTS semo_ops.feedback_loop_state (
17
+ slug text PRIMARY KEY,
18
+ lane text,
19
+ attempts int NOT NULL DEFAULT 0,
20
+ last_run text,
21
+ human_state text,
22
+ human_actor text,
23
+ snooze_until timestamptz,
24
+ no_response_streak int NOT NULL DEFAULT 0,
25
+ last_card_ts text,
26
+ updated_at timestamptz NOT NULL DEFAULT now(),
27
+ CONSTRAINT feedback_loop_state_lane_ck
28
+ CHECK (lane IS NULL OR lane IN ('quick-kb','deep-repo','needs-human','waiting')),
29
+ CONSTRAINT feedback_loop_state_human_ck
30
+ CHECK (human_state IS NULL OR human_state IN ('mine','snoozed','declined'))
31
+ );
32
+
33
+ COMMENT ON TABLE semo_ops.feedback_loop_state IS
34
+ 'Feedback resolution loop runtime state, keyed by the KB feedback slug. Replaces ledger.json on hermes.';
35
+
36
+ -- 새 테이블의 기본 권한이 PUBLIC 으로 새지 않게 한다. 216 에서 SECURITY DEFINER 함수가
37
+ -- PUBLIC 실행 가능한 채 배포됐던 것과 같은 실수를 막는다 — 순서가 중요하다.
38
+ REVOKE ALL ON TABLE semo_ops.feedback_loop_state FROM PUBLIC;
39
+
40
+ GRANT SELECT, INSERT, UPDATE ON semo_ops.feedback_loop_state TO sc_ops_records;
41
+ GRANT SELECT, INSERT, UPDATE ON semo_ops.feedback_loop_state TO semo_ops_knowledge_review;
42
+ GRANT SELECT ON semo_ops.feedback_loop_state TO sc_fleet_board;
43
+
44
+ DO $$
45
+ BEGIN
46
+ IF has_table_privilege('public', 'semo_ops.feedback_loop_state', 'SELECT') THEN
47
+ RAISE EXCEPTION 'feedback_loop_state is readable by PUBLIC after grants';
48
+ END IF;
49
+ END
50
+ $$;
@@ -0,0 +1,357 @@
1
+ -- 219_feedback_action_item_on_demand.sql
2
+ --
3
+ -- record_feedback 이 피드백마다 액션아이템을 만드는 것을 멈춘다.
4
+ --
5
+ -- 2026-09-03 실측: source_kind='feedback' 액션아이템 322건 중 대부분이 unassigned 이고
6
+ -- 아무도 보지 않는다. 루프의 입력이 KB slug 로 옮겨가면서 액션아이템은 "사람에게 배정된 일"
7
+ -- 만 담는 그릇이 된다 — 그래야 카드의 [내가 한다] 가 배정할 대상을 갖는다.
8
+ --
9
+ -- 함께 옮기는 것: duplicate 판정의 근거. 지금은 "같은 slug 의 열린 액션아이템이 있는가" 로
10
+ -- 정하는데, 액션아이템을 안 만들면 그 판정이 늘 false 가 된다. 근거를 KB 의 occurrences 로
11
+ -- 옮긴다. worker-agent-feedback-client.ts 가 typeof response.duplicate !== 'boolean' 로
12
+ -- 검증하고 스풀 보고의 duplicates 카운터를 그 값으로 세므로 반환 타입은 건드리지 않는다.
13
+ --
14
+ -- CREATE OR REPLACE 는 RETURNS TABLE 의 컬럼을 바꾸지 못한다. 그래서 action_item_id 는
15
+ -- 컬럼으로 남기고 값만 NULL 로 낸다.
16
+ --
17
+ -- 본문은 215_record_feedback_recurrence.sql 을 그대로 가져와 위 두 곳만 고친 것이다.
18
+
19
+ CREATE OR REPLACE FUNCTION semo_ops.record_feedback(
20
+ p_slug text,
21
+ p_summary text,
22
+ p_content text,
23
+ p_metadata jsonb,
24
+ p_embedding text,
25
+ p_worker_id text,
26
+ p_device_id text
27
+ )
28
+ RETURNS TABLE(kb_id bigint, action_item_id uuid, duplicate boolean,
29
+ merged boolean, occurrences int)
30
+ LANGUAGE plpgsql
31
+ SECURITY DEFINER
32
+ SET search_path TO 'pg_catalog'
33
+ AS $function$
34
+ DECLARE
35
+ v_kb_id bigint;
36
+ v_action_item_id uuid;
37
+ v_duplicate boolean := false;
38
+ v_merged boolean := false;
39
+ v_occurrences int := 1;
40
+ v_metadata jsonb;
41
+ v_priority text;
42
+ v_source text;
43
+ -- 재발 판정
44
+ v_merge_distance numeric;
45
+ v_link_distance numeric;
46
+ v_promote_occurrences int;
47
+ v_promote_distinct_hosts int;
48
+ v_near_id bigint;
49
+ v_near_slug text;
50
+ v_near_status text;
51
+ v_near_meta jsonb;
52
+ v_near_severity text;
53
+ v_distance numeric;
54
+ v_hosts jsonb;
55
+ v_distinct_hosts int;
56
+ v_new_status text;
57
+ v_event_kind text;
58
+ v_promoted boolean := false;
59
+ BEGIN
60
+ -- ── 검증 블록 (기존 정의에서 한 글자도 바꾸지 않고 그대로 옮겼다) ────
61
+ IF p_slug IS NULL
62
+ OR char_length(p_slug) < 3
63
+ OR char_length(p_slug) > 60
64
+ OR p_slug !~ '^[[:alnum:]가-힣][[:alnum:]가-힣-]*$' THEN
65
+ RAISE EXCEPTION 'invalid feedback slug' USING ERRCODE = '22023';
66
+ END IF;
67
+ IF p_summary IS NULL OR char_length(btrim(p_summary)) < 8 OR char_length(p_summary) > 500 THEN
68
+ RAISE EXCEPTION 'invalid feedback summary' USING ERRCODE = '22023';
69
+ END IF;
70
+ IF p_content IS NULL OR char_length(p_content) < 30 OR char_length(p_content) > 30000 THEN
71
+ RAISE EXCEPTION 'invalid feedback content' USING ERRCODE = '22023';
72
+ END IF;
73
+ IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
74
+ RAISE EXCEPTION 'invalid feedback metadata' USING ERRCODE = '22023';
75
+ END IF;
76
+ IF p_metadata ->> 'category' NOT IN ('context-failure', 'env-drift', 'policy-gap', 'tooling') THEN
77
+ RAISE EXCEPTION 'invalid feedback category' USING ERRCODE = '22023';
78
+ END IF;
79
+ v_priority := coalesce(p_metadata ->> 'severity', 'normal');
80
+ IF v_priority NOT IN ('low', 'normal', 'high', 'urgent') THEN
81
+ RAISE EXCEPTION 'invalid feedback severity' USING ERRCODE = '22023';
82
+ END IF;
83
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
84
+ OR p_device_id IS NULL
85
+ OR (p_device_id !~ '^[a-z][a-z0-9-]{0,99}$'
86
+ AND p_device_id <> 'hermes-principals@hermes') THEN
87
+ RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
88
+ END IF;
89
+
90
+ v_source := coalesce(p_metadata ->> 'source', 'session-hook');
91
+ IF v_source NOT IN ('session-hook', 'runtime-native-tool') THEN
92
+ RAISE EXCEPTION 'invalid feedback source' USING ERRCODE = '22023';
93
+ END IF;
94
+ IF v_source = 'runtime-native-tool' AND NOT (
95
+ p_worker_id IN ('semi', 'colony')
96
+ AND p_device_id = 'hermes-principals@hermes'
97
+ AND p_metadata ->> 'reported_by' = p_worker_id
98
+ AND p_metadata ->> 'installation_id' = 'hermes-principals@hermes'
99
+ AND p_metadata ->> 'runtime_kind' = 'hermes-cli'
100
+ AND p_metadata ->> 'profile_id' = 'semo-ops-worker-agent'
101
+ AND (
102
+ (p_worker_id = 'semi' AND p_metadata ->> 'runtime_profile' = 'semo-semi')
103
+ OR (p_worker_id = 'colony' AND p_metadata ->> 'runtime_profile' = 'semo-colony')
104
+ )
105
+ ) THEN
106
+ RAISE EXCEPTION 'invalid runtime-native feedback attribution' USING ERRCODE = '22023';
107
+ END IF;
108
+
109
+ PERFORM p_embedding::public.vector;
110
+ PERFORM pg_advisory_xact_lock(hashtextextended('feedback:' || p_slug, 0));
111
+
112
+ v_priority := coalesce(p_metadata ->> 'severity', 'normal');
113
+ v_source := coalesce(p_metadata ->> 'source', 'session-hook');
114
+
115
+ -- ── 정책 ─────────────────────────────────────────────────────────
116
+ SELECT fp.merge_distance, fp.link_distance,
117
+ fp.promote_occurrences, fp.promote_distinct_hosts
118
+ INTO v_merge_distance, v_link_distance,
119
+ v_promote_occurrences, v_promote_distinct_hosts
120
+ FROM semo_ops.feedback_policy fp
121
+ WHERE fp.id = 1;
122
+
123
+ IF NOT FOUND THEN
124
+ -- 부재는 기본값이 아니다. 문턱을 지어내느니 포착을 실패시킨다 —
125
+ -- 실패는 보이지만 잘못된 기본값은 안 보인다.
126
+ RAISE EXCEPTION 'semo_ops.feedback_policy has no row; refusing to guess the recurrence thresholds'
127
+ USING ERRCODE = 'P0002';
128
+ END IF;
129
+
130
+ -- ── 가장 가까운 기존 레코드 ──────────────────────────────────────
131
+ -- 같은 slug 를 제외하지 않는다. 같은 slug 의 재보고가 바로 재발이고, 그 경우
132
+ -- 거리는 0 이라 병합 경로가 받는다 — 예전 ON CONFLICT 가 fixed 를 open 으로
133
+ -- 되돌리던 자리를 이 경로가 가져간다.
134
+ --
135
+ -- 거리 연산자는 스키마를 붙여 쓴다. 이 함수의 search_path 는 pg_catalog 하나뿐이라
136
+ -- 맨 <=> 는 "operator does not exist" 로 죽는다 (2026-08-31 실측).
137
+ SELECT k.kb_id, k.sub_key, coalesce(k.metadata ->> 'status', 'open'),
138
+ coalesce(k.metadata, '{}'::jsonb),
139
+ (k.embedding OPERATOR(public.<=>) p_embedding::public.vector)
140
+ INTO v_near_id, v_near_slug, v_near_status, v_near_meta, v_distance
141
+ FROM semo_ops.knowledge_base k
142
+ WHERE k.domain = 'semicolon' AND k.key = 'feedback'
143
+ AND coalesce(k.archived, false) = false
144
+ AND k.embedding IS NOT NULL
145
+ ORDER BY k.embedding OPERATOR(public.<=>) p_embedding::public.vector
146
+ LIMIT 1;
147
+
148
+ -- ── 병합 구간 ────────────────────────────────────────────────────
149
+ IF v_near_id IS NOT NULL AND v_distance < v_merge_distance THEN
150
+ v_merged := true;
151
+ v_occurrences := coalesce((v_near_meta ->> 'occurrences')::int, 1) + 1;
152
+
153
+ -- 호스트는 중복 없이 모은다. 두 번째 기기의 첫 재발이 횟수보다 강한 신호다 —
154
+ -- open 634건 중 620건이 한 기기에서 나왔기 때문이다.
155
+ --
156
+ -- 대상에 device_id 가 없으면 보고한 기기로 시드한다. 우리는 원래 호스트를 모르는
157
+ -- 것이지, 다른 호스트인 것이 아니다. 모르면 같은 것으로 센다 — 그래야 진짜 두 번째
158
+ -- 기기가 나타났을 때만 2가 된다. 자리표시자를 하나 끼워 넣으면 device_id 없는 행
159
+ -- (2026-08-31 실측: open 738건 중 363건, fixed 179건 중 112건)에서 첫 재발만으로
160
+ -- distinct_hosts 가 2가 되어 승격의 호스트 조건이 늘 참이 된다.
161
+ v_hosts := coalesce(v_near_meta -> 'occurrence_hosts',
162
+ to_jsonb(ARRAY[coalesce(v_near_meta ->> 'device_id', p_device_id)]));
163
+ IF NOT (v_hosts @> to_jsonb(ARRAY[p_device_id])) THEN
164
+ v_hosts := v_hosts || to_jsonb(ARRAY[p_device_id]);
165
+ END IF;
166
+ v_distinct_hosts := jsonb_array_length(v_hosts);
167
+
168
+ v_near_severity := coalesce(v_near_meta ->> 'severity', 'normal');
169
+ v_new_status := v_near_status;
170
+ v_event_kind := 'merged';
171
+
172
+ IF v_near_status = 'fixed' THEN
173
+ -- 고쳤는데 돌아왔다. 이 시스템이 낼 수 있는 가장 값진 신호라 자율 기각이
174
+ -- 개입하지 않는다 — 문턱을 보지 않고 바로 연다.
175
+ v_new_status := 'open';
176
+ v_event_kind := 'regressed';
177
+ v_near_severity := CASE v_near_severity
178
+ WHEN 'low' THEN 'normal' WHEN 'normal' THEN 'high'
179
+ WHEN 'high' THEN 'urgent' ELSE 'urgent' END;
180
+ v_promoted := true;
181
+
182
+ ELSIF v_near_status = 'rejected' THEN
183
+ IF v_occurrences >= v_promote_occurrences
184
+ OR v_distinct_hosts >= v_promote_distinct_hosts THEN
185
+ v_new_status := 'open';
186
+ v_event_kind := 'promoted';
187
+ v_promoted := true;
188
+ END IF;
189
+ END IF;
190
+
191
+ UPDATE semo_ops.knowledge_base AS kb
192
+ SET content = kb.content
193
+ || E'\n\n## 재발 ' || to_char(clock_timestamp(), 'YYYY-MM-DD')
194
+ || E'\n\n- 호스트: ' || p_device_id
195
+ || E'\n- 세션: ' || coalesce(p_metadata ->> 'session_id', '-')
196
+ || E'\n- 요약: ' || btrim(p_summary)
197
+ || E'\n- 거리: ' || round(v_distance, 4)
198
+ || E'\n',
199
+ metadata = coalesce(kb.metadata, '{}'::jsonb)
200
+ || jsonb_build_object(
201
+ 'status', v_new_status,
202
+ 'severity', v_near_severity,
203
+ 'occurrences', v_occurrences,
204
+ 'occurrence_hosts', v_hosts,
205
+ 'last_occurred_at', to_char(clock_timestamp(),
206
+ 'YYYY-MM-DD"T"HH24:MI:SSOF'),
207
+ -- 생기지 않은 slug. 병합이 틀렸을 때 되짚는 실이다.
208
+ 'merged_from',
209
+ coalesce(kb.metadata -> 'merged_from', '[]'::jsonb)
210
+ || to_jsonb(ARRAY[p_slug]))
211
+ || CASE WHEN v_event_kind = 'regressed'
212
+ THEN jsonb_build_object('regressed_at',
213
+ to_char(clock_timestamp(), 'YYYY-MM-DD"T"HH24:MI:SSOF'))
214
+ ELSE '{}'::jsonb END
215
+ || CASE WHEN v_event_kind = 'promoted'
216
+ THEN jsonb_build_object('promoted_at',
217
+ to_char(clock_timestamp(), 'YYYY-MM-DD"T"HH24:MI:SSOF'))
218
+ ELSE '{}'::jsonb END
219
+ -- 승격·회귀로 다시 열리면 기각 사유는 이력이지 현재가 아니다.
220
+ || CASE WHEN v_new_status = 'open'
221
+ THEN jsonb_build_object('rejected_reason', NULL)
222
+ ELSE '{}'::jsonb END,
223
+ archived = false,
224
+ updated_at = clock_timestamp()
225
+ WHERE kb.kb_id = v_near_id;
226
+
227
+ INSERT INTO semo_ops.feedback_merge_events
228
+ (kind, source_slug, target_slug, distance, occurrences_after,
229
+ distinct_hosts_after, target_status_before, metadata)
230
+ VALUES
231
+ (v_event_kind, p_slug, v_near_slug, v_distance, v_occurrences,
232
+ v_distinct_hosts, v_near_status,
233
+ jsonb_build_object('device_id', p_device_id, 'worker_id', p_worker_id,
234
+ 'category', p_metadata ->> 'category'));
235
+
236
+ -- 액션아이템은 더 이상 여기서 만들지 않는다. 루프가 needs-human 으로 판정한 것에만
237
+ -- 만든다 (2026-09-04 설계). 승격 사실은 KB 의 status·occurrences 와 merge_events 에 남고,
238
+ -- 화면은 /ops/feedback 이 KB 를 읽어 그린다.
239
+ v_action_item_id := NULL;
240
+
241
+ RETURN QUERY SELECT v_near_id, v_action_item_id, true, true, v_occurrences;
242
+ RETURN;
243
+ END IF;
244
+
245
+ -- ── 연결 구간 ────────────────────────────────────────────────────
246
+ -- 자동 병합하지 않는다. 이 거리대에는 서비스가 다른 건(SemiClass ↔ semi-colon-ops)과
247
+ -- 같은 파일 건이 섞여 있고, 거리로는 그 둘이 갈리지 않는다. 표시만 하고 트리아지가 본다.
248
+ v_metadata := p_metadata || jsonb_build_object(
249
+ 'status', 'open',
250
+ 'source', v_source,
251
+ 'worker_id', p_worker_id,
252
+ 'device_id', p_device_id,
253
+ 'occurrences', 1,
254
+ 'occurrence_hosts', to_jsonb(ARRAY[p_device_id]));
255
+
256
+ IF v_near_id IS NOT NULL AND v_distance < v_link_distance THEN
257
+ v_metadata := v_metadata || jsonb_build_object('related_to', v_near_slug,
258
+ 'related_distance', round(v_distance, 5));
259
+ INSERT INTO semo_ops.feedback_merge_events
260
+ (kind, source_slug, target_slug, distance, occurrences_after,
261
+ distinct_hosts_after, target_status_before, metadata)
262
+ VALUES ('linked', p_slug, v_near_slug, v_distance, 1, 1, v_near_status,
263
+ jsonb_build_object('device_id', p_device_id));
264
+ END IF;
265
+
266
+ -- ── 신규 ────────────────────────────────────────────────────────
267
+ INSERT INTO semo_ops.knowledge_base AS kb (
268
+ domain, key, sub_key, content, created_by, embedding, metadata
269
+ ) VALUES (
270
+ 'semicolon', 'feedback', p_slug, p_content,
271
+ 'feedback-pipeline:' || p_worker_id, p_embedding::public.vector, v_metadata
272
+ )
273
+ -- 같은 slug 재보고는 새 기록이 아니라 재발이다. 최근접 탐색이 못 본 행
274
+ -- (archived 이거나 임베딩이 없는 행)만 여기로 오므로, 병합 경로와 같은 규칙을
275
+ -- 적용해야 카운터와 이력이 살아남는다.
276
+ ON CONFLICT (domain, key, sub_key) DO UPDATE SET
277
+ -- 본문을 덮어쓰지 않는다. 병합 경로처럼 재발 절을 덧붙인다 — 덮어쓰면 그동안
278
+ -- 쌓인 `## 재발` 절이 통째로 사라진다.
279
+ content = kb.content
280
+ || E'\n\n## 재발 ' || to_char(clock_timestamp(), 'YYYY-MM-DD')
281
+ || E'\n\n- 호스트: ' || p_device_id
282
+ || E'\n- 세션: ' || coalesce(p_metadata ->> 'session_id', '-')
283
+ || E'\n- 요약: ' || btrim(p_summary)
284
+ || E'\n- 경로: 같은 slug 재보고 (최근접 탐색이 보지 못한 행)'
285
+ || E'\n',
286
+ embedding = EXCLUDED.embedding,
287
+ -- status 를 빼고 병합한다. 예전 판은 무조건 'open' 으로 덮어써서, 같은 slug
288
+ -- 재보고가 이미 fixed 인 항목을 조용히 되돌렸다.
289
+ --
290
+ -- 재발 카운터 셋도 함께 뺀다. EXCLUDED 는 언제나 occurrences=1 과 이번 기기
291
+ -- 하나짜리 occurrence_hosts 를 들고 오므로, 그대로 병합하면 그동안 센 횟수와
292
+ -- 호스트 목록과 merged_from 이 첫 재보고 한 번으로 0으로 돌아간다. 자율 기각
293
+ -- 아래에서 이 카운터가 유일한 안전망이라 조용히 지워지는 것이 최악이다.
294
+ --
295
+ -- 빼놓고 끝내지 않는다. 이 경로도 재발이므로 횟수를 올리고 호스트를 중복 없이
296
+ -- 더한다. 세는 자리를 비워 두면 트리아지가 보는 숫자가 재보고 때마다 제자리다.
297
+ --
298
+ -- 다만 상태 반응(승격·회귀)은 여기서 하지 않는다. 이 경로는 최근접 탐색이 병합
299
+ -- 거리 안에서 아무것도 못 찾았는데 slug 는 이미 있는 경우 — 즉 기존 행이
300
+ -- archived 이거나 임베딩이 없는 경우 — 에만 닿는다. archived 중복은 그 문제의
301
+ -- 정본이 아니다. 정본은 그 병합 대상이고 대상은 archived 가 아니므로 최근접이
302
+ -- 거리 0 으로 찾아 병합 경로에서 이미 끝난다. 그래서 실제로 여기 오는 것은
303
+ -- 임베딩 없는 레거시 행의 재보고이고, 거기서 승격을 판정할 근거가 우리에겐 없다.
304
+ -- 횟수는 오르므로 트리아지에는 보인다.
305
+ metadata = coalesce(kb.metadata, '{}'::jsonb)
306
+ || (EXCLUDED.metadata - 'status' - 'occurrences' - 'occurrence_hosts' - 'merged_from')
307
+ || jsonb_build_object(
308
+ 'occurrences', coalesce((kb.metadata ->> 'occurrences')::int, 0) + 1,
309
+ 'occurrence_hosts',
310
+ CASE WHEN coalesce(kb.metadata -> 'occurrence_hosts',
311
+ to_jsonb(ARRAY[coalesce(kb.metadata ->> 'device_id',
312
+ p_device_id)]))
313
+ @> to_jsonb(ARRAY[p_device_id])
314
+ THEN coalesce(kb.metadata -> 'occurrence_hosts',
315
+ to_jsonb(ARRAY[coalesce(kb.metadata ->> 'device_id',
316
+ p_device_id)]))
317
+ ELSE coalesce(kb.metadata -> 'occurrence_hosts',
318
+ to_jsonb(ARRAY[coalesce(kb.metadata ->> 'device_id',
319
+ p_device_id)]))
320
+ || to_jsonb(ARRAY[p_device_id])
321
+ END),
322
+ archived = false,
323
+ updated_at = clock_timestamp()
324
+ -- 저장된 값을 그대로 돌려준다. 리터럴 1 은 "4번째 재발" 을 1번째라고 말한다.
325
+ RETURNING kb.kb_id, coalesce((kb.metadata ->> 'occurrences')::int, 1)
326
+ INTO v_kb_id, v_occurrences;
327
+
328
+ -- 액션아이템은 이제 needs-human 판정에만 생긴다. 322건이 대부분 unassigned 로 쌓여
329
+ -- 아무도 보지 않았다 (2026-09-03 실측).
330
+ v_action_item_id := NULL;
331
+
332
+ -- 중복 판정의 근거를 KB 로 옮긴다. "같은 slug 의 열린 액션아이템이 있는가" 는 액션아이템을
333
+ -- 안 만들면 항상 false 가 된다. 중복이란 같은 것이 다시 왔다는 뜻이지 액션아이템이 남아
334
+ -- 있다는 뜻이 아니므로, 재발 카운터가 더 정확한 근거이기도 하다.
335
+ v_duplicate := (v_occurrences > 1);
336
+
337
+ RETURN QUERY SELECT v_kb_id, v_action_item_id, v_duplicate, false, v_occurrences;
338
+ END;
339
+ $function$;
340
+
341
+ -- 재생성으로 실행 권한이 PUBLIC 으로 되돌아갔다. 교체 전 ACL 을 그대로 복원한다 —
342
+ -- 게이트웨이는 sc_app 으로 붙는다.
343
+ REVOKE ALL ON FUNCTION semo_ops.record_feedback(
344
+ text, text, text, jsonb, text, text, text
345
+ ) FROM PUBLIC;
346
+ GRANT EXECUTE ON FUNCTION semo_ops.record_feedback(
347
+ text, text, text, jsonb, text, text, text
348
+ ) TO sc_app;
349
+
350
+ DO $$
351
+ BEGIN
352
+ IF has_function_privilege('public',
353
+ 'semo_ops.record_feedback(text, text, text, jsonb, text, text, text)', 'EXECUTE') THEN
354
+ RAISE EXCEPTION 'record_feedback is executable by PUBLIC after grants';
355
+ END IF;
356
+ END
357
+ $$;
@@ -5,29 +5,31 @@ SEMICOLONY CLI의 PostgreSQL 마이그레이션 파일들.
5
5
 
6
6
  ## 마이그레이션 목록
7
7
 
8
- | 파일 | 설명 |
9
- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
10
- | `001_initial.sql` | 초기 스키마 (skill/agent/command_definitions, bot_status 등) |
11
- | `002_add_indexes.sql` | 조회 성능 인덱스 추가 |
12
- | `003_add_fk.sql` | FK 제약조건 추가 |
13
- | `004_session_count_trigger.sql` | 봇 세션 카운트 트리거 |
14
- | `005_bot_workspace_audits.sql` | Bot workspace 감사 테이블 |
15
- | `006_bot_kb_subscriptions.sql` | 봇별 KB 구독 테이블 |
16
- | `007_flatten_skill_names.sql` | Flat skill naming 마이그레이션 |
17
- | `165_skill_release_retirements.sql` | 거버넌스 스킬 은퇴 프리미티브 (append-only 기록 + 뷰) |
18
- | `166_lease_operations_retire.sql` | `skill_capability_leases` profile→operations CHECK 재생성: operate 프로필에 `skills.release.retire` 추가 |
19
- | `169_worker_profile_releases.sql` | Immutable SEMO Ops worker profile releases and device rollout state |
20
- | `170_worker_release_credential_scopes.sql` | Worker credential owner 함수의 허용 범위에 release read/report scope 추가 |
21
- | `174_worker_fleet_board_grants.sql` | 플릿 상황판 전용 롤 `sc_fleet_board` + 오퍼레이터 액션 감사 테이블 |
22
- | `178_feedback_capture_capability.sql` | `sc_app` 일반 DML 없이 KB+액션아이템을 원자 기록하는 `record_feedback` capability |
23
- | `179_worker_feedback_credential_scope.sql` | 워커 자격증명 `feedback:write` 허용 및 활성 디바이스 바인딩에 범위 백필 |
24
- | `183_worker_agent_gateway_credentials.sql` | Hermes Agent host release credential과 Semi/Colony actor KB-read credential의 서버 바인딩 및 회전 |
25
- | `184_worker_agent_workspace_profile.sql` | Hermes Agent immutable release가 참조하는 `semo-ops-worker-agent` DB 정본 프로필 시드 |
26
- | `185_worker_agent_feedback_scope.sql` | Hermes Semi/Colony actor에 정확한 feedback 범위·귀속을 추가하고 기존 바인딩만 백필 |
27
- | `186_worker_agent_decision_capture.sql` | operator 승인 reference와 actor-bound decision recorder를 추가하고 정확한 decision scope만 백필 |
28
- | `187_worker_agent_decision_digest_repair.sql` | Phase 6 decision recorder를 extension schema와 무관한 PostgreSQL SHA-256 primitive로 교정 |
29
- | `195_worker_device_target_release.sql` | `worker_devices.active_release_id` (nullable, FK) 추가 — `/api/worker-fleet` DEVICE_ROWS_SQL의 COALESCE 대상 컬럼 누락으로 발생한 500 수정 |
30
- | `196_ops_control_room_board_grants.sql` | `sc_fleet_board` 에 `action_items`·`ontology`·`knowledge_base` SELECT 부여 + knowledge_base SELECT 정책 롤 확장 — /ops 홈의 `permission denied` 카드 수정 |
8
+ | 파일 | 설명 |
9
+ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
10
+ | `001_initial.sql` | 초기 스키마 (skill/agent/command_definitions, bot_status 등) |
11
+ | `002_add_indexes.sql` | 조회 성능 인덱스 추가 |
12
+ | `003_add_fk.sql` | FK 제약조건 추가 |
13
+ | `004_session_count_trigger.sql` | 봇 세션 카운트 트리거 |
14
+ | `005_bot_workspace_audits.sql` | Bot workspace 감사 테이블 |
15
+ | `006_bot_kb_subscriptions.sql` | 봇별 KB 구독 테이블 |
16
+ | `007_flatten_skill_names.sql` | Flat skill naming 마이그레이션 |
17
+ | `165_skill_release_retirements.sql` | 거버넌스 스킬 은퇴 프리미티브 (append-only 기록 + 뷰) |
18
+ | `166_lease_operations_retire.sql` | `skill_capability_leases` profile→operations CHECK 재생성: operate 프로필에 `skills.release.retire` 추가 |
19
+ | `169_worker_profile_releases.sql` | Immutable SEMO Ops worker profile releases and device rollout state |
20
+ | `170_worker_release_credential_scopes.sql` | Worker credential owner 함수의 허용 범위에 release read/report scope 추가 |
21
+ | `174_worker_fleet_board_grants.sql` | 플릿 상황판 전용 롤 `sc_fleet_board` + 오퍼레이터 액션 감사 테이블 |
22
+ | `178_feedback_capture_capability.sql` | `sc_app` 일반 DML 없이 KB+액션아이템을 원자 기록하는 `record_feedback` capability |
23
+ | `179_worker_feedback_credential_scope.sql` | 워커 자격증명 `feedback:write` 허용 및 활성 디바이스 바인딩에 범위 백필 |
24
+ | `183_worker_agent_gateway_credentials.sql` | Hermes Agent host release credential과 Semi/Colony actor KB-read credential의 서버 바인딩 및 회전 |
25
+ | `184_worker_agent_workspace_profile.sql` | Hermes Agent immutable release가 참조하는 `semo-ops-worker-agent` DB 정본 프로필 시드 |
26
+ | `185_worker_agent_feedback_scope.sql` | Hermes Semi/Colony actor에 정확한 feedback 범위·귀속을 추가하고 기존 바인딩만 백필 |
27
+ | `186_worker_agent_decision_capture.sql` | operator 승인 reference와 actor-bound decision recorder를 추가하고 정확한 decision scope만 백필 |
28
+ | `187_worker_agent_decision_digest_repair.sql` | Phase 6 decision recorder를 extension schema와 무관한 PostgreSQL SHA-256 primitive로 교정 |
29
+ | `195_worker_device_target_release.sql` | `worker_devices.active_release_id` (nullable, FK) 추가 — `/api/worker-fleet` DEVICE_ROWS_SQL의 COALESCE 대상 컬럼 누락으로 발생한 500 수정 |
30
+ | `196_ops_control_room_board_grants.sql` | `sc_fleet_board` 에 `action_items`·`ontology`·`knowledge_base` SELECT 부여 + knowledge_base SELECT 정책 롤 확장 — /ops 홈의 `permission denied` 카드 수정 |
31
+ | `218_feedback_loop_state.sql` | 피드백 해소 루프 상태를 KB slug 키로 DB 에 둔다 (ledger.json 대체) |
32
+ | `219_feedback_action_item_on_demand.sql` | `record_feedback` 이 액션아이템을 만들지 않고, `duplicate` 를 재발 카운터로 판정한다 |
31
33
 
32
34
  ## 007: Flat Skill Naming
33
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-semicolon/semicolony-cli",
3
- "version": "4.18.121",
3
+ "version": "4.18.123",
4
4
  "description": "SemiColony CLI - AI operations and agent orchestration installer",
5
5
  "main": "dist/bundle.js",
6
6
  "bin": {