@rulemetric/local 0.12.0 → 0.12.2

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,102 @@
1
+ -- 00201 — the canonical checkout stops depending on the query plan.
2
+ --
3
+ -- THE DEFECT (measured against production 2026-08-24).
4
+ --
5
+ -- 00186 replaced five hand-copied `ORDER BY is_worktree ASC, last_seen DESC`
6
+ -- with one view, on the recorded grounds that "the copies disagree in the
7
+ -- silent direction". The view fixed the disagreement between call sites and
8
+ -- introduced a worse one: its ORDER BY is not TOTAL, so when two checkouts of
9
+ -- a project tie on every sort key, `DISTINCT ON` returns whichever row the
10
+ -- executor happens to emit — and that differs by the CALLER's query plan.
11
+ --
12
+ -- Live, same second, same rows, project bbf29181 (local-ai-packaged):
13
+ --
14
+ -- SELECT ... WHERE project_id = $1 -- canonical-checkout.ts:43, :54
15
+ -- -> Sort -> Limit 1
16
+ -- -> /Users/nickyeager/Code/agents/local-ai-packaged (on disk)
17
+ --
18
+ -- LEFT JOIN ... ON c.project_id = p.project_id -- routes/loop.ts:244, :256
19
+ -- -> Incremental Sort (Presorted Key: project_id) -> Unique
20
+ -- -> /Users/.../local-ai-packaged/localai-admin-dashboard (deleted)
21
+ --
22
+ -- Both plans sort on a key set that does not identify a row: the two checkouts
23
+ -- carry the same is_worktree and the same last_seen TO THE MICROSECOND, because
24
+ -- a single backfill statement wrote both. Postgres sorts are not stable and the
25
+ -- two plans use different sort implementations, so each is deterministic on its
26
+ -- own and they disagree with each other.
27
+ --
28
+ -- So `/api/loop/status` renders one path to the operator while the enqueue
29
+ -- sites aim jobs at the other. This is strictly worse than the five copies it
30
+ -- replaced: those were consistently wrong and could be found by diffing them.
31
+ -- This cannot be found by reading the SQL at all.
32
+ --
33
+ -- THE FIX, in two parts.
34
+ --
35
+ -- 1. A TOTAL order. `path` is appended last and `project_checkouts.path` is
36
+ -- globally UNIQUE (00052), so the sort key now identifies exactly one row
37
+ -- and no plan has anything left to decide. This is the load-bearing half:
38
+ -- every rule below it is a preference, this is what makes the view a
39
+ -- function of its inputs.
40
+ --
41
+ -- 2. An ancestor preference, ranked directly under the staleness tier. A
42
+ -- checkout that sits strictly INSIDE another checkout of the SAME project is
43
+ -- a subdirectory somebody opened a session in, not a second checkout of the
44
+ -- repo. Both ties in the entire table are exactly this shape:
45
+ --
46
+ -- /Users/.../local-ai-packaged + /Users/.../local-ai-packaged/localai-admin-dashboard
47
+ -- /tmp/budget-1786799685058 + /tmp/budget-1786799685058/apps/cli
48
+ --
49
+ -- and it is the same shape as the ORIGINAL 00186 defect, whose crown went to
50
+ -- `…/momento-mori/apps/cli`. Preferring the ancestor states the rule that
51
+ -- accident has been standing in for.
52
+ --
53
+ -- Deliberately NOT part of this migration: whether the elected path still
54
+ -- EXISTS. The database cannot see a filesystem, and 6 of the 10 paths this view
55
+ -- currently elects are absent from the operator's machine. That is 00202's job,
56
+ -- and it is a separate defect — fixing this one does not stop a single one of
57
+ -- the no-op jobs, because `enqueue_auto_accept_suggestions` never consulted
58
+ -- this view in the first place.
59
+ --
60
+ -- `left(a, length(b) + 1) = b || '/'` rather than `a LIKE b || '/%'`: an
61
+ -- underscore is a legal character in a path and a wildcard in LIKE, so the
62
+ -- pattern form would rank unrelated siblings as descendants.
63
+
64
+ create or replace view public.project_canonical_checkouts as
65
+ select distinct on (project_id)
66
+ project_id,
67
+ path,
68
+ is_worktree,
69
+ last_seen,
70
+ -- Exposed so callers can SAY the path is stale rather than silently
71
+ -- acting on it. /api/loop/status renders this; a canonical path that
72
+ -- has gone quiet while the project has not is how 00186's defect hid.
73
+ (last_seen < freshest - interval '30 days') as is_stale,
74
+ freshest as project_last_seen
75
+ from (
76
+ select pc.project_id,
77
+ pc.path,
78
+ pc.is_worktree,
79
+ pc.last_seen,
80
+ max(pc.last_seen) over (partition by pc.project_id) as freshest,
81
+ exists (
82
+ select 1
83
+ from public.project_checkouts anc
84
+ where anc.project_id = pc.project_id
85
+ and anc.path <> pc.path
86
+ and left(pc.path, length(anc.path) + 1) = anc.path || '/'
87
+ ) as is_inside_another_checkout
88
+ from public.project_checkouts pc
89
+ where pc.path is not null
90
+ and pc.path <> ''
91
+ ) q
92
+ order by project_id,
93
+ (last_seen < freshest - interval '30 days') asc, -- live before stale
94
+ is_inside_another_checkout asc, -- repo before subdirectory
95
+ is_worktree asc, -- checkout before worktree
96
+ last_seen desc, -- then most recent
97
+ path asc; -- TOTAL: never let the plan decide
98
+
99
+ comment on view public.project_canonical_checkouts is
100
+ '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 a repo above its own subdirectories, THEN non-worktree, THEN most-recently-seen, THEN path as a TOTAL tiebreak. The final key is not cosmetic: without it (00186) two checkouts tied on every key and DISTINCT ON returned a different winner per caller query plan, so routes/loop.ts read a deleted subdirectory while canonical-checkout.ts read the live repo, for the same project in the same second. See 00201. Says nothing about whether the path exists on disk — see 00202.';
101
+
102
+ grant select on public.project_canonical_checkouts to authenticated, service_role;
@@ -0,0 +1,136 @@
1
+ -- 00202 — a producer stops re-enqueueing work against a path the worker has
2
+ -- already told it is not on disk.
3
+ --
4
+ -- THE DEFECT (measured against production 2026-08-24).
5
+ --
6
+ -- `cron_auto_accept_suggestions` stats its target directory before writing
7
+ -- anything — a deliberate guard, so an unattended writer can never recreate a
8
+ -- CLAUDE.md inside a deleted repo. When the directory is gone it returns
9
+ -- `{"skipped":"project-path-missing"}` and the job is marked COMPLETED.
10
+ --
11
+ -- Nothing consumes that observation. So the producer enqueues the same job
12
+ -- again an hour later (00200), the worker stats the same absent directory, and
13
+ -- the job completes clean. Every health view that counts failures sees a
14
+ -- healthy queue while the work never happens — the shape CLAUDE.md already
15
+ -- records for the producers: "a producer matching zero rows looks exactly like
16
+ -- a quiet night."
17
+ --
18
+ -- Live: `/Users/nickyeager/Code/agents/local-ai-packaged/localai-admin-dashboard`
19
+ -- is absent from disk and burned 17 no-op jobs in 24h. There are exactly TWO
20
+ -- paths in the whole system that this producer can enqueue, so half of all
21
+ -- auto-accept jobs were no-ops. The path is carried by ONE open suggestion row
22
+ -- (994feff0, insights-authored, score 0.5, generated 2026-08-08) which can
23
+ -- never be accepted, declined or staled, so this would have run forever.
24
+ --
25
+ -- WHY THIS IS NOT FIXED UPSTREAM. It is tempting to call this a canonical-path
26
+ -- defect, and 00201 does fix the election that first aimed at a subdirectory.
27
+ -- But this producer never consulted `project_canonical_checkouts` at all — it
28
+ -- groups by `instruction_suggestions.project_path` verbatim. Repairing the
29
+ -- election stops zero of these jobs. The two defects share a symptom and
30
+ -- nothing else.
31
+ --
32
+ -- THE RULE: RATE-LIMIT, NEVER BLOCK.
33
+ --
34
+ -- The obvious fix — suppress the path once a missing observation exists — is
35
+ -- the bug this repo keeps shipping. `is_stale` has no inverse. `declined_at` is
36
+ -- permanent for the machine. Both are one-way flags whose owners intended
37
+ -- "not now" and delivered "not ever". A hard skip here is worse than either,
38
+ -- because it is self-sealing: if no job is ever enqueued, no worker ever stats
39
+ -- the directory, so nothing can ever observe that it came back. The flag would
40
+ -- have no inverse BY CONSTRUCTION.
41
+ --
42
+ -- So the observation suppresses for 24 hours and then lets exactly one job
43
+ -- through. Cost: one no-op job a day instead of twenty-four. Recovery: the
44
+ -- directory reappears, the next probe does real work, emits no missing result,
45
+ -- and the path is never suppressed again. No flag, no backfill, no inverse to
46
+ -- forget — the suppression expires on its own because it is a statement about
47
+ -- an observation's age, not a bit somebody has to remember to clear.
48
+
49
+ create or replace function public.project_path_recently_missing(
50
+ p_user_id uuid,
51
+ p_path text,
52
+ p_window interval default interval '24 hours'
53
+ )
54
+ returns boolean
55
+ language sql
56
+ stable
57
+ security definer
58
+ set search_path = public
59
+ as $$
60
+ select exists (
61
+ select 1
62
+ from agent_jobs j
63
+ where j.user_id = p_user_id
64
+ and j.status = 'completed'
65
+ and j.result->>'skipped' = 'project-path-missing'
66
+ and j.payload->>'projectPath' = p_path
67
+ and j.completed_at > now() - p_window
68
+ );
69
+ $$;
70
+
71
+ comment on function public.project_path_recently_missing(uuid, text, interval) is
72
+ 'Has a worker reported, within p_window, that this path is not on its disk? The database cannot see a filesystem; the worker is the only thing that can, and it already records the answer in agent_jobs.result. Deliberately a WINDOW and not a flag: a hard skip would stop the probes that are the only way to learn the directory came back, making it a one-way flag by construction — the defect shape of is_stale and declined_at. See 00202.';
73
+
74
+ -- ── The auto-accept producer ────────────────────────────────────────────────
75
+ -- Body unchanged from 00197 except for the `project_path_recently_missing`
76
+ -- clause. Every comment there still applies and is not restated.
77
+
78
+ create or replace function public.enqueue_auto_accept_suggestions()
79
+ returns integer
80
+ language plpgsql
81
+ security definer
82
+ set search_path = public
83
+ as $$
84
+ declare
85
+ queued integer;
86
+ begin
87
+ with eligible as (
88
+ select s.user_id, s.project_path
89
+ from instruction_suggestions s
90
+ join profiles p on p.id = s.user_id
91
+ join instructions i on i.id = s.instruction_id
92
+ left join projects proj on proj.id = s.project_id
93
+ where
94
+ s.kind = 'add'
95
+ and (s.score >= 0.8 or i.frontmatter->>'source' = 'insights')
96
+ and i.type in ('instruction', 'skill')
97
+ and i.archived = false
98
+ and s.accepted_at is null
99
+ and s.dismissed_at is null
100
+ and s.declined_at is null
101
+ and s.is_stale = false
102
+ and coalesce(
103
+ proj.metadata->>'autoAcceptSuggestions',
104
+ p.auto_accept_suggestions::text
105
+ ) = 'true'
106
+ -- The worker said this directory is not there. Ask again tomorrow, not
107
+ -- in an hour. See the function's comment for why this expires rather
108
+ -- than latching.
109
+ and not public.project_path_recently_missing(s.user_id, s.project_path)
110
+ and not exists (
111
+ select 1 from agent_jobs j
112
+ where j.task_kind = 'cron_auto_accept_suggestions'
113
+ and j.status in ('pending', 'claimed', 'running')
114
+ and j.payload->>'projectPath' = s.project_path
115
+ and j.user_id = s.user_id
116
+ )
117
+ group by s.user_id, s.project_path
118
+ ),
119
+ ins as (
120
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
121
+ select e.user_id,
122
+ 'cron_auto_accept_suggestions',
123
+ 'pending',
124
+ jsonb_build_object('projectPath', e.project_path),
125
+ 'auto-accept:' || e.user_id::text || ':' || e.project_path
126
+ from eligible e
127
+ returning 1
128
+ )
129
+ select count(*)::int into queued from ins;
130
+
131
+ return queued;
132
+ end;
133
+ $$;
134
+
135
+ comment on function public.enqueue_auto_accept_suggestions() is
136
+ 'Hourly 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, whose instruction is a non-archived instruction body OR skill (00197), and whose path a worker has not reported missing from disk in the last 24h (00202 — one dead path burned 17 no-op COMPLETED jobs in 24h, half of every auto-accept job in the system, with nothing reporting a problem). Cheap gates only — the authoritative eligibility check is GET /api/instruction-suggestions/auto-acceptable.';
@@ -0,0 +1,120 @@
1
+ -- 00203 — a proposal nobody answered stops blocking the lane that is not asking.
2
+ --
3
+ -- THE DEFECT (measured against production 2026-08-24).
4
+ --
5
+ -- `declined_at` answers two different questions with one column, and every
6
+ -- reader treated them the same:
7
+ --
8
+ -- `surface_budget` — nobody answered. `sweepDeclinedBySilence` stamps it
9
+ -- after a proposal has been shown to 5 distinct sessions with no reply.
10
+ -- A statement about the HUMAN channel: stop asking this person.
11
+ --
12
+ -- `trial_neutral` — it WAS answered, by measurement. `settleExperiment`
13
+ -- stamps it when a randomised trial found no effect. A verdict on the
14
+ -- proposal itself.
15
+ --
16
+ -- The feed already knows the first kind is temporary and re-opens it after 90
17
+ -- days (`notDeclinedBySilence`). The machine lane did not: this producer and
18
+ -- GET /api/instruction-suggestions/auto-acceptable both required `declined_at
19
+ -- IS NULL` strictly, and re-nomination deliberately never clears the column
20
+ -- (00199), so a row that ran out of surface budget was machine-blocked FOREVER
21
+ -- while looking freshly generated.
22
+ --
23
+ -- What that cost, counted: 33 open `add` proposals of an auto-adoptable type at
24
+ -- score >= 0.8, average 0.87, top 0.95 (`antigravity--platform-mirrors`, on
25
+ -- this repo). 27 of them were silence-declined BEFORE 2026-08-20 — that is,
26
+ -- while the skill type-gate (00197) was still shut and the machine could not
27
+ -- have adopted them however loudly it was asked. They spent their surface
28
+ -- budget in an era with no actuator behind them and were locked out for it.
29
+ --
30
+ -- Every `declined_reason` in the entire table today is `surface_budget` (86 of
31
+ -- 86), so on current data this is equivalent to a full amnesty. It is written
32
+ -- as a predicate anyway, because the equivalence is an accident of history and
33
+ -- the rule has to hold when `trial_neutral` rows exist.
34
+ --
35
+ -- WHY A PREDICATE AND NOT AN AMNESTY. `sweepDeclinedBySilence` recomputes
36
+ -- surface units from the WHOLE event log on every feed read — explicitly never
37
+ -- from a checkpoint, so the guard applies to historic proposals for free. Every
38
+ -- one of these rows is therefore permanently over budget. `SET declined_at =
39
+ -- NULL` would be undone by the next SessionStart, quite possibly before the
40
+ -- hourly adoption pass ever saw the row. A predicate mutates nothing and has no
41
+ -- such race.
42
+ --
43
+ -- WHAT STILL BLOCKS. `trial_neutral` (declined by measurement). `dismissed_at`
44
+ -- (a human said no, including via undo) — a separate column, checked
45
+ -- separately, and never touched here. And downstream, the actuator's own
46
+ -- refusals: redundant, partial_overlap, unresolvable_reference, the 30-day
47
+ -- re-offer block, `maxPerWindow` and `maxPerRun`. Opening this gate cannot
48
+ -- flood a CLAUDE.md; it decides what may be ASKED about, not what is written.
49
+ --
50
+ -- The TRIAL lane is deliberately NOT changed (`/instruction-suggestions/
51
+ -- trialable` keeps `declined_at IS NULL`). A trial spends real session capacity
52
+ -- and a scarce slot, so "nobody engaged with this" is a fair reason to leave it
53
+ -- alone there. Auto-accept spends a file write nobody has to read.
54
+ --
55
+ -- Three readers must agree or the operator is shown a different set than the
56
+ -- loop adopts from. The other two are `notDeclinedForActuator` in
57
+ -- routes/instruction-suggestions/shared.ts (used by the auto-acceptable
58
+ -- endpoint) and the auto-accept eligibility count in lib/instructions/
59
+ -- promotion-paths.ts. Both ship in the same change as this migration.
60
+
61
+ create or replace function public.enqueue_auto_accept_suggestions()
62
+ returns integer
63
+ language plpgsql
64
+ security definer
65
+ set search_path = public
66
+ as $$
67
+ declare
68
+ queued integer;
69
+ begin
70
+ with eligible as (
71
+ select s.user_id, s.project_path
72
+ from instruction_suggestions s
73
+ join profiles p on p.id = s.user_id
74
+ join instructions i on i.id = s.instruction_id
75
+ left join projects proj on proj.id = s.project_id
76
+ where
77
+ s.kind = 'add'
78
+ and (s.score >= 0.8 or i.frontmatter->>'source' = 'insights')
79
+ and i.type in ('instruction', 'skill')
80
+ and i.archived = false
81
+ and s.accepted_at is null
82
+ -- A human's refusal. Never relaxed.
83
+ and s.dismissed_at is null
84
+ -- Nobody answering is not a refusal, and this lane is not asking.
85
+ -- `trial_neutral` — declined by measurement — still blocks. See above.
86
+ and (s.declined_at is null or s.declined_reason = 'surface_budget')
87
+ and s.is_stale = false
88
+ and coalesce(
89
+ proj.metadata->>'autoAcceptSuggestions',
90
+ p.auto_accept_suggestions::text
91
+ ) = 'true'
92
+ -- The worker said this directory is not there (00202).
93
+ and not public.project_path_recently_missing(s.user_id, s.project_path)
94
+ and not exists (
95
+ select 1 from agent_jobs j
96
+ where j.task_kind = 'cron_auto_accept_suggestions'
97
+ and j.status in ('pending', 'claimed', 'running')
98
+ and j.payload->>'projectPath' = s.project_path
99
+ and j.user_id = s.user_id
100
+ )
101
+ group by s.user_id, s.project_path
102
+ ),
103
+ ins as (
104
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
105
+ select e.user_id,
106
+ 'cron_auto_accept_suggestions',
107
+ 'pending',
108
+ jsonb_build_object('projectPath', e.project_path),
109
+ 'auto-accept:' || e.user_id::text || ':' || e.project_path
110
+ from eligible e
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_auto_accept_suggestions() is
120
+ 'Hourly producer for cron_auto_accept_suggestions. One row per (user, project) holding at least one consented ADD proposal that is high-confidence (>= 0.8) or insights-authored, of an auto-adoptable type (00197), not declined BY MEASUREMENT (00203 — a surface_budget decline means nobody answered the human channel, which this lane is not using; 33 adoption-grade proposals, 27 of them declined while the skill gate was still shut, were machine-blocked forever by conflating the two), and whose path a worker has not reported missing from disk in 24h (00202). Cheap gates only — the authoritative check is GET /api/instruction-suggestions/auto-acceptable.';
@@ -0,0 +1,115 @@
1
+ -- 00204 — the catalog-skill staleness backlog is released ON REQUEST, one
2
+ -- project at a time. Applying this migration moves ZERO rows.
3
+ --
4
+ -- 00198 cleared the collateral damage from the blanket mark-stale sweep and
5
+ -- explicitly declined to clear catalog skills, reasoning:
6
+ --
7
+ -- "catalog skills — the registry really may be stale, and the scoped sweep
8
+ -- will re-flag them on the next run if it is."
9
+ --
10
+ -- Sound, and wrong by one word: *re*-flag implies the flag can come off, and
11
+ -- nothing could take it off. The only writers of `is_stale = false` were the two
12
+ -- nomination upserts, which clear it for the ONE instruction being re-proposed.
13
+ -- So "the sweep will sort it out" described a sweep that could only ever add.
14
+ --
15
+ -- Measured 2026-08-24, open `add` proposals of type skill at score >= 0.8:
16
+ -- 51 flagged across 12 projects, against exactly ONE that was not. The flag was
17
+ -- not skewing that population, it WAS the population — and the operator's
18
+ -- registry file had been regenerated 17 hours earlier, so every one of those
19
+ -- rows was held out of the adoptable pool by an observation that had stopped
20
+ -- being true. `is_stale = false` is a hard clause in the auto-accept gate.
21
+ --
22
+ -- ── WHY A FUNCTION AND NOT AN UPDATE ────────────────────────────────────────
23
+ --
24
+ -- The inverse shipped alongside this (POST /api/instruction-suggestions/
25
+ -- mark-stale takes `{stale:boolean}`) is the real fix, and it is also a release
26
+ -- valve with no throttle: it clears the WHOLE catalog-skill scope for a user in
27
+ -- one statement. Counted against production, the auto-accept eligible pool moves
28
+ --
29
+ -- today 3 rules, 0 skills
30
+ -- with the decline predicate 3 rules, 8 skills (00203)
31
+ -- with staleness cleared 3 rules, 85 skills
32
+ --
33
+ -- and skills have had no window budget since `maxSkillsPerWindow` was removed on
34
+ -- 2026-08-20 — `windowRemaining` is literally Infinity for a skill at
35
+ -- routes/instruction-suggestions/loop.ts, so the only ceiling left is
36
+ -- `maxPerRun` (3) at an hourly cadence: up to 72 adoptions a day. 00200 accepted
37
+ -- that uncap explicitly and said the duplicate gate was the thing holding the
38
+ -- line, and that if it regressed the cadence was "the first thing to walk back".
39
+ -- An 85-row backlog arriving in a day and a half is exactly the case it meant.
40
+ --
41
+ -- Two further reasons a burst is worse than a trickle, neither of which is about
42
+ -- CLAUDE.md bloat:
43
+ --
44
+ -- * The undo path is per-instruction. Reviewable at 3/day; not at 72/day.
45
+ -- * `skill_usage` (00195) and `retire_unused_skills` (00196) can only tell you
46
+ -- which adoptions earned their place if tranches land far enough apart for
47
+ -- usage to accrue between them. Draining in one burst converts "measured
48
+ -- adoption" back into "bulk install", which is the thing this pipeline
49
+ -- exists to be better than.
50
+ --
51
+ -- So the backlog is released deliberately, per project, by an operator who is
52
+ -- watching:
53
+ --
54
+ -- select public.unstale_catalog_skill_suggestions(
55
+ -- '<user-id>'::uuid, '/Users/me/Code/some-project');
56
+ --
57
+ -- -- or, for every project that user has, in one go:
58
+ -- select public.unstale_catalog_skill_suggestions('<user-id>'::uuid);
59
+ --
60
+ -- ⚠️ The function is NOT the only thing that can release these rows, and that is
61
+ -- worth knowing before relying on it as a brake. A worker running a CLI new
62
+ -- enough to report `{stale:false}` clears the same scope on its next run against
63
+ -- a fresh registry. Until that CLI is published, the pool stays at 11 rows; once
64
+ -- it is, the backlog releases whether or not this function is ever called.
65
+ -- Pacing therefore means pacing the CLI publish too — this function exists so
66
+ -- the release can happen EARLIER and NARROWER than that, not so it can be
67
+ -- prevented.
68
+ --
69
+ -- NOT CLEARED, following 00198:
70
+ -- * anything with `dismissed_at` set — the other `is_stale` writers stamp that
71
+ -- column alongside, so it separates a deliberate retirement from collateral
72
+ -- damage.
73
+ -- * anything accepted, or declined by MEASUREMENT (`trial_neutral`). A
74
+ -- `surface_budget` decline is not an answer and IS cleared, matching the
75
+ -- actuator predicate in 00203 — leaving it out would half-open two gates and
76
+ -- adopt nothing, the exact shape #372 shipped when it opened two of four.
77
+
78
+ create or replace function public.unstale_catalog_skill_suggestions(
79
+ p_user_id uuid,
80
+ p_project_path text default null
81
+ )
82
+ returns integer
83
+ language plpgsql
84
+ security definer
85
+ set search_path = public
86
+ as $$
87
+ declare
88
+ cleared integer;
89
+ begin
90
+ with released as (
91
+ update instruction_suggestions s
92
+ set is_stale = false
93
+ from instructions i
94
+ where i.id = s.instruction_id
95
+ and s.user_id = p_user_id
96
+ and (p_project_path is null or s.project_path = p_project_path)
97
+ and s.is_stale = true
98
+ and i.type = 'skill'
99
+ and coalesce(i.frontmatter->>'source', '') <> 'insights'
100
+ and i.archived = false
101
+ and s.accepted_at is null
102
+ and s.dismissed_at is null
103
+ and (s.declined_at is null or s.declined_reason = 'surface_budget')
104
+ returning 1
105
+ )
106
+ select count(*)::int into cleared from released;
107
+
108
+ return cleared;
109
+ end;
110
+ $$;
111
+
112
+ comment on function public.unstale_catalog_skill_suggestions(uuid, text) is
113
+ 'Release the catalog-skill staleness backlog for one user, optionally scoped to one project. Returns how many rows were cleared. Applying 00204 moves zero rows BY DESIGN: 00198 left 51 of these flagged on the reasoning that the sweep would re-flag them, which assumed an inverse that did not exist until 2026-08-24, and clearing them all at once takes the auto-accept eligible pool from 11 to 88 with no window budget on skills to absorb it (maxSkillsPerWindow was removed 2026-08-20; maxPerRun 3 at hourly cadence is up to 72 adoptions/day). Release per project, watching. See 00204.';
114
+
115
+ grant execute on function public.unstale_catalog_skill_suggestions(uuid, text) to service_role;
@@ -0,0 +1,110 @@
1
+ -- 00205 — an unattended file writer may only write to a project's canonical
2
+ -- checkout.
3
+ --
4
+ -- THE DEFECT, found 2026-08-24 by releasing the staleness backlog and LOOKING at
5
+ -- what became eligible rather than assuming.
6
+ --
7
+ -- `enqueue_auto_accept_suggestions` groups by `instruction_suggestions.project_path`
8
+ -- verbatim. It has never asked whether that path is a checkout, let alone THE
9
+ -- checkout. While the catalog-skill backlog was stale that was invisible: the
10
+ -- pool held two paths. The moment the backlog was released, the producer's match
11
+ -- set was 13 paths, of which exactly TWO are canonical checkouts:
12
+ --
13
+ -- /Users/nickyeager 14 skills HOME DIRECTORY
14
+ -- /private/var/folders/dn/.../T 3 skills temp directory
15
+ -- /Users/nickyeager/Code/agents/momento-mori/apps/cli 7 skills the dead path from 00186
16
+ -- …/tinyworlds/.worktrees/image-blast-m4, -m6, -m7 13 skills worktrees
17
+ -- /Users/kylechalmers/Development 2 skills a parent of two workspaces
18
+ --
19
+ -- `/Users/nickyeager` is the one that matters most: `acceptSkill` writes under
20
+ -- `<projectPath>/.claude/skills/`, so the home directory means `~/.claude/skills/`
21
+ -- — the GLOBAL skills directory, loaded into every session of every project. An
22
+ -- unattended loop would have installed 14 skills globally and reported success.
23
+ --
24
+ -- 00202 cannot catch any of these. Its question is "is this path on disk", and
25
+ -- all of them are. This is the same defect as 00202's — a producer aiming at a
26
+ -- path nobody validated — wearing a different hat.
27
+ --
28
+ -- THE RULE, and it is not new. `public.project_canonical_checkouts` (00186) is
29
+ -- the single authority for which path IS a project, and CLAUDE.md's invariant is
30
+ -- that it must never be re-derived. `enqueue_instruction_training` has been
31
+ -- gated on it since 00188, whose comment records the identical failure: "the
32
+ -- project-level activity join in 00174 let every subpath of an active repo
33
+ -- through, and apps/cli collected 6 undead trials that way". Auto-accept — which
34
+ -- writes FILES rather than nominating trials — was never given the same gate.
35
+ -- It is the site that needed it most.
36
+ --
37
+ -- CONSEQUENCE, stated rather than buried: this blocks the external user
38
+ -- entirely, because none of his three paths is a canonical checkout. That is the
39
+ -- correct default for an unattended writer (better to write nowhere than into a
40
+ -- directory holding two unrelated workspaces), but it means his skill lane moves
41
+ -- from "dead via is_stale" to "dead via this gate", and the real fix is that his
42
+ -- checkouts are not registered in `project_checkouts`. Tracked separately; do not
43
+ -- widen this gate to paper over it.
44
+
45
+ create or replace function public.enqueue_auto_accept_suggestions()
46
+ returns integer
47
+ language plpgsql
48
+ security definer
49
+ set search_path = public
50
+ as $$
51
+ declare
52
+ queued integer;
53
+ begin
54
+ with eligible as (
55
+ select s.user_id, s.project_path
56
+ from instruction_suggestions s
57
+ join profiles p on p.id = s.user_id
58
+ join instructions i on i.id = s.instruction_id
59
+ left join projects proj on proj.id = s.project_id
60
+ where
61
+ s.kind = 'add'
62
+ and (s.score >= 0.8 or i.frontmatter->>'source' = 'insights')
63
+ and i.type in ('instruction', 'skill')
64
+ and i.archived = false
65
+ and s.accepted_at is null
66
+ and s.dismissed_at is null
67
+ -- Nobody answering is not a refusal (00203).
68
+ and (s.declined_at is null or s.declined_reason = 'surface_budget')
69
+ and s.is_stale = false
70
+ and coalesce(
71
+ proj.metadata->>'autoAcceptSuggestions',
72
+ p.auto_accept_suggestions::text
73
+ ) = 'true'
74
+ -- WHERE an unattended writer may write: the project's canonical checkout,
75
+ -- and nowhere else. Not a home directory, not a temp directory, not a
76
+ -- worktree, not a subdirectory somebody once opened a session in. Read
77
+ -- from the view, never re-derived (00186, and CLAUDE.md's invariant).
78
+ and exists (
79
+ select 1 from public.project_canonical_checkouts c
80
+ where c.path = s.project_path
81
+ )
82
+ -- The worker said this directory is not there (00202).
83
+ and not public.project_path_recently_missing(s.user_id, s.project_path)
84
+ and not exists (
85
+ select 1 from agent_jobs j
86
+ where j.task_kind = 'cron_auto_accept_suggestions'
87
+ and j.status in ('pending', 'claimed', 'running')
88
+ and j.payload->>'projectPath' = s.project_path
89
+ and j.user_id = s.user_id
90
+ )
91
+ group by s.user_id, s.project_path
92
+ ),
93
+ ins as (
94
+ insert into agent_jobs (user_id, task_kind, status, payload, dedupe_key)
95
+ select e.user_id,
96
+ 'cron_auto_accept_suggestions',
97
+ 'pending',
98
+ jsonb_build_object('projectPath', e.project_path),
99
+ 'auto-accept:' || e.user_id::text || ':' || e.project_path
100
+ from eligible e
101
+ returning 1
102
+ )
103
+ select count(*)::int into queued from ins;
104
+
105
+ return queued;
106
+ end;
107
+ $$;
108
+
109
+ comment on function public.enqueue_auto_accept_suggestions() is
110
+ 'Hourly producer for cron_auto_accept_suggestions. Requires a consented ADD proposal that is high-confidence or insights-authored, of an auto-adoptable type (00197), not declined by MEASUREMENT (00203), on a path that IS the project canonical checkout (00205 — releasing the staleness backlog revealed the match set was 13 paths of which 2 were checkouts; the others were a HOME directory, a temp directory, three worktrees and the dead apps/cli path, and acceptSkill would have written 14 skills into ~/.claude/skills/ globally), and that a worker has not reported missing from disk in 24h (00202).';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/local",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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.0"
31
+ "@rulemetric/skills-registry": "0.12.2"
32
32
  },
33
33
  "devDependencies": {
34
34
  "esbuild": "^0.25.0",