@rulemetric/local 0.10.0 → 0.12.0

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 (26) hide show
  1. package/dist/meta.json +2 -2
  2. package/dist/server.mjs +682 -496
  3. package/dist/supabase/migrations/00087_cleanup_test_data_fix.sql +1 -1
  4. package/dist/supabase/migrations/00184_auto_accept_budget_settings.sql +113 -0
  5. package/dist/supabase/migrations/00185_trial_throughput_settings.sql +43 -0
  6. package/dist/supabase/migrations/00186_canonical_checkout_prefers_live.sql +256 -0
  7. package/dist/supabase/migrations/00187_recommendation_applications.sql +32 -0
  8. package/dist/supabase/migrations/00188_training_producer_canonical_path.sql +117 -0
  9. package/dist/supabase/migrations/00189_limit_obs_window_index.sql +37 -0
  10. package/dist/supabase/migrations/00190_eval_targets_artifact_link.sql +176 -0
  11. package/dist/supabase/migrations/00191_recommendation_applications_effect_ledger.sql +106 -0
  12. package/dist/supabase/migrations/00192_cleanup_reaps_memberless_orgs.sql +142 -0
  13. package/dist/supabase/migrations/00193_judge_segment_pin_and_anchors.sql +367 -0
  14. package/dist/supabase/migrations/00194_refine_cases_producer.sql +91 -0
  15. package/dist/supabase/migrations/00195_skill_usage_views.sql +109 -0
  16. package/dist/supabase/migrations/00196_retire_unused_skills_producer.sql +102 -0
  17. package/dist/supabase/migrations/00197_auto_accept_producer_admits_skills.sql +115 -0
  18. package/dist/supabase/migrations/00198_unstale_non_catalog_suggestions.sql +51 -0
  19. package/dist/supabase/migrations/00199_auto_accept_runs_every_two_hours.sql +54 -0
  20. package/dist/supabase/migrations/00200_auto_accept_runs_hourly.sql +42 -0
  21. package/dist/web/assets/{docs-D_fJ3Svo.js → docs-KIeJWTb-.js} +35 -48
  22. package/dist/web/assets/index-CFJatBZ3.css +1 -0
  23. package/dist/web/assets/{index-D8PcENaI.js → index-MYof2fnF.js} +59 -59
  24. package/dist/web/index.html +3 -3
  25. package/package.json +2 -2
  26. package/dist/web/assets/index-B2QaicI_.css +0 -1
