@ericrisco/rsc 1.0.32 → 1.0.33

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.32",
2
+ "version": "1.0.33",
3
3
  "counts": {
4
4
  "skills": 276
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.32",
3
+ "version": "1.0.33",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -93,32 +93,142 @@ function alwaysOnBytesFor(scopeRoot, settingsRaw) {
93
93
  return bytesOf(join(scopeRoot, '.rsc', 'skills', 'suggest', 'SKILL.md'));
94
94
  }
95
95
 
96
+ // Every hook rsc wires, as a table rather than a chain of `if`s: adding one is a row, and
97
+ // tests/doctor-hook-counts.test.js iterates it, so a hook added without a row fails the suite instead
98
+ // of shipping uncounted. `needle` is the SCRIPT PATH, never the bare `.rsc` — a user's own hook that
99
+ // merely mentions the directory must not be counted as ours.
100
+ //
101
+ // `injects` marks the two hooks that put text into the conversation. It matters because a duplicated
102
+ // guard costs processes, not bytes, and conflating the two is how this report lied in the first place.
103
+ export const RSC_HOOKS = [
104
+ { id: 'session-start', label: 'session-start', needle: '.rsc/session-start.', injects: 'sessionStart' },
105
+ { id: 'userprompt-gate', label: 'userprompt-gate', needle: '.rsc/userprompt-gate.', injects: 'perTurn' },
106
+ { id: 'worklog-checkpoint', label: 'worklog-checkpoint', needle: '.rsc/worklog-checkpoint.', injects: null },
107
+ { id: 'ship-guard', label: 'ship-guard', needle: '.rsc/ship-guard.', injects: null },
108
+ { id: 'danger-guard', label: 'danger-guard', needle: '.rsc/danger-guard.', injects: null },
109
+ { id: 'gitmoji-guard', label: 'gitmoji-guard', needle: '.rsc/gitmoji-guard.', injects: null },
110
+ // The bash-era form, still retired by unwireHook. If uninstall knows how to remove it, the report
111
+ // has to know how to count it.
112
+ { id: 'legacy-cat-form', label: 'legacy suggest hook', needle: 'skills/rsc/suggest', injects: 'sessionStart' },
113
+ ];
114
+
115
+ // Separators come from whatever platform installed: JSON escapes a Windows backslash as a pair, so
116
+ // the haystack is normalized before comparing. Same lesson as targets/claude.js, same one-liner.
117
+ const entryWiring = (entry) => JSON.stringify(entry).replace(/\\\\/g, '/');
118
+
119
+ /**
120
+ * How many times each rsc hook is wired, per event.
121
+ * @returns {{perEvent: Object<string, Object<string, number>>, indeterminate: string[]}}
122
+ */
123
+ export function countHookEntries(settings) {
124
+ const perEvent = {};
125
+ const indeterminate = [];
126
+ const hooks = settings && typeof settings === 'object' ? settings.hooks : null;
127
+ if (!hooks || typeof hooks !== 'object') return { perEvent, indeterminate };
128
+
129
+ for (const [event, entries] of Object.entries(hooks)) {
130
+ // A hand-edited file can put anything here. An event we cannot read is REPORTED, never counted as
131
+ // zero — a silent zero is exactly the "all clear" this whole delivery exists to stop.
132
+ if (!Array.isArray(entries)) { indeterminate.push(event); continue; }
133
+ for (const entry of entries) {
134
+ const wiring = entryWiring(entry);
135
+ for (const hook of RSC_HOOKS) {
136
+ if (!wiring.includes(hook.needle)) continue;
137
+ perEvent[event] ||= {};
138
+ perEvent[event][hook.id] = (perEvent[event][hook.id] || 0) + 1;
139
+ }
140
+ }
141
+ }
142
+ return { perEvent, indeterminate };
143
+ }
144
+
145
+ // The most copies any one hook has, per injection channel — that is the multiplier that channel pays.
146
+ function maxCopies(perEvent, channel) {
147
+ let most = 1;
148
+ for (const byHook of Object.values(perEvent)) {
149
+ for (const [id, n] of Object.entries(byHook)) {
150
+ const hook = RSC_HOOKS.find((h) => h.id === id);
151
+ if (hook && hook.injects === channel && n > most) most = n;
152
+ }
153
+ }
154
+ return most;
155
+ }
156
+
96
157
  function readScope(scopeRoot, label) {
97
158
  const settingsPath = join(scopeRoot, '.claude', 'settings.json');
98
159
  if (!existsSync(settingsPath)) {
99
160
  return { label, root: scopeRoot, wired: false, status: 'ok', alwaysOnBytes: 0, perTurnBytes: 0, version: null };
100
161
  }
101
162
  let raw;
163
+ let settings;
102
164
  try {
103
165
  raw = readFileSync(settingsPath, 'utf8');
104
- JSON.parse(raw); // a scope we cannot parse is reported, not guessed at
166
+ settings = JSON.parse(raw); // a scope we cannot parse is reported, not guessed at
105
167
  } catch {
106
168
  return { label, root: scopeRoot, wired: true, status: 'unknown', alwaysOnBytes: 0, perTurnBytes: 0, version: null };
107
169
  }
108
170
  const wired = raw.includes('.rsc/') || raw.includes('.rsc\\\\');
109
171
  let version = null;
110
172
  try { version = readFileSync(join(scopeRoot, '.rsc', '.version'), 'utf8').trim() || null; } catch { /* unknown */ }
173
+
174
+ const { perEvent, indeterminate } = countHookEntries(settings);
175
+ // The context regime is a STATIC fact, not a caveat: the repeated body is suppressed because the
176
+ // hook calls the single-shot guard, and that guard is a file on disk. Present → the body lands once
177
+ // however many entries fire. Absent (an install older than the guard) → every entry injects.
178
+ //
179
+ // Measured during clarify: four entries sharing a session_id cost ~1.27× context, not 4×. A figure
180
+ // that multiplied unconditionally would be the same lie that started this spec, aimed the other way.
181
+ const dedupeGuard = existsSync(join(scopeRoot, '.rsc', 'hook-once.mjs'));
182
+ const bodyCopies = dedupeGuard ? 1 : maxCopies(perEvent, 'sessionStart');
183
+ const gateCopies = dedupeGuard ? 1 : maxCopies(perEvent, 'perTurn');
184
+
111
185
  return {
112
186
  label,
113
187
  root: scopeRoot,
114
188
  wired,
115
189
  status: 'ok',
116
190
  version,
117
- alwaysOnBytes: wired ? alwaysOnBytesFor(scopeRoot, raw) : 0,
118
- perTurnBytes: wired && raw.includes('userprompt-gate') ? Buffer.byteLength(SDD_GATE_TEXT) : 0,
191
+ hookCounts: perEvent,
192
+ hookCountsUnknown: indeterminate.length ? indeterminate : null,
193
+ dedupeGuard,
194
+ alwaysOnBytes: wired ? alwaysOnBytesFor(scopeRoot, raw) * bodyCopies : 0,
195
+ perTurnBytes: wired && raw.includes('userprompt-gate') ? Buffer.byteLength(SDD_GATE_TEXT) * gateCopies : 0,
119
196
  };
120
197
  }
121
198
 
199
+ // One finding per scope that has any hook wired more than once. Both costs are named, because they
200
+ // are different quantities: processes are 4× unconditionally, bytes only when the guard is missing.
201
+ function duplicateEntryFindings(scopes) {
202
+ const out = [];
203
+ for (const scope of scopes) {
204
+ if (scope.status !== 'ok' || !scope.hookCounts) continue;
205
+ const repeated = [];
206
+ for (const [event, byHook] of Object.entries(scope.hookCounts)) {
207
+ for (const [id, n] of Object.entries(byHook)) {
208
+ if (n > 1) repeated.push({ event, id, n, label: RSC_HOOKS.find((h) => h.id === id)?.label || id });
209
+ }
210
+ }
211
+ if (!repeated.length) continue;
212
+ const worst = Math.max(...repeated.map((r) => r.n));
213
+ const list = repeated.map((r) => `${r.event}/${r.label} ×${r.n}`).join(', ');
214
+ const contextNote = scope.dedupeGuard
215
+ ? 'Context cost stays near 1× — the single-shot guard suppresses the repeated always-on body '
216
+ + '(the residue is the banners printed outside it).'
217
+ : `Context cost is the full ${worst}× — this scope has no single-shot guard materialized, so every `
218
+ + 'entry injects the always-on body again.';
219
+ out.push({
220
+ id: 'duplicate-hook-entries',
221
+ severity: 'high',
222
+ summary: `The ${scope.label} scope wires the same hook more than once: ${list}. `
223
+ + `Execution cost is ${worst}× processes per event — every session start, every turn, and every `
224
+ + `Bash command spawns that many. ${contextNote}`,
225
+ action: 'Re-run an install or sync with a current rsc (`npx @ericrisco/rsc@latest`); one pass '
226
+ + 'collapses the extra copies. No need to hand-edit settings.json.',
227
+ });
228
+ }
229
+ return out;
230
+ }
231
+
122
232
  // Frontmatter `description` of an installed skill — the part of a skill that is ALWAYS in
123
233
  // context, whether or not the skill is ever invoked. Cheap to parse, and the figure that
124
234
  // matters most as the catalog grows.
@@ -165,7 +275,18 @@ export function contextBudget({ target, home = homedir(), cwd = process.cwd() }
165
275
  + 'root, or update both to a version that de-duplicates at runtime.',
166
276
  });
