@rulemetric/local 0.11.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 (25) hide show
  1. package/dist/meta.json +2 -2
  2. package/dist/server.mjs +685 -416
  3. package/dist/supabase/migrations/00087_cleanup_test_data_fix.sql +1 -1
  4. package/dist/supabase/migrations/00185_trial_throughput_settings.sql +43 -0
  5. package/dist/supabase/migrations/00186_canonical_checkout_prefers_live.sql +256 -0
  6. package/dist/supabase/migrations/00187_recommendation_applications.sql +32 -0
  7. package/dist/supabase/migrations/00188_training_producer_canonical_path.sql +117 -0
  8. package/dist/supabase/migrations/00189_limit_obs_window_index.sql +37 -0
  9. package/dist/supabase/migrations/00190_eval_targets_artifact_link.sql +176 -0
  10. package/dist/supabase/migrations/00191_recommendation_applications_effect_ledger.sql +106 -0
  11. package/dist/supabase/migrations/00192_cleanup_reaps_memberless_orgs.sql +142 -0
  12. package/dist/supabase/migrations/00193_judge_segment_pin_and_anchors.sql +367 -0
  13. package/dist/supabase/migrations/00194_refine_cases_producer.sql +91 -0
  14. package/dist/supabase/migrations/00195_skill_usage_views.sql +109 -0
  15. package/dist/supabase/migrations/00196_retire_unused_skills_producer.sql +102 -0
  16. package/dist/supabase/migrations/00197_auto_accept_producer_admits_skills.sql +115 -0
  17. package/dist/supabase/migrations/00198_unstale_non_catalog_suggestions.sql +51 -0
  18. package/dist/supabase/migrations/00199_auto_accept_runs_every_two_hours.sql +54 -0
  19. package/dist/supabase/migrations/00200_auto_accept_runs_hourly.sql +42 -0
  20. package/dist/web/assets/{docs-B_21wyvj.js → docs-KIeJWTb-.js} +1 -1
  21. package/dist/web/assets/index-CFJatBZ3.css +1 -0
  22. package/dist/web/assets/{index-OhhQFJmS.js → index-MYof2fnF.js} +54 -54
  23. package/dist/web/index.html +3 -3
  24. package/package.json +2 -2
  25. package/dist/web/assets/index-_M9l6iMX.css +0 -1