@@ -0,0 +1,367 @@
1
+ -- The judge segment gets ONE definition, and a pin outranks anything observed.
2
+ --
3
+ -- §6c scopes calibration to the current judge, and until now "current judge"
4
+ -- was hand-copied into four places (00148, 00169, 00190, judge-calibration.ts)
5
+ -- as "the most recently stamped run". Recency asks who graded LAST when the
6
+ -- question is whose bias we can CORRECT, and the two come apart the instant a
7
+ -- stray run lands. Measured 2026-08-18 on the one evolve-enrolled target:
8
+ --
9
+ -- claude-opus-5[1m] 355 runs Jul 30 - Aug 13
10
+ -- claude-opus-4-8[1m] 303 runs Jul 20 - Jul 29 <- all 37 labels
11
+ -- (unstamped) 159 runs Mar 25 - Aug 17
12
+ -- claude-fable-5 113 runs Aug 08 - Aug 14
13
+ -- claude-haiku-4-5 80 runs Aug 14 - Aug 16 <- elected, 0 labels
14
+ --
15
+ -- Recency elected the segment with zero anchors, so the adequacy gate reported
16
+ -- `0/50 human-labeled runs` and asked for fifty more while thirty-seven sat one
17
+ -- segment away. 00148's grader pin then pinned the NEXT batch to that same
18
+ -- stray, making the drift self-reinforcing; #357 quarantined fallback runs from
19
+ -- electing, but a run the producer never pinned has nothing to fall back FROM,
20
+ -- so unpinned evolution batches walked straight through the quarantine.
21
+ --
22
+ -- Three tiers, descending authority:
23
+ -- 1. PIN metadata.graderModel — a decision a person made. Nothing
24
+ -- observed in the run table may overrule it. A pinned segment
25
+ -- with zero runs is a correct answer: grading starts there.
26
+ -- 2. ANCHORS the segment holding the most fully human-labeled runs — the
27
+ -- only segment that can produce a corrected estimate at all.
28
+ -- 3. RECENCY the old rule, surviving as the tie-break and the
29
+ -- no-anchors-anywhere fallback.
30
+ --
31
+ -- Fallback runs (stamped != requested) are quarantined from ELECTING but still
32
+ -- count as data, exactly as #357 established. If every stamped run fell back,
33
+ -- the whole stamped set votes rather than electing nothing — an empty segment
34
+ -- would silently drop every LLM-graded run.
35
+ --
36
+ -- apps/api/src/lib/judge-calibration.ts `electJudgeSegment()` implements the
37
+ -- same three tiers for the TypeScript readers, and its unit tests pin the tier
38
+ -- order. Change one side without the other and the scheduled path will measure
39
+ -- a different segment than the reviewed path.
40
+ -- ---------------------------------------------------------------------------
41
+
42
+ create or replace function public.eval_target_judge_segment(p_target_id uuid)
43
+ returns text
44
+ language sql
45
+ stable
46
+ security definer
47
+ set search_path = public
48
+ as $$
49
+ with elector as (
50
+ select
51
+ r.grading->>'judge_model' as model,
52
+ r.created_at,
53
+ (
54
+ r.grading->>'judge_model_requested' is null
55
+ or r.grading->>'judge_model_requested' = r.grading->>'judge_model'
56
+ ) as clean,
57
+ -- Guarded: a grading blob whose expectations is not an array would make
58
+ -- jsonb_array_elements raise, and one malformed run must not take the
59
+ -- whole election down.
60
+ case
61
+ when jsonb_typeof(r.grading->'expectations') = 'array' then (
62
+ select count(*)
63
+ from jsonb_array_elements(r.grading->'expectations') e
64
+ where coalesce(e->>'method', 'llm') = 'llm'
65
+ and coalesce(e->>'errored', 'false') <> 'true'
66
+ )
67
+ else 0
68
+ end as llm_total,
69
+ coalesce(a.llm_annotated, 0) as llm_annotated
70
+ from eval_runs r
71
+ left join (
72
+ select eval_run_id, count(*) filter (where judge_method = 'llm') as llm_annotated
73
+ from eval_grade_annotations
74
+ where eval_target_id = p_target_id
75
+ group by eval_run_id
76
+ ) a on a.eval_run_id = r.id
77
+ where r.eval_target_id = p_target_id
78
+ and r.status = 'completed'
79
+ and r.configuration in ('with_target', 'without_target')
80
+ and r.grading->>'judge_model' is not null
81
+ ),
82
+ has_clean as (
83
+ select exists (select 1 from elector where clean) as v
84
+ ),
85
+ electorate as (
86
+ select e.* from elector e, has_clean h where e.clean or not h.v
87
+ ),
88
+ pin as (
89
+ select nullif(btrim(t.metadata->>'graderModel'), '') as model
90
+ from eval_targets t
91
+ where t.id = p_target_id
92
+ ),
93
+ anchors as (
94
+ -- Ties broken by recency then model string: a tie-break that fell through
95
+ -- to nothing would leave the segment depending on row order, which is the
96
+ -- same instability recency had.
97
+ select model
98
+ from electorate
99
+ where llm_total > 0 and llm_annotated >= llm_total
100
+ group by model
101
+ order by count(*) desc, max(created_at) desc, model desc
102
+ limit 1
103
+ ),
104
+ recency as (
105
+ select model from electorate order by created_at desc, model desc limit 1
106
+ )
107
+ select coalesce(
108
+ (select model from pin),
109
+ (select model from anchors),
110
+ (select model from recency)
111
+ );
112
+ $$;
113
+
114
+ comment on function public.eval_target_judge_segment(uuid) is
115
+ 'Canonical §6c judge segment for an eval target: metadata.graderModel pin, else the segment holding the most fully human-labeled runs, else the most recently stamped run. Fallback-graded runs (stamped <> requested) cannot elect. Mirrors electJudgeSegment() in apps/api/src/lib/judge-calibration.ts — the two must not diverge. Never re-derive this inline: the hand-copied recency rule lived in four places and elected a zero-anchor segment for four days (2026-08-18).';
116
+
117
+ -- ---------------------------------------------------------------------------
118
+ -- Both producers now ask the function instead of carrying their own copy.
119
+ -- ---------------------------------------------------------------------------
120
+
121
+ create or replace function public.enqueue_eval_autorun()
122
+ returns integer
123
+ language plpgsql
124
+ security definer
125
+ set search_path = public
126
+ as $$
127
+ declare
128
+ queued integer;
129
+ begin
130
+ with eligible as (
131
+ select t.id, t.user_id,
132
+ -- Canonical segment (00193). Was an inline "most recent stamped run"
133
+ -- subquery, which is how a credit-exhaustion fallback became the pin.
134
+ public.eval_target_judge_segment(t.id) as current_judge
135
+ from eval_targets t
136
+ where coalesce(t.metadata->>'autoRunEnabled', 'false') = 'true'
137
+ -- Gate 0 (00190): the target must be anchored to something a promotion
138
+ -- could land on. Without this, a batch is two LLM executions plus a
139
+ -- judged grade spent to rewrite a row nothing reads.
140
+ and (
141
+ t.instruction_id is not null
142
+ or t.project_path is not null
143
+ or t.name like '/%'
144
+ )
145
+ -- Gate 2a: recent grading wasn't MOSTLY ungradeable (proportional, and
146
+ -- identical to the evolution producer's 2a since 00163).
147
+ and not exists (
148
+ select 1 from eval_runs r
149
+ where r.eval_target_id = t.id
150
+ and r.created_at > now() - interval '7 days'
151
+ group by r.eval_target_id
152
+ having count(*) >= 5
153
+ and count(*) filter (
154
+ where jsonb_typeof(r.grading->'expectations') = 'array'
155
+ and exists (
156
+ select 1 from jsonb_array_elements(r.grading->'expectations') e
157
+ where coalesce(e->>'errored', 'false') = 'true'
158
+ )
159
+ ) * 4 >= count(*)
160
+ )
161
+ -- Gate 2b: no human anchors pointing at an ungradeable assertion.
162
+ and not exists (
163
+ select 1
164
+ from eval_grade_annotations a
165
+ join eval_runs r on r.id = a.eval_run_id
166
+ where a.eval_target_id = t.id
167
+ and jsonb_typeof(r.grading->'expectations') = 'array'
168
+ and coalesce(
169
+ r.grading->'expectations'->(a.expectation_index)->>'errored', 'false'
170
+ ) = 'true'
171
+ )
172
+ -- Gate 2c: last week's batch wasn't mostly failures.
173
+ and not exists (
174
+ select 1 from eval_runs r
175
+ where r.eval_target_id = t.id
176
+ and r.created_at > now() - interval '7 days'
177
+ group by r.eval_target_id
178
+ having count(*) >= 5
179
+ and count(*) filter (where r.status = 'failed') * 4 >= count(*)
180
+ )
181
+ -- Gate 3: the target has live cases to actually run.
182
+ and exists (
183
+ select 1 from evals ev
184
+ where ev.eval_target_id = t.id and ev.retired_at is null
185
+ )
186
+ -- One in flight at a time, per the 00083/00086 pattern.
187
+ and not exists (
188
+ select 1 from agent_jobs j
189
+ where j.task_kind = 'cron_eval_autorun'
190
+ and j.status in ('pending', 'claimed', 'running')
191
+ and j.payload->>'evalTargetId' = t.id::text
192
+ )
193
+ ), ins as (
194
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
195
+ select e.user_id,
196
+ 'cron_eval_autorun',
197
+ 'pending',
198
+ jsonb_build_object('evalTargetId', e.id::text, 'maxRuns', 12)
199
+ || case
200
+ when e.current_judge is not null
201
+ then jsonb_build_object('graderModel', e.current_judge)
202
+ else '{}'::jsonb
203
+ end,
204
+ 'eval-autorun:' || e.id::text
205
+ from eligible e
206
+ returning 1
207
+ )
208
+ select count(*)::int into queued from ins;
209
+
210
+ return queued;
211
+ end;
212
+ $$;
213
+
214
+ comment on function public.enqueue_eval_autorun() is
215
+ 'Nightly producer for cron_eval_autorun. Enqueues one bounded depth batch per OPTED-IN, ANCHORED, healthy, under-powered eval target, with the grader pinned to the target''s canonical judge segment (public.eval_target_judge_segment, 00193 — pin > anchors > recency; was an inline recency subquery from 00148/#196). Gate 0 (00190) requires instruction_id, project_path, or an absolute-path name. Health 2a is PROPORTIONAL as of 00169. Opt in with: update eval_targets set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRunEnabled":true}''::jsonb where id = ...';
216
+
217
+ -- ---------------------------------------------------------------------------
218
+ -- The evolution producer never pinned a grader AT ALL.
219
+ --
220
+ -- 00148 pinned the autorun batch precisely so a scheduled run could not fork
221
+ -- the segment. The evolution producer — which spends far more per night, and
222
+ -- whose paired replays are the ONLY route to a promotion (§13, Mode A) — was
223
+ -- never given the same treatment. Its runs therefore carried no
224
+ -- `judge_model_requested`, so #357's fallback quarantine had nothing to compare
225
+ -- against and they elected freely. Unpinned evolution batches are how
226
+ -- claude-haiku-4-5 took the segment on 2026-08-14.
227
+ --
228
+ -- Function is otherwise byte-identical to 00183.
229
+ -- ---------------------------------------------------------------------------
230
+
231
+ create or replace function public.enqueue_instruction_evolution()
232
+ returns integer
233
+ language plpgsql
234
+ security definer
235
+ set search_path = public
236
+ as $$
237
+ declare
238
+ queued integer;
239
+ begin
240
+ with health_ok as (
241
+ -- The gates SHARED by both enrolment paths, evaluated once. All copied
242
+ -- byte-identical from the live definition (00163's revision): pause,
243
+ -- proportional 2a, absolute 2b, proportional 2c, live cases, change
244
+ -- budget, one-in-flight.
245
+ select t.id, t.user_id
246
+ from eval_targets t
247
+ where coalesce(t.metadata->>'autoEvolvePaused', 'false') <> 'true'
248
+ -- Health 2a: recent grading wasn't MOSTLY ungradeable (proportional, 00163).
249
+ and not exists (
250
+ select 1 from eval_runs r
251
+ where r.eval_target_id = t.id
252
+ and r.created_at > now() - interval '7 days'
253
+ group by r.eval_target_id
254
+ having count(*) >= 5
255
+ and count(*) filter (
256
+ where jsonb_typeof(r.grading->'expectations') = 'array'
257
+ and exists (
258
+ select 1 from jsonb_array_elements(r.grading->'expectations') e
259
+ where coalesce(e->>'errored', 'false') = 'true'
260
+ )
261
+ ) * 4 >= count(*)
262
+ )
263
+ -- Health 2b: no human anchors pointing at an ungradeable assertion (absolute).
264
+ and not exists (
265
+ select 1
266
+ from eval_grade_annotations a
267
+ join eval_runs r on r.id = a.eval_run_id
268
+ where a.eval_target_id = t.id
269
+ and jsonb_typeof(r.grading->'expectations') = 'array'
270
+ and coalesce(
271
+ r.grading->'expectations'->(a.expectation_index)->>'errored', 'false'
272
+ ) = 'true'
273
+ )
274
+ -- Health 2c: last week's batch wasn't mostly failures.
275
+ and not exists (
276
+ select 1 from eval_runs r
277
+ where r.eval_target_id = t.id
278
+ and r.created_at > now() - interval '7 days'
279
+ group by r.eval_target_id
280
+ having count(*) >= 5
281
+ and count(*) filter (where r.status = 'failed') * 4 >= count(*)
282
+ )
283
+ -- Live cases to measure against — at least MIN_LIVE_CASES (3).
284
+ -- `exists` (>= 1) was the bug: see 00183's header.
285
+ and (
286
+ select count(*) from evals ev
287
+ where ev.eval_target_id = t.id and ev.retired_at is null
288
+ ) >= 3
289
+ -- Change budget (cheap exclusion; the API is the authority).
290
+ and (
291
+ select count(*)
292
+ from instruction_promotions p
293
+ where p.eval_target_id = t.id
294
+ and p.applied
295
+ and p.created_at > now() - interval '7 days'
296
+ ) < 3
297
+ -- One in flight at a time.
298
+ and not exists (
299
+ select 1 from agent_jobs j
300
+ where j.task_kind = 'cron_instruction_evolution'
301
+ and j.status in ('pending', 'claimed', 'running')
302
+ and j.payload->>'evalTargetId' = t.id::text
303
+ )
304
+ ),
305
+ manual as (
306
+ -- The hand-picked path, unchanged and uncapped.
307
+ select h.id, h.user_id
308
+ from health_ok h
309
+ join eval_targets t on t.id = h.id
310
+ where coalesce(t.metadata->>'autoEvolveEnabled', 'false') = 'true'
311
+ ),
312
+ auto_ranked as (
313
+ -- The stated rule. Round-robin: never-decided first (nulls first), then
314
+ -- least-recently-decided, so the cap rotates through the candidate pool
315
+ -- instead of re-measuring the same winners nightly.
316
+ select h.id, h.user_id,
317
+ row_number() over (
318
+ partition by h.user_id
319
+ order by (
320
+ select max(p.created_at)
321
+ from instruction_promotions p
322
+ where p.eval_target_id = h.id
323
+ ) asc nulls first,
324
+ h.id
325
+ ) as rn
326
+ from health_ok h
327
+ join eval_targets t on t.id = h.id
328
+ where t.type = 'instruction'
329
+ and coalesce(t.metadata->>'autoEvolveOptOut', 'false') <> 'true'
330
+ -- Not already covered by the manual path.
331
+ and coalesce(t.metadata->>'autoEvolveEnabled', 'false') <> 'true'
332
+ -- ACTIVE (00145's load-bearing gate): a run in the last 30 days.
333
+ and exists (
334
+ select 1 from eval_runs r
335
+ where r.eval_target_id = t.id
336
+ and r.created_at > now() - interval '30 days'
337
+ )
338
+ ),
339
+ eligible as (
340
+ select id, user_id from manual
341
+ union
342
+ select id, user_id from auto_ranked where rn <= 3
343
+ ),
344
+ ins as (
345
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
346
+ select e.user_id,
347
+ 'cron_instruction_evolution',
348
+ 'pending',
349
+ jsonb_build_object('evalTargetId', e.id::text)
350
+ || case
351
+ when public.eval_target_judge_segment(e.id) is not null
352
+ then jsonb_build_object(
353
+ 'graderModel', public.eval_target_judge_segment(e.id))
354
+ else '{}'::jsonb
355
+ end,
356
+ 'instruction-evolution:' || e.id::text
357
+ from eligible e
358
+ returning 1
359
+ )
360
+ select count(*)::int into queued from ins;
361
+
362
+ return queued;
363
+ end;
364
+ $$;
365
+
366
+ comment on function public.enqueue_instruction_evolution() is
367
+ 'Nightly producer for cron_instruction_evolution. Two enrolment paths as of 00166: MANUAL (autoEvolveEnabled=true, uncapped) and the STATED RULE (type=instruction, not opted out via autoEvolveOptOut, an eval run in 30 days, capped at 3/user/night round-robin by least-recent decision). Both share the health gates (2a proportional per 00163, 2b absolute, 2c proportional), live-cases (>= 3 per 00183), change-budget and one-in-flight guards. As of 00193 the payload PINS graderModel to the canonical judge segment — without it the paired replays carried no judge_model_requested, so #357''s fallback quarantine could not see them and they forked the segment freely. Auto-enrolled cycles are measure-only: writing still requires autoEvolveApply AND an explicit immutableSections.';
@@ -0,0 +1,91 @@
1
+ -- An actuator for the refusal the loop had been printing every night.
2
+ --
3
+ -- `cron_instruction_evolution` has been ending in `{"skipped":"not-adequate"}`
4
+ -- with the reason "3/5 cases do not discriminate with vs without — cull or
5
+ -- replace them before adding runs; more runs of a non-discriminating case
6
+ -- cannot lower the MDE; MDE ±49pp exceeds the ±20pp target effect" on every
7
+ -- night measured (2026-08-16, -17, -18). The remedy shipped in 2026-07 as
8
+ -- `rulemetric evals refine-cases <id> --yes` — a command a human types. So the
9
+ -- nightly loop diagnosed its own blocker correctly, spent a two-arm replay
10
+ -- proving it, and had no way to act.
11
+ --
12
+ -- Slot: 05:30, inside the existing nightly chain and deliberately BEFORE the
13
+ -- 06:10 autorun batch, so replacements generated tonight start collecting depth
14
+ -- tonight rather than waiting a day:
15
+ --
16
+ -- 04:30 suggestions -> 04:45 training -> 05:00 auto-accept
17
+ -- -> 05:30 REFINE -> 06:10 autorun -> 07:30 evolution
18
+ --
19
+ -- Opt-in per target (metadata.autoRefineCases), matching autoEvolveEnabled:
20
+ -- refinement rewrites what a target MEASURES, and a measurement apparatus that
21
+ -- reshapes itself without consent cannot then be trusted to report on itself.
22
+ -- The handler re-reads consent when it claims the job, so revoking it stops an
23
+ -- already-queued run.
24
+ -- ---------------------------------------------------------------------------
25
+
26
+ create or replace function public.enqueue_refine_cases()
27
+ returns integer
28
+ language plpgsql
29
+ security definer
30
+ set search_path = public
31
+ as $$
32
+ declare
33
+ queued integer;
34
+ begin
35
+ with eligible as (
36
+ select t.id, t.user_id
37
+ from eval_targets t
38
+ where coalesce(t.metadata->>'autoRefineCases', 'false') = 'true'
39
+ -- Discrimination is a CROSS-COHORT quantity: without runs in both arms
40
+ -- there is nothing to diagnose, and the handler would spend an API round
41
+ -- trip to say so. Cheap pre-filter, same answer.
42
+ and exists (
43
+ select 1 from eval_runs r
44
+ where r.eval_target_id = t.id
45
+ and r.status = 'completed'
46
+ and r.configuration = 'with_target'
47
+ )
48
+ and exists (
49
+ select 1 from eval_runs r
50
+ where r.eval_target_id = t.id
51
+ and r.status = 'completed'
52
+ and r.configuration = 'without_target'
53
+ )
54
+ -- There must be something left to measure with. A target already at the
55
+ -- floor cannot afford a retirement, and the handler refuses anyway —
56
+ -- but not enqueuing it keeps the refusal out of the nightly feed.
57
+ and (
58
+ select count(*) from evals ev
59
+ where ev.eval_target_id = t.id and ev.retired_at is null
60
+ ) >= 1
61
+ -- One in flight at a time, per the 00083/00086 pattern.
62
+ and not exists (
63
+ select 1 from agent_jobs j
64
+ where j.task_kind = 'cron_refine_cases'
65
+ and j.status in ('pending', 'claimed', 'running')
66
+ and j.payload->>'evalTargetId' = t.id::text
67
+ )
68
+ ), ins as (
69
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
70
+ select e.user_id,
71
+ 'cron_refine_cases',
72
+ 'pending',
73
+ jsonb_build_object('evalTargetId', e.id::text),
74
+ 'refine-cases:' || e.id::text
75
+ from eligible e
76
+ returning 1
77
+ )
78
+ select count(*)::int into queued from ins;
79
+
80
+ return queued;
81
+ end;
82
+ $$;
83
+
84
+ comment on function public.enqueue_refine_cases() is
85
+ 'Nightly producer for cron_refine_cases (§6e). Enqueues one refinement per OPTED-IN target (metadata.autoRefineCases=true) that has completed runs in BOTH cohorts and at least one live case. Retires cases carrying no treatment signal and regenerates replacements — the actuator for the "N/M cases do not discriminate" refusal the evolution loop printed nightly with no way to act on it. Runs at 05:30, before the 06:10 autorun slot, so tonight''s replacements collect depth tonight. Opt in with: update eval_targets set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRefineCases":true}''::jsonb where id = ...';
86
+
87
+ select cron.schedule(
88
+ 'enqueue_refine_cases',
89
+ '30 5 * * *',
90
+ $$select public.enqueue_refine_cases();$$
91
+ );
@@ -0,0 +1,109 @@
1
+ -- Skills were being USED and the product could not see it.
2
+ --
3
+ -- Measured 2026-08-18: `session_events` holds 158 `Skill` tool invocations over
4
+ -- 30 days, most recent that day, with the skill name sitting in plain sight at
5
+ -- `tool_input->>'skill'`. Meanwhile `session_instructions` held 0 rows for any
6
+ -- skill, `treatment_exposures` had no `skill` type at all, and of 6,086 skill
7
+ -- instructions 0 had ever been accepted and 0 archived. The signal was captured
8
+ -- and dropped on the floor.
9
+ --
10
+ -- That gap is why the loop has never proposed retiring a skill: grooming
11
+ -- reasons over measured effect, and there was no measurement to reason over.
12
+ --
13
+ -- ## Views, not a table
14
+ --
15
+ -- The obvious shape is a `skill_invocations` table written at ingest. It is the
16
+ -- wrong one here: the event side-effects already exist in TWO mirrored copies
17
+ -- (`routes/sessions/ingest/events.ts` and `lib/ingest-batcher.ts`), and a third
18
+ -- dual-write would be a third thing to keep in sync — plus a backfill to get
19
+ -- wrong. A view over the events cannot drift from its source, needs no
20
+ -- backfill, and is correct for all history the moment it exists.
21
+ --
22
+ -- ## Why this is a stronger signal than anything already recorded
23
+ --
24
+ -- Every existing `session_instructions.link_method` — project_match,
25
+ -- context_file, auto_capture, manual — means the instruction was PRESENT in
26
+ -- context. A Skill invocation means it was USED. §12 spends its length on the
27
+ -- gap between exposure and effect; this is the first record on the right side
28
+ -- of that gap, and it is worth keeping distinct from presence rather than
29
+ -- folding into it.
30
+ -- ---------------------------------------------------------------------------
31
+
32
+ -- Skill events are a rounding error in session_events (158 of ~30k), so a
33
+ -- partial index keeps the scan proportional to what is actually read.
34
+ create index if not exists idx_session_events_skill_tool
35
+ on public.session_events (session_id, timestamp)
36
+ where tool_name = 'Skill';
37
+
38
+ -- Catalog skills are named `<source-repo>--<skill>`; invocations carry either
39
+ -- the bare `<skill>` or `<plugin>:<skill>`. Matching on the leaf therefore
40
+ -- needs the leaf to be indexable — a `like '%--' || name` predicate cannot use
41
+ -- an index at all, and there are 6,086 skill rows to scan without one.
42
+ create index if not exists idx_instructions_skill_leaf
43
+ on public.instructions ((split_part(name, '--', 2)))
44
+ where type = 'skill';
45
+
46
+ -- ---------------------------------------------------------------------------
47
+ -- Raw invocations, parsed.
48
+ -- ---------------------------------------------------------------------------
49
+ create or replace view public.skill_invocations as
50
+ select
51
+ e.session_id,
52
+ e.timestamp as invoked_at,
53
+ e.tool_input->>'skill' as raw_name,
54
+ -- `plugin:skill` is one namespace form Claude Code emits; a bare name is the
55
+ -- other. Splitting rather than storing the raw string keeps the join below
56
+ -- honest about which half is the identity.
57
+ nullif(split_part(e.tool_input->>'skill', ':', 1), e.tool_input->>'skill')
58
+ as plugin,
59
+ case
60
+ when position(':' in e.tool_input->>'skill') > 0
61
+ then split_part(e.tool_input->>'skill', ':', 2)
62
+ else e.tool_input->>'skill'
63
+ end as skill_name
64
+ from public.session_events e
65
+ where e.tool_name = 'Skill'
66
+ and e.tool_input->>'skill' is not null
67
+ and e.tool_input->>'skill' <> '';
68
+
69
+ comment on view public.skill_invocations is
70
+ 'Every Skill tool invocation, parsed out of session_events. A view rather than a table: the event side-effects already exist in two mirrored copies and a third dual-write would be a third thing to keep in sync. Names arrive as `<skill>` or `<plugin>:<skill>`.';
71
+
72
+ -- ---------------------------------------------------------------------------
73
+ -- Invocations resolved to the catalog, WITHOUT discarding the unresolved ones.
74
+ --
75
+ -- Measured 2026-08-18: only 11 of 81 distinct invoked skills exist in the
76
+ -- instructions catalog. The other 70 are the user's own local skills
77
+ -- (`git-ship`, `commit`, `publish`, `auto-deploy`) — the ones actually used
78
+ -- every day, and the ones a catalog-only join would silently drop. An inner
79
+ -- join here would have reported 14% of reality and looked complete.
80
+ --
81
+ -- `instruction_id` is therefore NULLABLE and null means "used but not in the
82
+ -- catalog", which is a finding, not a failure.
83
+ -- ---------------------------------------------------------------------------
84
+ create or replace view public.skill_usage as
85
+ select
86
+ si.session_id,
87
+ si.invoked_at,
88
+ si.raw_name,
89
+ si.plugin,
90
+ si.skill_name,
91
+ s.user_id,
92
+ s.project_id,
93
+ s.project_path,
94
+ m.instruction_id
95
+ from public.skill_invocations si
96
+ join public.sessions s on s.id = si.session_id
97
+ left join lateral (
98
+ select i.id as instruction_id
99
+ from public.instructions i
100
+ where i.type = 'skill'
101
+ and (i.name = si.skill_name or split_part(i.name, '--', 2) = si.skill_name)
102
+ -- Exact name beats a leaf match, so a skill whose full name happens to equal
103
+ -- another's leaf cannot steal the attribution.
104
+ order by (i.name = si.skill_name) desc, i.id
105
+ limit 1
106
+ ) m on true;
107
+
108
+ comment on view public.skill_usage is
109
+ 'Skill invocations joined to the session that ran them and, where one exists, the catalog instruction. instruction_id is NULL for skills used but not catalogued — measured 2026-08-18, that was 70 of 81 distinct skills, so an inner join here would report 14% of reality.';
@@ -0,0 +1,102 @@
1
+ -- The loop's first unattended REMOVAL.
2
+ --
3
+ -- Everything the loop has actuated until now has added: a rule appended to
4
+ -- CLAUDE.md, a skill installed, a hook registered, an instruction promoted.
5
+ -- Grooming's stated contract was that removals are surfaced for human review
6
+ -- and NEVER auto-archived, and this producer deliberately changes that for one
7
+ -- narrow case (owner's decision, 2026-08-18). Recording the change here so the
8
+ -- next reader finds the departure rather than inferring it.
9
+ --
10
+ -- What makes it narrow:
11
+ --
12
+ -- * ONLY skills, and only ones with ZERO recorded invocations since install.
13
+ -- "Invoked but measures unhelpful" is an observational estimate and stays a
14
+ -- proposal, exactly as measured-harmful rules do. An absence of use is a
15
+ -- fact; an absence of benefit is an inference.
16
+ -- * ONLY skills the loop itself installed, evidenced by a
17
+ -- `recommendation_applications` row with status='applied'. The handler
18
+ -- re-checks this; the API is the authority.
19
+ -- * ONLY while skill capture is demonstrably live. A dark Skill channel makes
20
+ -- every skill look unused, and the first quiet fortnight would otherwise
21
+ -- read as "retire everything". Judged by `loadUnusedSkillCandidates`.
22
+ -- * A per-run cap in the handler, and a backup written before every delete
23
+ -- (`applyRetraction`) because an untracked skill file has no other copy and
24
+ -- git cannot restore it.
25
+ --
26
+ -- Slot: 05:45, after auto-accept (05:00) and refine (05:30), before the autorun
27
+ -- batch (06:10). Adding and removing in the same nightly pass, in that order,
28
+ -- means a skill adopted tonight is never a retirement candidate tonight — it
29
+ -- has to survive the unused window first.
30
+ --
31
+ -- 04:30 suggestions -> 04:45 training -> 05:00 auto-accept
32
+ -- -> 05:30 refine -> 05:45 RETIRE -> 06:10 autorun -> 07:30 evolution
33
+ --
34
+ -- Opt-in per project (metadata.autoRetireSkills). Nothing is retired for a user
35
+ -- who has not asked for it, and the handler cannot be reached without a job.
36
+ -- ---------------------------------------------------------------------------
37
+
38
+ create or replace function public.enqueue_retire_unused_skills()
39
+ returns integer
40
+ language plpgsql
41
+ security definer
42
+ set search_path = public
43
+ as $$
44
+ declare
45
+ queued integer;
46
+ begin
47
+ with eligible as (
48
+ -- The canonical checkout comes from the VIEW, never re-derived: is_worktree
49
+ -- is rewritten from each session's own metadata on every ingest, so a
50
+ -- hand-rolled ranking elects a path nobody works in (00186).
51
+ select pr.created_by as user_id, p.path as canonical_path
52
+ from public.project_canonical_checkouts p
53
+ join public.projects pr on pr.id = p.project_id
54
+ where coalesce(pr.metadata->'autoRetireSkills'->>'enabled', 'false') = 'true'
55
+ -- Cheap pre-filter: a user with no skill invocations captured at all has
56
+ -- a dark channel, and the handler would refuse anyway. Not enqueuing it
57
+ -- keeps a guaranteed refusal out of the nightly feed.
58
+ and exists (
59
+ select 1 from public.skill_usage su
60
+ where su.user_id = pr.created_by
61
+ and su.invoked_at > now() - interval '14 days'
62
+ )
63
+ -- There must be something that could plausibly be retired: at least one
64
+ -- of the user's own skills old enough to have had a fair chance.
65
+ and exists (
66
+ select 1 from public.instructions i
67
+ where i.created_by = pr.created_by
68
+ and i.type = 'skill'
69
+ and i.archived = false
70
+ and i.created_at < now() - interval '30 days'
71
+ )
72
+ -- One in flight at a time, per the 00083/00086 pattern.
73
+ and not exists (
74
+ select 1 from public.agent_jobs j
75
+ where j.task_kind = 'cron_retire_unused_skills'
76
+ and j.status in ('pending', 'claimed', 'running')
77
+ and j.payload->>'projectPath' = p.path
78
+ )
79
+ ), ins as (
80
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
81
+ select e.user_id,
82
+ 'cron_retire_unused_skills',
83
+ 'pending',
84
+ jsonb_build_object('projectPath', e.canonical_path),
85
+ 'retire-unused-skills:' || e.canonical_path
86
+ from eligible e
87
+ returning 1
88
+ )
89
+ select count(*)::int into queued from ins;
90
+
91
+ return queued;
92
+ end;
93
+ $$;
94
+
95
+ comment on function public.enqueue_retire_unused_skills() is
96
+ 'Nightly producer for cron_retire_unused_skills (05:45). Enqueues one run per OPTED-IN project (projects.metadata.autoRetireSkills.enabled=true) whose user has live skill capture and at least one own skill older than the unused window. The loop''s first unattended REMOVAL: only skills, only never-invoked ones, only ones the loop itself installed, only while skill capture is live, capped per run, and with a backup written before every delete. Used-but-unhelpful skills remain proposals. Opt in with: update projects set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRetireSkills":{"enabled":true}}''::jsonb where id = ...';
97
+
98
+ select cron.schedule(
99
+ 'enqueue_retire_unused_skills',
100
+ '45 5 * * *',
101
+ $$select public.enqueue_retire_unused_skills();$$
102
+ );