@ericrisco/rsc 1.0.31 → 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 +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +124 -3
- package/scripts/rsc.js +5 -0
- package/targets/session-start.mjs +12 -1
package/manifest.json
CHANGED
package/package.json
CHANGED
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
|
-
|
|
118
|
-
|
|
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}`);
|
|
@@ -57,7 +57,18 @@ function versionAt(scopeRoot) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function isWiredScope(scopeRoot) {
|
|
60
|
-
|
|
60
|
+
// settings.json stores the wired command with the separator the installing platform produced, and
|
|
61
|
+
// JSON escapes a Windows backslash as a pair. Looking only for `.rsc/` therefore answered "not
|
|
62
|
+
// wired" for every Windows scope — which made THIS detector, the one that warns about a duplicated
|
|
63
|
+
// always-on body, blind on the exact platform where the body was being injected four times.
|
|
64
|
+
//
|
|
65
|
+
// Normalized inline rather than imported: hooks are materialized file by file, so this script can
|
|
66
|
+
// only import a sibling in `.rsc/`. `targets/claude.js` (hookWiringOf) and `scripts/doctor.js`
|
|
67
|
+
// carry the same one-liner for the same reason; the duplication is the price of the hook layout.
|
|
68
|
+
try {
|
|
69
|
+
const raw = readFileSync(join(scopeRoot, '.claude', 'settings.json'), 'utf8');
|
|
70
|
+
return raw.replace(/\\\\/g, '/').includes('.rsc/');
|
|
71
|
+
} catch { return false; }
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
function olderThan(a, b) {
|