@@ -0,0 +1,91 @@
1
+ -- An actuator for the refusal the loop had been printing every night.
2
+ --
3
+ -- `cron_instruction_evolution` has been ending in `{"skipped":"not-adequate"}`
4
+ -- with the reason "3/5 cases do not discriminate with vs without — cull or
5
+ -- replace them before adding runs; more runs of a non-discriminating case
6
+ -- cannot lower the MDE; MDE ±49pp exceeds the ±20pp target effect" on every
7
+ -- night measured (2026-08-16, -17, -18). The remedy shipped in 2026-07 as
8
+ -- `rulemetric evals refine-cases <id> --yes` — a command a human types. So the
9
+ -- nightly loop diagnosed its own blocker correctly, spent a two-arm replay
10
+ -- proving it, and had no way to act.
11
+ --
12
+ -- Slot: 05:30, inside the existing nightly chain and deliberately BEFORE the
13
+ -- 06:10 autorun batch, so replacements generated tonight start collecting depth
14
+ -- tonight rather than waiting a day:
15
+ --
16
+ -- 04:30 suggestions -> 04:45 training -> 05:00 auto-accept
17
+ -- -> 05:30 REFINE -> 06:10 autorun -> 07:30 evolution
18
+ --
19
+ -- Opt-in per target (metadata.autoRefineCases), matching autoEvolveEnabled:
20
+ -- refinement rewrites what a target MEASURES, and a measurement apparatus that
21
+ -- reshapes itself without consent cannot then be trusted to report on itself.
22
+ -- The handler re-reads consent when it claims the job, so revoking it stops an
23
+ -- already-queued run.
24
+ -- ---------------------------------------------------------------------------
25
+
26
+ create or replace function public.enqueue_refine_cases()
27
+ returns integer
28
+ language plpgsql
29
+ security definer
30
+ set search_path = public
31
+ as $$
32
+ declare
33
+ queued integer;
34
+ begin
35
+ with eligible as (
36
+ select t.id, t.user_id
37
+ from eval_targets t
38
+ where coalesce(t.metadata->>'autoRefineCases', 'false') = 'true'
39
+ -- Discrimination is a CROSS-COHORT quantity: without runs in both arms
40
+ -- there is nothing to diagnose, and the handler would spend an API round
41
+ -- trip to say so. Cheap pre-filter, same answer.
42
+ and exists (
43
+ select 1 from eval_runs r
44
+ where r.eval_target_id = t.id
45
+ and r.status = 'completed'
46
+ and r.configuration = 'with_target'
47
+ )
48
+ and exists (
49
+ select 1 from eval_runs r
50
+ where r.eval_target_id = t.id
51
+ and r.status = 'completed'
52
+ and r.configuration = 'without_target'
53
+ )
54
+ -- There must be something left to measure with. A target already at the
55
+ -- floor cannot afford a retirement, and the handler refuses anyway —
56
+ -- but not enqueuing it keeps the refusal out of the nightly feed.
57
+ and (
58
+ select count(*) from evals ev
59
+ where ev.eval_target_id = t.id and ev.retired_at is null
60
+ ) >= 1
61
+ -- One in flight at a time, per the 00083/00086 pattern.
62
+ and not exists (
63
+ select 1 from agent_jobs j
64
+ where j.task_kind = 'cron_refine_cases'
65
+ and j.status in ('pending', 'claimed', 'running')
66
+ and j.payload->>'evalTargetId' = t.id::text
67
+ )
68
+ ), ins as (
69
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
70
+ select e.user_id,
71
+ 'cron_refine_cases',
72
+ 'pending',
73
+ jsonb_build_object('evalTargetId', e.id::text),
74
+ 'refine-cases:' || e.id::text
75
+ from eligible e
76
+ returning 1
77
+ )
78
+ select count(*)::int into queued from ins;
79
+
80
+ return queued;
81
+ end;
82
+ $$;
83
+
84
+ comment on function public.enqueue_refine_cases() is
85
+ 'Nightly producer for cron_refine_cases (§6e). Enqueues one refinement per OPTED-IN target (metadata.autoRefineCases=true) that has completed runs in BOTH cohorts and at least one live case. Retires cases carrying no treatment signal and regenerates replacements — the actuator for the "N/M cases do not discriminate" refusal the evolution loop printed nightly with no way to act on it. Runs at 05:30, before the 06:10 autorun slot, so tonight''s replacements collect depth tonight. Opt in with: update eval_targets set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRefineCases":true}''::jsonb where id = ...';
86
+
87
+ select cron.schedule(
88
+ 'enqueue_refine_cases',
89
+ '30 5 * * *',
90
+ $$select public.enqueue_refine_cases();$$
91
+ );
@@ -0,0 +1,109 @@
1
+ -- Skills were being USED and the product could not see it.
2
+ --
3
+ -- Measured 2026-08-18: `session_events` holds 158 `Skill` tool invocations over
4
+ -- 30 days, most recent that day, with the skill name sitting in plain sight at
5
+ -- `tool_input->>'skill'`. Meanwhile `session_instructions` held 0 rows for any
6
+ -- skill, `treatment_exposures` had no `skill` type at all, and of 6,086 skill
7
+ -- instructions 0 had ever been accepted and 0 archived. The signal was captured
8
+ -- and dropped on the floor.
9
+ --
10
+ -- That gap is why the loop has never proposed retiring a skill: grooming
11
+ -- reasons over measured effect, and there was no measurement to reason over.
12
+ --
13
+ -- ## Views, not a table
14
+ --
15
+ -- The obvious shape is a `skill_invocations` table written at ingest. It is the
16
+ -- wrong one here: the event side-effects already exist in TWO mirrored copies
17
+ -- (`routes/sessions/ingest/events.ts` and `lib/ingest-batcher.ts`), and a third
18
+ -- dual-write would be a third thing to keep in sync — plus a backfill to get
19
+ -- wrong. A view over the events cannot drift from its source, needs no
20
+ -- backfill, and is correct for all history the moment it exists.
21
+ --
22
+ -- ## Why this is a stronger signal than anything already recorded
23
+ --
24
+ -- Every existing `session_instructions.link_method` — project_match,
25
+ -- context_file, auto_capture, manual — means the instruction was PRESENT in
26
+ -- context. A Skill invocation means it was USED. §12 spends its length on the
27
+ -- gap between exposure and effect; this is the first record on the right side
28
+ -- of that gap, and it is worth keeping distinct from presence rather than
29
+ -- folding into it.
30
+ -- ---------------------------------------------------------------------------
31
+
32
+ -- Skill events are a rounding error in session_events (158 of ~30k), so a
33
+ -- partial index keeps the scan proportional to what is actually read.
34
+ create index if not exists idx_session_events_skill_tool
35
+ on public.session_events (session_id, timestamp)
36
+ where tool_name = 'Skill';
37
+
38
+ -- Catalog skills are named `<source-repo>--<skill>`; invocations carry either
39
+ -- the bare `<skill>` or `<plugin>:<skill>`. Matching on the leaf therefore
40
+ -- needs the leaf to be indexable — a `like '%--' || name` predicate cannot use
41
+ -- an index at all, and there are 6,086 skill rows to scan without one.
42
+ create index if not exists idx_instructions_skill_leaf
43
+ on public.instructions ((split_part(name, '--', 2)))
44
+ where type = 'skill';
45
+
46
+ -- ---------------------------------------------------------------------------
47
+ -- Raw invocations, parsed.
48
+ -- ---------------------------------------------------------------------------
49
+ create or replace view public.skill_invocations as
50
+ select
51
+ e.session_id,
52
+ e.timestamp as invoked_at,
53
+ e.tool_input->>'skill' as raw_name,
54
+ -- `plugin:skill` is one namespace form Claude Code emits; a bare name is the
55
+ -- other. Splitting rather than storing the raw string keeps the join below
56
+ -- honest about which half is the identity.
57
+ nullif(split_part(e.tool_input->>'skill', ':', 1), e.tool_input->>'skill')
58
+ as plugin,
59
+ case
60
+ when position(':' in e.tool_input->>'skill') > 0
61
+ then split_part(e.tool_input->>'skill', ':', 2)
62
+ else e.tool_input->>'skill'
63
+ end as skill_name
64
+ from public.session_events e
65
+ where e.tool_name = 'Skill'
66
+ and e.tool_input->>'skill' is not null
67
+ and e.tool_input->>'skill' <> '';
68
+
69
+ comment on view public.skill_invocations is
70
+ 'Every Skill tool invocation, parsed out of session_events. A view rather than a table: the event side-effects already exist in two mirrored copies and a third dual-write would be a third thing to keep in sync. Names arrive as `<skill>` or `<plugin>:<skill>`.';
71
+
72
+ -- ---------------------------------------------------------------------------
73
+ -- Invocations resolved to the catalog, WITHOUT discarding the unresolved ones.
74
+ --
75
+ -- Measured 2026-08-18: only 11 of 81 distinct invoked skills exist in the
76
+ -- instructions catalog. The other 70 are the user's own local skills
77
+ -- (`git-ship`, `commit`, `publish`, `auto-deploy`) — the ones actually used
78
+ -- every day, and the ones a catalog-only join would silently drop. An inner
79
+ -- join here would have reported 14% of reality and looked complete.
80
+ --
81
+ -- `instruction_id` is therefore NULLABLE and null means "used but not in the
82
+ -- catalog", which is a finding, not a failure.
83
+ -- ---------------------------------------------------------------------------
84
+ create or replace view public.skill_usage as
85
+ select
86
+ si.session_id,
87
+ si.invoked_at,
88
+ si.raw_name,
89
+ si.plugin,
90
+ si.skill_name,
91
+ s.user_id,
92
+ s.project_id,
93
+ s.project_path,
94
+ m.instruction_id
95
+ from public.skill_invocations si
96
+ join public.sessions s on s.id = si.session_id
97
+ left join lateral (
98
+ select i.id as instruction_id
99
+ from public.instructions i
100
+ where i.type = 'skill'
101
+ and (i.name = si.skill_name or split_part(i.name, '--', 2) = si.skill_name)
102
+ -- Exact name beats a leaf match, so a skill whose full name happens to equal
103
+ -- another's leaf cannot steal the attribution.
104
+ order by (i.name = si.skill_name) desc, i.id
105
+ limit 1
106
+ ) m on true;
107
+
108
+ comment on view public.skill_usage is
109
+ 'Skill invocations joined to the session that ran them and, where one exists, the catalog instruction. instruction_id is NULL for skills used but not catalogued — measured 2026-08-18, that was 70 of 81 distinct skills, so an inner join here would report 14% of reality.';
@@ -0,0 +1,102 @@
1
+ -- The loop's first unattended REMOVAL.
2
+ --
3
+ -- Everything the loop has actuated until now has added: a rule appended to
4
+ -- CLAUDE.md, a skill installed, a hook registered, an instruction promoted.
5
+ -- Grooming's stated contract was that removals are surfaced for human review
6
+ -- and NEVER auto-archived, and this producer deliberately changes that for one
7
+ -- narrow case (owner's decision, 2026-08-18). Recording the change here so the
8
+ -- next reader finds the departure rather than inferring it.
9
+ --
10
+ -- What makes it narrow:
11
+ --
12
+ -- * ONLY skills, and only ones with ZERO recorded invocations since install.
13
+ -- "Invoked but measures unhelpful" is an observational estimate and stays a
14
+ -- proposal, exactly as measured-harmful rules do. An absence of use is a
15
+ -- fact; an absence of benefit is an inference.
16
+ -- * ONLY skills the loop itself installed, evidenced by a
17
+ -- `recommendation_applications` row with status='applied'. The handler
18
+ -- re-checks this; the API is the authority.
19
+ -- * ONLY while skill capture is demonstrably live. A dark Skill channel makes
20
+ -- every skill look unused, and the first quiet fortnight would otherwise
21
+ -- read as "retire everything". Judged by `loadUnusedSkillCandidates`.
22
+ -- * A per-run cap in the handler, and a backup written before every delete
23
+ -- (`applyRetraction`) because an untracked skill file has no other copy and
24
+ -- git cannot restore it.
25
+ --
26
+ -- Slot: 05:45, after auto-accept (05:00) and refine (05:30), before the autorun
27
+ -- batch (06:10). Adding and removing in the same nightly pass, in that order,
28
+ -- means a skill adopted tonight is never a retirement candidate tonight — it
29
+ -- has to survive the unused window first.
30
+ --
31
+ -- 04:30 suggestions -> 04:45 training -> 05:00 auto-accept
32
+ -- -> 05:30 refine -> 05:45 RETIRE -> 06:10 autorun -> 07:30 evolution
33
+ --
34
+ -- Opt-in per project (metadata.autoRetireSkills). Nothing is retired for a user
35
+ -- who has not asked for it, and the handler cannot be reached without a job.
36
+ -- ---------------------------------------------------------------------------
37
+
38
+ create or replace function public.enqueue_retire_unused_skills()
39
+ returns integer
40
+ language plpgsql
41
+ security definer
42
+ set search_path = public
43
+ as $$
44
+ declare
45
+ queued integer;
46
+ begin
47
+ with eligible as (
48
+ -- The canonical checkout comes from the VIEW, never re-derived: is_worktree
49
+ -- is rewritten from each session's own metadata on every ingest, so a
50
+ -- hand-rolled ranking elects a path nobody works in (00186).
51
+ select pr.created_by as user_id, p.path as canonical_path
52
+ from public.project_canonical_checkouts p
53
+ join public.projects pr on pr.id = p.project_id
54
+ where coalesce(pr.metadata->'autoRetireSkills'->>'enabled', 'false') = 'true'
55
+ -- Cheap pre-filter: a user with no skill invocations captured at all has
56
+ -- a dark channel, and the handler would refuse anyway. Not enqueuing it
57
+ -- keeps a guaranteed refusal out of the nightly feed.
58
+ and exists (
59
+ select 1 from public.skill_usage su
60
+ where su.user_id = pr.created_by
61
+ and su.invoked_at > now() - interval '14 days'
62
+ )
63
+ -- There must be something that could plausibly be retired: at least one
64
+ -- of the user's own skills old enough to have had a fair chance.
65
+ and exists (
66
+ select 1 from public.instructions i
67
+ where i.created_by = pr.created_by
68
+ and i.type = 'skill'
69
+ and i.archived = false
70
+ and i.created_at < now() - interval '30 days'
71
+ )
72
+ -- One in flight at a time, per the 00083/00086 pattern.
73
+ and not exists (
74
+ select 1 from public.agent_jobs j
75
+ where j.task_kind = 'cron_retire_unused_skills'
76
+ and j.status in ('pending', 'claimed', 'running')
77
+ and j.payload->>'projectPath' = p.path
78
+ )
79
+ ), ins as (
80
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
81
+ select e.user_id,
82
+ 'cron_retire_unused_skills',
83
+ 'pending',
84
+ jsonb_build_object('projectPath', e.canonical_path),
85
+ 'retire-unused-skills:' || e.canonical_path
86
+ from eligible e
87
+ returning 1
88
+ )
89
+ select count(*)::int into queued from ins;
90
+
91
+ return queued;
92
+ end;
93
+ $$;
94
+
95
+ comment on function public.enqueue_retire_unused_skills() is
96
+ 'Nightly producer for cron_retire_unused_skills (05:45). Enqueues one run per OPTED-IN project (projects.metadata.autoRetireSkills.enabled=true) whose user has live skill capture and at least one own skill older than the unused window. The loop''s first unattended REMOVAL: only skills, only never-invoked ones, only ones the loop itself installed, only while skill capture is live, capped per run, and with a backup written before every delete. Used-but-unhelpful skills remain proposals. Opt in with: update projects set metadata = coalesce(metadata, ''{}''::jsonb) || ''{"autoRetireSkills":{"enabled":true}}''::jsonb where id = ...';
97
+
98
+ select cron.schedule(
99
+ 'enqueue_retire_unused_skills',
100
+ '45 5 * * *',
101
+ $$select public.enqueue_retire_unused_skills();$$
102
+ );
@@ -0,0 +1,115 @@
1
+ -- 00197 — the auto-accept producer admits skills, and the loop can promote again.
2
+ --
3
+ -- #372 ("Skills become adoptable") opened two gates and said two was all there
4
+ -- were: `isAutoAdoptable()` and the operator-facing eligibility count in
5
+ -- `promotion-paths.ts`. There were FOUR. The two it did not touch are the two
6
+ -- that actually decide anything:
7
+ --
8
+ -- 3. GET /api/instruction-suggestions/auto-acceptable — the endpoint the
9
+ -- worker asks for candidates. Opened in the same change as this file.
10
+ -- 4. THIS producer, which decides whether a job is created at all.
11
+ --
12
+ -- Measured 2026-08-19, on the owner's own account:
13
+ --
14
+ -- * Last applied instruction promotion: 2026-08-01. Last auto-accept
15
+ -- adoption: 2026-08-15. Nothing since.
16
+ -- * Every `add` proposal generated from 08-16 onward was type `skill` or
17
+ -- `feature`. Not one `instruction`. On 08-19 alone: 20 skills, 13 of them
18
+ -- at or above the 0.8 score gate, top score 0.95.
19
+ -- * So this function's `eligible` CTE matched zero rows on 08-17, 08-18 and
20
+ -- 08-19. The cron fired and succeeded all three mornings and enqueued
21
+ -- NOTHING — no job, no log line, no refusal anyone could read. The loop
22
+ -- did not report that it was blocked, because from its own point of view
23
+ -- there was nothing to do.
24
+ --
25
+ -- The gate's original comment said "a skill pasted into CLAUDE.md is a category
26
+ -- error". It was right, and it no longer describes the actuator: `acceptSkill`
27
+ -- writes a file under `.claude/skills/` and `.agents/skills/` and never touches
28
+ -- CLAUDE.md. The rule this enforced outlived the behaviour it was protecting
29
+ -- against by exactly as long as it took someone to check.
30
+ --
31
+ -- ── Why no skill budget check here ──────────────────────────────────────────
32
+ --
33
+ -- 00184's comment warns that "queueing a job that can only refuse is how the
34
+ -- loop reported nothing eligible for three days", so it is fair to ask why the
35
+ -- skill window budget is not also checked here. Because the two failure modes
36
+ -- are not the same kind:
37
+ --
38
+ -- * A TYPE ban can never pass. A project whose only proposals are skills is
39
+ -- permanently unreachable, and that is what this migration fixes.
40
+ -- * A BUDGET is transient and self-resetting. A run that refuses on budget
41
+ -- still writes its refusals to the job log with the numbers in them, which
42
+ -- is a stage saying why it did nothing — the thing that was missing above.
43
+ --
44
+ -- Same cheap-gates-only contract as 00170 and 00184: this decides whether to
45
+ -- ASK. The authoritative check, including both window budgets, stays in
46
+ -- GET /api/instruction-suggestions/auto-acceptable.
47
+
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
+ -- The two auto-adoptable types, mirroring `isAutoAdoptable()` and the
73
+ -- endpoint. Executable artifacts (hook, mcp, subagent) and advisory ones
74
+ -- (feature) are deliberately absent: installing those runs code or edits
75
+ -- tool configuration, which always requires a human, and the actuator
76
+ -- refuses them anyway. Widening this must never widen that.
77
+ and i.type in ('instruction', 'skill')
78
+ and i.archived = false
79
+ and s.accepted_at is null
80
+ and s.dismissed_at is null
81
+ and s.declined_at is null
82
+ and s.is_stale = false
83
+ -- Consent: per-project override wins, else the user default (true).
84
+ and coalesce(
85
+ proj.metadata->>'autoAcceptSuggestions',
86
+ p.auto_accept_suggestions::text
87
+ ) = 'true'
88
+ -- One in flight per (user, project), per the 00083/00086 pattern.
89
+ and not exists (
90
+ select 1 from agent_jobs j
91
+ where j.task_kind = 'cron_auto_accept_suggestions'
92
+ and j.status in ('pending', 'claimed', 'running')
93
+ and j.payload->>'projectPath' = s.project_path
94
+ and j.user_id = s.user_id
95
+ )
96
+ group by s.user_id, s.project_path
97
+ ),
98
+ ins as (
99
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
100
+ select e.user_id,
101
+ 'cron_auto_accept_suggestions',
102
+ 'pending',
103
+ jsonb_build_object('projectPath', e.project_path),
104
+ 'auto-accept:' || e.user_id::text || ':' || e.project_path
105
+ from eligible e
106
+ returning 1
107
+ )
108
+ select count(*)::int into queued from ins;
109
+
110
+ return queued;
111
+ end;
112
+ $$;
113
+
114
+ comment on function public.enqueue_auto_accept_suggestions() is
115
+ '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 OR skill (00197 — skills joined the auto-adoptable set in #372; this producer and the auto-acceptable endpoint were the two gates that change missed, and the loop adopted nothing from 2026-08-16 to 2026-08-19 as a result). Cheap gates only — the authoritative eligibility check is GET /api/instruction-suggestions/auto-acceptable, which applies BOTH window budgets: maxPerWindow for rules (CLAUDE.md bloat) and maxSkillsPerWindow for skills (directory sprawl). 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,51 @@
1
+ -- Clear the is_stale flags the blanket mark-stale sweep should never have set.
2
+ --
3
+ -- `POST /api/instruction-suggestions/mark-stale` fires when the LOCAL skills
4
+ -- registry (`~/.config/rulemetric/skills-registry.json`) is older than 48h, or
5
+ -- simply unreadable — `checkSkillsRegistryStale()` catches to `true`. Until
6
+ -- today it ran as `UPDATE instruction_suggestions SET is_stale = true WHERE
7
+ -- user_id = ?`, with no second predicate: the age of one JSON file on the
8
+ -- operator's laptop invalidated every proposal the system had ever made for
9
+ -- that user, of every type and from every producer.
10
+ --
11
+ -- That would be recoverable if anything cleared the flag. Nothing does. The
12
+ -- only writers of `is_stale = false` are the two nomination upserts, and they
13
+ -- clear it for the ONE instruction being re-proposed. Insights nomination
14
+ -- identifies items by content hash, so a re-worded finding files a NEW row
15
+ -- rather than reviving the flagged one. So the sweep was a one-way drain, and
16
+ -- `is_stale = false` is a hard clause in the auto-accept eligibility gate.
17
+ --
18
+ -- Measured 2026-08-20 on the production database, trailing 30 days:
19
+ --
20
+ -- type stale / total
21
+ -- instruction 49 / 49 (100%)
22
+ -- skill 198 / 214 (93%)
23
+ -- feature 43 / 55 (78%)
24
+ -- context_file 26 / 35 (74%)
25
+ --
26
+ -- and the 05:00 auto-accept run reported `{"skipped": "nothing-eligible"}` —
27
+ -- the same shape it reports on a genuinely quiet night. Nothing anywhere said
28
+ -- the pool had been emptied by a file-age check.
29
+ --
30
+ -- The route is now scoped to catalog-backed skill proposals (the only thing the
31
+ -- registry is evidence about); this clears the backlog it left behind.
32
+ --
33
+ -- Deliberately NOT cleared:
34
+ -- * catalog skills — the registry really may be stale, and the scoped sweep
35
+ -- will re-flag them on the next run if it is.
36
+ -- * anything with `dismissed_at` set — the other two `is_stale` writers
37
+ -- (`retire-placeholder-nominations`, the instruction-delete path) always
38
+ -- stamp `dismissed_at` alongside it, so that column separates a deliberate
39
+ -- retirement from collateral damage.
40
+ -- * anything already accepted or declined — those rows have an answer, and
41
+ -- `is_stale` is not what is holding them.
42
+
43
+ UPDATE instruction_suggestions s
44
+ SET is_stale = false
45
+ FROM instructions i
46
+ WHERE i.id = s.instruction_id
47
+ AND s.is_stale = true
48
+ AND s.dismissed_at IS NULL
49
+ AND s.accepted_at IS NULL
50
+ AND s.declined_at IS NULL
51
+ AND NOT (i.type = 'skill' AND coalesce(i.frontmatter->>'source', '') <> 'insights');
@@ -0,0 +1,54 @@
1
+ -- 00199 — auto-accept stops running once a day and starts running every two hours.
2
+ --
3
+ -- This is a LATENCY fix, not a throughput one — for RULES. Rule adoption stays
4
+ -- bounded by `maxPerWindow` (15 per project per 7 days) in
5
+ -- GET /api/instruction-suggestions/auto-acceptable, so twelve runs a day cannot
6
+ -- adopt more rules than one run a day could over a week; they can only adopt
7
+ -- SOONER.
8
+ --
9
+ -- CORRECTION (2026-08-20, same PR): this comment originally also cited
10
+ -- `maxSkillsPerWindow` (5), which that PR REMOVES — skills now return
11
+ -- `skillsMax: null` and are bounded only by `maxPerRun` (3), counted per type.
12
+ -- So for skills this cadence change IS a throughput change: 3 x 12 = up to 36 a
13
+ -- day where the ceiling was 5 a week. The deliberate argument for uncapping is
14
+ -- that skills load on demand and so do not carry the measured -22.1% cost of an
15
+ -- over-large CLAUDE.md, which is what `maxPerWindow` exists to bound. What holds
16
+ -- the line instead is the duplicate gate in `readInstalledSkills` /
17
+ -- `assessSkillDuplication` — which means that gate must see the WHOLE disk, both
18
+ -- `.agents/skills` and `.claude/skills`. It did not when this migration was
19
+ -- written; that is fixed in the same PR.
20
+ --
21
+ -- Sooner is the entire problem. A proposal is permanently declined once it has
22
+ -- been surfaced in 5 distinct session-hours (`SURFACE_BUDGET`, the
23
+ -- `declined_by_silence` sweep in instruction-suggestions/shared.ts), and
24
+ -- `declined_at` is deliberately never cleared by re-nomination. The owner opens
25
+ -- ~12 substantial sessions a day, so a proposal burns its budget well inside one
26
+ -- working day — while the only pass that could adopt it ran at 05:00 and took at
27
+ -- most 3.
28
+ --
29
+ -- The two clocks were incompatible, and the scoreboard says so: 59 proposals
30
+ -- declined by silence against 34 ever accepted. Measured 2026-08-20, this repo
31
+ -- had FIVE skills at or above the score gate and adopted none — four already
32
+ -- carried `declined_at` from the sweep, stamped before 05:00 ever came round.
33
+ --
34
+ -- Safe to run this often for two independent reasons:
35
+ -- * The producer's `not exists (... status in ('pending','claimed','running'))`
36
+ -- clause admits one job per (user, project) at a time, so runs cannot pile
37
+ -- up. `idx_agent_jobs_dedupe` is a plain lookup index, not a unique one, so
38
+ -- a COMPLETED job correctly does not block the next run.
39
+ -- * A run with nothing to do costs one producer query and enqueues nothing.
40
+ --
41
+ -- Cadence chosen over hourly deliberately: two hours is comfortably inside the
42
+ -- window in which a proposal burns 5 session-hours, and leaves the nightly
43
+ -- ordering (generate 04:30 → adopt) intact rather than interleaving with it
44
+ -- twenty-four times.
45
+ --
46
+ -- SUPERSEDED the same day by 00200, which goes hourly: "comfortably inside the
47
+ -- window" held for the average day and not for a morning burst, which is where
48
+ -- proposals were being lost. Read 00200 for the reasoning that replaced this
49
+ -- paragraph — this file is left as it ran.
50
+
51
+ select cron.alter_job(
52
+ (select jobid from cron.job where jobname = 'enqueue_auto_accept_suggestions'),
53
+ schedule => '0 */2 * * *'
54
+ );
@@ -0,0 +1,42 @@
1
+ -- 00200 — auto-accept goes from every two hours to hourly.
2
+ --
3
+ -- This REVERSES the cadence choice 00199 made eight hours earlier, deliberately
4
+ -- and with the owner's decision on 2026-08-20. 00199's reasoning was:
5
+ --
6
+ -- "Cadence chosen over hourly deliberately: two hours is comfortably inside
7
+ -- the window in which a proposal burns 5 session-hours, and leaves the
8
+ -- nightly ordering (generate 04:30 → adopt) intact rather than interleaving
9
+ -- with it twenty-four times."
10
+ --
11
+ -- Two hours is *inside* that window on an average day, which is the weakest
12
+ -- form of the claim: it depends on the owner opening ~12 substantial sessions a
13
+ -- day spread evenly, and they are not spread evenly. A morning burst of five
14
+ -- sessions exhausts `SURFACE_BUDGET` before a two-hourly pass ever looks, and
15
+ -- the proposal is `declined_by_silence` without a human having declined
16
+ -- anything. Hourly halves the worst case rather than the average one, which is
17
+ -- the case that was actually losing proposals.
18
+ --
19
+ -- The interleaving objection stands and is accepted rather than answered: the
20
+ -- 04:30 generate → adopt ordering now has an adoption pass inside it. That is
21
+ -- safe for the same reason 00199 gave for running at all — a pass with nothing
22
+ -- eligible costs one producer query and enqueues nothing — and the producer's
23
+ -- `not exists (... status in ('pending','claimed','running'))` clause still
24
+ -- admits one job per (user, project) at a time, so a slow run cannot pile up
25
+ -- behind a fast schedule.
26
+ --
27
+ -- THROUGHPUT, stated plainly because 00199's original comment got this wrong:
28
+ -- rules stay bounded by `maxPerWindow` (15 per project per 7 days), so hourly
29
+ -- changes only WHEN a rule is adopted, never how many. Skills have no window
30
+ -- cap since `maxSkillsPerWindow` was removed, so for skills this is a real
31
+ -- throughput increase: `maxPerRun` (3) × 24 = up to 72 a day, from 36. What
32
+ -- bounds skills instead is the duplicate gate (`readInstalledSkills` +
33
+ -- `assessSkillDuplication`), which as of the same PR reads BOTH `.agents/skills`
34
+ -- and `.claude/skills` — on the owner's checkout that is 19 installed skills
35
+ -- visible where the gate previously saw 2. A ceiling that depends on a gate is
36
+ -- only as good as the gate's coverage, so if that regresses, this cadence is
37
+ -- the first thing to walk back.
38
+
39
+ select cron.alter_job(
40
+ (select jobid from cron.job where jobname = 'enqueue_auto_accept_suggestions'),
41
+ schedule => '0 * * * *'
42
+ );
@@ -804,4 +804,4 @@ rulemetric capture scope --tool hooks --off
804
804
 
