singular-skill 0.0.0-stage → 0.2.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.
@@ -0,0 +1,672 @@
1
+ #!/usr/bin/env node
2
+ // bin/on-prompt.js — UserPromptSubmit hook entrypoint for singular.
3
+ //
4
+ // Hook mode: reads the hook JSON on stdin, prints {"hookSpecificOutput":{...additionalContext}} or nothing.
5
+ // Dry-run: node bin/on-prompt.js --dry-run "<prompt>" [--cwd <dir>] [--json] [--verbose] [--session <id>]
6
+ //
7
+ // Exit 0 always. Never writes to stdout except the hook JSON (or the dry-run text).
8
+ // Everything runs inside config.totalBudgetMs (hard deadline: a watchdog prints the
9
+ // local-only context and exits if the remote phase overruns).
10
+ //
11
+ // Hot path (per prompt): index + score installed skills, stack detection, then (suggest/auto, when no
12
+ // strong local match) rank the prompt against the cached allowlisted-owner catalogs plus at most two
13
+ // single tech-lexicon words searched on skills.sh (`q=stripe`; never free-form prompt text; see
14
+ // docs/security.md "Outbound data"). HEAD shas are read from the cache only; a detached background prefetch
15
+ // (`on-expand.js <ref> --prefetch`) resolves the pin and caches the skill text so `/singular load` and the
16
+ // next auto prompt are instant. Auto mode fetches inline only when the text is cached or enough budget
17
+ // is left for a cold `npx skills use`.
18
+
19
+ import { spawn as nodeSpawn } from 'node:child_process';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
23
+
24
+ import { cacheGet, cacheSet } from '../lib/cache.js';
25
+ import { hardenEnv, homeDir, isAllowlisted, loadConfig } from '../lib/config.js';
26
+ import { composeContext, hookOutput, spillIfNeeded, TAG } from '../lib/context.js';
27
+ import { log, timer } from '../lib/log.js';
28
+ import { discover, fetchSkillText, sanitizeSkillText, textCacheKey } from '../lib/registry.js';
29
+ import { formatRef, normalizeRefCase, parseRef, REF_RE, resolveHeadSha, unpinnedRef } from '../lib/refs.js';
30
+ import { isGenericToken, isTechToken, isWeakMatch, scoreSkills, stem } from '../lib/score.js';
31
+ import { buildIndex } from '../lib/skill-index.js';
32
+ import { detectStack } from '../lib/stack.js';
33
+
34
+ export const STRONG_LOCAL_SCORE = 10;
35
+ // A match on ONE name token needs a much higher score to count as strong: the score already carries the x3
36
+ // name weight, so >= 20 means the token is rare across the index (build-dashboard 44.7 for "dashboard"),
37
+ // while a word shared by many skill names stays below it (system-design 11.5 for "design", routing-middleware
38
+ // 15.9 for "route"). Tech-lexicon nouns (nextjs, stripe, pdf, auth) are strong at the normal bar.
39
+ export const STRONG_SINGLE_NAME_SCORE = 20;
40
+ // Common English words that name many skills; a single hit on one of them is never strong.
41
+ export const NAME_GENERIC = new Set('design designs route routes routing system systems review reviews plan plans start starts setup status update updates explore'.split(' '));
42
+ export const MIN_REMOTE_MS = 1500;
43
+ export const MIN_AUTO_FETCH_MS = 4500; // measured: a warm `npx skills use` takes ~4.2 s (cold npm cache: longer); a cached text takes ~0 ms
44
+ export const MIN_RESOLVE_MS = 1200;
45
+ export const HEAD_CACHE_PREFIX = 'head:';
46
+ export const SESSION_TTL_HOURS = 24 * 7;
47
+ // One factual line for prompts that talk about singular itself: /singular is user-only (disable-model-invocation),
48
+ // so the model cannot see its subcommands and would otherwise guess them.
49
+ export const COMMANDS_HINT = 'singular commands (typed by the user): /singular (status + help), /singular status (config, pins, cache), /singular find <query> (search skills.sh; the singular-find skill is also model-invocable), /singular load owner/repo@skill (load text into this session), /singular mode <off|local|suggest|auto>, /singular add owner/repo@skill (pinned persistent install).';
50
+ // A prompt is about the plugin when it names the command (/singular, /singular-find) or uses the word next to a
51
+ // plugin noun; the word alone is ordinary English ("singular value decomposition") and must not trigger the hint.
52
+ const SINGULAR_COMMAND_RE = /\/singular(?:-find)?\b/i;
53
+ const SINGULAR_WORD_RE = /\bsingular\b/i;
54
+ const SINGULAR_TOPIC_RE = /\b(?:skills?|plugins?|commands?|modes?|status|config|hooks?)\b/i;
55
+ export function mentionsSingular(text) {
56
+ const s = String(text ?? '');
57
+ return SINGULAR_COMMAND_RE.test(s) || (SINGULAR_WORD_RE.test(s) && SINGULAR_TOPIC_RE.test(s));
58
+ }
59
+ const ON_EXPAND = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'on-expand.js');
60
+ const PASTED_WRAPPER_RE = /^[ \t]*<\/?pasted_content(?:\s+id="[^"]*")?>[ \t]*$/gm;
61
+ // Synthetic prompts Claude Code delivers to the main agent (2.1.293): a backgrounded subagent's completion
62
+ // notice, and a bare system reminder. They are not user requests; routing them wastes time and may send
63
+ // tech words from a subagent's output to skills.sh.
64
+ const TASK_NOTIFICATION_RE = /^<task-notification>/i;
65
+ const SYSTEM_REMINDER_RE = /^<system-reminder>[\s\S]*<\/system-reminder>$/i;
66
+
67
+ function readStdinSync() {
68
+ try {
69
+ return fs.readFileSync(0, 'utf8');
70
+ } catch {
71
+ return '';
72
+ }
73
+ }
74
+
75
+ function parseArgs(argv) {
76
+ const out = { dryRun: false, prompt: null, cwd: null, json: false, verbose: false, help: false, session: null, prefetch: false };
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const a = argv[i];
79
+ if (a === '--dry-run') {
80
+ out.dryRun = true;
81
+ if (argv[i + 1] != null && !argv[i + 1].startsWith('--')) out.prompt = argv[++i];
82
+ } else if (a === '--cwd') out.cwd = argv[++i] ?? null;
83
+ else if (a === '--session') out.session = argv[++i] ?? null;
84
+ else if (a === '--json') out.json = true;
85
+ else if (a === '--prefetch') out.prefetch = true;
86
+ else if (a === '--verbose' || a === '-v') out.verbose = true;
87
+ else if (a === '--help' || a === '-h') out.help = true;
88
+ else if (out.dryRun && out.prompt == null && !a.startsWith('--')) out.prompt = a;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /** Strip the `<pasted_content id="…">` wrapper lines Claude Code adds around pasted text; trim. */
94
+ export function normalizePrompt(prompt) {
95
+ return String(prompt ?? '').replace(PASTED_WRAPPER_RE, '').trim();
96
+ }
97
+
98
+ /**
99
+ * Why a prompt gets no routing. The slash-command rule follows Claude Code's own: a command is expanded only
100
+ * when the RAW prompt starts with `/`, so a pasted block whose first inner line is a slash command
101
+ * (`<pasted_content>\n/singular load ...`) is a normal prompt and keeps its stack + routing.
102
+ */
103
+ export function skipReason(clean, config, raw = clean) {
104
+ const t = clean;
105
+ const rawText = String(raw ?? '').trim();
106
+ if (config.mode === 'off') return 'mode off';
107
+ if (!t) return 'empty prompt';
108
+ if (rawText.startsWith('/')) return 'slash command';
109
+ if (TASK_NOTIFICATION_RE.test(rawText) || TASK_NOTIFICATION_RE.test(t)) return 'task notification (synthetic prompt)';
110
+ if (SYSTEM_REMINDER_RE.test(t)) return 'system reminder (synthetic prompt)';
111
+ if (t.startsWith(TAG)) return 'own output';
112
+ if (t.length < Number(config.minPromptChars)) return `prompt shorter than minPromptChars (${t.length} < ${config.minPromptChars})`;
113
+ return null;
114
+ }
115
+
116
+ function installedNamesOf(index) {
117
+ const names = new Set();
118
+ for (const s of index) {
119
+ if (s.name) names.add(String(s.name).toLowerCase());
120
+ if (s.frontmatterName) names.add(String(s.frontmatterName).toLowerCase());
121
+ }
122
+ return [...names];
123
+ }
124
+
125
+ /**
126
+ * A local match is strong (suppresses remote discovery) when it scores >= STRONG_LOCAL_SCORE and either
127
+ * matched >= 2 meaningful (non-generic) tokens, or matched a single NAME token that is a tech-lexicon noun
128
+ * (nextjs, stripe, pdf, auth ...), or a single rare, non-generic name token with score >=
129
+ * STRONG_SINGLE_NAME_SCORE ("dashboard" -> data:build-dashboard, "karpathy"). A single hit on "file",
130
+ * "function", "clean" ... is never strong and is not even listed (isWeakMatch); a single common English name
131
+ * word ("design" -> system-design, "route" -> routing-middleware, "plan", "review", "setup") is listed but
132
+ * never strong, so discovery still runs (measured 2026-10-07: those two made tangential skills strong and
133
+ * hid vercel:nextjs / frontend-design).
134
+ */
135
+ export function isStrongMatch(m) {
136
+ if (!m || Number(m.score) < STRONG_LOCAL_SCORE) return false;
137
+ const matched = Array.isArray(m.matched) ? m.matched : [];
138
+ if (isWeakMatch(matched)) return false;
139
+ // Only tokens that carry signal count: "tools, code" or "pull, requests" must not suppress discovery.
140
+ const meaningful = matched.filter((t) => !isGenericToken(t));
141
+ if (meaningful.length >= 2) return true;
142
+ const nameMatched = Array.isArray(m.nameMatched) ? m.nameMatched : [];
143
+ return nameMatched.some((t) => {
144
+ const w = String(t ?? '').toLowerCase();
145
+ if (!w || isGenericToken(w) || NAME_GENERIC.has(w) || NAME_GENERIC.has(stem(w))) return false;
146
+ return isTechToken(w) || Number(m.score) >= STRONG_SINGLE_NAME_SCORE;
147
+ });
148
+ }
149
+
150
+ function cacheOf(deps, env) {
151
+ return {
152
+ get: (k, ttl) => (deps.cacheGet ? deps.cacheGet(k, ttl) : cacheGet(k, ttl, env)),
153
+ set: (k, v) => (deps.cacheSet ? deps.cacheSet(k, v) : cacheSet(k, v, env)),
154
+ };
155
+ }
156
+
157
+ /** Attach cached HEAD shas (display only); never touches the network on the hot path. */
158
+ function pinFromCache(candidates, { ttlHours, cache }) {
159
+ return candidates.map((c) => {
160
+ try {
161
+ const hit = cache.get(`${HEAD_CACHE_PREFIX}${c.source}`, ttlHours);
162
+ if (typeof hit === 'string' && /^[0-9a-f]{40}$/.test(hit)) return { ...c, sha: hit };
163
+ } catch {
164
+ /* ignore */
165
+ }
166
+ return c;
167
+ });
168
+ }
169
+
170
+ async function resolvePin(cand, { remaining, ttlHours, cache, deps }) {
171
+ if (cand.sha) return cand.sha;
172
+ const left = remaining();
173
+ if (left < MIN_RESOLVE_MS) return null;
174
+ try {
175
+ const [owner, repo] = cand.source.split('/');
176
+ const sha = await resolveHeadSha(owner, repo, { timeoutMs: Math.min(3000, left - 300) }, deps);
177
+ cache.set(`${HEAD_CACHE_PREFIX}${cand.source}`, sha);
178
+ return sha;
179
+ } catch (err) {
180
+ log('debug', 'head sha resolve failed', { source: cand.source, error: String(err?.message ?? err) });
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /** Detached `node bin/on-expand.js <ref> --prefetch` (stdio ignored, unref) so the next load is instant. */
186
+ function spawnPrefetch(ref, { env, deps, result }) {
187
+ const spawnImpl = deps.spawnImpl ?? nodeSpawn;
188
+ try {
189
+ const child = spawnImpl(process.execPath, [ON_EXPAND, ref, '--prefetch'], { detached: true, stdio: 'ignore', env, windowsHide: true });
190
+ child?.unref?.();
191
+ result.prefetched = [...(result.prefetched ?? []), ref];
192
+ return true;
193
+ } catch (err) {
194
+ log('debug', 'prefetch spawn failed', { ref, error: String(err?.message ?? err) });
195
+ return false;
196
+ }
197
+ }
198
+
199
+ /** The one-time auto warning. The marker is written only for a real run (`persist`); a --dry-run must not consume it. */
200
+ function autoWarning(home, { persist = true } = {}) {
201
+ const marker = path.join(home, '.auto-mode-warned');
202
+ if (fs.existsSync(marker)) return null;
203
+ if (persist) {
204
+ try {
205
+ fs.writeFileSync(marker, `${new Date().toISOString()}\n`);
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ }
210
+ return `${TAG} WARNING: mode "auto" is enabled. Third-party skill text from allowlisted skills.sh sources will be injected into your prompts without confirmation. It is text only (nothing is installed), pinned to a commit and sanitized, but it is still untrusted prose. Run /singular mode suggest (or edit ${path.join(home, 'config.json')}) to require /singular load. This warning is shown once.`;
211
+ }
212
+
213
+ /**
214
+ * One line for the user (hook `systemMessage`, shown in the transcript) naming fresh third-party candidates,
215
+ * so a suggestion no longer depends on the model relaying it. Each ref is announced once per session
216
+ * (cache key session:<id>:suggested); without a session id every call announces.
217
+ */
218
+ export function suggestionMessage(candidates, { sessionId, cache }) {
219
+ const refs = (Array.isArray(candidates) ? candidates : []).map((c) => `${c.source}@${c.skill}`).filter(Boolean);
220
+ if (refs.length === 0) return null;
221
+ let fresh = refs;
222
+ if (sessionId && cache) {
223
+ const key = `session:${sessionId}:suggested`;
224
+ let seen = [];
225
+ try {
226
+ const v = cache.get(key, SESSION_TTL_HOURS);
227
+ seen = Array.isArray(v) ? v : [];
228
+ } catch {
229
+ seen = [];
230
+ }
231
+ fresh = refs.filter((r) => !seen.includes(r));
232
+ if (fresh.length) {
233
+ try {
234
+ cache.set(key, [...seen, ...fresh]);
235
+ } catch {
236
+ /* ignore */
237
+ }
238
+ }
239
+ }
240
+ if (fresh.length === 0) return null;
241
+ return `${TAG} ${fresh.length} third-party skill${fresh.length > 1 ? 's' : ''} may fit this prompt (not loaded): ${fresh.map((r) => `/singular load ${r}`).join(', ')}`;
242
+ }
243
+
244
+ /** Split config.pinnedSkills into installed names (hint) and allowlisted refs (text), dropping the rest with warnings. */
245
+ export function classifyPinned(pinnedSkills, index, config) {
246
+ const names = new Map();
247
+ for (const s of index) {
248
+ for (const n of [s.name, s.frontmatterName, s.invocableName, s.pluginName ? `${s.pluginName}:${s.name}` : null]) {
249
+ if (n) names.set(String(n).toLowerCase(), s.invocableName || s.name);
250
+ }
251
+ }
252
+ const installed = [];
253
+ const refs = [];
254
+ const warnings = [];
255
+ for (const raw of Array.isArray(pinnedSkills) ? pinnedSkills : []) {
256
+ const entry = String(raw ?? '').trim();
257
+ if (!entry) continue;
258
+ const hit = names.get(entry.toLowerCase());
259
+ if (hit) {
260
+ if (!installed.includes(hit)) installed.push(hit);
261
+ continue;
262
+ }
263
+ if (REF_RE.test(entry)) {
264
+ const parsed = normalizeRefCase(parseRef(entry));
265
+ if (parsed && isAllowlisted(`${parsed.owner}/${parsed.repo}`, config.allowlist)) refs.push(parsed);
266
+ else warnings.push(`pinnedSkills: ${entry} ignored (owner not allowlisted)`);
267
+ continue;
268
+ }
269
+ warnings.push(`pinnedSkills: ${entry} is neither an installed skill name nor an allowlisted owner/repo@skill ref`);
270
+ }
271
+ return { installed, refs, warnings };
272
+ }
273
+
274
+ function readLoadedPins(home) {
275
+ try {
276
+ const v = JSON.parse(fs.readFileSync(path.join(home, 'loaded.json'), 'utf8'));
277
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
278
+ } catch {
279
+ return {};
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Core pipeline. Pure with respect to process I/O: returns the context instead of printing it.
285
+ * `state.partial` is updated after the local phase so a watchdog can flush it on overrun.
286
+ * deps: {fetchImpl, execFileImpl, spawnImpl, cacheGet, cacheSet, now} forwarded to registry/refs (tests).
287
+ * opts.prefetch: spawn background prefetches (hook mode default; off in --dry-run).
288
+ * opts.dryRun: never consume the one-time auto warning marker.
289
+ */
290
+ export async function runOnPrompt({ prompt, cwd, sessionId = null, env: rawEnv = process.env, deps = {}, state = {}, config: cfgOverride, prefetch = false, dryRun = false } = {}) {
291
+ const t = timer();
292
+ const timings = {};
293
+ const dir = path.resolve(cwd || rawEnv.PWD || process.cwd());
294
+ const hardened = hardenEnv(rawEnv, { cwd: dir });
295
+ const env = hardened.env;
296
+ const config = cfgOverride ?? loadConfig(env);
297
+ const home = homeDir(env);
298
+ const clean = normalizePrompt(prompt);
299
+ const result = {
300
+ mode: config.mode,
301
+ skipped: null,
302
+ context: '',
303
+ systemMessage: null,
304
+ stack: null,
305
+ localMatches: [],
306
+ candidates: [],
307
+ loaded: null,
308
+ pinned: [],
309
+ discover: null,
310
+ hint: false,
311
+ timings,
312
+ warnings: [...hardened.warnings, ...(config.warnings ?? [])],
313
+ };
314
+ const aboutSingular = config.mode !== 'off' && mentionsSingular(clean);
315
+ const skip = skipReason(clean, config, prompt);
316
+ if (skip) {
317
+ result.skipped = skip;
318
+ // "show me the singular status" (30 chars) is under minPromptChars but asks about the plugin itself:
319
+ // answer with the command list only (no index, no stack, no network).
320
+ if (aboutSingular && /shorter than minPromptChars/.test(skip)) {
321
+ result.hint = true;
322
+ result.context = `${TAG} ${COMMANDS_HINT}`;
323
+ }
324
+ timings.totalMs = t.elapsed();
325
+ return result;
326
+ }
327
+ const notes = aboutSingular ? [COMMANDS_HINT] : [];
328
+ if (aboutSingular) result.hint = true;
329
+ const budget = Math.max(500, Number(config.totalBudgetMs) || 6000);
330
+ const now = deps.now ?? Date.now;
331
+ const deadline = now() + budget;
332
+ const remaining = () => deadline - now();
333
+ const cache = cacheOf(deps, env);
334
+ const ttlHours = Number(config.cacheTtlHours) || 24;
335
+ const canPrefetch = prefetch && config.prefetch !== false;
336
+
337
+ // --- local phase: index + score, stack (parallel, all local I/O)
338
+ const tLocal = timer();
339
+ const indexOpts = { cwd: dir, home: env.HOME };
340
+ if (env.CLAUDE_CONFIG_DIR) indexOpts.configDir = env.CLAUDE_CONFIG_DIR;
341
+ const [index, stack] = await Promise.all([
342
+ buildIndex(indexOpts).catch(() => []),
343
+ config.stackDetection ? detectStack(dir).catch(() => ({ summary: '', tags: [] })) : Promise.resolve({ summary: '', tags: [] }),
344
+ ]);
345
+ timings.indexMs = tLocal.elapsed();
346
+ const scored = scoreSkills(clean, index, { max: Math.max(0, Number(config.maxLocalSkills) || 3) + 3, minScore: 1.5 });
347
+ const localMatches = scored.filter((m) => !isWeakMatch(m.matched)).slice(0, Math.max(0, Number(config.maxLocalSkills) || 3));
348
+ timings.localMs = tLocal.elapsed();
349
+ result.stack = stack;
350
+ result.localMatches = localMatches.map((m) => ({
351
+ name: m.skill.invocableName || m.skill.name,
352
+ scope: m.skill.scope,
353
+ score: m.score,
354
+ matched: m.matched,
355
+ nameMatched: m.nameMatched,
356
+ strong: isStrongMatch(m),
357
+ skill: m.skill,
358
+ }));
359
+ result.weakMatches = scored.filter((m) => isWeakMatch(m.matched)).map((m) => `${m.skill.invocableName || m.skill.name}:${m.score}`);
360
+ result.indexSize = index.length;
361
+
362
+ // --- pinned skills (config.pinnedSkills): once per session
363
+ const pinned = classifyPinned(config.pinnedSkills, index, config);
364
+ result.warnings.push(...pinned.warnings);
365
+ const sessionKey = sessionId ? `session:${sessionId}:pinned` : null;
366
+ let pinnedDone = false;
367
+ if (sessionKey) {
368
+ try {
369
+ pinnedDone = cache.get(sessionKey, SESSION_TTL_HOURS) === true;
370
+ } catch {
371
+ pinnedDone = false;
372
+ }
373
+ }
374
+ const wantPinned = (pinned.installed.length > 0 || pinned.refs.length > 0) && !pinnedDone;
375
+ if (wantPinned) result.pinned = pinned.installed;
376
+
377
+ const composeArgs = () => ({
378
+ stack,
379
+ localMatches,
380
+ candidates: result.candidates,
381
+ mode: config.mode,
382
+ budgetChars: config.contextBudgetChars,
383
+ loaded: result.loaded,
384
+ pinned: result.pinned,
385
+ notes,
386
+ });
387
+ state.partial = composeContext(composeArgs());
388
+
389
+ if (wantPinned && pinned.refs.length > 0) {
390
+ // Load the first pinned ref's text (cached forever by pinned sha); pin like /singular load does.
391
+ const loadedPins = readLoadedPins(home);
392
+ for (const parsed of pinned.refs) {
393
+ const key = unpinnedRef(parsed);
394
+ const prev = loadedPins[key] && typeof loadedPins[key] === 'object' ? loadedPins[key] : null;
395
+ let sha = parsed.sha || (prev && /^[0-9a-f]{40}$/.test(String(prev.sha ?? '')) ? prev.sha : null);
396
+ const source = `${parsed.owner}/${parsed.repo}`;
397
+ if (!sha) sha = await resolvePin({ source, sha: null }, { remaining, ttlHours, cache, deps });
398
+ if (!sha) {
399
+ if (canPrefetch) spawnPrefetch(key, { env, deps, result });
400
+ continue;
401
+ }
402
+ const pinnedRef = formatRef({ ...parsed, sha });
403
+ let hit = null;
404
+ try {
405
+ hit = cache.get(textCacheKey(pinnedRef), 24 * 365 * 20);
406
+ } catch {
407
+ hit = null;
408
+ }
409
+ if (!hit || typeof hit.text !== 'string') {
410
+ if (remaining() >= MIN_AUTO_FETCH_MS) {
411
+ try {
412
+ hit = await fetchSkillText({ ...parsed, sha }, { cliVersion: config.skillsCliVersion, timeoutMs: remaining() - 200, cache: true }, { ...deps, env });
413
+ } catch (err) {
414
+ log('warn', 'pinned fetch failed', { ref: pinnedRef, error: String(err?.message ?? err) });
415
+ hit = null;
416
+ }
417
+ } else if (canPrefetch) spawnPrefetch(pinnedRef, { env, deps, result });
418
+ }
419
+ if (hit && typeof hit.text === 'string') {
420
+ const cleanText = sanitizeSkillText(hit.text);
421
+ result.loaded = { ref: pinnedRef, sha, text: cleanText.text, stripped: cleanText.stripped, reason: 'pinned in config.pinnedSkills' };
422
+ break; // one loaded block per prompt
423
+ }
424
+ }
425
+ }
426
+ if (wantPinned && sessionKey && (result.pinned.length > 0 || result.loaded)) {
427
+ try {
428
+ cache.set(sessionKey, true);
429
+ } catch {
430
+ /* ignore */
431
+ }
432
+ }
433
+ state.partial = composeContext(composeArgs());
434
+
435
+ // --- remote phase (suggest/auto only, and only when nothing local is a strong match)
436
+ const strong = localMatches.some(isStrongMatch);
437
+ const wantRemote = (config.mode === 'suggest' || config.mode === 'auto') && !strong;
438
+ if (wantRemote) {
439
+ const left = remaining();
440
+ if (left < MIN_REMOTE_MS) {
441
+ result.discover = { skipped: `only ${Math.round(left)}ms left of ${budget}ms budget` };
442
+ } else {
443
+ const tRemote = timer();
444
+ const d = await discover(clean, {
445
+ config,
446
+ stackTags: stack.tags,
447
+ timeoutMs: left - 300,
448
+ deps: { ...deps, env, cacheGet: deps.cacheGet, cacheSet: deps.cacheSet },
449
+ installedNames: installedNamesOf(index),
450
+ });
451
+ timings.discoverMs = tRemote.elapsed();
452
+ result.discover = {
453
+ keywords: d.keywords,
454
+ intent: d.intent,
455
+ shared: d.shared,
456
+ cached: d.cached,
457
+ queries: d.queries,
458
+ rejected: d.rejected.length,
459
+ auditSkipped: d.auditSkipped,
460
+ cooldownUntil: d.cooldownUntil,
461
+ errors: d.errors,
462
+ };
463
+ result.candidates = pinFromCache(d.candidates, { ttlHours, cache });
464
+ state.partial = composeContext(composeArgs());
465
+
466
+ const top = result.candidates[0];
467
+ if (config.mode === 'auto' && top && !result.loaded) {
468
+ const warning = autoWarning(home, { persist: !dryRun });
469
+ if (warning) result.systemMessage = warning;
470
+ const tFetch = timer();
471
+ const [owner, repo] = top.source.split('/');
472
+ const sha = await resolvePin(top, { remaining, ttlHours, cache, deps });
473
+ const pinnedRef = sha ? `${top.source}#${sha}@${top.skill}` : null;
474
+ let hit = null;
475
+ if (pinnedRef) {
476
+ try {
477
+ hit = cache.get(textCacheKey(pinnedRef), 24 * 365 * 20);
478
+ } catch {
479
+ hit = null;
480
+ }
481
+ }
482
+ if (hit && typeof hit.text === 'string') {
483
+ const cleanText = sanitizeSkillText(hit.text);
484
+ result.loaded = { ref: pinnedRef, sha, text: cleanText.text, stripped: cleanText.stripped, cached: true };
485
+ result.candidates = result.candidates.filter((c) => c !== top);
486
+ } else {
487
+ const leftAuto = remaining();
488
+ if (!sha) {
489
+ result.autoSkipped = `HEAD not resolved (only ${Math.round(leftAuto)}ms left); suggestion emitted instead`;
490
+ if (canPrefetch) spawnPrefetch(`${top.source}@${top.skill}`, { env, deps, result });
491
+ } else if (leftAuto < MIN_AUTO_FETCH_MS) {
492
+ result.autoSkipped = `only ${Math.round(leftAuto)}ms left (< ${MIN_AUTO_FETCH_MS}ms for a cold fetch); suggestion emitted, ${canPrefetch ? 'text prefetched in the background' : 'text would be prefetched in the background (hook mode or --prefetch; not in this run)'}`;
493
+ if (canPrefetch) spawnPrefetch(pinnedRef, { env, deps, result });
494
+ } else {
495
+ try {
496
+ const fetched = await fetchSkillText({ owner, repo, sha, skill: top.skill }, { cliVersion: config.skillsCliVersion, timeoutMs: leftAuto - 200, cache: true }, { ...deps, env });
497
+ const cleanText = sanitizeSkillText(fetched.text);
498
+ result.loaded = { ref: fetched.ref, sha: fetched.sha, text: cleanText.text, stripped: cleanText.stripped, cached: fetched.cached === true };
499
+ result.candidates = result.candidates.filter((c) => c !== top);
500
+ } catch (err) {
501
+ result.autoError = String(err?.message ?? err);
502
+ log('warn', 'auto fetch failed', { ref: `${top.source}@${top.skill}`, error: result.autoError });
503
+ if (canPrefetch) spawnPrefetch(pinnedRef, { env, deps, result });
504
+ }
505
+ }
506
+ }
507
+ timings.autoFetchMs = tFetch.elapsed();
508
+ } else if (config.mode === 'suggest' && top && canPrefetch) {
509
+ // Make /singular load (and a later auto prompt) instant: prefetch the top candidate once.
510
+ const pinnedRef = top.sha ? `${top.source}#${top.sha}@${top.skill}` : null;
511
+ let cached = false;
512
+ if (pinnedRef) {
513
+ try {
514
+ cached = Boolean(cache.get(textCacheKey(pinnedRef), 24 * 365 * 20));
515
+ } catch {
516
+ cached = false;
517
+ }
518
+ }
519
+ if (!cached) spawnPrefetch(pinnedRef ?? `${top.source}@${top.skill}`, { env, deps, result });
520
+ }
521
+ // Tell the user directly (systemMessage) about candidates that are only suggested, never loaded.
522
+ if (result.candidates.length && (config.mode === 'suggest' || (config.mode === 'auto' && !result.loaded))) {
523
+ const msg = suggestionMessage(result.candidates, { sessionId, cache });
524
+ if (msg) result.systemMessage = result.systemMessage ? `${result.systemMessage}\n${msg}` : msg;
525
+ }
526
+ }
527
+ }
528
+
529
+ result.context = spillIfNeeded(composeContext(composeArgs()), home);
530
+ state.partial = result.context;
531
+ timings.totalMs = t.elapsed();
532
+ return result;
533
+ }
534
+
535
+ function printVerbose(result) {
536
+ const lines = [
537
+ `${TAG} mode=${result.mode} skipped=${result.skipped ?? 'no'} index=${result.indexSize ?? 0}`,
538
+ `${TAG} timings=${JSON.stringify(result.timings)}`,
539
+ `${TAG} local=${result.localMatches.map((m) => `${m.name}:${m.score}${m.strong ? '*' : ''}`).join(', ') || 'none'}${result.weakMatches?.length ? ` weak(dropped)=${result.weakMatches.join(', ')}` : ''}`,
540
+ ];
541
+ if (result.pinned.length) lines.push(`${TAG} pinned=${result.pinned.join(', ')}`);
542
+ if (result.discover) lines.push(`${TAG} discover=${JSON.stringify(result.discover)}`);
543
+ if (result.candidates.length) lines.push(`${TAG} candidates=${result.candidates.map((c) => `${c.source}@${c.skill} (rank ${c.rank}, matched ${JSON.stringify(c.matched)})`).join(', ')}`);
544
+ if (result.loaded) lines.push(`${TAG} loaded=${result.loaded.ref} (${result.loaded.text.length} chars, cached ${result.loaded.cached === true})`);
545
+ if (result.prefetched) lines.push(`${TAG} prefetched=${result.prefetched.join(', ')}`);
546
+ if (result.autoError) lines.push(`${TAG} autoError=${result.autoError}`);
547
+ if (result.autoSkipped) lines.push(`${TAG} autoSkipped=${result.autoSkipped}`);
548
+ if (result.hint) lines.push(`${TAG} hint=singular command list appended`);
549
+ if (result.systemMessage) lines.push(`${TAG} systemMessage=${result.systemMessage.replace(/\n/g, ' | ')}`);
550
+ for (const w of result.warnings) lines.push(`${TAG} config warning: ${w}`);
551
+ process.stderr.write(`${lines.join('\n')}\n`);
552
+ }
553
+
554
+ export async function main(argv = process.argv.slice(2)) {
555
+ const args = parseArgs(argv);
556
+ if (args.help) {
557
+ process.stdout.write('usage: on-prompt.js [--dry-run "<prompt>"] [--cwd <dir>] [--session <id>] [--json] [--verbose] [--prefetch]\n (no args: read the UserPromptSubmit hook JSON from stdin)\n');
558
+ return 0;
559
+ }
560
+ let prompt;
561
+ let cwd = args.cwd;
562
+ let sessionId = args.session;
563
+ let promptId = null;
564
+ let hookMode = !args.dryRun;
565
+ if (args.dryRun) {
566
+ prompt = args.prompt ?? readStdinSync();
567
+ } else {
568
+ const raw = readStdinSync();
569
+ let input = {};
570
+ try {
571
+ input = raw.trim() ? JSON.parse(raw) : {};
572
+ } catch (err) {
573
+ log('warn', 'on-prompt: stdin is not JSON', { error: err.message, head: raw.slice(0, 120) });
574
+ return 0;
575
+ }
576
+ if (input.hook_event_name && input.hook_event_name !== 'UserPromptSubmit') {
577
+ log('debug', 'on-prompt: ignoring event', { event: input.hook_event_name });
578
+ return 0;
579
+ }
580
+ prompt = typeof input.prompt === 'string' ? input.prompt : '';
581
+ cwd = cwd ?? (typeof input.cwd === 'string' && input.cwd ? input.cwd : null);
582
+ sessionId = sessionId ?? (typeof input.session_id === 'string' && input.session_id ? input.session_id : null);
583
+ promptId = typeof input.prompt_id === 'string' && input.prompt_id ? input.prompt_id : null;
584
+ hookMode = true;
585
+ }
586
+
587
+ // The environment is untrusted (a repository's .claude/settings.json `env` reaches this process).
588
+ const dir = path.resolve(cwd || process.cwd());
589
+ const hardened = hardenEnv(process.env, { cwd: dir });
590
+ for (const k of Object.keys(process.env)) if (!(k in hardened.env)) delete process.env[k];
591
+ for (const [k, v] of Object.entries(hardened.env)) process.env[k] = v;
592
+ for (const w of hardened.warnings) log('warn', /TEST SEAM/.test(w) ? 'on-prompt: test seam active' : 'on-prompt: environment override ignored', { warning: w });
593
+
594
+ const config = loadConfig(process.env);
595
+ for (const w of config.warnings ?? []) log('warn', 'on-prompt: config warning', { warning: w });
596
+ const state = { partial: '' };
597
+ const budget = Math.max(500, Number(config.totalBudgetMs) || 6000);
598
+ let flushed = false;
599
+ const flush = (ctx, why, extra) => {
600
+ if (flushed) return;
601
+ flushed = true;
602
+ if (ctx || extra?.systemMessage) process.stdout.write(hookMode ? hookOutput('UserPromptSubmit', ctx, extra) : `${ctx}\n`);
603
+ if (why) log('warn', 'on-prompt: watchdog fired', { why, chars: ctx.length });
604
+ };
605
+ const watchdog = setTimeout(() => {
606
+ flush(state.partial || '', `deadline ${budget}ms + grace exceeded`);
607
+ process.exit(0);
608
+ }, budget + 600);
609
+
610
+ try {
611
+ const result = await runOnPrompt({ prompt, cwd, sessionId, env: process.env, state, config, prefetch: hookMode || args.prefetch, dryRun: args.dryRun });
612
+ clearTimeout(watchdog);
613
+ if (args.verbose) printVerbose(result);
614
+ const debug = process.env.SINGULAR_DEBUG === '1';
615
+ log('info', 'on-prompt', {
616
+ sessionId,
617
+ promptId,
618
+ mode: result.mode,
619
+ skipped: result.skipped,
620
+ hint: result.hint,
621
+ promptChars: normalizePrompt(prompt).length,
622
+ local: result.localMatches.map((m) => `${m.name}:${m.score}${m.strong ? '*' : ''}`),
623
+ candidates: result.candidates.map((c) => `${c.source}@${c.skill}`),
624
+ loaded: result.loaded?.ref ?? null,
625
+ pinned: result.pinned,
626
+ prefetched: result.prefetched ?? [],
627
+ discover: result.discover
628
+ ? { keywordCount: result.discover.keywords?.length ?? 0, shared: result.discover.shared ?? [], ...(debug ? { keywords: result.discover.keywords, intent: result.discover.intent } : {}), cached: result.discover.cached, cooldownUntil: result.discover.cooldownUntil ?? null, errors: result.discover.errors, skipped: result.discover.skipped }
629
+ : null,
630
+ autoSkipped: result.autoSkipped ?? null,
631
+ timings: result.timings,
632
+ chars: result.context.length,
633
+ });
634
+ // In hook mode the JSON on stdout carries systemMessage; echoing it to stderr too only pollutes the
635
+ // hook_response `output` Claude Code records (stderr + stdout concatenated). Humans get it in --dry-run.
636
+ if (result.systemMessage && args.dryRun) process.stderr.write(`${result.systemMessage}\n`);
637
+ if (args.dryRun && args.json) {
638
+ const { localMatches, ...rest } = result;
639
+ process.stdout.write(`${JSON.stringify({ ...rest, localMatches: localMatches.map(({ skill, ...m }) => m) }, null, 2)}\n`);
640
+ flushed = true;
641
+ } else if (args.dryRun) {
642
+ flushed = true;
643
+ process.stdout.write(result.context ? `${result.context}\n` : `${TAG} (no context: ${result.skipped ?? 'nothing relevant found'})\n`);
644
+ } else if (result.context || result.systemMessage) {
645
+ flush(result.context, null, { systemMessage: result.systemMessage });
646
+ }
647
+ return 0;
648
+ } catch (err) {
649
+ clearTimeout(watchdog);
650
+ log('error', 'on-prompt failed', { error: String(err?.stack ?? err?.message ?? err) });
651
+ if (args.dryRun) process.stderr.write(`${TAG} error: ${String(err?.message ?? err)}\n`);
652
+ return 0;
653
+ }
654
+ }
655
+
656
+ const isEntrypoint = (() => {
657
+ try {
658
+ return process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
659
+ } catch {
660
+ return false;
661
+ }
662
+ })();
663
+
664
+ if (isEntrypoint) {
665
+ main().then(
666
+ (code) => process.exit(code ?? 0),
667
+ (err) => {
668
+ log('error', 'on-prompt crashed', { error: String(err?.stack ?? err) });
669
+ process.exit(0);
670
+ },
671
+ );
672
+ }