@rulemetric/local 0.12.3 → 0.12.5

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,393 @@
1
+ -- Sessions carrying a bare home-directory project_path are repairable, and the
2
+ -- reconciler must chase them.
3
+ --
4
+ -- THE DEFECT CLASS (found 2026-08-27, via "why does Thrakopontia show no
5
+ -- suggestions?"). Until 2026-08-26 the subdir-consolidation normalizer in
6
+ -- POST /api/sessions rewrote real project paths into `/Users/<name>` — home is
7
+ -- a prefix of every path the user works in, and the loop had no home-dir
8
+ -- guard. 219 sessions on the cloud DB carry a bare home path; for 34 of them
9
+ -- the transcript on disk still names the true checkout. Fill-only merge
10
+ -- semantics then froze the junk in place: the very next hook POST carried the
11
+ -- correct cwd and was discarded, and the SessionEnd reimport that would have
12
+ -- corrected it is an unreliable messenger (Claude Code cancels the hook after
13
+ -- its first network call). The path keys real behaviour — insights job
14
+ -- targeting, suggestion project scoping, the session feed's exact-path filter
15
+ -- — so these rows starve their real project of its own evidence.
16
+ --
17
+ -- 00206's producer explicitly EXCLUDED home paths as "orphaned correctly".
18
+ -- That was written before we knew the normalizer had been rewriting real
19
+ -- sessions INTO home: a bare home path is not a fact about where the session
20
+ -- ran, it is (almost always) a fact about what the normalizer did to the row.
21
+ -- The worker-side recovery already treats it that way (recoverAnchors ignores
22
+ -- a home stored path and reads the transcript), and repairAttribution now
23
+ -- upgrades a junk stored path on anchor-backed evidence — this migration makes
24
+ -- the producer and the conformance monitor agree.
25
+ --
26
+ -- Three pieces:
27
+ -- 1. enqueue_reconcile_attribution(): junk-path sessions become eligible.
28
+ -- 2. suggestion_pipeline_conformance(): new metric + CHECK 6, so the shape
29
+ -- is visible even for users whose worker never runs (their repair lane).
30
+ -- 3. Idempotent DELETE of bare-home project_checkouts rows — the laundering
31
+ -- vector by which resolveProjectIdForPath('/Users/<name>') attributed
32
+ -- home-path sessions to an unrelated project. The API guards new inserts
33
+ -- (upsertProjectCheckout refuses home paths) but rows minted by older
34
+ -- API builds still running elsewhere (local-mode installs) would
35
+ -- re-poison resolution.
36
+ -- ---------------------------------------------------------------------------
37
+
38
+ -- ── 1. Producer: chase junk-path sessions too ──────────────────────────────
39
+
40
+ create or replace function public.enqueue_reconcile_attribution()
41
+ returns integer
42
+ language plpgsql
43
+ security definer
44
+ set search_path = public
45
+ as $$
46
+ declare
47
+ queued integer;
48
+ begin
49
+ with eligible as (
50
+ select s.user_id
51
+ from public.sessions s
52
+ where (
53
+ s.project_id is null
54
+ or not exists (
55
+ select 1 from public.project_checkouts pc where pc.project_id = s.project_id
56
+ )
57
+ -- A bare home dir is never a project checkout, so an attributed
58
+ -- session carrying one is self-contradictory — the pre-2026-08-26
59
+ -- normalizer shape. The handler repairs it from the transcript on the
60
+ -- machine that ran it.
61
+ or s.project_path ~ '^/(Users|home)/[^/]+/?$'
62
+ )
63
+ -- Only chase orphans a worker could plausibly still repair. A session
64
+ -- whose checkout was deleted years ago is permanently unrepairable, and
65
+ -- re-walking it every hour forever is pure noise. 90 days is well past
66
+ -- Claude Code's transcript retention, which is the recovery source for
67
+ -- the pathless shape.
68
+ and s.started_at > now() - interval '90 days'
69
+ -- Sessions with no external id cannot be addressed through
70
+ -- POST /api/sessions at all (it matches on external_session_id), so the
71
+ -- handler would only count them as unrepairable. Do not enqueue for a
72
+ -- user who has nothing BUT those.
73
+ and s.external_session_id is not null
74
+ -- Temp fixtures are orphaned CORRECTLY. (Home dirs used to sit in this
75
+ -- exclusion; they are the repair target now — see the header.)
76
+ and (
77
+ s.project_path is null
78
+ or s.project_path !~ '^/(private/)?(tmp|var/folders)'
79
+ )
80
+ group by s.user_id
81
+ ), fresh as (
82
+ select e.user_id
83
+ from eligible e
84
+ -- One in flight at a time, per the 00083/00086 pattern.
85
+ where not exists (
86
+ select 1 from public.agent_jobs j
87
+ where j.task_kind = 'cron_reconcile_attribution'
88
+ and j.status in ('pending', 'claimed', 'running')
89
+ and j.user_id = e.user_id
90
+ )
91
+ -- Do not re-run within the hour for a user whose last pass just completed.
92
+ -- The orphan set only changes when new sessions land or a checkout appears.
93
+ and not exists (
94
+ select 1 from public.agent_jobs j
95
+ where j.task_kind = 'cron_reconcile_attribution'
96
+ and j.user_id = e.user_id
97
+ and j.status = 'completed'
98
+ and j.completed_at > now() - interval '50 minutes'
99
+ )
100
+ ), ins as (
101
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
102
+ select f.user_id,
103
+ 'cron_reconcile_attribution',
104
+ 'pending',
105
+ jsonb_build_object('limit', 200),
106
+ 'reconcile-attribution:' || f.user_id
107
+ from fresh f
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_reconcile_attribution() is
117
+ 'Hourly producer for cron_reconcile_attribution (:20). Enqueues one run per user holding sessions that are invisible to insights (project_id NULL, checkout-less project) OR self-contradictory (attributed but carrying a bare home-dir project_path — the pre-2026-08-26 normalizer shape). Does NOT repair anything itself: the evidence (transcript cwd, git anchors) lives on the machine that ran the session; the handler reads it and POSTs to POST /api/sessions and /api/sessions/:id/reimport.';
118
+
119
+ -- ── 1b. Health view: agree with the producer ───────────────────────────────
120
+ -- Three definitions of "the reconciler's universe" exist (producer, endpoint,
121
+ -- this view); a shape one includes and another excludes is exactly how the
122
+ -- home-path sessions stayed invisible for months. Same widening as above.
123
+
124
+ create or replace view public.session_attribution_health as
125
+ select
126
+ s.user_id,
127
+ count(*)::int as total_unattributed,
128
+ count(*) filter (
129
+ where s.started_at > now() - interval '90 days'
130
+ and s.external_session_id is not null
131
+ and (
132
+ s.project_path is null
133
+ or s.project_path !~ '^/(private/)?(tmp|var/folders)'
134
+ )
135
+ )::int as repairable_recent,
136
+ sum(s.event_count)::bigint as orphaned_events,
137
+ max(s.started_at) as newest_orphan
138
+ from public.sessions s
139
+ where s.project_id is null
140
+ or not exists (
141
+ select 1 from public.project_checkouts pc where pc.project_id = s.project_id
142
+ )
143
+ or s.project_path ~ '^/(Users|home)/[^/]+/?$'
144
+ group by s.user_id;
145
+
146
+ comment on view public.session_attribution_health is
147
+ 'Per-user count of sessions invisible to the insights pipeline (unattributed, checkout-less project, or a self-contradictory bare home-dir project_path). repairable_recent is the subset cron_reconcile_attribution will attempt; a total that keeps climbing while repairable_recent stays near zero means the orphans are structurally unrepairable, not that the reconciler is failing.';
148
+
149
+ -- ── 2. Conformance: metric + CHECK 6 ───────────────────────────────────────
150
+ -- Full replace of 00209''s function body with one metric and one check added.
151
+ -- (Producer-function churn is the known cost of logic-in-SQL; see the
152
+ -- 2026-08-27 migration-treadmill review.)
153
+
154
+ create or replace function public.suggestion_pipeline_conformance()
155
+ returns table (check_id text, user_id uuid, detail text)
156
+ language plpgsql
157
+ security definer
158
+ set search_path = public
159
+ as $$
160
+ begin
161
+ -- ── Snapshot metrics first, so the monotone checks below (and future ones)
162
+ -- have history even on days when every check is quiet. ────────────────────
163
+ insert into pipeline_conformance_metrics (user_id, metric, value)
164
+ select s.user_id, m.metric, m.value
165
+ from (
166
+ select distinct is2.user_id from instruction_suggestions is2
167
+ union
168
+ select distinct se.user_id from sessions se where se.started_at > now() - interval '90 days'
169
+ ) s
170
+ cross join lateral (
171
+ select 'stale_open_rows' as metric,
172
+ (select count(*) from instruction_suggestions x
173
+ where x.user_id = s.user_id and x.kind = 'add'
174
+ and x.accepted_at is null and x.dismissed_at is null
175
+ and x.is_stale) as value
176
+ union all
177
+ select 'undecided_add_rows',
178
+ (select count(*) from instruction_suggestions x
179
+ where x.user_id = s.user_id and x.kind = 'add'
180
+ and x.accepted_at is null and x.dismissed_at is null)
181
+ union all
182
+ select 'auto_accept_candidates',
183
+ (select count(*) from auto_accept_candidate_suggestions v
184
+ where v.user_id = s.user_id)
185
+ union all
186
+ -- The 0.5–0.79 band: emitted by the scorer (recommend.ts floors at 0.5),
187
+ -- below the adoption default (0.8). Feed-only by construction — counted so
188
+ -- "open suggestions" stops being one number over two populations (census
189
+ -- cause 1, 2026-08-27: 393 of 477 open skills sat in this band).
190
+ select 'feed_only_band_rows',
191
+ (select count(*) from instruction_suggestions x
192
+ join instructions i on i.id = x.instruction_id
193
+ where x.user_id = s.user_id and x.kind = 'add'
194
+ and x.accepted_at is null and x.dismissed_at is null
195
+ and i.type in ('instruction', 'skill')
196
+ and x.score < 0.8
197
+ and coalesce(i.frontmatter->>'source', '') <> 'insights')
198
+ union all
199
+ -- Undecided rows quiet for >90 days that the weekly prune can NEVER
200
+ -- collect, by design: they carry instruction_suggestion_events, the FK is
201
+ -- ON DELETE CASCADE, and those events are the only record the funnel's
202
+ -- numerator was ever zero (00180's comment names naive pruning as the
203
+ -- failure mode). Counted here instead of deleted: the census's 2026-08-27
204
+ -- "cause 4" proposed pruning them and was wrong — visibility is the fix
205
+ -- that does not destroy evidence.
206
+ select 'dormant_open_rows',
207
+ (select count(*) from instruction_suggestions x
208
+ where x.user_id = s.user_id and x.kind = 'add'
209
+ and x.accepted_at is null and x.dismissed_at is null
210
+ and x.generated_at < now() - interval '90 days'
211
+ and coalesce(
212
+ (select max(ev.created_at) from instruction_suggestion_events ev
213
+ where ev.suggestion_id = x.id),
214
+ x.generated_at
215
+ ) < now() - interval '90 days')
216
+ union all
217
+ -- Sessions attributed to a real project while carrying a bare home-dir
218
+ -- project_path — self-contradictory (a home dir is never a checkout), the
219
+ -- pre-2026-08-26 normalizer's signature. The reconciler repairs the ones
220
+ -- whose transcript survives; this count is the residue trending to zero.
221
+ select 'misattributed_session_paths',
222
+ (select count(*) from sessions x
223
+ where x.user_id = s.user_id
224
+ and x.project_id is not null
225
+ and x.project_path ~ '^/(Users|home)/[^/]+/?$'
226
+ and x.started_at > now() - interval '90 days')
227
+ ) m;
228
+
229
+ return query
230
+
231
+ -- ── CHECK 1: minted_on_forbidden_path ──────────────────────────────────────
232
+ -- Incident: census 2026-08-27 — 258 of 685 open proposals sat on paths the
233
+ -- writer is forbidden to touch (home dir, TMPDIR, worktrees, the dead
234
+ -- apps/cli). Fixed the same day by gating both mint sites on
235
+ -- assessMintablePath(). This check is that fix's regression alarm: any NEW
236
+ -- row on a non-canonical path means a mint site was added or the gate broke.
237
+ select 'minted_on_forbidden_path'::text,
238
+ s.user_id,
239
+ count(*) || ' suggestion(s) minted in the last 25h on non-canonical path(s), e.g. ' || min(s.project_path)
240
+ from instruction_suggestions s
241
+ where s.generated_at > now() - interval '25 hours'
242
+ and not exists (
243
+ select 1 from project_canonical_checkouts c where c.path = s.project_path
244
+ )
245
+ group by s.user_id
246
+
247
+ union all
248
+
249
+ -- ── CHECK 2: producer_silent_with_candidates ───────────────────────────────
250
+ -- Incident: gate 4 (00184→00197) — the DB producer's type clause matched zero
251
+ -- rows, pg_cron fired and SUCCEEDED at 05:00 for three days, enqueued
252
+ -- nothing, and no log line existed anywhere. Candidates older than 25h with
253
+ -- no job even CREATED in 25h cannot be a quiet night: the producer is hourly.
254
+ select 'producer_silent_with_candidates'::text,
255
+ v.user_id,
256
+ count(*) || ' candidate(s) (oldest ' ||
257
+ extract(day from now() - min(v.generated_at)) || 'd) but no auto-accept job created in 25h — the producer is not seeing them'
258
+ from auto_accept_candidate_suggestions v
259
+ where v.generated_at < now() - interval '25 hours'
260
+ and not exists (
261
+ select 1 from agent_jobs j
262
+ where j.user_id = v.user_id
263
+ and j.task_kind = 'cron_auto_accept_suggestions'
264
+ and j.created_at > now() - interval '25 hours'
265
+ )
266
+ group by v.user_id
267
+
268
+ union all
269
+
270
+ -- ── CHECK 3: endpoint_refuses_everything ───────────────────────────────────
271
+ -- Incident: gates 1–4 (#372, 2026-08-16..19) — the producer queued jobs, the
272
+ -- worker ran them, the ENDPOINT refused every candidate on a clause the
273
+ -- producer didn't have, and four days of `nothing-eligible` looked like four
274
+ -- quiet nights. Signature: jobs completing against a standing candidate set
275
+ -- while NOTHING moves — no acceptance, no refusal event. A healthy run
276
+ -- always moves one of those; a placeholder-refusal loop that repeats forever
277
+ -- without logging also lands here, and deserves to.
278
+ select 'endpoint_refuses_everything'::text,
279
+ v.user_id,
280
+ count(distinct v.suggestion_id) ||
281
+ ' candidate(s) standing >48h while auto-accept jobs completed — zero adoptions, zero refusal events. The producer and the endpoint disagree about eligibility.'
282
+ from auto_accept_candidate_suggestions v
283
+ where v.generated_at < now() - interval '48 hours'
284
+ and exists (
285
+ select 1 from agent_jobs j
286
+ where j.user_id = v.user_id
287
+ and j.task_kind = 'cron_auto_accept_suggestions'
288
+ and j.status = 'completed'
289
+ and j.updated_at > now() - interval '48 hours'
290
+ )
291
+ and not exists (
292
+ select 1 from instruction_suggestions a
293
+ where a.user_id = v.user_id
294
+ and a.accepted_at > now() - interval '48 hours'
295
+ )
296
+ and not exists (
297
+ select 1 from instruction_suggestion_events ev
298
+ join instruction_suggestions es on es.id = ev.suggestion_id
299
+ where es.user_id = v.user_id
300
+ and ev.event_type in ('redundant', 'partial_overlap', 'unresolvable_reference')
301
+ and ev.created_at > now() - interval '48 hours'
302
+ )
303
+ group by v.user_id
304
+
305
+ union all
306
+
307
+ -- ── CHECK 4: stale_flag_only_grows ─────────────────────────────────────────
308
+ -- Incident: gate 7 (2026-08-24) — is_stale was set by the mark-stale cron and
309
+ -- NOTHING ever cleared it; 51 adoption-grade proposals went invisible, and
310
+ -- one user's whole skill lane was structurally dead for weeks. A one-way flag
311
+ -- is invisible at any instant; over days it is a staircase. Fires when the
312
+ -- last 8 daily samples are all positive, never decrease, and end higher than
313
+ -- they started.
314
+ select 'stale_flag_only_grows'::text,
315
+ g.user_id,
316
+ 'stale-flagged open rows grew ' || g.first_value || ' → ' || g.last_value ||
317
+ ' over ' || g.samples || ' daily samples with no decrease — nothing is un-flagging them'
318
+ from (
319
+ select m.user_id,
320
+ count(*) as samples,
321
+ (array_agg(m.value order by m.run_at))[1] as first_value,
322
+ (array_agg(m.value order by m.run_at desc))[1] as last_value,
323
+ bool_and(m.value > 0) as all_positive,
324
+ -- Non-decreasing across the window: max of pairwise drops is 0.
325
+ coalesce(max(m.drop), 0) = 0 as never_decreased
326
+ from (
327
+ select mm.user_id, mm.run_at, mm.value,
328
+ greatest(lag(mm.value) over (partition by mm.user_id order by mm.run_at) - mm.value, 0) as drop
329
+ from pipeline_conformance_metrics mm
330
+ where mm.metric = 'stale_open_rows'
331
+ and mm.run_at > now() - interval '8 days'
332
+ ) m
333
+ group by m.user_id
334
+ ) g
335
+ where g.samples >= 8 and g.all_positive and g.never_decreased and g.last_value > g.first_value
336
+
337
+ union all
338
+
339
+ -- ── CHECK 5: surface_budget_spent_on_unadoptable ───────────────────────────
340
+ -- Incident: gates 5/6 and census cause 2 — ~840 feature/hook/mcp pitches per
341
+ -- 30 days entered the SessionStart feed, burned their five-ask surface budget
342
+ -- on an audience that cannot act on them, and converted into permanent
343
+ -- silence-declines. Fixed 2026-08-27 by excluding non-adoptable types from
344
+ -- the session-start render set (feed.ts). Any surfaced event on such a row
345
+ -- after that is the filter regressing.
346
+ select 'surface_budget_spent_on_unadoptable'::text,
347
+ s.user_id,
348
+ count(distinct ev.suggestion_id) ||
349
+ ' non-adoptable proposal(s) (feature/hook/mcp/…) surfaced into sessions in the last 7d — they burn budget for an audience that cannot act'
350
+ from instruction_suggestion_events ev
351
+ join instruction_suggestions s on s.id = ev.suggestion_id
352
+ join instructions i on i.id = s.instruction_id
353
+ where ev.event_type in ('surfaced', 'surfaced_with_content')
354
+ and ev.created_at > now() - interval '7 days'
355
+ and s.kind = 'add'
356
+ and i.type not in ('instruction', 'skill')
357
+ group by s.user_id
358
+
359
+ union all
360
+
361
+ -- ── CHECK 6: session_capture_misattributed ─────────────────────────────────
362
+ -- Incident: 2026-08-27 — Thrakopontia's only session carried
363
+ -- project_path='/Users/nickyeager' (the pre-08-26 normalizer rewrote it) and
364
+ -- froze at 188 events while its transcript grew for three days, so the
365
+ -- project produced zero suggestions and the only symptom was an empty VS
366
+ -- Code panel. A RECENT session in this shape means either the normalizer
367
+ -- guard regressed (new junk being minted) or the reconciler is not running
368
+ -- for this user (worker off) — both actionable. Rows older than 7 days sit
369
+ -- in the misattributed_session_paths metric instead: their transcripts are
370
+ -- likely pruned, and a check that can never clear is a permanent red light
371
+ -- nobody reads.
372
+ select 'session_capture_misattributed'::text,
373
+ x.user_id,
374
+ count(*) || ' session(s) active in the last 7d attributed to a project while carrying a bare home-directory project_path — normalizer regression, or the reconciler is not repairing them (worker offline / transcript gone)'
375
+ from sessions x
376
+ where x.project_id is not null
377
+ and x.project_path ~ '^/(Users|home)/[^/]+/?$'
378
+ and x.started_at > now() - interval '7 days'
379
+ group by x.user_id;
380
+ end;
381
+ $$;
382
+
383
+ comment on function public.suggestion_pipeline_conformance() is
384
+ 'Daily conformance sweep over the suggestion/promotion pipeline. Each check encodes the observable signature of a dated incident (gate 4 silent producer, #372 producer/endpoint drift, gate 7 one-way staleness, census causes 2 and 3, the 2026-08-27 misattributed-session-path capture defect). Detection only — never mutates pipeline state. Findings land as one consolidated notification per user (pattern: 00147).';
385
+
386
+ -- ── 3. Delete the laundering vector ────────────────────────────────────────
387
+ -- A bare-home project_checkouts row makes resolveProjectIdForPath attribute
388
+ -- every genuine home-dir session to whatever project the residue points at.
389
+ -- Zero rows exist on the cloud DB at authoring time (the API-side guard has
390
+ -- held since it shipped) — this is the backstop against rows minted by older
391
+ -- API builds (local-mode installs) that predate the guard.
392
+ delete from public.project_checkouts
393
+ where path ~ '^/(Users|home)/[^/]+/?$';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/local",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "RuleMetric local-mode runtime: bundled API server, web dashboard, and database migrations for fully local (zero cloud contact) deployments. Installed on demand by `rulemetric local up` — not meant to be used directly.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,7 +28,7 @@
28
28
  "postgres": "^3.4.0",
29
29
  "resend": "^4.0.0",
30
30
  "zod": "^3.24.0",
31
- "@rulemetric/skills-registry": "0.12.3"
31
+ "@rulemetric/skills-registry": "0.12.5"
32
32
  },
33
33
  "devDependencies": {
34
34
  "esbuild": "^0.25.0",