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.
package/docs/spec.md ADDED
@@ -0,0 +1,209 @@
1
+ # Singular — spec (v0.2, 2026-09-24; amended 2026-09-25, 2026-10-07/08 and 2026-10-13)
2
+
3
+ Claude Code plugin (plugin id `singular@singular`, npm package `singular-skill`, one user-facing command `/singular <subcommand>`) that, on every prompt, (1) routes to already-installed skills, (2) detects the project stack and injects it, (3) on a local miss, discovers third-party skills on skills.sh, gates them (owner allowlist, install floor, audit), and (4) loads them **ephemerally** (text only, never written to `~/.claude/skills`) when the user types `/singular load`, or automatically when `mode: "auto"` is explicitly enabled. Persistent installs go through `/singular add`, which pins to a commit SHA and rejects skill folders that would become plugins. Installation is two commands (`npm install -g singular-skill && singular install`): the `singular` CLI copies the plugin payload to `~/.claude/singular/plugin/` and registers it with `claude plugin marketplace add` + `claude plugin install singular@singular` (see "bin/singular-cli.js").
4
+
5
+ Grounding: `research/2026-09-15-auto-skill-discovery-feasibility.md`. Key measured facts it relies on:
6
+ - `UserPromptSubmit` command hooks run once before every prompt (never per tool call), get JSON on stdin (`session_id, transcript_path, cwd, prompt_id, permission_mode, hook_event_name, prompt`; for a typed slash command `prompt` is the raw slash text; piped stdin is appended to the prompt), may return `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"..."}}` (10,000-char cap; overflow spilled to file). Hooks cannot rewrite the prompt.
7
+ - `UserPromptExpansion` hooks fire when a typed `/command` expands; matcher = the plugin-prefixed command name (`singular:singular`, hence the `(^|:)singular$` regex); stdin has `command_args`; may return `additionalContext` or block with exit 2. Verified live on Claude Code 2.1.282 (2026-09-24) and 2.1.293 (2026-10-07).
8
+ - `SubagentStart` hooks fire when the Agent tool spawns a subagent; stdin `{session_id, prompt_id, transcript_path, cwd, agent_id, agent_type, hook_event_name}`; `hookSpecificOutput.additionalContext` reaches the subagent (context-only event; exit 2 does not block). Verified live on 2.1.293: the subagent echoed the injected `[singular] Stack:` line. A subagent's own prompt never fires `UserPromptSubmit`, and the parent's context is not inherited; the parent is resumed with a synthetic `<task-notification>` prompt that does fire `UserPromptSubmit`.
9
+ - A skill installed by a hook is NOT in the model's skill list for the first request of that turn. So never rely on hot-reload inside the turn; inject text instead.
10
+ - `GET https://skills.sh/api/search?q=<q>&limit=<n>` is anonymous JSON `{skills:[{id, skillId, name, installs, source}]}`; ranking is lexical, not install-weighted → always re-sort by `installs`, use `limit` ≥ 200.
11
+ - `GET https://skills.sh/api/v1/skills/audit/{source}/{skill}` public; advisory.
12
+ - `npx -y skills@1.5.26 use owner/repo#<40-hex-sha>@skill` prints SKILL.md wrapped in a prompt, writes only to a temp dir. `#sha` pinning is undocumented but works (`parseFragmentRef`, `cloneAtSha`). Pin the CLI version too.
13
+ - `npx skills add` copies the WHOLE folder; a folder with `.claude-plugin/`, `hooks/`, `.mcp.json`, `bin/` etc. becomes a `<name>@skills-dir` plugin at user scope on next session with no trust dialog. → `/singular add` must inspect and refuse.
14
+ - Anthropic: use skills only from trusted sources. Base rate ~13% critical (Snyk). Scanners bypassable. Hence defaults: `mode: "suggest"`, allowlist required, install floor 1000.
15
+
16
+ ## Zero dependencies. Node ≥ 20 (uses `fetch`, `node:test`). ESM (`"type":"module"`). No npm packages.
17
+
18
+ ## Layout
19
+ ```
20
+ package.json name "singular-skill" (the npm name; "singular" is taken), version 0.2.0, "bin": {"singular": "bin/singular-cli.js"},
21
+ "files" whitelist = the plugin payload (bin, lib, hooks, skills, .claude-plugin, README.md, LICENSE, config.example.json, docs/security.md, docs/spec.md, docs/testing.md),
22
+ prepublishOnly = npm test; zero dependencies
23
+ .claude-plugin/plugin.json name "singular", displayName "Singular"; hooks -> ./hooks/hooks.json; skills auto-discovered from ./skills
24
+ .claude-plugin/marketplace.json marketplace "singular" with one entry (source "./"), so the registered plugin id is singular@singular;
25
+ `singular install` registers ~/.claude/singular/plugin (the payload copy), a checkout can be registered the same way by hand
26
+ hooks/hooks.json UserPromptSubmit -> node ${CLAUDE_PLUGIN_ROOT}/bin/on-prompt.js (timeout 8)
27
+ UserPromptExpansion matcher "(^|:)singular$" -> node ${CLAUDE_PLUGIN_ROOT}/bin/on-expand.js (timeout 25)
28
+ (command_name is plugin-prefixed, `singular:singular`, so a bare "singular" matcher never fires for a plugin skill;
29
+ on-expand acts only when command_args starts with `load` and exits 0 with no output for every other subcommand)
30
+ SubagentStart (no matcher) -> node ${CLAUDE_PLUGIN_ROOT}/bin/on-subagent.js (timeout 3): stack line only
31
+ skills/singular/SKILL.md name "singular", disable-model-invocation: true, description "Singular: find, load, add and manage third-party skills (/singular help)".
32
+ Body: if $ARGUMENTS starts with `load`, the expansion hook has already injected the loaded text: acknowledge in one line (name + pinned sha), run nothing.
33
+ Otherwise run `node "${CLAUDE_PLUGIN_ROOT}/bin/singular.js" $ARGUMENTS` via Bash and relay its output verbatim.
34
+ skills/singular-find/SKILL.md model-invocable, reactive ("is there a skill for X"): run `node "${CLAUDE_PLUGIN_ROOT}/bin/singular.js" find "<query>"`, present candidates, never install.
35
+ bin/on-prompt.js UserPromptSubmit entrypoint
36
+ bin/on-expand.js UserPromptExpansion entrypoint for `/singular load` (+ direct CLI mode, + --prefetch mode)
37
+ bin/on-subagent.js SubagentStart entrypoint (stack line only; no index, no network)
38
+ bin/singular.js subcommand dispatcher: status | help | find | add | mode | load; the command code lives in lib/commands/{status,find,add,mode}.js
39
+ (the former 0.1.x per-command scripts, same output and exit codes) and is re-exported here; `load` is a terminal
40
+ convenience that calls the CLI mode of bin/on-expand.js (inside Claude Code the expansion hook handles /singular load)
41
+ bin/singular-cli.js the `singular` binary: install | update | uninstall | status | doctor, plus find | add | mode | load proxies for a terminal
42
+ lib/config.js lib/skill-index.js lib/score.js lib/stack.js lib/registry.js lib/refs.js lib/git.js lib/context.js lib/inspect.js lib/cache.js lib/log.js
43
+ lib/commands/status.js find.js add.js mode.js the subcommand implementations (collectStatus/formatStatus, runFind/formatReport/rankRejected, runAdd, setMode/describeMode)
44
+ test/*.test.js (node:test), test/fixtures/*, test/e2e/run.sh (+ README of the recipe); docs/testing.md describes the suite, coverage, e2e and the test seams
45
+ config.example.json README.md docs/spec.md docs/security.md docs/testing.md
46
+ ```
47
+
48
+ State directory `~/.claude/singular/` (or `$SINGULAR_HOME`): `config.json`, `cache/`, `loaded.json`, `installed.json`, `singular.log`, `spill-*.md`, `.auto-mode-warned`, and `plugin/` (the payload copy the CLI registers; `plugin.prev/` is the previous copy kept for rollback). Environment variables: `SINGULAR_HOME`, `SINGULAR_MODE`, `SINGULAR_DEBUG`, the test seams `SINGULAR_REGISTRY_BASE`, `SINGULAR_GIT_BASE`, `SINGULAR_SKILLS_DIR`, `SINGULAR_TEST_ALLOW_AUTO`, and the e2e runner's `SINGULAR_E2E_*`. Context and log lines are tagged `[singular]`; loaded text is delimited by `<singular-loaded ref="...">`.
49
+
50
+ ## Config — `~/.claude/singular/config.json` (created on first run from defaults; env `SINGULAR_HOME` overrides dir)
51
+ ```json
52
+ {
53
+ "mode": "suggest", // "off" | "local" | "suggest" | "auto"
54
+ "allowlist": ["anthropics", "vercel-labs", "microsoft", "multica-ai"],
55
+ "installFloor": 1000,
56
+ "maxLocalSkills": 3,
57
+ "maxCandidates": 2,
58
+ "minPromptChars": 40,
59
+ "remoteTimeoutMs": 2500,
60
+ "totalBudgetMs": 6000,
61
+ "contextBudgetChars": 2000,
62
+ "cacheTtlHours": 24,
63
+ "skillsCliVersion": "1.5.26",
64
+ "requireAuditPass": true,
65
+ "stackDetection": true,
66
+ "blockedRefs": [],
67
+ "pinnedSkills": [], // added 2026-09-25: installed names or allowlisted refs injected once per session
68
+ "prefetch": true // added 2026-09-25: background prefetch of the top suggestion's text
69
+ }
70
+ ```
71
+ `mode`: off = hook no-ops; local = only installed-skill routing + stack; suggest = local + remote candidates as text suggestions; auto = local + fetch the single best gated candidate and inject its (sanitized) text without confirmation. `auto` must show a one-time warning on first use (as the hook's `systemMessage`; stderr of an exit-0 hook is not shown to the user) and README must document the risk and the cost. `auto` is honoured only from the default `~/.claude/singular/config.json`; the environment can only lower the mode (see Amendments).
72
+
73
+ ## Module APIs (fixed; builders must match exactly)
74
+
75
+ ### lib/log.js
76
+ `export function log(level, msg, extra?)` → writes JSON lines to `$SINGULAR_HOME/singular.log` (default `~/.claude/singular/singular.log`), never stdout. `export function timer()` → `{ elapsed(): ms }`.
77
+
78
+ ### lib/config.js
79
+ `export function loadConfig(env = process.env) → Config` (defaults merged with file; validates types; unknown keys kept). `export const DEFAULTS`. `export function configPath(env)`, `export function homeDir(env)` (mkdir -p). `export function isAllowlisted(source, allowlist)` — `source` is `owner/repo`; allowlist entries are `owner` or `owner/repo` (exact, case-insensitive).
80
+
81
+ ### lib/cache.js
82
+ `export function cacheGet(key, ttlHours) → any|null`, `export function cacheSet(key, value)`; files under `$SINGULAR_HOME/cache/<sha256(key)>.json` with `{ts, value}`.
83
+
84
+ ### lib/skill-index.js
85
+ `export async function buildIndex({cwd, home}) → Skill[]` where `Skill = {name, description, path, scope: "user"|"project"|"plugin"|"synced", pluginName?}`. Scans: `$HOME/.claude/skills/**/SKILL.md` (depth ≤ 3; `synced/` → scope synced), `<cwd>/.claude/skills/**/SKILL.md`, and installed plugins' skills (read `$HOME/.claude/plugins/installed_plugins.json` if present to get install paths; else scan `$HOME/.claude/plugins/marketplaces/*/**/skills/*/SKILL.md` and `$HOME/.claude/plugins/cache/**/skills/*/SKILL.md`, depth-limited). Dedupe by name (project > user > plugin). Parse YAML frontmatter minimally (name, description; `description` may be multi-line `>`/`|`). Cache the index for 60 s keyed by dir mtimes. Must finish < 150 ms on ~100 skills.
86
+
87
+ ### lib/score.js
88
+ `export function tokenize(text) → string[]` (lowercase, split non-alnum, drop stopwords, keep tokens ≥ 2 chars, simple stemming: strip trailing s/ing/ed). `export function scoreSkills(prompt, skills, {max=3, minScore=1.5}) → {skill, score, matched: string[]}[]` — BM25-lite over name+description with name tokens weighted ×3; exact multi-word phrase matches ×2; return sorted, thresholded. `export function extractKeywords(prompt, {max=4}) → string[]` — top TF tokens minus stopwords, prefer tech nouns (a small built-in lexicon: react, nextjs, tailwind, svelte, vue, django, fastapi, postgres, supabase, stripe, playwright, docker, k8s, terraform, rust, go, swift, kotlin, ...).
89
+
90
+ ### lib/stack.js
91
+ `export async function detectStack(cwd) → {summary: string, tags: string[]}` — reads package.json (deps → framework tags: next, react, vue, nuxt, svelte, astro, remix, express, hono, nest, tailwind, prisma, drizzle, supabase, trpc, vitest, jest, playwright, typescript), lockfile → pm, `tsconfig.json` strict, `pyproject.toml`/`requirements.txt` (django, fastapi, flask, pytest), `go.mod`, `Cargo.toml`, `Gemfile`, `pom.xml`, git branch (via `.git/HEAD` read, no subprocess). Summary ≤ 160 chars e.g. `Next.js 16, React 19, Tailwind 4, TypeScript strict, pnpm; branch main`. Never throws; unknown → `{summary:"", tags:[]}`. Cache per cwd 60 s.
92
+
93
+ ### lib/refs.js
94
+ `export function parseRef(str) → {owner, repo, sha|null, skill} | null` accepting `owner/repo@skill` and `owner/repo#<40-hex>@skill`. `export function formatRef(ref)`, `unpinnedRef(ref)`, `normalizeRefCase(ref)` (lowercases owner/repo only: GitHub resolves them case-insensitively, so two spellings share one pin and one text-cache entry; the skill segment is case-sensitive). `export const REF_RE` (strict: `^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9._-]*(#[0-9a-f]{40})?@[A-Za-z0-9][A-Za-z0-9._-]*$` — every segment starts alphanumeric so `-y`, `--skill`, `.`, `..` can never become a flag or a path segment; `isSafeSegment` also caps a segment at 100 chars). `export async function resolveHeadSha(owner, repo, {timeoutMs}, deps) → sha` via `git ls-remote <base>/<o>/<r> HEAD` (execFile with an args array, timeout, `GIT_TERMINAL_PROMPT=0`; base is `https://github.com` or the `SINGULAR_GIT_BASE` test seam). A credential prompt (what GitHub does for a missing or private repository) is reported as `repository not found or private (GitHub asked for credentials): <raw detail>`. `export function isSha(s)`.
95
+
96
+ ### lib/git.js
97
+ `GITHUB_BASE`, `gitBase(env)`, `repoUrl(owner, repo, env)`; `describeGitFailure(what, r, timeoutMs)` (the credential-prompt mapping above); `runExecFile(impl, file, args, opts)` (promise wrapper, never rejects, closes stdin); `cloneAtSha({owner, repo, sha, dest, timeoutMs, execFileImpl, env, tmpdir}) → {ok} | {ok: false, step, message}` = `git init -q` / `remote add origin <url>` / `fetch --depth 1 origin <sha>` / `-c core.symlinks=true checkout -q FETCH_HEAD` in `dest`; `findSkillDirs(root) → {dir, rel, name}[]` (every SKILL.md folder, frontmatter name included, `.git`/`node_modules` skipped). Shared by `lib/refs.js`, `lib/registry.js` (git-based text fetch under the seam) and `bin/singular.js add`.
98
+
99
+ ### lib/registry.js
100
+ `export async function searchSkills(query, {limit=200, timeoutMs}) → Candidate[]` hitting `https://skills.sh/api/search?q=&limit=`; `Candidate = {source: "owner/repo", skill, installs, url}`; sorted by installs desc; AbortController timeout; on any error → `[]` (never throws). `export async function auditSkill(source, skill, {timeoutMs}) → {status: "pass"|"warn"|"fail"|"unknown", raw}` from `/api/v1/skills/audit/{source}/{skill}`. `export function gateCandidates(cands, config, extra={}) → {accepted: Candidate[], rejected: {cand, reason}[]}` applying allowlist, installFloor, blockedRefs, dedupe forks (same skill name from non-allowlisted source dropped). `export async function fetchSkillText(ref, {cliVersion, timeoutMs}) → {text, sha}` running `npx -y skills@<cliVersion> use owner/repo#sha@skill` with `DISABLE_TELEMETRY=1`, cwd = a fresh temp dir, execFile (no shell). If ref.sha is null → resolveHeadSha first. Strip the CLI's wrapper prose, return SKILL.md text. `export function sanitizeSkillText(text) → {text, stripped: string[]}` — remove frontmatter keys `allowed-tools, disallowed-tools, hooks, context, agent, shell, paths, model`; drop lines containing `` !` `` or fenced ```` ```! ```` blocks; drop `@`-file references (`@path` tokens at line start); cap 9,000 chars; record what was stripped. Discovery pipeline: `export async function discover(prompt, {config, stackTags, timeoutMs}) → {candidates: Gated[], keywords, cached: boolean}` = keywords → cache lookup → search (each keyword + joined query, merged) → gate → optional audit for top N → return top `maxCandidates`.
101
+
102
+ ### lib/context.js
103
+ `export function composeContext({stack, localMatches, candidates, mode, budgetChars, loaded?, pinned?, notes?}) → string` — factual, ≤ budget, no imperative "you must use". Format (sections omitted when empty; the first line carries the `[singular]` tag; candidates are joined with `; `):
104
+ ```
105
+ [singular] Stack: <summary>
106
+ Pinned installed skills (user-configured, consider invoking): <name>, ...
107
+ Installed skills likely relevant: <name> (matches <tok>, <tok>), <name> ...
108
+ If one of the third-party candidates below fits the request, end your reply with exactly one line `Load with /singular load <ref>` naming the single best fit; omit that line if none fits.
109
+ Third-party candidates (not loaded): <owner/repo[#sha7]@skill> (<installs> installs, audit <status>) — load with /singular load owner/repo@skill; <next candidate>
110
+ <notes, e.g. the singular command list>
111
+ Loaded third-party skill <owner/repo#sha@skill> (<reason>; text only, nothing installed). Stripped: <list>.
112
+ <singular-loaded ref="<pinned ref>">
113
+ <sanitized body, fitted under 9,500 chars with a trailing "[singular: truncated]" marker when cut>
114
+ </singular-loaded>
115
+ ```
116
+ The relay line (`RELAY_LINE`) precedes the candidate list; `#sha7` is shown only when the HEAD is already cached. Budget: candidates are dropped first, then installed matches, then a hard cut; `notes` are never dropped. Exports: `TAG`, `SPILL_THRESHOLD` (9500), `HOOK_HARD_CAP` (10000), `TRUNCATION_MARKER`, `RELAY_LINE`, `fitLoadedText(text, reservedChars) → {text, truncated}`. `export function spillIfNeeded(text, home) → text|pathNote` (if > 9,500 chars write to `$SINGULAR_HOME/spill-<ts>.md` and return a short note with the path; last resort, only for oversized routing text).
117
+ `export function hookOutput(eventName, additionalContext, {systemMessage?}) → string` (JSON for stdout: `{"hookSpecificOutput":{"hookEventName","additionalContext"}}` plus a top-level `systemMessage` when given; nothing else is ever printed).
118
+
119
+ ### lib/inspect.js
120
+ `export async function inspectSkillFolder(dir) → {ok: boolean, problems: string[]}` — problems if the folder contains any of: `.claude-plugin/`, `plugin.json`, `hooks/`, `.mcp.json`, `.lsp.json`, `bin/`, `monitors/`, `agents/`, `commands/`, `settings.json`, `scripts/`, symlinks, files with exec bit; or SKILL.md frontmatter has `hooks`, `allowed-tools`, `disallowed-tools`, `context`, `agent`, `shell`, `paths`, `user-invocable: false`; or body has `` !` `` / ```` ```! ```` / line-leading `@` refs. Never modifies.
121
+
122
+ ### bin/on-prompt.js
123
+ Read stdin JSON. Exit 0 with no output when (the logged `skipped` strings, in evaluation order): `mode off`; `empty prompt` (whitespace only after the `<pasted_content>` wrapper lines are stripped); `slash command` (the RAW prompt, trimmed, starts with `/` — Claude Code's own rule, so a pasted block whose first inner line is a slash command is an ordinary prompt); `task notification (synthetic prompt)` (`<task-notification>`, delivered to the main agent after a backgrounded subagent finishes); `system reminder (synthetic prompt)` (a bare `<system-reminder>…</system-reminder>` block); `own output` (starts with `[singular]`; a mid-prompt mention is routed); `prompt shorter than minPromptChars (N < 40)` (inclusive boundary: 40 characters route, 39 do not). Corrupt stdin or another `hook_event_name` also produce no output. `--dry-run` prints `[singular] (no context: <reason>)` for a skipped prompt. Exception: a prompt that mentions the plugin (`/singular`, or `/singular-find`, or the word `singular` next to `skill(s)`/`plugin(s)`/`command(s)`/`mode(s)`/`status`/`config`/`hook(s)`; the word alone is ordinary English and must not trigger it) always gets the one-line command list (`COMMANDS_HINT`: the `/singular` subcommands), even under minPromptChars, because every subcommand except `find` is hidden from the model. Else within `totalBudgetMs` (hard deadline; remote step skipped if < 1500 ms left): index+score local, stack detect, and if mode ∈ {suggest, auto} and no *strong* local match (`isStrongMatch`, see Amendments) → discover. In `auto`: fetch top candidate text (pinned; if no sha, resolve) → sanitize → append as `Loaded third-party skill <ref> (auto mode, user-configured):` + text within 9,000 chars. Print `hookOutput("UserPromptSubmit", ctx, {systemMessage})` only if ctx or systemMessage non-empty. `systemMessage` carries the one-time auto warning and, in suggest (or auto without a load), `[singular] N third-party skill(s) may fit this prompt (not loaded): /singular load a/b@c, ...` — each ref announced once per `session_id` (cache `session:<id>:suggested`), so the user learns about a candidate without the model relaying it. Any exception → log, exit 0 silently. The info log line carries `sessionId`, `promptId`, `hint`, timings. Also `--dry-run "<prompt>"` CLI arg for testing prints the context to stdout (never consumes the one-time auto warning marker; `autoSkipped` says the prefetch did not run).
124
+
125
+ ### bin/on-expand.js
126
+ Hook mode: `hooks.json` matches `(^|:)singular$`, so the hook fires for every `/singular ...` expansion (`command_name` `singular:singular`). It acts only when `command_args` starts with the subcommand `load` (`load <ref> [--update]`); for every other subcommand (`status`, `help`, `find`, `add`, `mode`, nothing) it exits 0 with **no output**, so the `skills/singular/SKILL.md` body runs the dispatcher through Bash. Read stdin JSON (`command_name`, `command_args` per spike). Validate args (the text after `load`): exactly one ref matching `REF_RE`; exactly one ref matching `REF_RE`; owner allowlisted (else print reason to stderr, exit 2 = block). Owner/repo are lowercased (`normalizeRefCase`) before pinning and caching. Resolve sha if missing; record `{ref, sha, ts}` in `$SINGULAR_HOME/loaded.json` (first load pins; later loads reuse unless `--update` present in args). fetchSkillText → sanitize → output `hookOutput("UserPromptExpansion", ctx)` where `ctx` is `[singular] The user explicitly loaded third-party skill <pinned ref> via /singular load (pinned <resolved | loaded.json | resolved (--update) | arg>; text only, nothing installed). Contents (sanitized; stripped: <list | nothing>):` followed by `<singular-loaded ref="<pinned ref>">`, the body (fitted under 9,500 chars, `truncated N chars to fit the 9,500-char hook budget` appended to the stripped list when cut) and `</singular-loaded>`. A block is `[singular] <reason>. usage: /singular load owner/repo@skill or /singular load owner/repo#<40-hex-sha>@skill [--update]` on stderr with exit 2 (hook mode) or exit 1 (CLI mode); reasons: `unknown option <flag>`, `missing skill reference`, `expected exactly one skill reference, got N`, `invalid skill reference "<first 80 chars>"`, `refused: owner "<o>" is not in the allowlist [...]. Add "<o>" (or "<o/r>") to "allowlist" in <config> if you trust that source, then retry.`, `refused: <o/r@s> matches "blockedRefs" in your config.`, `could not resolve HEAD of <url>: ...`, `could not fetch <ref>: ...`, `<ref> has no usable text after sanitizing`. `loaded.json` entry per unpinned (case-folded) ref: `{ref, sha, ts, lastTs, loads, chars, stripped}`. Also support direct CLI use: `node bin/on-expand.js <ref> [--update] [--json]` prints `[singular] loaded <pinned ref> (pinned <source>; stripped: <list | nothing>)`, a blank line and the sanitized text (`--json`: `{ref, sha, pinSource, stripped, text}`); `--prefetch` (spawned detached by on-prompt) caches HEAD and text without pinning, prints nothing and exits 0 even when blocked. Measured 2026-10-08: first load 3.2 s wall (HEAD resolve + pinned `npx skills use`, warm npm cache), cached text 0.05 s, `--update` 0.35 s.
127
+
128
+ ### bin/on-subagent.js (SubagentStart)
129
+ Read stdin JSON `{session_id, transcript_path, cwd, prompt_id, agent_id, agent_type, hook_event_name: "SubagentStart"}` (2.1.293; `permission_mode` absent). Ignore any other event or input without `cwd`. hardenEnv + loadConfig; nothing when mode is off or `stackDetection` is false; otherwise ONLY `detectStack(cwd)` and print `{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"[singular] Stack: <summary>"}}`. No index, no scoring, no network, no prefetch, no pinned or loaded third-party text (never forwarded to subagents). Measured 35 ms wall. SubagentStart is "context only": exit 2 does not block. `--dry-run [--cwd]` prints the line.
130
+
131
+ ### bin/singular.js add `<ref>` [--yes] [--json]
132
+ Requires allowlisted owner; refuses if `~/.claude/skills/<skill>` already exists BEFORE any network round trip; then resolves sha (pin). Does **not** use the skills CLI: `lib/git.js cloneAtSha` fetches the pinned commit into a temp dir (`git init` / `remote add origin https://github.com/<o>/<r>` / `fetch --depth 1 origin <sha>` / `checkout FETCH_HEAD`, execFile, no shell), locates the skill by an exact, case-sensitive directory or frontmatter name, rejects any `120000` / `100755` / `160000` entry from `git ls-tree -r`, runs `inspectSkillFolder` on the checkout, and only then copies (`cpSync`, `dereference: false`, `errorOnExist`) with a before/after snapshot of `~/.claude/skills` (exactly one new entry = the target) and a post-copy re-inspection; any failure removes exactly the folder this run created (guarded three ways) and prints `REJECTED: <problems>`; else prints `INSTALLED <ref> sha <sha>` + audit JSON + `Run /reload-skills or start a new session`. A rejected skill never touches `~/.claude/skills`.
133
+
134
+ ### bin/singular.js find `<query>` [--limit N] [--all] [--json] [--cwd <dir>] → prints installed matches, the gated candidates table (`ref installs audit allowlisted?`), the rejected count grouped by reason (`already installed locally (...)` collapsed to one line), `Best allowlist-only rejection: <ref> (<installs> installs, matched <tokens>) - add "<owner>" to "allowlist" in <config path>` when the most relevant rejected entry was refused only for its owner, and the top rejected entries **by relevance** (same BM25-lite + log10(installs) as the accepted ones; `--all` for every entry). During a skills.sh cooldown with cached catalogs it prints the real count `(from cached catalogs only)`; `Rejected: n/a` only when nothing could be evaluated. `--json` option (rejected entries carry `rank`, `matches`, `matched`; plus `bestRejected`, `configPath`). Known issue (observed 2026-10-08): `main()` calls `process.exit()` right after one large `process.stdout.write()`, so when stdout is a pipe the output is cut at 65,536 bytes (`--json` for a 500-entry rejected list is ~116 KB; the same run redirected to a file is complete). The plain report (~60 KB with `--all`) stayed under the limit.
135
+
136
+ ### bin/singular.js mode `[off|local|suggest|auto]` [--json] → shows the effective mode and its source (including hardenEnv notes such as an ignored relative `SINGULAR_HOME`, printed as `! ...` lines, also under `warnings` in `--json`), or rewrites only the `mode` key; refuses `auto` in a redirected home; refuses to touch an unreadable (EISDIR) or corrupt file. Every message starts with `[singular]`.
137
+
138
+ ### bin/singular.js `<status|help|find|add|mode> [...]` (dispatcher)
139
+ One entrypoint for everything the `/singular` skill body runs through Bash and for the terminal proxies of the CLI; the implementations live in `lib/commands/{status,find,add,mode}.js` and are re-exported by `bin/singular.js`. A leading option without a subcommand (`--json`, `--cwd`) means `status`; `load <ref> [--update] [--json]` is a terminal convenience that calls the CLI mode of `bin/on-expand.js` (it never falls through to the hook mode, which would wait for stdin JSON). `status [--json] [--cwd <dir>]` prints the report that `collectStatus`/`formatStatus` produced as a stand-alone status script until 0.2.0 (first line `[singular] singular <version> plugin root: <root>`, then state dir, config path, every config key, `! ...` warnings, env overrides, active test seams, effective-vs-file mode, the auto warning, pinned skills, `installed skills indexed: N (...)`, `stack (<cwd>): ...`, cache stats, cooldown, `loaded pins (/singular load, ephemeral): N`, `persistent installs (/singular add): N`, `log: <path>` + last 5 lines; `--json` the same facts as an object) followed, with no argument, by the one-screen help. `help` prints the subcommand list. `find`, `add`, `mode` are the sections below. Exit codes and output formats are those of the former 0.1.x per-command scripts (0 on success; `find` without a query, `mode` with a bad value, `add` with a rejection all exit 1); an unknown subcommand prints `[singular] unknown subcommand "<x>"; expected one of status | help | find | load | add | mode` plus the help to stderr and exits 1. The 0.1.x per-command scripts in `bin/` no longer exist (no shims).
140
+
141
+ ### bin/singular-cli.js (the `singular` binary; `"bin": {"singular": "bin/singular-cli.js"}`)
142
+ Zero dependencies; every subprocess is `execFile` with an argument array; `CLAUDE_CONFIG_DIR` and the rest of the environment are passed through to `claude`, so tests isolate with a fresh `CLAUDE_CONFIG_DIR`. Exit 1 with a clear message on any failure; never a partially registered state (if a `claude plugin ...` step fails the message names that exact command and how to retry). `--help` prints the list.
143
+ - `install [--dir <path>] [--dry-run] [--json]`: (1) `node >= 20` and `claude` on `PATH` with version `>= 2.1.282` (parsed from `claude --version`); (2) copy the payload (`.claude-plugin`, `hooks`, `skills`, `bin`, `lib`, `package.json`, `LICENSE`, `README.md`, `config.example.json`) from the package's own directory (resolved from `import.meta.url`) to `~/.claude/singular/plugin/` (or `--dir`), atomically: write `plugin.tmp`, keep the previous copy as `plugin.prev`, rename; the registered path therefore survives `nvm`/Node upgrades and `npm update`; (3) `claude plugin marketplace add ~/.claude/singular/plugin`, or `claude plugin marketplace update singular` if the marketplace exists; (4) `claude plugin install singular@singular`, or `claude plugin update singular@singular` if already installed; (5) print `Installed. Open a new Claude Code session (or run /reload-plugins) and type /singular`. `--dry-run` prints the plan only; `--json` the steps as JSON.
144
+ - `update`: same as `install` (re-copy from the currently installed package, then marketplace update + plugin update).
145
+ - `uninstall [--purge]`: `claude plugin uninstall singular@singular`, `claude plugin marketplace remove singular`, remove `~/.claude/singular/plugin`; `--purge` also removes the config, cache, log and pins.
146
+ - `status`: node and claude versions, payload path + version, marketplace registered?, plugin installed/enabled? (parsed from `claude plugin list --json`), config path + mode, last 5 `singular.log` lines. `doctor` = `status` plus an actionable hint per problem.
147
+ - `find | add | mode | load <...>`: proxies to `bin/singular.js` (`load` to the direct CLI mode of `bin/on-expand.js`), same output and exit codes as inside Claude Code.
148
+
149
+ ### Tests (node:test; `npm test` = `node --test 'test/*.test.js'` — the bare-directory form fails on Node >= 21)
150
+ - Unit per module with fixtures (`test/fixtures/skills.search.json`, `audit.json`, sample SKILL.md folders incl. a malicious-shaped one with hooks/, bin/, `!`cmd`, symlink).
151
+ - No network in unit tests: registry uses an injectable `fetchImpl`/`execImpl` (add `deps` param with defaults).
152
+ - `test/e2e/run.sh`: uses the verified recipe (`test/e2e/README.md`: `--plugin-dir`, `--setting-sources project`, `--strict-mcp-config`, `--no-session-persistence`, haiku, a fresh `SINGULAR_HOME`, cwd = a copy of the Next.js fixture) to run `claude -p` three times and make 9 assertions: A (`SINGULAR_MODE=local`, plain prompt) the `UserPromptSubmit` `hook_response` output contains `[singular] Stack: Next.js 16` and the model echoed it; B (`/singular load multica-ai/andrej-karpathy-skills@karpathy-guidelines`) the `UserPromptExpansion` output contains `The user explicitly loaded third-party skill` and `singular-loaded`, the result mentions the skill name, and `loaded.json` holds a 40-hex sha; C (`/singular load evil/repo@x`) the result begins `UserPromptExpansion operation blocked by hook` with `not in the allowlist`, the hook event shows `exit_code: 2`, and `total_cost_usd` is 0; D the hashes of `~/.claude/settings.json`, `settings.local.json`, `plugins/installed_plugins.json` and the listing of `~/.claude/skills` are unchanged. Prints PASS/FAIL per assertion; `SKIP` without `claude` or a login. Last run 2026-10-07 on 2.1.293: 9/9, < $0.03.
153
+
154
+ ## Non-goals v0.1
155
+ Browser automation; semantic/embedding search; writing to `~/.claude/skills` from hooks; rewriting the prompt; supporting agents other than Claude Code.
156
+
157
+ ## Amendments (2026-09-25, from the review of 2026-09-24)
158
+
159
+ Exported function names and signatures above are unchanged; these are behaviour changes, each covered by a regression test in `test/`.
160
+
161
+ - **Environment is untrusted** (`lib/config.js` `hardenEnv`, `loadConfig`): a repository's committed `.claude/settings.json` `env` reaches the hook process. `SINGULAR_MODE` can only lower the mode below the file's value; `SINGULAR_HOME` is honoured only as an absolute path outside the hook cwd; `mode: "auto"` counts only in the default `~/.claude/singular/config.json`; a `HOME` inside the project is replaced by the real home. The auto warning is emitted as the hook JSON `systemMessage` (`hookOutput(event, ctx, {systemMessage})`).
162
+ - **Reference grammar** (`lib/refs.js`, `lib/registry.js`): every segment must start with `[A-Za-z0-9]` (`REF_RE` = `^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9._-]*(#[0-9a-f]{40})?@[A-Za-z0-9][A-Za-z0-9._-]*$`); `isSafeSegment` also rejects `.`, `..` and segments over 100 chars. Registry entries are validated with the same grammar.
163
+ - **`/singular add` does not use the skills CLI to install.** It fetches the pinned commit itself (`git init/remote add/fetch --depth 1 origin <sha>/checkout FETCH_HEAD` in a temp dir), locates the skill by an exact, case-sensitive directory or frontmatter name, rejects any `120000`/`100755`/`160000` entry from `git ls-tree -r`, runs `inspectSkillFolder` on the checkout, and only then copies (`cpSync`, `dereference: false`, `errorOnExist`) with a before/after snapshot of `~/.claude/skills` (exactly one new entry = the target) and a post-copy re-inspection. A rejected skill never touches `~/.claude/skills`.
164
+ - **Inspection and sanitizer fail closed on frontmatter spellings** Claude Code honours: quoted keys (`"allowed-tools":`), uniformly indented keys (` hooks:`), mixed case, flow mappings (`{`/`[` lines), an indented first key, `user-invocable: false` in any spelling (`lib/inspect.js` `scanRawFrontmatter`, `lib/registry.js` `sanitizeSkillText`).
165
+ - **Discovery** (`lib/registry.js` `discover`): no free-form prompt text on the wire. Per allowlisted owner one catalog request (`q=skill&owner=<o>&limit=200`, cached `cacheTtlHours`), plus at most `MAX_KEYWORD_SEARCHES` (2) unscoped searches for single words that are in the tech lexicon (`shareableKeywords`; the API is lexical and capped at 200, so a repo whose name lacks "skill" is only reachable this way), cached per word; the joined-keyword query is sent only for `/singular find` (`search: true`). At most `MAX_CONCURRENT_REQUESTS` (2) in flight. An HTTP 429 writes a 5-minute cooldown (`COOLDOWN_KEY`) shown by `/singular status` and `/singular find`. `rankCandidates` scores the prompt (+ `expandIntent` terms) against catalog names with `scoreSkills`, requires an exact stemmed name-token match that is not a hyper-common word, treats stack tags as a weak bonus, and adds `log10(installs)` as a real term. `discover()` returns the extra fields `intent`, `shared`, `queries`, `auditSkipped`, `errors`, `cooldownUntil`, `elapsedMs`.
166
+ - **`on-prompt.js`**: `<pasted_content id="…">` wrapper lines are stripped (`normalizePrompt`) before the skip checks, scoring and keyword extraction. A local match is *strong* (suppresses discovery) only when `score >= STRONG_LOCAL_SCORE` (10) and it matched >= 2 tokens or a non-generic name token (`isStrongMatch`); matches made only of `ROUTING_STOPLIST` words are dropped (`isWeakMatch`). Suggestions use the short `/singular load owner/repo@skill` form with a `#sha7` display suffix when the HEAD is cached; no `git ls-remote` on the hot path. In `suggest` a detached `node bin/on-expand.js <ref> --prefetch` caches the top candidate's HEAD and text. In `auto` the text cache (`text:<pinned ref>`, no TTL) is checked first; an inline fetch needs >= `MIN_AUTO_FETCH_MS` (4500) left, otherwise the suggestion is emitted and the text prefetched. `config.pinnedSkills` (installed names or allowlisted refs) are injected once per `session_id`. `composeContext` appended `RELAY_LINE` after the candidates (moved before them on 2026-10-07) and budgets a loaded body under `SPILL_THRESHOLD` (9,500) with `fitLoadedText` instead of spilling the whole context; `spillIfNeeded` states the 9,500 threshold.
167
+ - **`/singular mode <off|local|suggest|auto>`** (`bin/singular.js mode`, `skills/singular/SKILL.md`) rewrites only the `mode` key and refuses `auto` in a redirected home.
168
+ - **Log privacy**: `singular.log` records the keyword count and the shared tech words; extracted keywords only with `SINGULAR_DEBUG=1`.
169
+
170
+ ## Amendments (2026-10-07, from four live explorations on Claude Code 2.1.293)
171
+
172
+ Exported names and signatures above are unchanged unless listed; each change has a regression test in the module's existing test file.
173
+
174
+ - **Index** (`lib/skill-index.js`): claude.ai-synced plugin directories without a `~g<N>` suffix are indexed (manifest.json lists `data` and `cowork-plugin-management` with `generation` undefined; 12 skills were invisible, so every data prompt routed to `xlsx`); an unsuffixed dir is generation 0 and `name~g2` still wins; only directories with `skills/` or `.claude-plugin/` count. Plugin skills and commands whose basename or frontmatter name starts with `_` (`vercel/commands/_conventions.md`) are internal partials and never indexed.
175
+ - **Routing noise** (`lib/score.js`): `claude anthropic code plugin(s) skill(s) command(s) slash hook(s) rule(s) guide(s) repo(s) repository` joined `ROUTING_STOPLIST` (they drove `claude-mem:version-bump` 46.9 for every "Claude Code hooks" prompt and `kyc-rules` for a terraform prompt); `configure` joined the generic verbs, `every` the stopwords; a bigram made only of noise (`claude code`) gets no phrase boost. `isTechToken(t)` exported.
176
+ - **Strong match** (`bin/on-prompt.js isStrongMatch`): score ≥ 10 and either ≥ 2 meaningful matched tokens, or a single NAME token that is a tech-lexicon noun, or a single rare non-generic name token with score ≥ `STRONG_SINGLE_NAME_SCORE` (20). A single common English name word (`NAME_GENERIC`: design, route, routing, system, review, plan, start, setup, status, update, explore) is listed but never strong, so `vercel:routing-middleware` ("route") and `engineering:system-design` ("design") no longer suppress discovery while `data:build-dashboard` (44.6, "dashboard") still does. Golden routing table over a 168-skill snapshot in `test/bin.test.js`.
177
+ - **Discovery resilience** (`lib/registry.js discover`): a catalog or keyword search that times out or fails on transport writes `fail:<cache key>` for `FAIL_MARKER_MS` (5 min) and is skipped (`queries[].skipped: "recent-failure"`) while fresh; an expired cached copy up to `STALE_TTL_HOURS` (14 d) old keeps ranking in the meantime (`queries[].stale: true`). A slow owner therefore costs `remoteTimeoutMs` once per 5 minutes, not on every prompt.
178
+ - **Suggestions reach the user** (`lib/context.js`, `bin/on-prompt.js`): the relay instruction now precedes the candidate list and names the exact line (`Load with /singular load <ref>`); independently, fresh candidates are announced through the hook `systemMessage` once per session and ref.
179
+ - **Synthetic prompts** (`bin/on-prompt.js skipReason`): `<task-notification>` (the main agent is resumed after a backgrounded subagent finishes) and bare `<system-reminder>` prompts are skipped; the slash-command skip applies to the RAW prompt, matching Claude Code (a pasted block starting with `/singular load` is a normal prompt).
180
+ - **Self-description**: a prompt mentioning singular gets the command list (`COMMANDS_HINT`), even under `minPromptChars`.
181
+ - **SubagentStart hook** (`hooks/hooks.json`, `bin/on-subagent.js`): stack line only (see above).
182
+ - **Case** (`lib/refs.js normalizeRefCase`, `bin/on-expand.js`, pinned refs in on-prompt): owner/repo lowercased before pinning and caching.
183
+ - **`/singular add`** checks the target folder before resolving HEAD; `lib/git.js` factors the git steps; credential prompts are explained (`repository not found or private`).
184
+ - **`/singular find`** ranks rejections by relevance and names the best allowlist-only rejection; cooldown wording fixed; `(...)` grouping.
185
+ - **`loadConfig`** distinguishes `ENOENT` (create defaults) from other read errors (`config.json unreadable (EISDIR); using defaults`, no create). `/singular mode` prints hardenEnv notes and tags every message.
186
+ - **Test seams** (`lib/config.js testSeams`, documented only in `docs/testing.md`): `SINGULAR_REGISTRY_BASE` (loopback http(s) only), `SINGULAR_GIT_BASE` (absolute local path or `file://` URL outside the project; `fetchSkillText` then clones with git and reads SKILL.md itself instead of `npx skills use`), `SINGULAR_SKILLS_DIR` (absolute, outside the project), `SINGULAR_TEST_ALLOW_AUTO=1` (honours `mode: "auto"` in a redirected home). Invalid values are dropped by `hardenEnv` with a warning; active seams are logged by every entrypoint and listed by `/singular status`; behaviour is byte-identical when they are unset (tests assert the exact production URLs and argv).
187
+
188
+ ## Amendments (2026-10-13: rename to Singular, one command, two-step install)
189
+
190
+ Module APIs above are unchanged; this is a rename plus a packaging change, each covered by the existing tests under the new names.
191
+
192
+ - **Names**: plugin `singular` (displayName `Singular`), marketplace `singular`, plugin id `singular@singular` (`singular@inline` under `--plugin-dir`), npm package `singular-skill` (`singular` and `singular-cli` are taken), version 0.2.0. State dir `~/.claude/singular/`; environment `SINGULAR_HOME`, `SINGULAR_MODE`, `SINGULAR_DEBUG`, seams `SINGULAR_REGISTRY_BASE` / `SINGULAR_GIT_BASE` / `SINGULAR_SKILLS_DIR` / `SINGULAR_TEST_ALLOW_AUTO`, e2e `SINGULAR_E2E_*`. Context prefix and log tag `[singular]`; trust delimiter `<singular-loaded ...>`; user agent `singular/0.2 (+node)`; mkdtemp prefixes `singular-add-` / `singular-use-`.
193
+ - **One command**: `/singular` (`skills/singular/SKILL.md`, user-only) with the subcommands `status` (default), `help`, `find <query>`, `load <ref> [--update]`, `add <ref>`, `mode [m]`; `skills/singular-find/SKILL.md` stays model-invocable for the reactive "is there a skill for X" path. The five 0.1.x per-command slash commands, their skill directories and `bin/` scripts are gone without aliases; `bin/singular.js` dispatches to the same module code. `load` is handled entirely by the expansion hook (matcher `(^|:)singular$`, acting only on `command_args` starting with `load`; silent exit 0 otherwise). `COMMANDS_HINT` lists the subcommands; the mention regex must not fire on the plain English word "singular".
194
+ - **Install**: `npm install -g singular-skill && singular install` (or `npx singular-skill install`, or `git clone` + `node bin/singular-cli.js install`). The CLI (`bin/singular-cli.js`, above) copies the payload to `~/.claude/singular/plugin/` and runs `claude plugin marketplace add` + `claude plugin install singular@singular`; it is the only component that runs `claude plugin ...` or writes under `~/.claude/singular/plugin`. The hooks are unchanged: `bin/on-prompt.js`, `bin/on-expand.js`, `bin/on-subagent.js` keep their file names and contracts.
195
+
196
+ ## Observed on Claude Code 2.1.293 (2026-10-07/08)
197
+
198
+ Facts the docs above rely on, each from a live `claude -p` run through the e2e recipe or from a `--dry-run` of the hook on the authoring machine (168 installed skills indexed, Next.js fixture cwd). Numbers are single measurements, not averages.
199
+
200
+ - **Inventory** (re-measured 2026-10-13 on Claude Code 2.1.296 with the two-skill layout of 0.2.0): `claude --plugin-dir <root> plugin details singular` lists `Skills (2) singular, singular-find`, `Hooks (3) UserPromptSubmit, UserPromptExpansion, SubagentStart (harness-only)`, `Always-on: ~98 tok` (`singular` ~20 always-on / ~770 on-invoke, `singular-find` ~80 / ~320; the five-skill 0.1.x layout cost ~507 tok always-on); `claude plugin validate <root>` passes; `claude --plugin-dir <root> plugin list` shows `singular@inline ... Status: ✔ loaded`.
201
+ - **Hook timings** (in-process): local phase 18-52 ms; `hi` 0.3 ms (skipped); cold discovery 1,834-1,887 ms for 4 catalogs + 2 keyword searches with no timeout, 5,631 ms when the `microsoft` catalog timed out (2026-10-07; the `fail:` marker then made the next prompt 6 ms); catalogs cached + 2 new keyword searches 1,207-2,424 ms; warm 5-32 ms; a 12,000-char prompt 20 ms; `SubagentStart` 1-35 ms; `/singular find` 1.5 s cold (7 requests) / 12 ms warm; `/singular add` HEAD resolve ~0.7 s; first `/singular load` 1.9 s in-process (3.2 s wall), cached 0.05-0.3 s.
202
+ - **Hook events in `-p`**: `system` events `hook_started` / `hook_response` with keys `type, subtype, hook_id, hook_name, hook_event, output, stdout, stderr, exit_code, outcome, uuid, session_id`; one `UserPromptSubmit` per prompt regardless of tool calls; for `/singular load`, `UserPromptExpansion` first, then `UserPromptSubmit` (skipped as a slash command); an exit-2 block suppresses `UserPromptSubmit`, makes no model call (`total_cost_usd` 0, `num_turns` 0) and the CLI exits 0 with `is_error: false`. Every observed hook exit was 0 or 2, every `.err` file empty.
203
+ - **Context sizes**: `local` 172-322 chars (no stack line in a directory without manifests), `suggest` with candidates 715-833 chars, `auto` with a loaded body 9,498 chars, `/singular load karpathy-guidelines` 2,913 chars, `frontend-design` 9,387 chars (`truncated 388 chars to 9000`).
204
+ - **Relay**: with candidates in context the model ended its reply with a `/singular load` line in 2 of 6 runs (sonnet and haiku, Playwright prompt; 0 of 4 for a pandas prompt), and in 1 of 2 haiku runs after `RELAY_LINE` was moved before the candidates. The `systemMessage` is therefore the user-facing channel.
205
+ - **Multi-turn**: `/singular load` text persisted across `--resume` and `--continue` as a `hook_additional_context` attachment (section titles and a verbatim bullet recalled two turns later); a second `/singular load` coexisted with the first; `--update` re-resolved HEAD (same sha) and reused the cached text; `MULTICA-AI/...` shared the lowercase pin.
206
+ - **Subagents**: the Agent tool is auto-backgrounded in `-p`; the subagent received and echoed the `SubagentStart` stack line; the parent's `<task-notification>` resume prompt fired `UserPromptSubmit` and was skipped; the stream carried two `result` events.
207
+ - **Environment**: a repository's `.claude/settings.json` `env.SINGULAR_MODE=off` reached the hook (mode lowered); `env.HOME` from the same file did not reach the hook. `HOME=<dir under the cwd>` from a shell is replaced by the real home (so a test that `cd`s into the directory holding its fake `HOME` installs into the real `~/.claude/skills`). `CLAUDE_CONFIG_DIR=<fresh>`, `--bare`, and `HOME=<tmp>+CLAUDE_CONFIG_DIR=<real>` cannot log in, so `auto` was exercised only by feeding the hook directly.
208
+ - **Model behaviour on the commands** (haiku): `/singular status` relayed verbatim ($0.0025); `/singular mode turbo` reported the failure without choosing another mode; `/singular mode auto` in a redirected home relayed the refusal; `/singular find $(id) ; echo pwned` was refused by the model, and the script itself treats the same string as argv (no `uid=` anywhere); the natural-language `is there a skill for playwright e2e testing?` invoked `Skill {"skill":"singular:singular-find"}` and then the script; `show me the singular status` made no Skill call (user-only skills are invisible to the model).
209
+ - **Costs**: haiku $0.001-0.004 per prompt ($0.038 once under `--permission-mode plan`; $0.02 for a 12,000-char prompt that ran 13 tool turns), sonnet $0.02-0.06, e2e < $0.03, 21-prompt haiku probe $0.12, subagent run $0.0037.