@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
@@ -1,7 +1,7 @@
1
1
  -- Fix cleanup_stale_test_users: it failed nightly 2026-06-07..09 on
2
2
  -- projects_created_by_fkey because projects / project_instructions gained
3
3
  -- NO ACTION FKs to auth.users after 00062 was written. Also extend the
4
- -- sweep to delete leaked smoke-test sessions — scripts/smoke-test.sh's
4
+ -- sweep to delete leaked smoke-test sessions — scripts/verify/smoke-test.sh's
5
5
  -- hook-execution section creates sessions under the dev user
6
6
  -- (external_session_id '00000000-0000-4000-8000-…') that the teardown
7
7
  -- only closed, never deleted.
@@ -0,0 +1,113 @@
1
+ -- 00184 — the auto-accept budget becomes a setting, and the producer stops
2
+ -- starving the source it was built to serve.
3
+ --
4
+ -- Three facts measured on 2026-08-12, all from the same project:
5
+ --
6
+ -- 1. The per-window budget was counted by RAW project_path string equality.
7
+ -- `/Code/agents/momento-mori` and `/Code/agents/momento-mori/apps/cli`
8
+ -- are one repo and resolve to ONE projects row, but each drew its own
9
+ -- allowance and each hit exactly 5/5 in the same 7 days — the repo
10
+ -- adopted 10 rules under a policy that intends 5. Any repo whose
11
+ -- sessions start in a subdirectory has as many budgets as it has
12
+ -- subdirectories. Fixed in auto-accept-eligibility.ts by counting
13
+ -- through instruction_suggestions.project_id; nothing to do here.
14
+ --
15
+ -- 2. The budget was a compile-time constant, so raising it for one noisy
16
+ -- repo meant a deploy. It is now a setting: NULL columns here mean "use
17
+ -- the shipped default", per-project overrides live in
18
+ -- projects.metadata.autoAcceptLimits and win over these.
19
+ --
20
+ -- 3. THIS producer only ever queued a run when the project held a
21
+ -- suggestion scoring >= 0.8. But GET /api/instruction-suggestions/
22
+ -- auto-acceptable deliberately exempts insights-authored rows from the
23
+ -- score gate (they are filed at 0.5 by the nomination endpoint). So in a
24
+ -- project whose only proposals came from insights, the authoritative
25
+ -- gate said "eligible" and this function never queued the job that would
26
+ -- have asked it. Every insights nomination in such a project was
27
+ -- unreachable — not declined, not surfaced, just never considered.
28
+
29
+ -- ── Settable budget ─────────────────────────────────────────────────────────
30
+ alter table public.profiles
31
+ add column if not exists auto_accept_min_score real,
32
+ add column if not exists auto_accept_max_per_run integer,
33
+ add column if not exists auto_accept_max_per_window integer,
34
+ add column if not exists auto_accept_window_days integer;
35
+
36
+ comment on column public.profiles.auto_accept_min_score is
37
+ 'Account override for the auto-accept score gate. NULL = shipped default (0.8). Bounded 0..1 by the API on read AND write — a settable brake still has to be a brake.';
38
+ comment on column public.profiles.auto_accept_max_per_run is
39
+ 'Account override for auto-accepts per nightly run per project. NULL = shipped default (3). Bounded 0..25.';
40
+ comment on column public.profiles.auto_accept_max_per_window is
41
+ 'Account override for auto-accepts per project per window. NULL = shipped default (15). Bounded 0..100. The brake on CLAUDE.md bloat: an over-large CLAUDE.md measured -22.1% (stratified, significant).';
42
+ comment on column public.profiles.auto_accept_window_days is
43
+ 'Account override for the budget window. NULL = shipped default (7). Bounded 1..90.';
44
+
45
+ -- ── Producer ────────────────────────────────────────────────────────────────
46
+ -- Same cheap-gates-only contract as 00170: this decides whether to ASK, never
47
+ -- whether to adopt. The authoritative check stays server-side.
48
+ create or replace function public.enqueue_auto_accept_suggestions()
49
+ returns integer
50
+ language plpgsql
51
+ security definer
52
+ set search_path = public
53
+ as $$
54
+ declare
55
+ queued integer;
56
+ begin
57
+ with eligible as (
58
+ select s.user_id, s.project_path
59
+ from instruction_suggestions s
60
+ join profiles p on p.id = s.user_id
61
+ join instructions i on i.id = s.instruction_id
62
+ left join projects proj on proj.id = s.project_id
63
+ where
64
+ s.kind = 'add'
65
+ -- Score OR provenance. Insights nominations are filed at 0.5 by
66
+ -- POST /api/insights/recommendations/nominate and exempted from the
67
+ -- score gate by the authoritative endpoint; requiring 0.8 here made
68
+ -- that exemption unreachable in any project without a high-scoring
69
+ -- catalog proposal. The per-window budget still bounds what a run may
70
+ -- take, so a permissive trigger cannot become a permissive adoption.
71
+ and (s.score >= 0.8 or i.frontmatter->>'source' = 'insights')
72
+ -- Instruction bodies only, mirroring the endpoint: a skill pasted into
73
+ -- CLAUDE.md is a category error, and queueing a job that can only refuse
74
+ -- is how the loop reported "nothing eligible" for three days.
75
+ and i.type = 'instruction'
76
+ and i.archived = false
77
+ and s.accepted_at is null
78
+ and s.dismissed_at is null
79
+ and s.declined_at is null
80
+ and s.is_stale = false
81
+ -- Consent: per-project override wins, else the user default (true).
82
+ and coalesce(
83
+ proj.metadata->>'autoAcceptSuggestions',
84
+ p.auto_accept_suggestions::text
85
+ ) = 'true'
86
+ -- One in flight per (user, project), per the 00083/00086 pattern.
87
+ and not exists (
88
+ select 1 from agent_jobs j
89
+ where j.task_kind = 'cron_auto_accept_suggestions'
90
+ and j.status in ('pending', 'claimed', 'running')
91
+ and j.payload->>'projectPath' = s.project_path
92
+ and j.user_id = s.user_id
93
+ )
94
+ group by s.user_id, s.project_path
95
+ ),
96
+ ins as (
97
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
98
+ select e.user_id,
99
+ 'cron_auto_accept_suggestions',
100
+ 'pending',
101
+ jsonb_build_object('projectPath', e.project_path),
102
+ 'auto-accept:' || e.user_id::text || ':' || e.project_path
103
+ from eligible e
104
+ returning 1
105
+ )
106
+ select count(*)::int into queued from ins;
107
+
108
+ return queued;
109
+ end;
110
+ $$;
111
+
112
+ comment on function public.enqueue_auto_accept_suggestions() is
113
+ 'Nightly producer for cron_auto_accept_suggestions. One row per (user, project) holding at least one consented ADD proposal that is either high-confidence (>= 0.8) or insights-authored, and whose instruction is a non-archived instruction body. Cheap gates only — the authoritative eligibility check is GET /api/instruction-suggestions/auto-acceptable, which also applies the per-project budget. Budget overrides: profiles.auto_accept_max_per_window (account) and projects.metadata.autoAcceptLimits (project). Turn it off for one repo: update projects set metadata = coalesce(metadata,''{}''::jsonb) || ''{"autoAcceptSuggestions":false}''::jsonb where id = ...; or globally: update profiles set auto_accept_suggestions = false where id = ...';
@@ -0,0 +1,43 @@
1
+ -- Randomised measurement gets a throughput dial.
2
+ --
3
+ -- ## The complaint, and what actually caused it
4
+ --
5
+ -- "One a day per project takes forever." Correct, and it was not the
6
+ -- concurrency ceiling that caused it. Two constants in
7
+ -- `routes/instruction-suggestions/loop.ts` compose badly:
8
+ --
9
+ -- TRIAL_MAX_CONCURRENT = 3 how many rules may be under measurement at once
10
+ -- TRIAL_MAX_PER_RUN = 1 how many NEW trials one cycle may start
11
+ --
12
+ -- The producer runs nightly, so `per run` is really `per night`. Starting from
13
+ -- zero, a project needs THREE NIGHTS to reach a ceiling of three — the first
14
+ -- two nights leave measurement capacity deliberately idle. That is pure
15
+ -- latency: it buys no statistical protection, because the steady state it
16
+ -- ramps toward is the same either way.
17
+ --
18
+ -- The ceiling is a real trade and stays put by default: every running
19
+ -- experiment withholds its rule from ~half that project's sessions, so
20
+ -- concurrency is context the agent does not get. The RAMP is not a trade at
21
+ -- all. So the default for per-run rises to fill the ceiling in one cycle
22
+ -- (3 nights -> 1), and both numbers become settings for anyone who wants to
23
+ -- spend more context to answer more questions at once.
24
+ --
25
+ -- What this does NOT touch: GRADUATION_MIN_N (25 measured sessions per arm).
26
+ -- Sessions are the scarce resource and that gate is what keeps a verdict from
27
+ -- enshrining noise. Testing faster must mean asking more questions in
28
+ -- parallel, never accepting thinner evidence for each.
29
+ --
30
+ -- NULL = use the shipped default, so an untouched account tracks changes to
31
+ -- the default instead of freezing today's value at signup. Per-project
32
+ -- overrides live in `projects.metadata.trialLimits` and win over these.
33
+ -- Mirrors 00184's auto-accept budget settings exactly.
34
+
35
+ ALTER TABLE public.profiles
36
+ ADD COLUMN IF NOT EXISTS trial_max_concurrent integer,
37
+ ADD COLUMN IF NOT EXISTS trial_max_per_run integer;
38
+
39
+ COMMENT ON COLUMN public.profiles.trial_max_concurrent IS
40
+ 'Account override for how many instruction experiments may run at once on one project. NULL = shipped default (3). Each running experiment withholds its rule from ~half that project''s sessions, so this is a context-vs-questions trade. Per-project override: projects.metadata.trialLimits.maxConcurrent.';
41
+
42
+ COMMENT ON COLUMN public.profiles.trial_max_per_run IS
43
+ 'Account override for how many NEW experiments one training cycle may start. NULL = shipped default (3, i.e. fill the concurrency ceiling in a single run). Raising this shortens ramp latency only; it cannot exceed the concurrency ceiling, which is what actually bounds context withholding. Per-project override: projects.metadata.trialLimits.maxPerRun.';
@@ -0,0 +1,256 @@
1
+ -- One authority for "which path is this project", and it may never be a dead one.
2
+ --
3
+ -- THE DEFECT (measured against production 2026-08-13).
4
+ --
5
+ -- Five call sites resolved a project's canonical checkout with the same
6
+ -- hand-copied rule: `distinct on (project_id) ... order by is_worktree asc,
7
+ -- last_seen desc`. Non-worktree first, most-recent second. `project_checkouts`
8
+ -- for momento-mori holds three rows:
9
+ --
10
+ -- /Users/nickyeager/Code/agents/momento-mori is_worktree=TRUE 2026-08-10
11
+ -- /Users/nickyeager/Code/agents/momento-mori/.worktrees/… is_worktree=true 2026-05-04
12
+ -- /Users/nickyeager/Code/agents/momento-mori/apps/cli is_worktree=false 2026-05-01
13
+ --
14
+ -- Session ingest upserts `is_worktree` from each session's own metadata on
15
+ -- every create (routes/sessions/ingest/create.ts), so ONE session that reported
16
+ -- `isWorktree: true` while its projectPath was the repo root flipped the root's
17
+ -- flag. `is_worktree ASC` then outranks recency absolutely, and the crown went
18
+ -- to `apps/cli` — a path no session has rendered from since 2026-05-01.
19
+ --
20
+ -- Consequences, all silent, all of them looking like "nothing eligible":
21
+ --
22
+ -- * `regenerate_insights_jobs` analysed apps/cli every 6h from 2026-08-10
23
+ -- onward — 136 completed jobs re-deriving the same May-era conclusions
24
+ -- ("Across 13 CLI sessions…") while the repo they describe moved on.
25
+ -- * The insights-authored suggestions those runs produce are filed under
26
+ -- project_path=…/apps/cli, so `cron_auto_accept_suggestions` writes
27
+ -- apps/cli/CLAUDE.md and reports `nothing-eligible` for the repo root
28
+ -- every single night.
29
+ -- * 00175 pinned trial nomination to this same rule. Its own comment
30
+ -- diagnoses the apps/cli experiment as undead ("no session has rendered
31
+ -- from apps/cli in months") and then adopts the rule that elects it.
32
+ --
33
+ -- tinyworlds is mis-routed identically, to `…/tinyworlds/src` (2026-05-04).
34
+ --
35
+ -- THE RULE. Recency outranks the worktree flag, but only when the gap is
36
+ -- material: a path may not win if it is more than 30 days staler than the
37
+ -- freshest path that project has. Stated against the project's OWN freshest
38
+ -- path rather than against `now()`, so a project dormant for a year still
39
+ -- resolves to the checkout it was last worked in instead of falling back to
40
+ -- whichever dead sibling happens to carry is_worktree=false.
41
+ --
42
+ -- Within a staleness tier the original intent survives untouched: a real
43
+ -- checkout beats a worktree, and the most recently seen breaks the tie.
44
+ --
45
+ -- This is a VIEW rather than a sixth copy of the ORDER BY on purpose. The
46
+ -- rule has now been wrong in five places at once; the repo's own recorded
47
+ -- lesson for two-copies drift (00177's comment) is that the copies disagree
48
+ -- in the silent direction. TypeScript callers select from this view too.
49
+
50
+ create or replace view public.project_canonical_checkouts as
51
+ select distinct on (project_id)
52
+ project_id,
53
+ path,
54
+ is_worktree,
55
+ last_seen,
56
+ -- Exposed so callers can SAY the path is stale rather than silently
57
+ -- acting on it. /api/loop/status renders this; a canonical path that
58
+ -- has gone quiet while the project has not is how this defect hid.
59
+ (last_seen < freshest - interval '30 days') as is_stale,
60
+ freshest as project_last_seen
61
+ from (
62
+ select pc.project_id,
63
+ pc.path,
64
+ pc.is_worktree,
65
+ pc.last_seen,
66
+ max(pc.last_seen) over (partition by pc.project_id) as freshest
67
+ from public.project_checkouts pc
68
+ where pc.path is not null
69
+ and pc.path <> ''
70
+ ) q
71
+ order by project_id,
72
+ (last_seen < freshest - interval '30 days') asc, -- live before stale
73
+ is_worktree asc, -- checkout before worktree
74
+ last_seen desc; -- then most recent
75
+
76
+ comment on view public.project_canonical_checkouts is
77
+ 'The single authority for a project''s canonical checkout path. Ranks live paths above ones more than 30 days staler than the project''s freshest, THEN non-worktree, THEN most-recently-seen. Replaces the hand-copied `order by is_worktree asc, last_seen desc` in regenerate_insights_jobs, enqueue_instruction_training, routes/insights/jobs.ts and routes/sessions/query.ts (x3), which elected a path dead since 2026-05-01 for momento-mori and tinyworlds because one session flipped the live root''s is_worktree flag. See 00186.';
78
+
79
+ grant select on public.project_canonical_checkouts to authenticated, service_role;
80
+
81
+ -- ── The insights producer ───────────────────────────────────────────────────
82
+ -- Body unchanged from 00176 except that `canonical` now selects from the view.
83
+
84
+ create or replace function public.regenerate_insights_jobs(
85
+ p_min_sessions integer default 3,
86
+ p_freshness_hours integer default 6
87
+ )
88
+ returns text
89
+ language plpgsql
90
+ security definer
91
+ set search_path = public
92
+ as $$
93
+ declare
94
+ v_inserted int := 0;
95
+ v_skipped_fresh int := 0;
96
+ v_skipped_queued int := 0;
97
+ v_reclaimed int := 0;
98
+ v_id uuid;
99
+ r record;
100
+ begin
101
+ for r in
102
+ with eligible as (
103
+ select s.user_id, s.project_id
104
+ from sessions s
105
+ where s.project_id is not null
106
+ group by s.user_id, s.project_id
107
+ having count(*) >= p_min_sessions
108
+ )
109
+ select e.user_id, e.project_id, c.path as project_path
110
+ from eligible e
111
+ join public.project_canonical_checkouts c on c.project_id = e.project_id
112
+ -- ACTIVITY since this project's last analysis — start, progress, or end.
113
+ where exists (
114
+ select 1 from sessions s2
115
+ where s2.user_id = e.user_id
116
+ and s2.project_id = e.project_id
117
+ and greatest(
118
+ s2.started_at,
119
+ coalesce(s2.ended_at, s2.started_at)
120
+ ) > coalesce(
121
+ (select max(j.completed_at)
122
+ from insights_jobs j
123
+ where j.user_id = e.user_id
124
+ and j.project_path = c.path
125
+ and j.status = 'completed'),
126
+ now() - interval '30 days'
127
+ )
128
+ )
129
+ loop
130
+ if exists (
131
+ select 1 from insights_jobs j
132
+ where j.user_id = r.user_id
133
+ and j.project_path = r.project_path
134
+ and j.status = 'completed'
135
+ and j.completed_at > now() - (p_freshness_hours || ' hours')::interval
136
+ ) then
137
+ v_skipped_fresh := v_skipped_fresh + 1;
138
+ continue;
139
+ end if;
140
+
141
+ update insights_jobs j
142
+ set status = 'failed',
143
+ completed_at = now(),
144
+ error = 'reclaimed by regenerate_insights_jobs: queued/in-flight >24h with no progress — worker likely died before any state write'
145
+ where j.user_id = r.user_id
146
+ and j.project_path = r.project_path
147
+ and j.status in ('pending', 'claimed', 'running')
148
+ and j.created_at < now() - interval '24 hours';
149
+ if found then
150
+ v_reclaimed := v_reclaimed + 1;
151
+ end if;
152
+
153
+ if exists (
154
+ select 1 from insights_jobs j
155
+ where j.user_id = r.user_id
156
+ and j.project_path = r.project_path
157
+ and j.status in ('pending', 'claimed', 'running')
158
+ ) then
159
+ v_skipped_queued := v_skipped_queued + 1;
160
+ continue;
161
+ end if;
162
+
163
+ v_id := gen_random_uuid();
164
+ insert into insights_jobs (id, user_id, project_id, status, project_path, task_kind, payload)
165
+ values (
166
+ v_id,
167
+ r.user_id,
168
+ r.project_id,
169
+ 'pending',
170
+ r.project_path,
171
+ 'process_insights',
172
+ jsonb_build_object('mirrorId', v_id, 'projectPath', r.project_path)
173
+ );
174
+ v_inserted := v_inserted + 1;
175
+ end loop;
176
+
177
+ return format(
178
+ 'regenerated: %s inserted, %s skipped (fresh), %s skipped (queued), %s reclaimed (stale)',
179
+ v_inserted, v_skipped_fresh, v_skipped_queued, v_reclaimed
180
+ );
181
+ end;
182
+ $$;
183
+
184
+ comment on function public.regenerate_insights_jobs(integer, integer) is
185
+ 'Every-6h producer for process_insights. Enqueues one job per (user, project) with >= p_min_sessions sessions and activity since that project''s last completed analysis. As of 00186 the canonical path comes from public.project_canonical_checkouts, so the analysis can no longer be aimed at a checkout dead for months.';
186
+
187
+ -- ── The training producer ───────────────────────────────────────────────────
188
+ -- Body unchanged from 00175 except that `canonical` now selects from the view.
189
+ -- The settle half stays ungated and per-path so off-canonical experiments
190
+ -- retire rather than orphan.
191
+
192
+ create or replace function public.enqueue_instruction_training(p_active_days integer default 7)
193
+ returns integer
194
+ language plpgsql
195
+ security definer
196
+ set search_path = public
197
+ as $$
198
+ declare
199
+ queued integer;
200
+ begin
201
+ with eligible as (
202
+ select s.user_id, s.project_path
203
+ from instruction_suggestions s
204
+ join public.project_canonical_checkouts c on c.path = s.project_path
205
+ where s.kind = 'add'
206
+ and s.accepted_at is null
207
+ and s.dismissed_at is null
208
+ and s.declined_at is null
209
+ and s.is_stale = false
210
+ and exists (
211
+ select 1 from sessions se
212
+ where se.project_id = c.project_id
213
+ and se.user_id = s.user_id
214
+ and se.started_at > now() - (p_active_days || ' days')::interval
215
+ )
216
+ and not exists (
217
+ select 1 from agent_jobs j
218
+ where j.task_kind = 'cron_instruction_training'
219
+ and j.status in ('pending', 'claimed', 'running')
220
+ and j.payload->>'projectPath' = s.project_path
221
+ and j.user_id = s.user_id
222
+ )
223
+ group by s.user_id, s.project_path
224
+
225
+ union
226
+
227
+ select e.user_id, e.project_path
228
+ from instruction_experiments e
229
+ where e.status = 'running'
230
+ and not exists (
231
+ select 1 from agent_jobs j
232
+ where j.task_kind = 'cron_instruction_training'
233
+ and j.status in ('pending', 'claimed', 'running')
234
+ and j.payload->>'projectPath' = e.project_path
235
+ and j.user_id = e.user_id
236
+ )
237
+ group by e.user_id, e.project_path
238
+ ),
239
+ ins as (
240
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
241
+ select e.user_id,
242
+ 'cron_instruction_training',
243
+ 'pending',
244
+ jsonb_build_object('projectPath', e.project_path),
245
+ 'instruction-training:' || e.user_id::text || ':' || e.project_path
246
+ from eligible e
247
+ returning 1
248
+ )
249
+ select count(*)::int into queued from ins;
250
+
251
+ return queued;
252
+ end;
253
+ $$;
254
+
255
+ comment on function public.enqueue_instruction_training(integer) is
256
+ 'Nightly producer for cron_instruction_training. The TRIAL half nominates only on a project''s canonical checkout path with a session inside p_active_days (default 7); SETTLE stays ungated and per-path so existing off-path experiments retire rather than orphan. As of 00186 canonical comes from public.project_canonical_checkouts — 00175 pinned this to a rule that elected the very apps/cli path its own comment describes as undead.';
@@ -0,0 +1,32 @@
1
+ create table public.recommendation_applications (
2
+ id uuid primary key default gen_random_uuid(),
3
+ user_id uuid not null references public.profiles(id) on delete cascade,
4
+ instruction_id uuid not null references public.instructions(id) on delete cascade,
5
+ project_id uuid references public.projects(id) on delete set null,
6
+ project_path text not null,
7
+ harness text not null check (harness in ('claude_code', 'codex', 'shared')),
8
+ target_path text not null,
9
+ status text not null default 'proposed' check (status in ('proposed', 'ready', 'applied', 'measuring', 'improved', 'inconclusive', 'harmful', 'rollback_ready', 'reverted', 'failed')),
10
+ apply_mode text not null check (apply_mode in ('auto', 'human')),
11
+ before_hash text,
12
+ applied_hash text,
13
+ applied_at timestamptz,
14
+ measurement_started_at timestamptz,
15
+ measurement_ended_at timestamptz,
16
+ verdict text check (verdict is null or verdict in ('improved', 'inconclusive', 'harmful')),
17
+ verdict_detail text,
18
+ reverted_at timestamptz,
19
+ created_at timestamptz not null default now(),
20
+ updated_at timestamptz not null default now(),
21
+ unique (user_id, instruction_id, project_path, harness, target_path)
22
+ );
23
+
24
+ create index idx_recommendation_applications_user_status on public.recommendation_applications(user_id, status, updated_at desc);
25
+ create index idx_recommendation_applications_user_harness on public.recommendation_applications(user_id, harness, updated_at desc);
26
+ create index idx_recommendation_applications_project on public.recommendation_applications(project_id, updated_at desc);
27
+
28
+ alter table public.recommendation_applications enable row level security;
29
+ create policy "Users manage own recommendation applications"
30
+ on public.recommendation_applications for all
31
+ using (auth.uid() = user_id)
32
+ with check (auth.uid() = user_id);
@@ -0,0 +1,117 @@
1
+ -- The training producer's ACTIVE gate passes for paths nobody works in.
2
+ --
3
+ -- ## Measured 2026-08-14
4
+ --
5
+ -- 00174 added the gate that stops undead trials, and it resolves
6
+ -- `suggestion.project_path → project_checkouts → project_id → sessions`. That
7
+ -- last hop is the defect: it asks "has this PROJECT been active?", not "has
8
+ -- this PATH been active?". Every checkout of an active repo therefore passes,
9
+ -- including ones with no session in months.
10
+ --
11
+ -- Concretely, `/Users/nickyeager/Code/agents/momento-mori/apps/cli` maps to the
12
+ -- momento-mori project, whose ROOT is worked in daily, so the subpath cleared
13
+ -- the gate and the producer enqueued a training job for it every night. Those
14
+ -- jobs nominated real trials against a checkout with no session since
15
+ -- 2026-05-01: 6 running experiments were sitting on it tonight, holding slots,
16
+ -- unable to ever enroll an arm — the exact "undead" failure 00174 was written
17
+ -- to end, arriving through the one hop it did not close. (They were stopped by
18
+ -- hand under `inactive_project`; this stops them being created again.)
19
+ --
20
+ -- ## The rule
21
+ --
22
+ -- A suggestion's path may start a trial only if it IS the project's canonical
23
+ -- checkout. `public.project_canonical_checkouts` (00186) is the single
24
+ -- authority — CLAUDE.md's hard invariant is that this is never re-derived, and
25
+ -- 00174's hand-rolled join is precisely a re-derivation. The view already ranks
26
+ -- live-before-stale, so "canonical" cannot silently mean a dead path.
27
+ --
28
+ -- The per-path activity check is KEPT as well: canonical and recently active
29
+ -- are different claims, and a project that has gone quiet entirely should still
30
+ -- start no new trials.
31
+ --
32
+ -- The SETTLE half stays ungated, unchanged, for 00174's reason: gating it would
33
+ -- orphan an experiment whose project went quiet mid-trial.
34
+
35
+ create or replace function public.enqueue_instruction_training(p_active_days integer default 7)
36
+ returns integer
37
+ language plpgsql
38
+ security definer
39
+ set search_path = public
40
+ as $$
41
+ declare
42
+ queued integer;
43
+ begin
44
+ with eligible as (
45
+ select s.user_id, s.project_path
46
+ from instruction_suggestions s
47
+ where s.kind = 'add'
48
+ and s.accepted_at is null
49
+ and s.dismissed_at is null
50
+ and s.declined_at is null
51
+ and s.is_stale = false
52
+ -- CANONICAL (new in 00188): this path is the project's canonical
53
+ -- checkout, per the view that owns that answer. Without it, every
54
+ -- subdirectory of an active repo is treated as its own live project.
55
+ and exists (
56
+ select 1
57
+ from project_checkouts pc
58
+ join project_canonical_checkouts c on c.project_id = pc.project_id
59
+ where pc.path = s.project_path
60
+ and c.path = s.project_path
61
+ )
62
+ -- ACTIVE (00174, unchanged in intent): the project produced a session
63
+ -- inside the window. An experiment enrolls sessions at render time, so a
64
+ -- project with none coming can only ever produce an undead trial.
65
+ and exists (
66
+ select 1
67
+ from project_checkouts pc
68
+ join sessions se on se.project_id = pc.project_id
69
+ where pc.path = s.project_path
70
+ and se.user_id = s.user_id
71
+ and se.started_at > now() - (p_active_days || ' days')::interval
72
+ )
73
+ and not exists (
74
+ select 1 from agent_jobs j
75
+ where j.task_kind = 'cron_instruction_training'
76
+ and j.status in ('pending', 'claimed', 'running')
77
+ and j.payload->>'projectPath' = s.project_path
78
+ and j.user_id = s.user_id
79
+ )
80
+ group by s.user_id, s.project_path
81
+
82
+ union
83
+
84
+ -- Settle half: deliberately NOT gated (neither on activity nor on
85
+ -- canonicality). One cheap API call per project per night, and gating it
86
+ -- would orphan a running experiment the moment its project went quiet — or,
87
+ -- with the new gate, the moment its checkout stopped being canonical.
88
+ select e.user_id, e.project_path
89
+ from instruction_experiments e
90
+ where e.status = 'running'
91
+ and not exists (
92
+ select 1 from agent_jobs j
93
+ where j.task_kind = 'cron_instruction_training'
94
+ and j.status in ('pending', 'claimed', 'running')
95
+ and j.payload->>'projectPath' = e.project_path
96
+ and j.user_id = e.user_id
97
+ )
98
+ group by e.user_id, e.project_path
99
+ ),
100
+ ins as (
101
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
102
+ select e.user_id,
103
+ 'cron_instruction_training',
104
+ 'pending',
105
+ jsonb_build_object('projectPath', e.project_path),
106
+ 'instruction-training:' || e.user_id::text || ':' || e.project_path
107
+ from eligible e
108
+ returning 1
109
+ )
110
+ select count(*)::int into queued from ins;
111
+
112
+ return queued;
113
+ end;
114
+ $$;
115
+
116
+ comment on function public.enqueue_instruction_training(integer) is
117
+ 'Nightly producer for cron_instruction_training. TRIAL half requires (a) the path IS the project canonical checkout per project_canonical_checkouts (00188 — the project-level activity join in 00174 let every subpath of an active repo through, and apps/cli collected 6 undead trials that way), and (b) a session on the project within p_active_days (default 7). SETTLE half stays ungated so a quiet or non-canonical project cannot orphan a running experiment. Widen deliberately: select enqueue_instruction_training(30).';
@@ -0,0 +1,37 @@
1
+ -- 00189: make GET /api/usage/current-limits index-bounded instead of
2
+ -- partition-scanning.
3
+ --
4
+ -- Measured on prod 2026-08-15 with EXPLAIN (ANALYZE, BUFFERS): 133 ms and
5
+ -- 155,494 shared buffer hits PER CALL, x 122,479 calls = 19.0B buffer hits.
6
+ -- That matched the 19.3B in pg_stat_statements exactly and made this the #1
7
+ -- query by total DB time (25.3%) — ahead of realtime WAL decode and far ahead
8
+ -- of the context_snapshots inserts everyone was looking at.
9
+ --
10
+ -- The cost was never the row count of the ANSWER (3 rows). It was that both
11
+ -- LATERAL probes read the ENTIRE (user, provider, source_type, limit_type)
12
+ -- partition — 37,946 index tuples x 3 limit types x 2 probes = 227,676 tuples
13
+ -- to return 3 rows:
14
+ -- * probe 1 is `max(resets_at)`, an aggregate with no LIMIT, so it must visit
15
+ -- every row in the prefix;
16
+ -- * probe 2 filters `resets_at >= <window> - interval '5 minutes'`, which is
17
+ -- NOT sargable against idx_limit_obs_current — that index orders its
18
+ -- trailing column by observed_at, so resets_at is only ever a filter.
19
+ --
20
+ -- Ordering the trailing column by resets_at makes both probes index-bounded:
21
+ -- probe 1 becomes ORDER BY ... LIMIT 1 (one tuple), probe 2 becomes a range
22
+ -- scan over just the current window. NULLS LAST is load-bearing — Postgres
23
+ -- defaults DESC to NULLS FIRST, which would put legacy null-window rows at the
24
+ -- head and make `ORDER BY resets_at DESC LIMIT 1` return NULL instead of the
25
+ -- live window.
26
+ --
27
+ -- INCLUDE (value, observed_at) keeps it index-only: those are the only other
28
+ -- columns the endpoint reads.
29
+ --
30
+ -- idx_limit_obs_current is NOT dropped — the legacy null-window fallback path
31
+ -- still orders by observed_at DESC, and the ingest dedup path uses it too.
32
+ --
33
+ -- CONCURRENTLY; run OUTSIDE a transaction (same convention as 00094/00182).
34
+
35
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_limit_obs_window
36
+ ON limit_observations (user_id, provider, source_type, limit_type, resets_at DESC NULLS LAST)
37
+ INCLUDE (value, observed_at);