@rulemetric/local 0.12.2 → 0.12.4

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,156 @@
1
+ -- A routine that looks for sessions nobody counted.
2
+ --
3
+ -- THE DEFECT CLASS. The proxy auto-bootstraps a session from LLM traffic before
4
+ -- any hook has run, so it has the wire id but no cwd, and the row lands with
5
+ -- project_path and project_id NULL. Events keep flowing. Nothing errors. But
6
+ -- BOTH insights consumers filter on attribution --
7
+ -- `regenerate_insights_jobs()` selects `where s.project_id is not null`, and
8
+ -- routes/insights/jobs.ts requires it too -- so the session is never counted.
9
+ --
10
+ -- The only symptom is silence: a project simply has no insights. Measured
11
+ -- 2026-08-26 on the cloud DB, 354 sessions / 39,193 events were orphaned this
12
+ -- way, one repo having lost every session it ever had across four days of work,
13
+ -- and it surfaced only because a human asked why that project looked empty.
14
+ -- `create.ts:repairAttribution` stops new sessions from orphaning; this
15
+ -- producer is what finds the ones already on the books, and anything a future
16
+ -- gap lets through.
17
+ --
18
+ -- WHY A WORKER JOB AND NOT A SQL BACKFILL. This function deliberately does no
19
+ -- repairing itself. Repair needs a git remote or root commit, and only 6 of
20
+ -- those 354 rows carried ANY git metadata -- the server has nothing to match
21
+ -- on. Matching on project_path alone is precisely the cwd-only fallback Phase
22
+ -- 12 removed for causing misassignment, and a routine running unattended every
23
+ -- hour must not reintroduce it. The evidence lives on the machine that ran the
24
+ -- session: the checkout is still on disk and `git remote get-url origin` still
25
+ -- answers. So the handler reads the anchors there and POSTs them to
26
+ -- POST /api/sessions, which routes into the same `repairAttribution` the live
27
+ -- hook uses -- one implementation of the match order, two callers.
28
+ --
29
+ -- SCHEDULE. Hourly at :20, off the nightly loop slots (04:30 suggestions ->
30
+ -- 05:00 auto-accept -> 05:30 refine -> 05:45 retire -> 06:10 autorun -> 07:30
31
+ -- evolution). Attribution repair is cheap, reads no LLM, and wants to happen
32
+ -- BEFORE the 6-hourly insights producer at 00/06/12/18 so a session repaired
33
+ -- this hour is counted in the next insights pass rather than waiting a day.
34
+ -- ---------------------------------------------------------------------------
35
+
36
+ create or replace function public.enqueue_reconcile_attribution()
37
+ returns integer
38
+ language plpgsql
39
+ security definer
40
+ set search_path = public
41
+ as $$
42
+ declare
43
+ queued integer;
44
+ begin
45
+ with eligible as (
46
+ select s.user_id
47
+ from public.sessions s
48
+ -- INVISIBLE TO INSIGHTS is wider than project_id IS NULL. This function
49
+ -- INNER JOINs project_canonical_checkouts, which is built from
50
+ -- project_checkouts -- so a project with no checkout row is skipped no
51
+ -- matter how well its sessions are attributed, and a project whose every
52
+ -- session was orphaned has never had one recorded (the insert path only
53
+ -- upserts on a match). Chasing only the NULL half is how the first real
54
+ -- reconcile pass attributed Thrakopontia and left it exactly as unable to
55
+ -- produce insights as before.
56
+ where (
57
+ s.project_id is null
58
+ or not exists (
59
+ select 1 from public.project_checkouts pc where pc.project_id = s.project_id
60
+ )
61
+ )
62
+ -- Only chase orphans a worker could plausibly still repair. A session
63
+ -- whose checkout was deleted years ago is permanently unrepairable, and
64
+ -- re-walking it every hour forever is pure noise. 90 days is well past
65
+ -- Claude Code's transcript retention, which is the recovery source for
66
+ -- the pathless shape.
67
+ and s.started_at > now() - interval '90 days'
68
+ -- Sessions with no external id cannot be addressed through
69
+ -- POST /api/sessions at all (it matches on external_session_id), so the
70
+ -- handler would only count them as unrepairable. Do not enqueue for a
71
+ -- user who has nothing BUT those.
72
+ and s.external_session_id is not null
73
+ -- Temp fixtures and home directories are orphaned CORRECTLY. The handler
74
+ -- refuses them; not enqueuing on their account keeps a guaranteed refusal
75
+ -- out of the hourly feed.
76
+ and (
77
+ s.project_path is null
78
+ or (
79
+ s.project_path !~ '^/(private/)?(tmp|var/folders)'
80
+ and s.project_path !~ '^/(Users|home)/[^/]+/?$'
81
+ )
82
+ )
83
+ group by s.user_id
84
+ ), fresh as (
85
+ select e.user_id
86
+ from eligible e
87
+ -- One in flight at a time, per the 00083/00086 pattern.
88
+ where not exists (
89
+ select 1 from public.agent_jobs j
90
+ where j.task_kind = 'cron_reconcile_attribution'
91
+ and j.status in ('pending', 'claimed', 'running')
92
+ and j.user_id = e.user_id
93
+ )
94
+ -- Do not re-run within the hour for a user whose last pass just completed.
95
+ -- The orphan set only changes when new sessions land or a checkout appears.
96
+ and not exists (
97
+ select 1 from public.agent_jobs j
98
+ where j.task_kind = 'cron_reconcile_attribution'
99
+ and j.user_id = e.user_id
100
+ and j.status = 'completed'
101
+ and j.completed_at > now() - interval '50 minutes'
102
+ )
103
+ ), ins as (
104
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
105
+ select f.user_id,
106
+ 'cron_reconcile_attribution',
107
+ 'pending',
108
+ jsonb_build_object('limit', 200),
109
+ 'reconcile-attribution:' || f.user_id
110
+ from fresh f
111
+ returning 1
112
+ )
113
+ select count(*)::int into queued from ins;
114
+
115
+ return queued;
116
+ end;
117
+ $$;
118
+
119
+ comment on function public.enqueue_reconcile_attribution() is
120
+ 'Hourly producer for cron_reconcile_attribution (:20). Enqueues one run per user holding sessions with project_id NULL that a worker could still repair (under 90 days old, has an external_session_id, not a temp/home path). Does NOT repair anything itself: attribution needs git anchors that live on the machine that ran the session, and matching on project_path alone is the cwd-only fallback Phase 12 removed. The handler reads those anchors from disk and POSTs them to POST /api/sessions, where repairAttribution performs the write.';
121
+
122
+ -- Observability: the residual count, so a rising floor of unrepairable orphans
123
+ -- is visible instead of being smoothed away. `repairable_recent` is what the
124
+ -- producer actually chases; `total` is every orphan on the books.
125
+ create or replace view public.session_attribution_health as
126
+ select
127
+ s.user_id,
128
+ count(*)::int as total_unattributed,
129
+ count(*) filter (
130
+ where s.started_at > now() - interval '90 days'
131
+ and s.external_session_id is not null
132
+ and (
133
+ s.project_path is null
134
+ or (
135
+ s.project_path !~ '^/(private/)?(tmp|var/folders)'
136
+ and s.project_path !~ '^/(Users|home)/[^/]+/?$'
137
+ )
138
+ )
139
+ )::int as repairable_recent,
140
+ sum(s.event_count)::bigint as orphaned_events,
141
+ max(s.started_at) as newest_orphan
142
+ from public.sessions s
143
+ where s.project_id is null
144
+ or not exists (
145
+ select 1 from public.project_checkouts pc where pc.project_id = s.project_id
146
+ )
147
+ group by s.user_id;
148
+
149
+ comment on view public.session_attribution_health is
150
+ 'Per-user count of sessions invisible to the insights pipeline (project_id IS NULL). 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.';
151
+
152
+ select cron.schedule(
153
+ 'enqueue_reconcile_attribution',
154
+ '20 * * * *',
155
+ $$select public.enqueue_reconcile_attribution();$$
156
+ );
@@ -0,0 +1,183 @@
1
+ -- Repair the two data shapes that leave a correctly-attributed project unable
2
+ -- to produce insights.
3
+ --
4
+ -- Both are consequences of the same root cause fixed in code on 2026-08-26: the
5
+ -- subdir-consolidation normalizer in POST /sessions collapsed any path under a
6
+ -- known session path to that parent, treated `/Users/<user>` as a valid parent,
7
+ -- and picked an ARBITRARY first match. So sessions landed with the HOME
8
+ -- DIRECTORY as their project_path, and `upsertProjectCheckout` (rightly)
9
+ -- refuses to record a home dir as a checkout. The project_id was correct the
10
+ -- whole time -- it comes from the git remote at capture -- but with no row in
11
+ -- project_checkouts, `project_canonical_checkouts` is empty for that project
12
+ -- and `regenerate_insights_jobs()` INNER JOINs that view. Attributed, and
13
+ -- invisible.
14
+ --
15
+ -- Measured on prod 2026-08-26:
16
+ -- * kc-content-workspace: 191 sessions, 181 of them at `/Users/kylechalmers`,
17
+ -- ZERO checkouts. Same for kc-ops-workspace (41 sessions, 28 at home).
18
+ -- This is also what 00205's canonical-checkout gate blocks: that user's
19
+ -- auto-accept lane is dead until his checkouts are registered. Registering
20
+ -- them is the fix; widening the gate is not.
21
+ -- * context-engineering-intro exists TWICE -- one row with a git remote and
22
+ -- root commit (4 sessions, no checkout) and one anchorless legacy row (no
23
+ -- remote, no root commit, 1 session) that HOLDS the checkout for the path.
24
+ -- Because project_checkouts has a GLOBAL unique index on `path` while the
25
+ -- upsert conflicts on the (project_id, path) primary key, the real project
26
+ -- could never record its own checkout: 23505, swallowed, forever.
27
+ --
28
+ -- Both halves are written as idempotent FUNCTIONS rather than one-shot DO
29
+ -- blocks so they can be tested, re-run, and called again if the shape recurs.
30
+ -- ---------------------------------------------------------------------------
31
+
32
+ -- ── 1. Merge anchorless duplicate project rows ──────────────────────────────
33
+ --
34
+ -- A project row with NEITHER a git_remote_url NOR a git_root_commit has no
35
+ -- identity anchor at all -- it predates git-context-as-identity and can never
36
+ -- be matched again by capture. When such a row shares a name with a project
37
+ -- that DOES have an anchor, it is the same repo, and everything hanging off it
38
+ -- belongs to the anchored row.
39
+ --
40
+ -- STRICT: only merges when exactly ONE candidate keeper exists for the name.
41
+ -- Two anchored rows for one name is a different problem (a real fork, or a
42
+ -- rename) and must not be resolved by guessing.
43
+ create or replace function public.merge_anchorless_duplicate_projects()
44
+ returns integer
45
+ language plpgsql
46
+ security definer
47
+ set search_path = public
48
+ as $$
49
+ declare
50
+ merged integer := 0;
51
+ r record;
52
+ begin
53
+ for r in
54
+ select dud.id as dud_id, keeper.id as keeper_id
55
+ from public.projects dud
56
+ join lateral (
57
+ select k.id
58
+ from public.projects k
59
+ where k.name = dud.name
60
+ and k.id <> dud.id
61
+ and (k.git_remote_url is not null or k.git_root_commit is not null)
62
+ limit 2
63
+ ) keeper on true
64
+ where dud.git_remote_url is null
65
+ and dud.git_root_commit is null
66
+ -- Exactly one keeper, or we are guessing.
67
+ and (
68
+ select count(*) from public.projects k2
69
+ where k2.name = dud.name and k2.id <> dud.id
70
+ and (k2.git_remote_url is not null or k2.git_root_commit is not null)
71
+ ) = 1
72
+ loop
73
+ -- project_checkouts carries a GLOBAL unique index on `path`, so a straight
74
+ -- repoint collides whenever the keeper already knows the path. Drop the
75
+ -- dud's row in that case; otherwise move it.
76
+ delete from public.project_checkouts c
77
+ where c.project_id = r.dud_id
78
+ and exists (
79
+ select 1 from public.project_checkouts k
80
+ where k.path = c.path and k.project_id = r.keeper_id
81
+ );
82
+ update public.project_checkouts set project_id = r.keeper_id where project_id = r.dud_id;
83
+
84
+ update public.sessions set project_id = r.keeper_id where project_id = r.dud_id;
85
+ update public.insights_jobs set project_id = r.keeper_id where project_id = r.dud_id;
86
+ update public.instruction_suggestions set project_id = r.keeper_id where project_id = r.dud_id;
87
+ update public.agent_runs set project_id = r.keeper_id where project_id = r.dud_id;
88
+ update public.treatment_assignments set project_id = r.keeper_id where project_id = r.dud_id;
89
+ update public.memories set project_id = r.keeper_id where project_id = r.dud_id;
90
+ update public.pending_treatment_exposures set project_id = r.keeper_id where project_id = r.dud_id;
91
+ update public.recommendation_applications set project_id = r.keeper_id where project_id = r.dud_id;
92
+
93
+ -- project_instructions is UNIQUE on (project_id, instruction_id) and
94
+ -- CASCADE-deletes, so drop links the keeper already holds before moving.
95
+ delete from public.project_instructions pi
96
+ where pi.project_id = r.dud_id
97
+ and exists (
98
+ select 1 from public.project_instructions k
99
+ where k.project_id = r.keeper_id and k.instruction_id = pi.instruction_id
100
+ );
101
+ update public.project_instructions set project_id = r.keeper_id where project_id = r.dud_id;
102
+
103
+ delete from public.projects where id = r.dud_id;
104
+ merged := merged + 1;
105
+ end loop;
106
+
107
+ return merged;
108
+ end;
109
+ $$;
110
+
111
+ comment on function public.merge_anchorless_duplicate_projects() is
112
+ 'Merges a projects row that has NEITHER git_remote_url NOR git_root_commit into the single same-named row that does have an anchor, repointing every referencing table first. Such a row predates git-context-as-identity and can never be matched by capture again, but it can still HOLD a project_checkouts row that the real project then cannot record (project_checkouts has a global unique index on path while the upsert conflicts on the (project_id, path) PK) -- which is how context-engineering-intro had two rows, one owning the path and the other owning the sessions. Refuses to act when more than one anchored candidate shares the name.';
113
+
114
+ -- ── 2. Register the checkout an attributed project already demonstrates ─────
115
+ --
116
+ -- NOT attribution inference. `sessions.project_id` is already set here, decided
117
+ -- at capture time by a git remote or root commit; this only records WHICH
118
+ -- DIRECTORY those already-attributed sessions ran in. That distinction is why
119
+ -- this is safe and why the cwd-only fallback removed in Phase 12 is not being
120
+ -- reintroduced: nothing here decides which project a session belongs to.
121
+ --
122
+ -- Refuses: home directories and temp paths (never checkouts), worktree paths
123
+ -- (a worktree is not the canonical root -- 00186/00201 exist because electing
124
+ -- one aims the whole loop at a path nobody works in), and any path already
125
+ -- claimed by another project (the global unique index).
126
+ create or replace function public.backfill_project_checkouts_from_sessions()
127
+ returns integer
128
+ language plpgsql
129
+ security definer
130
+ set search_path = public
131
+ as $$
132
+ declare
133
+ inserted integer;
134
+ begin
135
+ with candidate as (
136
+ select
137
+ s.project_id,
138
+ s.project_path,
139
+ count(*) as sessions,
140
+ row_number() over (
141
+ partition by s.project_id
142
+ -- Most-used real path wins; shallower breaks ties, so a repo root is
143
+ -- preferred over a subdirectory of it.
144
+ order by count(*) desc, length(s.project_path) asc, s.project_path asc
145
+ ) as rank
146
+ from public.sessions s
147
+ where s.project_id is not null
148
+ and s.project_path is not null
149
+ and s.project_path !~ '^/(Users|home)/[^/]+/?$'
150
+ and s.project_path !~ '^/(private/)?(tmp|var/folders)'
151
+ and s.project_path not like '%/.claude/worktrees/%'
152
+ and s.project_path not like '%/.worktrees/%'
153
+ -- Only projects that have NO checkout at all. Never second-guess a
154
+ -- project whose checkouts are already known.
155
+ and not exists (
156
+ select 1 from public.project_checkouts c where c.project_id = s.project_id
157
+ )
158
+ -- Global unique on path: skip anything another project already owns.
159
+ and not exists (
160
+ select 1 from public.project_checkouts c2 where c2.path = s.project_path
161
+ )
162
+ group by s.project_id, s.project_path
163
+ ), ins as (
164
+ insert into public.project_checkouts (project_id, path, is_worktree)
165
+ select c.project_id, c.project_path, false
166
+ from candidate c
167
+ where c.rank = 1
168
+ on conflict do nothing
169
+ returning 1
170
+ )
171
+ select count(*)::int into inserted from ins;
172
+
173
+ return inserted;
174
+ end;
175
+ $$;
176
+
177
+ comment on function public.backfill_project_checkouts_from_sessions() is
178
+ 'Registers one canonical checkout for each ALREADY-ATTRIBUTED project that has none, using the most-used real directory its own sessions ran in. Not attribution inference: sessions.project_id is already set from a git anchor, and this only records which directory those sessions used. Skips home dirs, temp paths, worktree paths, and any path another project already claims. Exists because the POST /sessions path normalizer used to collapse session paths onto the home directory, which upsertProjectCheckout then rightly refused to record -- leaving projects correctly attributed and still invisible to regenerate_insights_jobs(), which INNER JOINs project_canonical_checkouts.';
179
+
180
+ -- Run both once, now. Order matters: merging frees a path that the keeper can
181
+ -- then be given, and the backfill skips projects that already have a checkout.
182
+ select public.merge_anchorless_duplicate_projects();
183
+ select public.backfill_project_checkouts_from_sessions();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/local",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
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.2"
31
+ "@rulemetric/skills-registry": "0.12.4"
32
32
  },
33
33
  "devDependencies": {
34
34
  "esbuild": "^0.25.0",