805
805
  # The worker's Codex transcript sweep
806
806
  rulemetric capture scope --add ~/work/client-a
807
- rulemetric capture scope --off`}),i.jsx("p",{className:"text-sm text-muted-foreground",children:"One honest caveat: the optional mitmproxy network-capture chain is governed by its host allowlist, not by these scopes. Hooks-first capture (the default) is fully governed."})]})})})]})}function xx(){const[e,t]=gc(["getting-started","cli","api","evals","proxy","insights","data","local"],"getting-started");return i.jsxs("div",{className:"space-y-6",children:[i.jsxs("div",{children:[i.jsx("h2",{className:"text-2xl font-mono font-bold tracking-tight",children:"Documentation"}),i.jsx("p",{className:"text-muted-foreground",children:"Guides, CLI reference, API reference, and system documentation"})]}),i.jsxs(hc,{value:e,onValueChange:t,children:[i.jsxs(mc,{children:[i.jsxs(ue,{value:"getting-started",className:"cursor-pointer",children:[i.jsx(au,{className:"mr-2 h-4 w-4"}),"Quick Start"]}),i.jsxs(ue,{value:"cli",className:"cursor-pointer",children:[i.jsx(pu,{className:"mr-2 h-4 w-4"}),"CLI Reference"]}),i.jsxs(ue,{value:"api",className:"cursor-pointer",children:[i.jsx(cu,{className:"mr-2 h-4 w-4"}),"API Reference"]}),i.jsxs(ue,{value:"evals",className:"cursor-pointer",children:[i.jsx(uu,{className:"mr-2 h-4 w-4"}),"Eval System"]}),i.jsxs(ue,{value:"proxy",className:"cursor-pointer",children:[i.jsx(du,{className:"mr-2 h-4 w-4"}),"Proxy & Context"]}),i.jsxs(ue,{value:"insights",className:"cursor-pointer",children:[i.jsx(hu,{className:"mr-2 h-4 w-4"}),"Insights"]}),i.jsxs(ue,{value:"local",className:"cursor-pointer",children:[i.jsx(ks,{className:"mr-2 h-4 w-4"}),"Local Mode"]}),i.jsxs(ue,{value:"data",className:"cursor-pointer",children:[i.jsx(fu,{className:"mr-2 h-4 w-4"}),"Data & Retention"]})]}),i.jsx(de,{value:"getting-started",className:"mt-6",children:i.jsx(Sg,{})}),i.jsx(de,{value:"cli",className:"mt-6",children:i.jsx(Rg,{})}),i.jsx(de,{value:"api",className:"mt-6",children:i.jsx(Mg,{})}),i.jsx(de,{value:"evals",className:"mt-6",children:i.jsx(Tg,{})}),i.jsx(de,{value:"proxy",className:"mt-6",children:i.jsx(Pg,{})}),i.jsx(de,{value:"insights",className:"mt-6",children:i.jsx(jg,{})}),i.jsx(de,{value:"local",className:"mt-6",children:i.jsx(Ag,{})}),i.jsx(de,{value:"data",className:"mt-6",children:i.jsx(Ng,{})})]})]})}export{Ny as $,$g as A,gg as B,av as C,gv as D,Gy as E,uu as F,Pv as G,Wy as H,Nv as I,Dy as J,Fy as K,Fv as L,Hv as M,Og as N,Wv as O,Yv as P,Py as Q,We as R,cy as S,gy as T,_y as U,Ly as V,Iy as W,Sy as X,Oy as Y,Ay as Z,My as _,kc as a,mc as a$,jy as a0,Er as a1,Xg as a2,tv as a3,ov as a4,fv as a5,qg as a6,Yg as a7,zv as a8,by as a9,pu as aA,hv as aB,cu as aC,jv as aD,my as aE,Tv as aF,qy as aG,Zl as aH,ge as aI,mx as aJ,gx as aK,vx as aL,ve as aM,yx as aN,ix as aO,cx as aP,lx as aQ,ux as aR,dx as aS,fx as aT,Bv as aU,Mv as aV,pv as aW,kv as aX,Cs as aY,gc as aZ,hc as a_,$v as aa,sv as ab,hx as ac,uv as ad,vg as ae,xg as af,iu as ag,lu as ah,px as ai,Av as aj,Jv as ak,co as al,Gv as am,Zv as an,ry as ao,dv as ap,Wg as aq,Dv as ar,Qg as as,oy as at,ty as au,ev as av,wy as aw,Rv as ax,wv as ay,Ry as az,l as b,ue as b0,de as b1,vv as b2,ky as b3,Cy as b4,_v as b5,bv as b6,Xv as b7,hy as b8,Cv as b9,iv as bA,Vv as bB,Ey as bC,xv as bD,vy as bE,ny as bF,qv as bG,ey as bH,Ug as bI,uy as bJ,lv as bK,Kg as bL,Sv as bM,Jg as bN,Uv as bO,xy as bP,yv as bQ,Bg as bR,mv as bS,yy as bT,sy as bU,Fg as bV,Dg as bW,wl as bX,xx as bY,nv as ba,Gg as bb,Hg as bc,Vg as bd,Ig as be,zg as bf,cv as bg,Ky as bh,Yy as bi,Zy as bj,Xy as bk,Jy as bl,Qy as bm,ex as bn,tx as bo,rx as bp,nx as bq,ox as br,rv as bs,sx as bt,Qv as bu,Ev as bv,Kv as bw,py as bx,fy as by,ly as bz,mu as c,Is as d,we as e,Lg as f,_g as g,ax as h,Ov as i,i as j,Iv as k,hu as l,Zg as m,Lv as n,dy as o,iy as p,au as q,br as r,gn as s,ay as t,He as u,Uy as v,Hy as w,zy as x,By as y,Vy as z};
807
+ rulemetric capture scope --off`}),i.jsx("p",{className:"text-sm text-muted-foreground",children:"One honest caveat: the optional mitmproxy network-capture chain is governed by its host allowlist, not by these scopes. Hooks-first capture (the default) is fully governed."})]})})})]})}function xx(){const[e,t]=gc(["getting-started","cli","api","evals","proxy","insights","data","local"],"getting-started");return i.jsxs("div",{className:"space-y-6",children:[i.jsxs("div",{children:[i.jsx("h2",{className:"text-2xl font-mono font-bold tracking-tight",children:"Documentation"}),i.jsx("p",{className:"text-muted-foreground",children:"Guides, CLI reference, API reference, and system documentation"})]}),i.jsxs(hc,{value:e,onValueChange:t,children:[i.jsxs(mc,{children:[i.jsxs(ue,{value:"getting-started",className:"cursor-pointer",children:[i.jsx(au,{className:"mr-2 h-4 w-4"}),"Quick Start"]}),i.jsxs(ue,{value:"cli",className:"cursor-pointer",children:[i.jsx(pu,{className:"mr-2 h-4 w-4"}),"CLI Reference"]}),i.jsxs(ue,{value:"api",className:"cursor-pointer",children:[i.jsx(cu,{className:"mr-2 h-4 w-4"}),"API Reference"]}),i.jsxs(ue,{value:"evals",className:"cursor-pointer",children:[i.jsx(uu,{className:"mr-2 h-4 w-4"}),"Eval System"]}),i.jsxs(ue,{value:"proxy",className:"cursor-pointer",children:[i.jsx(du,{className:"mr-2 h-4 w-4"}),"Proxy & Context"]}),i.jsxs(ue,{value:"insights",className:"cursor-pointer",children:[i.jsx(hu,{className:"mr-2 h-4 w-4"}),"Insights"]}),i.jsxs(ue,{value:"local",className:"cursor-pointer",children:[i.jsx(ks,{className:"mr-2 h-4 w-4"}),"Local Mode"]}),i.jsxs(ue,{value:"data",className:"cursor-pointer",children:[i.jsx(fu,{className:"mr-2 h-4 w-4"}),"Data & Retention"]})]}),i.jsx(de,{value:"getting-started",className:"mt-6",children:i.jsx(Sg,{})}),i.jsx(de,{value:"cli",className:"mt-6",children:i.jsx(Rg,{})}),i.jsx(de,{value:"api",className:"mt-6",children:i.jsx(Mg,{})}),i.jsx(de,{value:"evals",className:"mt-6",children:i.jsx(Tg,{})}),i.jsx(de,{value:"proxy",className:"mt-6",children:i.jsx(Pg,{})}),i.jsx(de,{value:"insights",className:"mt-6",children:i.jsx(jg,{})}),i.jsx(de,{value:"local",className:"mt-6",children:i.jsx(Ag,{})}),i.jsx(de,{value:"data",className:"mt-6",children:i.jsx(Ng,{})})]})]})}export{Ay as $,$g as A,gg as B,av as C,gv as D,Gy as E,uu as F,Pv as G,Wy as H,Nv as I,Dy as J,Fy as K,Fv as L,Hv as M,Og as N,Wv as O,Yv as P,Py as Q,We as R,cy as S,gy as T,_y as U,Ly as V,Iy as W,Sy as X,Oy as Y,jy as Z,My as _,kc as a,mc as a$,Ny as a0,Er as a1,Xg as a2,tv as a3,ov as a4,fv as a5,qg as a6,Yg as a7,zv as a8,by as a9,pu as aA,hv as aB,cu as aC,jv as aD,my as aE,Tv as aF,qy as aG,Zl as aH,ge as aI,mx as aJ,gx as aK,vx as aL,ve as aM,yx as aN,ix as aO,cx as aP,lx as aQ,ux as aR,dx as aS,fx as aT,Bv as aU,Mv as aV,pv as aW,kv as aX,Cs as aY,gc as aZ,hc as a_,$v as aa,sv as ab,hx as ac,uv as ad,vg as ae,xg as af,iu as ag,lu as ah,px as ai,Av as aj,Jv as ak,co as al,Gv as am,Zv as an,ry as ao,dv as ap,Wg as aq,Dv as ar,Qg as as,oy as at,ty as au,ev as av,wy as aw,Rv as ax,wv as ay,Ry as az,l as b,ue as b0,de as b1,vv as b2,ky as b3,Cy as b4,_v as b5,bv as b6,Xv as b7,hy as b8,Cv as b9,xx as bA,iv as bB,Vv as bC,Ey as bD,xv as bE,vy as bF,ny as bG,qv as bH,ey as bI,Ug as bJ,uy as bK,lv as bL,Kg as bM,Sv as bN,Jg as bO,Uv as bP,xy as bQ,yv as bR,Bg as bS,mv as bT,yy as bU,sy as bV,Fg as bW,Dg as bX,wl as bY,nv as ba,Gg as bb,Hg as bc,Vg as bd,Ig as be,zg as bf,cv as bg,Ky as bh,Yy as bi,Zy as bj,Xy as bk,Jy as bl,Qy as bm,ex as bn,tx as bo,rx as bp,nx as bq,ox as br,rv as bs,sx as bt,Qv as bu,Ev as bv,Kv as bw,py as bx,fy as by,ly as bz,mu as c,Is as d,we as e,Lg as f,_g as g,ax as h,Ov as i,i as j,Iv as k,hu as l,Zg as m,Lv as n,dy as o,iy as p,au as q,br as r,gn as s,ay as t,He as u,Uy as v,Hy as w,zy as x,By as y,Vy as z};