167
277
  }
278
+ findings.push(...duplicateEntryFindings(scopes));
168
279
  for (const s of scopes) {
280
+ if (s.hookCountsUnknown) {
281
+ findings.push({
282
+ id: 'indeterminate-hook-event',
283
+ severity: 'low',
284
+ summary: `In the ${s.label} scope, ${s.hookCountsUnknown.join(', ')} is not a list of hook entries, `
285
+ + 'so its copies cannot be counted. A count this report cannot make is said out loud, never '
286
+ + 'reported as zero.',
287
+ action: 'Fix that entry in settings.json (or remove it), then re-run `rsc doctor`.',
288
+ });
289
+ }
169
290
  if (s.status === 'unknown') {
170
291
  findings.push({
171
292
  id: 'unreadable-scope',
package/scripts/rsc.js CHANGED
@@ -144,6 +144,11 @@ function printContextBudget(b) {
144
144
  }
145
145
  for (const s of b.scopes.filter((x) => x.wired)) {
146
146
  say(` Scope ${s.label.padEnd(8)}: ${s.root}${s.version ? ` (${s.version})` : ''}${s.status === 'unknown' ? ' — UNREADABLE' : ''}`);
147
+ // Copies are printed only when there is something to see. A line saying "×1" on every healthy
148
+ // box is noise, and noise is what gets a report skimmed instead of read.
149
+ const repeated = Object.entries(s.hookCounts || {})
150
+ .flatMap(([event, byHook]) => Object.entries(byHook).filter(([, n]) => n > 1).map(([id, n]) => `${event}/${id} ×${n}`));
151
+ if (repeated.length) say(` Hook copies : ${repeated.join(' · ')}`);
147
152
  }
148
153
  for (const f of b.findings) {
149
154
  say(`\n [${f.severity.toUpperCase()}] ${f.summary}`);