@ericrisco/rsc 1.0.20 → 1.0.22

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/og.png" alt="rsc — the plain-language wizard installing 5 of 264 skills into Claude Code and Codex CLI, leaving the other 259 out of your context window" width="960">
3
+ <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/og.png" alt="rsc — your agent needs a memory (02-DOCS/, or it invents what it cannot remember), arms (01-TOOLS/, or it never touches your database), a trade (264 skills, or it guesses how the job is done), and reflexes (deterministic hooks, or it forgets the rule mid-session). One meta-harness for 17 coding assistants." width="960">
4
4
 
5
5
  # `rsc` — 264 agent skills, one CLI, zero bloat
6
6
 
@@ -26,12 +26,12 @@ npx @ericrisco/rsc # plain-language wizard — no jargon, installs wh
26
26
 
27
27
  <br>
28
28
 
29
- <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/meta-harness.png" alt="rsc is a meta-harness above your coding agents: TOOLS (01-TOOLS/, one folder per provider — Postgres, MongoDB, Stripe, Redis, Supabase, Firebase, Cloudflare, Hetzner, plus any API via api-connector-builder), 02-DOCS (the company brain — a knowledge graph turning raw/ into wiki/, OKF v0.1), and SKILLS (264, installed one at a time). All three feed Claude Code, Codex CLI and opencode, which work on your repo — and the results feed back into the brain." width="960">
29
+ <img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/meta-harness.png" alt="rsc — the model (Claude, GPT, Gemini) is the brain your coding agent rents, and you already have that. Claude Code, Codex and opencode sit in the middle. Below them the rsc meta-harness adds everything the model lacks: memory (02-DOCS/) so it stops inventing, arms (01-TOOLS/) so it can execute, a trade (264 skills) so it stops guessing, and reflexes (deterministic hooks) so it cannot forget the rule mid-session." width="960">
30
30
 
31
- **One harness above every agent.** `rsc` wires three layers into whichever assistant you
32
- already use — the **tools** that connect your providers, the **02-DOCS brain** that turns your
33
- own results into knowledge, and the **skills** that teach the agent the job — then keeps all
34
- three in sync as you work.
31
+ **The model is the only part your agent already has.** `rsc` is the meta-harness that adds
32
+ the rest: a **memory** so it stops inventing, **arms** so it can execute, a **trade** so it stops
33
+ guessing, and **reflexes** so it cannot forget the rule mid-session — into whichever assistant
34
+ you already use, and kept in sync as you work.
35
35
 
36
36
  </div>
