@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,176 @@
1
+ -- Give an eval target a real link to the artifact it measures.
2
+ --
3
+ -- THE STRUCTURAL DEFECT
4
+ --
5
+ -- `eval_targets` stored no reference to the thing under test: no
6
+ -- `instruction_id`, no project. Just a COPY of the content and a `name` string.
7
+ -- "Which artifact does this target measure?" was answered by parsing the name —
8
+ -- if it starts with '/', treat it as a file path — a convention enforced
9
+ -- nowhere that the actuator nonetheless depends on
10
+ -- (`cron-instruction-evolution.ts`: `target.name.startsWith('/') && existsSync`).
11
+ --
12
+ -- When the name is not a path, the loop still measures the target and still
13
+ -- "promotes" it; the promotion writes back to `eval_targets.content`, the
14
+ -- target's own copy, which nothing downstream reads. Measured 2026-08-15:
15
+ -- `TypeScript Best Practices` — a 129-character demo row seeded 2026-03-10 —
16
+ -- was one of only 2 autorun-enabled targets out of 251, write-enabled, and had
17
+ -- burned 102 eval runs producing edits no session could ever load.
18
+ --
19
+ -- The link was available and discarded. `sessions/linking.ts` auto-creates
20
+ -- targets from captured instructions (169 of 197 instruction targets came from
21
+ -- there); it SELECTs `instructions` by `inst.id` to read the content, then
22
+ -- stores only `inst.name`. The session — and therefore the project — is in
23
+ -- scope in the same function.
24
+ --
25
+ -- WHY project_path RATHER THAN project_id
26
+ --
27
+ -- The trial half of the system already models this correctly:
28
+ -- `instruction_experiments` carries `instruction_id` + `project_path`, and its
29
+ -- verdicts (`instruction_verdicts`) carry the same pair. Skills are globally
30
+ -- defined (scope 'public') but their EFFECTIVENESS is per-project — the same
31
+ -- skill helps in one repo and not another. Matching that table's shape keeps
32
+ -- the two measurement modes joinable on the same keys. (Modes A and B are
33
+ -- complementary, not redundant: a repo file cannot be withheld from real work,
34
+ -- so CLAUDE.md is Mode A only, while a skill can be, so it is trialled.)
35
+ --
36
+ -- Both columns are NULLABLE and nothing is backfilled. 249 of 251 targets are
37
+ -- not enrolled in anything; guessing their provenance from a name would be
38
+ -- inventing data. The gate below applies only where promotions can happen.
39
+
40
+ alter table public.eval_targets
41
+ add column if not exists instruction_id uuid references public.instructions(id) on delete set null,
42
+ add column if not exists project_path text;
43
+
44
+ comment on column public.eval_targets.instruction_id is
45
+ 'The instruction this target measures. Null for targets whose provenance was never recorded (everything created before 00190) and for prompt-type targets captured from a session. Populated at auto-creation, where the id is already in hand.';
46
+
47
+ comment on column public.eval_targets.project_path is
48
+ 'Project the target is measured FOR. Mirrors instruction_experiments.project_path so the eval (Mode A) and trial (Mode B) halves join on the same keys. A public skill is global; its effectiveness is not.';
49
+
50
+ create index if not exists idx_eval_targets_instruction
51
+ on public.eval_targets (instruction_id) where instruction_id is not null;
52
+
53
+ create index if not exists idx_eval_targets_project
54
+ on public.eval_targets (project_path) where project_path is not null;
55
+
56
+ -- ---------------------------------------------------------------------------
57
+ -- The gate, at the producer — the same place 00188 gated the training producer
58
+ -- on the canonical checkout, and for the same reason: enrolment is a manual
59
+ -- metadata toggle, so the producer is the only chokepoint every scheduled batch
60
+ -- must pass through.
61
+ --
62
+ -- A target may only consume the autorun budget if the loop can name what a
63
+ -- promotion would land on. Two ways to qualify:
64
+ --
65
+ -- (a) it carries a link (instruction_id or project_path), or
66
+ -- (b) it is named by an absolute path — the legacy convention, kept so the
67
+ -- 163 file-anchored targets that predate this migration keep working
68
+ -- without a backfill.
69
+ --
70
+ -- A target with neither is refused. That is exactly the demo row: no
71
+ -- instruction, no project, and a name that is a label rather than a path.
72
+ -- ---------------------------------------------------------------------------
73
+
74
+ create or replace function public.enqueue_eval_autorun()
75
+ returns integer
76
+ language plpgsql
77
+ security definer
78
+ set search_path = public
79
+ as $$
80
+ declare
81
+ queued integer;
82
+ begin
83
+ with eligible as (
84
+ select t.id, t.user_id,
85
+ -- Current judge for this target = most recent stamped run, mirroring
86
+ -- judge-calibration's own resolution (created_at desc, model desc for a
87
+ -- stable tie-break).
88
+ (
89
+ select r.grading->>'judge_model'
90
+ from eval_runs r
91
+ where r.eval_target_id = t.id
92
+ and r.grading->>'judge_model' is not null
93
+ order by r.created_at desc, r.grading->>'judge_model' desc
94
+ limit 1
95
+ ) as current_judge
96
+ from eval_targets t
97
+ where coalesce(t.metadata->>'autoRunEnabled', 'false') = 'true'
98
+ -- Gate 0 (00190): the target must be anchored to something a promotion
99
+ -- could land on. Without this, a batch is two LLM executions plus a
100
+ -- judged grade spent to rewrite a row nothing reads.
101
+ and (
102
+ t.instruction_id is not null
103
+ or t.project_path is not null
104
+ or t.name like '/%'
105
+ )
106
+ -- Gate 2a: recent grading wasn't MOSTLY ungradeable (proportional, and
107
+ -- identical to the evolution producer's 2a since 00163).
108
+ and not exists (
109
+ select 1 from eval_runs r
110
+ where r.eval_target_id = t.id
111
+ and r.created_at > now() - interval '7 days'
112
+ group by r.eval_target_id
113
+ having count(*) >= 5
114
+ and count(*) filter (
115
+ where jsonb_typeof(r.grading->'expectations') = 'array'
116
+ and exists (
117
+ select 1 from jsonb_array_elements(r.grading->'expectations') e
118
+ where coalesce(e->>'errored', 'false') = 'true'
119
+ )
120
+ ) * 4 >= count(*)
121
+ )
122
+ -- Gate 2b: no human anchors pointing at an ungradeable assertion.
123
+ and not exists (
124
+ select 1
125
+ from eval_grade_annotations a
126
+ join eval_runs r on r.id = a.eval_run_id
127
+ where a.eval_target_id = t.id
128
+ and jsonb_typeof(r.grading->'expectations') = 'array'
129
+ and coalesce(
130
+ r.grading->'expectations'->(a.expectation_index)->>'errored', 'false'
131
+ ) = 'true'
132
+ )
133
+ -- Gate 2c: last week's batch wasn't mostly failures.
134
+ and not exists (
135
+ select 1 from eval_runs r
136
+ where r.eval_target_id = t.id
137
+ and r.created_at > now() - interval '7 days'
138
+ group by r.eval_target_id
139
+ having count(*) >= 5
140
+ and count(*) filter (where r.status = 'failed') * 4 >= count(*)
141
+ )
142
+ -- Gate 3: the target has live cases to actually run.
143
+ and exists (
144
+ select 1 from evals ev
145
+ where ev.eval_target_id = t.id and ev.retired_at is null
146
+ )
147
+ -- One in flight at a time, per the 00083/00086 pattern.
148
+ and not exists (
149
+ select 1 from agent_jobs j
150
+ where j.task_kind = 'cron_eval_autorun'
151
+ and j.status in ('pending', 'claimed', 'running')
152
+ and j.payload->>'evalTargetId' = t.id::text
153
+ )
154
+ ), ins as (
155
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
156
+ select e.user_id,
157
+ 'cron_eval_autorun',
158
+ 'pending',
159
+ jsonb_build_object('evalTargetId', e.id::text, 'maxRuns', 12)
160
+ || case
161
+ when e.current_judge is not null
162
+ then jsonb_build_object('graderModel', e.current_judge)
163
+ else '{}'::jsonb
164
+ end,
165
+ 'eval-autorun:' || e.id::text
166
+ from eligible e
167
+ returning 1
168
+ )
169
+ select count(*)::int into queued from ins;
170
+
171
+ return queued;
172
+ end;
173
+ $$;
174
+
175
+ comment on function public.enqueue_eval_autorun() is
176
+ '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 current judge (00148, #196). Gate 0 (00190) requires instruction_id, project_path, or an absolute-path name: a target anchored to nothing can be measured and "promoted" forever while rewriting only its own content column — measured 2026-08-15, a 129-char demo row had burned 102 runs that way. Health 2a is PROPORTIONAL as of 00169. Opt in with: update eval_targets set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRunEnabled":true}''::jsonb where id = ...';
@@ -0,0 +1,106 @@
1
+ -- Turn `recommendation_applications` into the effect ledger.
2
+ --
3
+ -- 00187 shipped this table with the apply → measure → revert lifecycle, the
4
+ -- before/after hashes, and a CAS transition guard — and then nothing ever wrote
5
+ -- a row (0 in prod as of 2026-08-16). The multi-artifact actuator plan
6
+ -- specified a second table, `applied_artifacts`, for the same job. Two empty
7
+ -- ledgers and two dead read surfaces is the outcome nobody wanted, so the
8
+ -- columns that plan needed are added here instead.
9
+ --
10
+ -- What was missing, and why each one is load-bearing:
11
+ --
12
+ -- boundary_class Whether the actuator owned its targets. `inside` means an
13
+ -- exact inverse was possible and is therefore required;
14
+ -- `outside` means the user/git/another tool also writes there,
15
+ -- so the row owes a compensation instead. Declared per
16
+ -- artifact rather than rediscovered per actuator.
17
+ --
18
+ -- targets EVERY path the apply path wrote. `target_path` (00187) holds
19
+ -- one, which is fine for a single-file recommendation and
20
+ -- wrong for a skill that lands in two locations. `target_path`
21
+ -- stays as the primary target so the existing unique index and
22
+ -- its callers keep working; `targets` is the complete set.
23
+ --
24
+ -- inverse How to undo this row, RECORDED AT WRITE TIME. The system's
25
+ -- other undos are reconstructed — `removeHookByCommand`
26
+ -- searches a settings file for a command substring, and skill
27
+ -- retraction searches six directories by name. A search is not
28
+ -- an inverse: it misses silently when the thing was renamed
29
+ -- and removes the wrong entry when it matches twice.
30
+ --
31
+ -- requires The coeffect declaration. Two rules make it load-bearing:
32
+ -- provides do not apply a dependent before its dependency is present,
33
+ -- and do not withdraw a provider while an applied dependent
34
+ -- names it. The second closes a live hole — archiving is a
35
+ -- soft delete, so archiving a skill today leaves every
36
+ -- instruction naming it pointing at nothing, silently.
37
+ --
38
+ -- apply_batch_id The ordering. Not decoration: inverses must run LIFO,
39
+ -- apply_seq because a batch can install a provider and its dependent
40
+ -- moments apart, and undoing in application order withdraws
41
+ -- the provider while the dependent is still live. It is also
42
+ -- the only thing that can answer "undo everything last
43
+ -- night's loop applied", a question that currently has no
44
+ -- answer for any artifact type.
45
+ --
46
+ -- Every column is nullable or defaulted: the 00187 rows that do not exist yet,
47
+ -- and any that appear between deploy and this migration, stay readable.
48
+
49
+ alter table public.recommendation_applications
50
+ add column if not exists boundary_class text,
51
+ add column if not exists targets text[] not null default '{}',
52
+ add column if not exists inverse jsonb,
53
+ add column if not exists requires text[] not null default '{}',
54
+ add column if not exists provides text[] not null default '{}',
55
+ add column if not exists apply_batch_id uuid,
56
+ add column if not exists apply_seq integer;
57
+
58
+ do $$
59
+ begin
60
+ if not exists (
61
+ select 1 from pg_constraint
62
+ where conname = 'recommendation_applications_boundary_class_check'
63
+ ) then
64
+ alter table public.recommendation_applications
65
+ add constraint recommendation_applications_boundary_class_check
66
+ check (boundary_class is null or boundary_class in ('inside', 'outside'));
67
+ end if;
68
+ end $$;
69
+
70
+ -- A row that reached `applied` must carry the inverse it recorded at that
71
+ -- moment. Enforced in the database rather than only in the actuator because the
72
+ -- actuator is one of several writers-to-be (CLI accept, worker auto-apply, the
73
+ -- API's own transition endpoint), and "we forgot to record the undo" is not a
74
+ -- failure any of them would notice at the time — it surfaces months later when
75
+ -- someone asks to revert and the row cannot say how.
76
+ --
77
+ -- Statuses before `applied` (proposed, ready) and the terminal `failed` are
78
+ -- exempt: nothing was written, so there is nothing to undo.
79
+ do $$
80
+ begin
81
+ if not exists (
82
+ select 1 from pg_constraint
83
+ where conname = 'recommendation_applications_applied_has_inverse'
84
+ ) then
85
+ alter table public.recommendation_applications
86
+ add constraint recommendation_applications_applied_has_inverse
87
+ check (
88
+ status in ('proposed', 'ready', 'failed')
89
+ or inverse is not null
90
+ ) not valid;
91
+ end if;
92
+ end $$;
93
+
94
+ -- NOT VALID above, validated here: the constraint applies to every future write
95
+ -- immediately, and the validation pass confirms the (currently empty) backlog
96
+ -- without taking an ACCESS EXCLUSIVE lock for the scan.
97
+ alter table public.recommendation_applications
98
+ validate constraint recommendation_applications_applied_has_inverse;
99
+
100
+ -- The withdrawal guard's query: "which applied rows require any of these keys?"
101
+ create index if not exists idx_recommendation_applications_requires
102
+ on public.recommendation_applications using gin (requires);
103
+
104
+ -- The reversal query: one batch, newest first.
105
+ create index if not exists idx_recommendation_applications_batch
106
+ on public.recommendation_applications (user_id, apply_batch_id, apply_seq desc);
@@ -0,0 +1,142 @@
1
+ -- cleanup_stale_test_users cannot reach an org whose members are already gone.
2
+ --
3
+ -- The org sweep (00087, unchanged through 00121) elects orgs by joining
4
+ -- `user_organizations` to the set of users it is about to delete:
5
+ --
6
+ -- and exists (select 1 from user_organizations uo
7
+ -- where uo.org_id = o.id and uo.user_id = any(v_user_ids))
8
+ --
9
+ -- That arm only ever matches while the membership row still exists. Anything
10
+ -- that deletes the user first — the e2e suites' own `deleteTestUser`, an admin
11
+ -- delete, a user deleting their account — cascades `user_organizations` away,
12
+ -- and from that moment the org is invisible to every subsequent sweep. It has no
13
+ -- members, so nobody can see it, join it, or delete it through the product; it
14
+ -- is unreachable and permanent.
15
+ --
16
+ -- Measured in prod 2026-08-16: 30,276 memberless orgs of 30,307 total,
17
+ -- accumulating since 2026-07-12 (30,261 of them named "…'s Workspace", the
18
+ -- signup default; the other 15 are timestamp-suffixed org/team test fixtures).
19
+ -- They hold nothing — 0 projects, 0 sessions, 0 invitations, 0 agent_runs,
20
+ -- 3 test teams.
21
+ --
22
+ -- The fix is a second, independent sweep: an org with zero members, older than
23
+ -- an hour, is dead by construction. It cannot arise from normal use — the
24
+ -- `check_last_owner` trigger refuses to remove an org's final owner, so the only
25
+ -- way to reach zero members is the cascade above — and the hour of grace keeps a
26
+ -- signup that inserts the org before the membership safe.
27
+ --
28
+ -- Note this sweep is deliberately NOT restricted to test-shaped names. The
29
+ -- defect is not "test orgs leak"; it is "an org outlives its last member". A
30
+ -- real user who deletes their account leaves exactly the same corpse, and it
31
+ -- should be reaped for exactly the same reason.
32
+ --
33
+ -- Two other changes, both forced by the first:
34
+ --
35
+ -- * The `v_user_ids is null` early return (00087) skipped everything below it.
36
+ -- A quiet hour with no test users to reap would have skipped the memberless
37
+ -- sweep too, which is the one that has 30k rows of backlog. The early return
38
+ -- is gone; the user sweep is now guarded in place.
39
+ -- * `superadmin_audit_log.target_org_id` is ON DELETE NO ACTION, so it is
40
+ -- nulled for the memberless set before the delete, the same way the existing
41
+ -- path does it for the membered set.
42
+ --
43
+ -- Return signature is unchanged (callers: `crons run cleanup_stale_test_users`).
44
+ -- `deleted_orgs` now counts both sweeps.
45
+
46
+ drop function if exists public.cleanup_stale_test_users();
47
+
48
+ create function public.cleanup_stale_test_users()
49
+ returns table(deleted_users int, deleted_orgs int, deleted_smoke_sessions int) as $$
50
+ declare
51
+ v_user_ids uuid[];
52
+ v_org_ids uuid[];
53
+ v_orphan_org_ids uuid[];
54
+ v_deleted_users int := 0;
55
+ v_deleted_orgs int := 0;
56
+ v_deleted_orphan_orgs int := 0;
57
+ v_deleted_smoke int := 0;
58
+ begin
59
+ -- Smoke-test session residue (any user). 1h threshold keeps an
60
+ -- in-flight smoke run safe.
61
+ delete from sessions
62
+ where created_at < now() - interval '1 hour'
63
+ and (
64
+ external_session_id like '00000000-0000-4000-8000-%'
65
+ or project_path = '/tmp/smoke-test-project'
66
+ or metadata->>'clientName' = 'smoke-test'
67
+ );
68
+ get diagnostics v_deleted_smoke = row_count;
69
+
70
+ -- Candidates: obvious test users older than 1h, excluding the pinned dev
71
+ -- login. See 00121 for why each arm can't hit a real signup.
72
+ select array_agg(id) into v_user_ids
73
+ from auth.users
74
+ where email != 'nick@rulemetric.test'
75
+ and created_at < now() - interval '1 hour'
76
+ and (
77
+ email ~* '\.(test|example|invalid|localhost|local)$'
78
+ or email ~* '@(example\.(com|net|org))$'
79
+ or email ~ '1[0-9]{12}@'
80
+ );
81
+
82
+ if v_user_ids is not null and array_length(v_user_ids, 1) > 0 then
83
+ -- Orgs whose every member is in the to-delete set. Cascade carve-out in
84
+ -- check_last_owner lets the user_organizations rows go when the parent
85
+ -- org is being deleted.
86
+ select array_agg(o.id) into v_org_ids
87
+ from organizations o
88
+ where not exists (
89
+ select 1 from user_organizations uo
90
+ where uo.org_id = o.id
91
+ and uo.user_id != all(v_user_ids)
92
+ )
93
+ and exists (
94
+ select 1 from user_organizations uo
95
+ where uo.org_id = o.id and uo.user_id = any(v_user_ids)
96
+ );
97
+
98
+ -- 1. NULL test-user references on NO ACTION FKs (preserve content)
99
+ update instructions set created_by = null where created_by = any(v_user_ids);
100
+ update instructions set archived_by = null where archived_by = any(v_user_ids);
101
+ update instruction_versions set created_by = null where created_by = any(v_user_ids);
102
+ update projects set created_by = null where created_by = any(v_user_ids);
103
+ update project_instructions set added_by = null where added_by = any(v_user_ids);
104
+
105
+ -- 2. Scrub superadmin_audit_log (actor_id is NOT NULL → delete; targets nullable)
106
+ delete from superadmin_audit_log where actor_id = any(v_user_ids);
107
+ update superadmin_audit_log set target_user_id = null where target_user_id = any(v_user_ids);
108
+ if v_org_ids is not null then
109
+ update superadmin_audit_log set target_org_id = null where target_org_id = any(v_org_ids);
110
+ end if;
111
+
112
+ -- 3. Delete orphan orgs
113
+ if v_org_ids is not null then
114
+ delete from organizations where id = any(v_org_ids);
115
+ get diagnostics v_deleted_orgs = row_count;
116
+ end if;
117
+
118
+ -- 4. Delete users (cascades to profiles, user_organizations, sessions, ...)
119
+ delete from auth.users where id = any(v_user_ids);
120
+ get diagnostics v_deleted_users = row_count;
121
+ end if;
122
+
123
+ -- 5. Memberless orgs, whoever emptied them and whenever. This runs on every
124
+ -- sweep, independent of whether step 4 deleted anyone, because the orgs it
125
+ -- reaps were emptied by an EARLIER run (or by a delete this function never
126
+ -- saw) and no later run can otherwise reach them.
127
+ select array_agg(o.id) into v_orphan_org_ids
128
+ from organizations o
129
+ where o.created_at < now() - interval '1 hour'
130
+ and not exists (select 1 from user_organizations uo where uo.org_id = o.id);
131
+
132
+ if v_orphan_org_ids is not null then
133
+ update superadmin_audit_log set target_org_id = null
134
+ where target_org_id = any(v_orphan_org_ids);
135
+ delete from organizations where id = any(v_orphan_org_ids);
136
+ get diagnostics v_deleted_orphan_orgs = row_count;
137
+ v_deleted_orgs := v_deleted_orgs + v_deleted_orphan_orgs;
138
+ end if;
139
+
140
+ return query select v_deleted_users, v_deleted_orgs, v_deleted_smoke;
141
+ end;
142
+ $$ language plpgsql security definer set search_path = public, auth;