37
37
 
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.20",
2
+ "version": "1.0.22",
3
3
  "counts": {
4
4
  "skills": 264
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -148,20 +148,39 @@ export function capabilities({ target, home, cwd = process.cwd(), full = false }
148
148
  // ONLY — it cannot tell a description from a paraphrase, and it does not pretend to.
149
149
  // The rule lives in skills/skill-scout/SKILL.md, where it is held by whoever writes.
150
150
 
151
+ // `proposed-capability` added 2026-08-18: none of the original five could express "this needs an
152
+ // executable capability, not a skill", which is the majority of what the repetition detector actually
153
+ // finds. Without it the honest case had to be recorded as one of the other five, and all five would
154
+ // have been false. Additive — the existing five keep validating unchanged.
151
155
  export const GAP_VERDICTS = [
152
156
  'covered-installed', 'covered-catalog', 'covered-agent',
153
- 'proposed-accepted', 'proposed-declined',
157
+ 'proposed-accepted', 'proposed-declined', 'proposed-capability',
154
158
  ];
155
159
 
156
160
  export function gapLogPath(cwd = process.cwd()) {
157
161
  return join(cwd, '.rsc', 'automation-gaps.md');
158
162
  }
159
163
 
164
+ /**
165
+ * The user-level log. A procedure repeated across three projects is STRONGER evidence that it should
166
+ * stop being manual, not weaker — and the per-repo log makes that pattern invisible by construction
167
+ * (clarify 2026-08-18). Nothing "wins" when the two disagree: they are summed, de-duplicated by
168
+ * date+procedure+verdict, so double-writing cannot double-count.
169
+ */
170
+ export function globalGapLogPath(home = homedir()) {
171
+ return join(home, '.rsc', 'automation-gaps.md');
172
+ }
173
+
174
+ /** Repo name as it appears in a log line, so the offer can say WHERE it repeated. */
175
+ export function repoNameOf(cwd = process.cwd()) {
176
+ return basename(resolve(cwd));
177
+ }
178
+
160
179
  // Any whitespace run collapses, not just \n: a lone \r is a CommonMark line ending,
161
180
  // so it would render as an extra (forged, dated) entry while `cat` hid it.
162
181
  const oneLine = (s) => String(s || '').replace(/\s+/g, ' ').trim();
163
182
 
164
- export function appendGap({ procedure, verdict, cwd = process.cwd(), now }) {
183
+ export function appendGap({ procedure, verdict, cwd = process.cwd(), now, home = homedir() }) {
165
184
  const text = oneLine(procedure);
166
185
  if (!text) throw new Error('gap-log: a procedure description is required. Recover: pass --procedure "<what you observed doing>" — your own description of the work, never the user\'s words.');
167
186
  if (!GAP_VERDICTS.includes(verdict)) {
@@ -177,6 +196,19 @@ export function appendGap({ procedure, verdict, cwd = process.cwd(), now }) {
177
196
  const d = now || new Date();
178
197
  const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
179
198
  appendFileSync(p, `${header}- ${day} · ${text} · **${verdict}**\n`);
199
+
200
+ // Also record globally, tagged with the repo. Wrapped: a home directory that cannot be written must
201
+ // never take down the local record, which is the one the current repo depends on.
202
+ try {
203
+ const g = globalGapLogPath(home);
204
+ mkdirSync(dirname(g), { recursive: true });
205
+ let gHeader = true;
206
+ try { gHeader = statSync(g).size === 0; } catch { gHeader = true; }
207
+ const gh = gHeader
208
+ ? '# Huecos de automatización (global)\n\nAcumulado de todos los repos, para que un procedimiento repetido en varios proyectos sea visible.\nLo escribe `rsc capabilities gap-log`. Cada línea lleva `[repo:<nombre>]`. Fichero local: no lo commitees.\n\n'
209
+ : '';
210
+ appendFileSync(g, `${gh}- ${day} · ${text} · [repo:${repoNameOf(cwd)}] · **${verdict}**\n`);
211
+ } catch { /* the local log is what matters; a global failure is not fatal */ }
180
212
  return p;
181
213
  }
182
214
 
@@ -0,0 +1,47 @@
1
+ // repetition-report.js — the I/O half of the repetition detector.
2
+ //
3
+ // Kept apart from repetition.js on purpose: the deciding is pure and heavily tested, the reading of two
4
+ // files is not. Mixing them would make the interesting logic need fixtures on disk.
5
+
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { gapLogPath, globalGapLogPath, capabilities } from './capabilities.js';
9
+ import { parseGapLog, groupProcedures, decideOffer, mergeLogs, THRESHOLD } from './repetition.js';
10
+
11
+ /**
12
+ * Read local + global logs, group, decide. FAILS CLOSED: an unreadable log yields `blocked`, never an
13
+ * invented count. countGaps() already sets the precedent of not taking `doctor` down over a malformed
14
+ * log; the difference here is that the degradation is announced instead of swallowed.
15
+ */
16
+ export function repetitionReport({ cwd = process.cwd(), home = homedir(), target = 'claude' } = {}) {
17
+ const read = (p, label) => {
18
+ if (!existsSync(p)) return { entries: [], missing: true };
19
+ try { return { entries: parseGapLog(readFileSync(p, 'utf8')), missing: false }; }
20
+ catch (e) { return { error: `no se pudo leer el registro ${label} (${p}): ${e.message}` }; }
21
+ };
22
+ const local = read(gapLogPath(cwd), 'local');
23
+ const global = read(globalGapLogPath(home), 'global');
24
+ if (local.error || global.error) {
25
+ return { blocked: true, reason: local.error || global.error, offer: null, uncertain: [], entries: 0 };
26
+ }
27
+ const merged = mergeLogs(local.entries, global.entries);
28
+ if (!merged.length) {
29
+ return { blocked: false, offer: null, reason: 'no hay entradas registradas todavía', uncertain: [], entries: 0 };
30
+ }
31
+ const { groups, uncertain } = groupProcedures(merged);
32
+
33
+ // "Already covered" is answered by the command that reads the disk, never by memory — the decision
34
+ // capabilities.js already made and for the right reason.
35
+ let covered = false;
36
+ try {
37
+ const caps = capabilities({ target, cwd });
38
+ const top = [...groups].sort((a, b) => b.entries.length - a.entries.length)[0];
39
+ if (top) {
40
+ const words = top.key.toLowerCase();
41
+ covered = caps.installed.some((s) => words.includes(s.id)) || (caps.agents || []).some((a) => words.includes(a.id));
42
+ }
43
+ } catch { /* enumeration failing must not fabricate "covered" — leave it false */ }
44
+
45
+ const { offer, reason } = decideOffer({ groups, threshold: THRESHOLD, covered });
46
+ return { blocked: false, offer, reason, uncertain, entries: merged.length, groups: groups.length };
47
+ }
@@ -0,0 +1,186 @@
1
+ // repetition.js — has this procedure been done before, and does it deserve to stop being manual?
2
+ //
3
+ // The always-on rule already asks "did you just deliver a repeatable PROCEDURE?" — a judgment about ONE
4
+ // episode, made by the same agent that just performed it, at the moment it is most convinced its work
5
+ // was special. Grepping the always-on layer for repeat|repetit|twice|recurr returns nothing: nothing
6
+ // looks backwards.
7
+ //
8
+ // The registry that would allow it already exists. `rsc capabilities gap-log` writes one line per
9
+ // observed procedure, and the only thing that consumes it is countGaps(), which returns a TOTAL for
10
+ // `doctor` to display. The ledger is written and nobody interrogates it — the passive form of the same
11
+ // P2 defect.
12
+ //
13
+ // The evidence is the session that wrote this: four manual mutation runs (8, 17, 12, 6 mutants) and
14
+ // five identical score-then-rescore passes, none of which ever triggered the rule, because each episode
15
+ // looked like one-off work on its own.
16
+ //
17
+ // Two decisions from clarify (2026-08-18): the count is GLOBAL with a per-repo breakdown (a procedure
18
+ // repeated across projects is stronger signal, not weaker), and the threshold is THREE — a reasoned
19
+ // bet, not a measurement, and it says so.
20
+
21
+ import { tokenize } from './text-rank.js';
22
+
23
+ export const THRESHOLD = 3;
24
+
25
+ // Numerals and structural filler carry no information about WHICH procedure this is — "eight mutants"
26
+ // and "twelve mutants" are the same work. Dropping them is what made the margin measurable at all.
27
+ const FILLER = /^(uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieciseis|diecisiet|dieciocho|diecinuev|veint|cada|por|sobr|un|una|one|two|three|four|five|six|seven|eight|nine|ten|each)$/;
28
+
29
+ // CALIBRATED, NOT GUESSED. Three metrics were measured on real descriptions from the session that
30
+ // motivated this (3 same-procedure pairs, 12 different-procedure pairs):
31
+ //
32
+ // metric same different margin
33
+ // jaccard 0.17–0.30 ≤0.09 0.08
34
+ // overlap 0.29–0.50 ≤0.20 0.09
35
+ // dice 0.29–0.46 ≤0.17 0.12 ← widest, so this is the one
36
+ //
37
+ // The first version used Jaccard at 0.6 and grouped nothing: the real case came out as four groups of
38
+ // one. Guessing a threshold for a similarity metric is how you ship a detector that never fires.
39
+ //
40
+ // HONEST LIMIT: n=15 pairs from one session. This is calibration, not validation. If it groups wrongly
41
+ // in practice the number moves, and the measurement above is the record of how it was chosen.
42
+ export const GROUP_AT = 0.25;
43
+ // The band where the answer is "maybe": it does NOT group, and it records what it refused to assume.
44
+ // Grouping wrongly inflates the count and fires an offer about something that never repeated — the more
45
+ // expensive of the two mistakes (P7: a system that nags in the harmless 80% gets switched off).
46
+ export const UNCERTAIN_AT = 0.17;
47
+
48
+ const LOG_LINE = /^- (\d{4}-\d{2}-\d{2}) · (.+?) · \*\*([a-z-]+)\*\*\s*$/;
49
+ const REPO_TAG = /\s*·?\s*\[repo:([^\]]+)\]\s*$/;
50
+
51
+ /** Parse a gap log. Malformed lines are skipped, not guessed at; the caller decides what silence means. */
52
+ export function parseGapLog(text) {
53
+ const out = [];
54
+ const lines = String(text || '').split('\n');
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const m = LOG_LINE.exec(lines[i]);
57
+ if (!m) continue;
58
+ let procedure = m[2];
59
+ let repo = null;
60
+ const tag = REPO_TAG.exec(procedure);
61
+ if (tag) { repo = tag[1]; procedure = procedure.slice(0, tag.index).trim(); }
62
+ out.push({ date: m[1], procedure, verdict: m[3], repo, line: i + 1 });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** Significant tokens: text-rank's (accent-folded, stopworded, stemmed) minus numerals and filler. */
68
+ export function terms(text) {
69
+ return new Set(tokenize(String(text || '')).filter((tk) => !FILLER.test(tk)));
70
+ }
71
+
72
+ /**
73
+ * Dice coefficient over significant tokens: 2|A∩B| / (|A|+|B|). Not cosine — that weights by IDF over a
74
+ * corpus and here there are just two sentences. Not Jaccard — measured narrower on real data (see the
75
+ * table above).
76
+ */
77
+ export function similarity(a, b) {
78
+ const A = terms(a);
79
+ const B = terms(b);
80
+ if (!A.size || !B.size) return 0;
81
+ let inter = 0;
82
+ for (const tk of A) if (B.has(tk)) inter++;
83
+ return (2 * inter) / (A.size + B.size);
84
+ }
85
+
86
+ /**
87
+ * Group entries describing the same procedure. Deterministic: first entry of a group is its key, and a
88
+ * candidate joins only if it clears GROUP_AT against that key. Anything in the uncertain band is left
89
+ * ungrouped and recorded, so a human can see what the detector refused to assume.
90
+ */
91
+ export function groupProcedures(entries, threshold = GROUP_AT) {
92
+ const groups = [];
93
+ const uncertain = [];
94
+ for (const e of entries) {
95
+ let best = null;
96
+ let bestScore = 0;
97
+ for (const g of groups) {
98
+ const s = similarity(e.procedure, g.key);
99
+ if (s > bestScore) { bestScore = s; best = g; }
100
+ }
101
+ if (best && bestScore >= threshold) {
102
+ best.entries.push(e);
103
+ if (e.repo) best.repos.set(e.repo, (best.repos.get(e.repo) || 0) + 1);
104
+ continue;
105
+ }
106
+ if (best && bestScore >= UNCERTAIN_AT) {
107
+ uncertain.push({ procedure: e.procedure, nearest: best.key, score: Number(bestScore.toFixed(2)) });
108
+ }
109
+ groups.push({
110
+ key: e.procedure,
111
+ entries: [e],
112
+ repos: new Map(e.repo ? [[e.repo, 1]] : []),
113
+ });
114
+ }
115
+ return { groups, uncertain };
116
+ }
117
+
118
+ const CAPABILITY_VERBS = /\b(plantar|plant|correr|ejecutar|run|contar|count|restaurar|restore|medir|measure|extraer|extract|puntuar|score|comparar|compare|generar|generate)\b/i;
119
+ const AGENT_VERBS = /\b(revisar|review|refutar|refute|auditar|audit|verificar|verify|juzgar|judge|a ciegas|blind)\b/i;
120
+
121
+ /**
122
+ * Which shape does this procedure want? Order matters: the first match wins, and capability comes
123
+ * first because it is the majority case observed and the one with no verdict until now. Writing a
124
+ * paragraph for something that needs a script is the failure the generalization gate already named.
125
+ */
126
+ export function recommendKind(procedure) {
127
+ const p = String(procedure || '');
128
+ if (CAPABILITY_VERBS.test(p)) {
129
+ return {
130
+ kind: 'capability',
131
+ why: 'describe algo que hay que ejecutar y comparar; una skill escribiría un párrafo donde hace falta un script o un hook',
132
+ };
133
+ }
134
+ if (AGENT_VERBS.test(p)) {
135
+ return {
136
+ kind: 'agent',
137
+ why: 'describe mirar o juzgar sin arrastrar el contexto de quien lo pide, que es para lo que sirve un contexto fresco',
138
+ };
139
+ }
140
+ return { kind: 'skill', why: 'describe guía y disciplina sobre lo que ya se está haciendo en la conversación' };
141
+ }
142
+
143
+ const DECLINED = 'proposed-declined';
144
+
145
+ /**
146
+ * Should anything be said? Silence is the default and the common case.
147
+ *
148
+ * Never offers when: below threshold, already covered, or previously declined. A "no" that has to be
149
+ * repeated every week is what makes someone switch the whole harness off.
150
+ */
151
+ export function decideOffer({ groups, threshold = THRESHOLD, covered = false }) {
152
+ if (covered) return { offer: null, reason: 'ya cubierto por una skill o agente instalado' };
153
+ const ranked = [...groups].sort((a, b) => b.entries.length - a.entries.length);
154
+ for (const g of ranked) {
155
+ if (g.entries.length < threshold) continue;
156
+ // A decline anywhere in the group retires it permanently.
157
+ if (g.entries.some((e) => e.verdict === DECLINED)) continue;
158
+ const rec = recommendKind(g.key);
159
+ return {
160
+ offer: {
161
+ procedure: g.key,
162
+ seen: g.entries.length,
163
+ dates: g.entries.map((e) => e.date),
164
+ repos: [...g.repos.entries()].map(([repo, n]) => ({ repo, n })),
165
+ kind: rec.kind,
166
+ why: rec.why,
167
+ threshold,
168
+ },
169
+ reason: null,
170
+ };
171
+ }
172
+ return { offer: null, reason: `ningún procedimiento alcanza el umbral de ${threshold}` };
173
+ }
174
+
175
+ /** Merge local + global entries, de-duplicated by date+procedure+verdict, so double-writing cannot double-count. */
176
+ export function mergeLogs(localEntries, globalEntries) {
177
+ const seen = new Set();
178
+ const out = [];
179
+ for (const e of [...localEntries, ...globalEntries]) {
180
+ const k = `${e.date}|${e.procedure}|${e.verdict}`;
181
+ if (seen.has(k)) continue;
182
+ seen.add(k);
183
+ out.push(e);
184
+ }
185
+ return out;
186
+ }
package/scripts/rsc.js CHANGED
@@ -352,6 +352,26 @@ async function main() {
352
352
  } catch (e) { say(e.message); process.exitCode = 1; }
353
353
  return;
354
354
  }
355
+ // `capabilities repetition` — read the ledger nobody was interrogating. It lives here and not in
356
+ // a new command because the enumeration and the writing already live here, and splitting them
357
+ // across two places is the parallel bookkeeping P3 forbids.
358
+ if (argv.includes('repetition')) {
359
+ const root = resolveRoot(process.cwd());
360
+ const { repetitionReport } = await import('./lib/repetition-report.js');
361
+ const rep = repetitionReport({ cwd: root });
362
+ if (argv.includes('--json')) return void say(JSON.stringify(rep, null, 2));
363
+ if (rep.blocked) { say(`⚠️ ${rep.reason}`); process.exitCode = 1; return; }
364
+ if (!rep.offer) { say(`# nada que ofrecer — ${rep.reason} (${rep.entries} entradas leídas)`); return; }
365
+ const o = rep.offer;
366
+ const where = o.repos.length ? ` · ${o.repos.map((r) => `${r.repo}×${r.n}`).join(', ')}` : '';
367
+ say(`🔁 «${o.procedure}» visto ${o.seen} veces (${o.dates.join(', ')})${where}`);
368
+ say(` → ${o.kind}: ${o.why}`);
369
+ say(` registra la decisión con: rsc capabilities gap-log --procedure "..." --verdict proposed-${o.kind === 'capability' ? 'capability' : 'accepted'}|proposed-declined`);
370
+ if (rep.uncertain.length) {
371
+ say(` (${rep.uncertain.length} parecido(s) que NO se agruparon por debajo del umbral — deliberado)`);
372
+ }
373
+ return;
374
+ }
355
375
  const full = argv.includes('--full');
356
376
  const reports = targets.map((t) => capabilities({ target: t, full }));
357
377
  if (argv.includes('--json')) {