From e5e522e174ff1dbeb0f6cef735e1d42c2406fda4 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Wed, 20 May 2026 15:35:45 +0200 Subject: [PATCH 1/2] cscript: rename skill, add UserPromptSubmit hook, harden which-matcher - Rename the skill from compile-task to cscript so the slash command, binary, and skill directory all share one name. - Add scripts/cscript-hook: a Claude Code UserPromptSubmit hook that runs `cscript which` on every prompt and injects a catalogue-hint block when matches exist. Without it, the catalogue is only consulted when /cscript is invoked explicitly. - Tighten `cscript which` token matching: filter out stopwords and tokens shorter than 4 chars so noise like "is" no longer substring-matches "isolate". Use `string.punctuation` for the punctuation translation table. - Add `cscript version` (and a VERSION constant) so the bootstrap step can detect a stale installed binary and re-copy the dispatcher on upgrade. - SKILL.md bootstrap step 2 now compares installed version against source version and re-installs on mismatch. Co-Authored-By: Claude Opus 4.7 --- README.md | 10 +-- skills/{compile-task => cscript}/README.md | 17 +++-- skills/{compile-task => cscript}/SKILL.md | 42 ++++++++--- .../references/bash-template.sh | 0 .../references/powershell-template.ps1 | 0 .../references/uv-python-template.py | 0 .../{compile-task => cscript}/scripts/cscript | 44 ++++++++++-- skills/cscript/scripts/cscript-hook | 71 +++++++++++++++++++ .../scripts/cscript.cmd | 0 .../scripts/smoke-test.sh | 0 10 files changed, 160 insertions(+), 24 deletions(-) rename skills/{compile-task => cscript}/README.md (73%) rename skills/{compile-task => cscript}/SKILL.md (80%) rename skills/{compile-task => cscript}/references/bash-template.sh (100%) rename skills/{compile-task => cscript}/references/powershell-template.ps1 (100%) rename skills/{compile-task => cscript}/references/uv-python-template.py (100%) rename skills/{compile-task => cscript}/scripts/cscript (88%) create mode 100755 skills/cscript/scripts/cscript-hook rename skills/{compile-task => cscript}/scripts/cscript.cmd (100%) rename skills/{compile-task => cscript}/scripts/smoke-test.sh (100%) diff --git a/README.md b/README.md index a531752..d368a4e 100644 --- a/README.md +++ b/README.md @@ -104,19 +104,19 @@ Trello board health audit. Queries five dimensions of card data to identify thro /audit-trello board="My Board" since=2024-07-01 ``` -### `/compile-task` +### `/cscript` Compile one-off task descriptions into reusable, self-contained executables so the LLM doesn't redo deterministic work each time. Picks bash for POSIX shell ops, PowerShell for Windows-native work, or single-file `uv` Python (PEP 723 inline deps) for anything needing libraries. Scripts are registered in an appdata-backed catalogue and dispatched through a `cscript` binary the skill installs to a user-writable directory on `PATH`. From the second invocation onward the agent finds the script via `cscript which`, confirms with you, and runs it directly — no regeneration. -Ships with a `cscript` dispatcher (`list`, `which`, `run`, `show`, `edit`, `rm`, `state-dir`, `where`, `register`), a Windows `cscript.cmd` wrapper, and a smoke-test script. Verified on macOS and Linux; Windows support is implemented (subprocess dispatch + `cscript.cmd` wrapper + PowerShell language option) and awaits validation by a Windows user. +Ships with a `cscript` dispatcher (`list`, `which`, `run`, `show`, `edit`, `rm`, `state-dir`, `where`, `register`), a Windows `cscript.cmd` wrapper, an optional `UserPromptSubmit` hook that surfaces matching catalogue entries on every prompt, and a smoke-test script. Verified on macOS and Linux; Windows support is implemented (subprocess dispatch + `cscript.cmd` wrapper + PowerShell language option) and awaits validation by a Windows user. **Arguments:** None — describe the task in natural language and the skill decides whether to compile it, match an existing script, or skip. **Usage:** ``` -/compile-task rename JPGs in this directory by the EXIF date they were shot -/compile-task pull all comments from GitHub PR https://github.com/foo/bar/pull/42 as markdown -/compile-task strip EXIF from every image under a folder +/cscript rename JPGs in this directory by the EXIF date they were shot +/cscript pull all comments from GitHub PR https://github.com/foo/bar/pull/42 as markdown +/cscript strip EXIF from every image under a folder ``` ### `/workflow-advisor` diff --git a/skills/compile-task/README.md b/skills/cscript/README.md similarity index 73% rename from skills/compile-task/README.md rename to skills/cscript/README.md index 83f5007..4239be8 100644 --- a/skills/compile-task/README.md +++ b/skills/cscript/README.md @@ -1,4 +1,4 @@ -# compile-task +# cscript Compile one-off LLM tasks into reusable, self-contained executables so you stop paying tokens to redo deterministic work. @@ -31,6 +31,15 @@ cscript state-dir # print per-script state dir cscript where # print the data directory ``` +## The catalogue hint hook + +Without help, the AI only consults the catalogue when you invoke `/cscript` explicitly. Casual prompts ("sort my downloads") skip the catalogue and pay LLM tokens to regenerate equivalent work. + +The skill ships a `UserPromptSubmit` hook (`scripts/cscript-hook`) for Claude Code that closes that loop: on every prompt, it runs `cscript which ""` and, if anything matches, injects a `` block into the agent's context with the candidates. The agent considers reusing one instead of doing it from scratch. + +- Pure stdlib Python (~60ms per prompt). No-op when `cscript` isn't installed, when the prompt is conversational, or when nothing matches. +- Opt-in: the skill asks before wiring it into `~/.claude/settings.json`. + ## Design choices - **Self-contained scripts.** No project-local imports, no relative paths. A script written for one repo can run from anywhere. @@ -46,9 +55,9 @@ It won't compile one-off explorations, judgement-laden tasks (refactoring, PR de ## Usage ``` -/compile-task rename JPGs in this directory by the EXIF date they were shot -/compile-task pull all comments from GitHub PR https://github.com/foo/bar/pull/42 as markdown -/compile-task strip EXIF from every image under a folder +/cscript rename JPGs in this directory by the EXIF date they were shot +/cscript pull all comments from GitHub PR https://github.com/foo/bar/pull/42 as markdown +/cscript strip EXIF from every image under a folder ``` Or just describe a task and let the skill decide whether to compile it. diff --git a/skills/compile-task/SKILL.md b/skills/cscript/SKILL.md similarity index 80% rename from skills/compile-task/SKILL.md rename to skills/cscript/SKILL.md index 36817b8..b7c3f2b 100644 --- a/skills/compile-task/SKILL.md +++ b/skills/cscript/SKILL.md @@ -1,20 +1,20 @@ --- -name: compile-task +name: cscript description: | Compile a one-off task description into a reusable, self-contained executable script so the LLM doesn't have to do the same work again. Picks bash for trivial file/shell ops, single-file uv Python (PEP 723) - for anything needing libraries. Registers scripts in an OS-correct - appdata directory and exposes them through a `cscript` dispatcher - installed on PATH. Future invocations match the description against - the index and re-run the existing script instead of regenerating. - Use when the user says "compile this", "make a script for X", - "stash this as a script", "save this so I don't have to ask again", - or describes a task that sounds like it will recur. + for anything needing libraries, or PowerShell for Windows-native work. + Registers scripts in an OS-correct appdata directory and exposes them + through a `cscript` dispatcher installed on PATH. Future invocations + match the description against the index and re-run the existing script + instead of regenerating. Use when the user says "compile this", "make + a script for X", "stash this as a script", "save this so I don't have + to ask again", or describes a task that sounds like it will recur. user-invocable: true --- -# compile-task — turn prompts into reusable executables +# cscript — turn prompts into reusable executables The point of this skill is to **stop paying an LLM to redo deterministic work**. Each time the user describes a task that could be a script, compile it once, register it in the catalogue, and run the registered script from then on. Generation is the exception; execution is the rule. @@ -38,7 +38,9 @@ In order, before anything else: 2. **Check whether the dispatcher is installed and on PATH.** Run `command -v cscript >/dev/null 2>&1`. -3. **If `cscript` is missing, install it.** The source lives at `scripts/cscript` relative to this `SKILL.md`. Resolve the path from wherever this file was loaded; if you cannot find it, ask the user for the skill directory rather than guessing. + If installed, also check it is not stale: compare `cscript version` against the source's version. The source's version is the `VERSION = "..."` line near the top of `scripts/cscript`. If they differ (or the installed binary predates `version` and errors), treat it as missing and re-install it the same way as a fresh install — copying over the existing file. Tell the user you are upgrading their dispatcher. + +3. **If `cscript` is missing (or stale), install it.** The source lives at `scripts/cscript` relative to this `SKILL.md`. Resolve the path from wherever this file was loaded; if you cannot find it, ask the user for the skill directory rather than guessing. Pick a destination directory that is **user-writable and already on `PATH`**: @@ -63,6 +65,26 @@ In order, before anything else: 4. **Verify the install worked.** After copying, run `cscript --help` in a fresh shell invocation. If it does not resolve, the chosen directory is not on `PATH` in interactive shells — tell the user how to add it for their shell and stop. Do not proceed until they confirm `cscript --help` works. +5. **Offer to install the catalogue hint hook (Claude Code only).** This is the loop-closer: it makes future prompts trigger a catalogue lookup automatically, even when the user doesn't invoke `/cscript`. Without it, the second-time-doing-X savings only kick in when the user remembers to use the slash command. + + - Source: `scripts/cscript-hook` next to this `SKILL.md`. Stdlib Python; no install dependencies beyond `cscript` itself. + - Install path: copy to `~/.claude/hooks/cscript-hook` (create the dir if missing) and `chmod +x` on POSIX. + - Wire-up: add the hook to `~/.claude/settings.json` under `hooks.UserPromptSubmit`. Merge with any existing entries — do not overwrite. A minimal additive snippet: + + ```json + { + "hooks": { + "UserPromptSubmit": [ + { "hooks": [ { "type": "command", "command": "~/.claude/hooks/cscript-hook" } ] } + ] + } + } + ``` + + - Verify: run `echo '{"hook_event_name":"UserPromptSubmit","prompt":""}' | ~/.claude/hooks/cscript-hook` and confirm it emits a `` block. + + Ask the user before installing the hook. It runs on every prompt; some users prefer to wire that up themselves or skip it entirely. Default to yes if they don't have a strong preference — the loop is much weaker without it. + The dispatcher itself is a uv single-file script — it bootstraps its own Python deps the first time it runs. ### 2. Match against the catalogue diff --git a/skills/compile-task/references/bash-template.sh b/skills/cscript/references/bash-template.sh similarity index 100% rename from skills/compile-task/references/bash-template.sh rename to skills/cscript/references/bash-template.sh diff --git a/skills/compile-task/references/powershell-template.ps1 b/skills/cscript/references/powershell-template.ps1 similarity index 100% rename from skills/compile-task/references/powershell-template.ps1 rename to skills/cscript/references/powershell-template.ps1 diff --git a/skills/compile-task/references/uv-python-template.py b/skills/cscript/references/uv-python-template.py similarity index 100% rename from skills/compile-task/references/uv-python-template.py rename to skills/cscript/references/uv-python-template.py diff --git a/skills/compile-task/scripts/cscript b/skills/cscript/scripts/cscript similarity index 88% rename from skills/compile-task/scripts/cscript rename to skills/cscript/scripts/cscript index 38558b4..0e8667f 100755 --- a/skills/compile-task/scripts/cscript +++ b/skills/cscript/scripts/cscript @@ -3,7 +3,7 @@ # requires-python = ">=3.11" # dependencies = ["platformdirs>=4.2"] # /// -"""cscript — dispatcher for scripts created by the compile-task skill. +"""cscript — dispatcher for scripts created by the /cscript skill. Stores generated scripts in an OS-correct appdata directory and exposes them through a small set of subcommands. See `cscript --help` for usage. @@ -17,11 +17,14 @@ import json import os from pathlib import Path import shutil +import string import subprocess import sys from platformdirs import user_data_dir +VERSION = "0.2.0" + APP = "cscript" DATA = Path(os.environ.get("CSCRIPT_DATA_DIR") or user_data_dir(APP)) SCRIPTS = DATA / "scripts" @@ -75,7 +78,7 @@ def resolve(name: str) -> dict | None: def cmd_list(_args: argparse.Namespace) -> int: items = load_index() if not items: - print("No scripts registered yet. Use the /compile-task skill to create one.") + print("No scripts registered yet. Use the /cscript skill to create one.") return 0 width = max(len(i["name"]) for i in items) for item in sorted(items, key=lambda x: x["name"]): @@ -84,10 +87,34 @@ def cmd_list(_args: argparse.Namespace) -> int: return 0 +_STOPWORDS = frozenset( + """ + this that what with from have your into onto over under about above below + when where which while would should could does done been being just like + such than then them they those well very also even more most some many + much both either neither + """.split() +) +_MIN_QUERY_TOKEN_LEN = 4 +_PUNCT_TRANS = str.maketrans({c: " " for c in string.punctuation}) + + +def _query_tokens(query: str) -> list[str]: + """Split the query into matchable tokens. + + Drops tokens shorter than `_MIN_QUERY_TOKEN_LEN` and common stopwords so + short noise words ("is", "the", "what") don't substring-match into longer + haystack words ("isolate", "there", "whatever"). + """ + cleaned = query.lower().translate(_PUNCT_TRANS) + return [t for t in cleaned.split() if len(t) >= _MIN_QUERY_TOKEN_LEN and t not in _STOPWORDS] + + def cmd_which(args: argparse.Namespace) -> int: items = load_index() - query = args.query.lower() - tokens = [t for t in query.split() if t] + tokens = _query_tokens(args.query) + if not tokens: + return 1 scored: list[tuple[int, dict]] = [] for item in items: score = 0 @@ -260,6 +287,11 @@ def cmd_where(_args: argparse.Namespace) -> int: return 0 +def cmd_version(_args: argparse.Namespace) -> int: + print(VERSION) + return 0 + + def build_parser() -> argparse.ArgumentParser: summary = (__doc__ or "cscript").splitlines()[0] p = argparse.ArgumentParser(prog="cscript", description=summary) @@ -267,6 +299,7 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("list", help="list registered scripts") sub.add_parser("where", help="print the data directory") + sub.add_parser("version", help="print the dispatcher version") pw = sub.add_parser("which", help="fuzzy match against names/descriptions") pw.add_argument("query") @@ -297,7 +330,7 @@ def build_parser() -> argparse.ArgumentParser: ) psd.add_argument("name") - preg = sub.add_parser("register", help="(used by the compile-task skill)") + preg = sub.add_parser("register", help="(used by the /cscript skill)") preg.add_argument("--source", required=True, help="path to the freshly-written script file") preg.add_argument("--name", required=True) preg.add_argument("--description", required=True) @@ -324,6 +357,7 @@ def main(argv: list[str] | None = None) -> int: "state-dir": cmd_state_dir, "register": cmd_register, "where": cmd_where, + "version": cmd_version, } return handlers[args.cmd](args) diff --git a/skills/cscript/scripts/cscript-hook b/skills/cscript/scripts/cscript-hook new file mode 100755 index 0000000..8490f73 --- /dev/null +++ b/skills/cscript/scripts/cscript-hook @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Claude Code UserPromptSubmit hook for the /cscript skill. + +Reads the user's prompt from the hook JSON payload on stdin, runs it through +`cscript which`, and if any catalogue entries match, injects them as extra +context so the agent considers re-using an existing script instead of +regenerating equivalent work. + +Designed to be a no-op when: +- cscript is not installed on PATH +- the prompt is too short to be a task +- the catalogue has no matches +- stdin isn't a JSON payload (e.g. invoked manually) + +Stdlib only — must start fast since it runs on every prompt. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys + + +MIN_PROMPT_LEN = 8 +WHICH_TIMEOUT_SECONDS = 3 +HINT_TEMPLATE = """ +The user's cscript catalogue contains entries that may match this prompt. Before +generating new work, consider whether one of these scripts already does the job: + +{matches} + +To use one: `cscript run [args]` (or `cscript show ` to inspect first). +If none fit the request, ignore this hint and proceed normally. +""" + + +def main() -> int: + if not shutil.which("cscript"): + return 0 + + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + + prompt = (payload.get("prompt") or "").strip() + if len(prompt) < MIN_PROMPT_LEN: + return 0 + + try: + result = subprocess.run( + ["cscript", "which", prompt], + capture_output=True, + text=True, + timeout=WHICH_TIMEOUT_SECONDS, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return 0 + + matches = result.stdout.strip() + if not matches: + return 0 + + print(HINT_TEMPLATE.format(matches=matches)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/compile-task/scripts/cscript.cmd b/skills/cscript/scripts/cscript.cmd similarity index 100% rename from skills/compile-task/scripts/cscript.cmd rename to skills/cscript/scripts/cscript.cmd diff --git a/skills/compile-task/scripts/smoke-test.sh b/skills/cscript/scripts/smoke-test.sh similarity index 100% rename from skills/compile-task/scripts/smoke-test.sh rename to skills/cscript/scripts/smoke-test.sh From 69065df39fecc396eba58f40e41f1a508a88897b Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Wed, 20 May 2026 15:46:24 +0200 Subject: [PATCH 2/2] cscript: replace per-prompt hook with `cscript mine` over a which log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UserPromptSubmit hook added in the previous commit paid ~60ms on every prompt and could only surface already-registered scripts — it could never propose new ones. Replace it with a batch approach: - `cscript which` appends every query to `which.log` under the data dir (best-effort; never raises). The query text and the top matched name (or null on miss) are recorded. - New `cscript mine` aggregates the log, ranks repeated catalogue misses, and prints candidates with sample queries. `--min N` controls the repeat threshold (default 2); `--include-hits` also surfaces matched queries; `--limit` caps the output. - Empty-log and no-pattern cases print friendly guidance to stderr. - Delete `scripts/cscript-hook` and the bootstrap step that wired it into `~/.claude/settings.json`. The catalogue-mining workflow is now on-demand only. - Bump VERSION to 0.3.0 (subcommand surface changed). - Smoke test covers version, which-logs-to-disk, mine-ranks-misses, and the empty-log message. A smarter miner that reads Claude Code session transcripts and shell history (with first-run consent) is planned but deliberately deferred until the invocation log has enough real data to validate the clustering algorithm. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- skills/cscript/README.md | 16 ++-- skills/cscript/SKILL.md | 35 ++++---- skills/cscript/scripts/cscript | 115 ++++++++++++++++++++++++++- skills/cscript/scripts/cscript-hook | 71 ----------------- skills/cscript/scripts/smoke-test.sh | 30 +++++++ 6 files changed, 171 insertions(+), 98 deletions(-) delete mode 100755 skills/cscript/scripts/cscript-hook diff --git a/README.md b/README.md index d368a4e..5029cb0 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Trello board health audit. Queries five dimensions of card data to identify thro Compile one-off task descriptions into reusable, self-contained executables so the LLM doesn't redo deterministic work each time. Picks bash for POSIX shell ops, PowerShell for Windows-native work, or single-file `uv` Python (PEP 723 inline deps) for anything needing libraries. Scripts are registered in an appdata-backed catalogue and dispatched through a `cscript` binary the skill installs to a user-writable directory on `PATH`. From the second invocation onward the agent finds the script via `cscript which`, confirms with you, and runs it directly — no regeneration. -Ships with a `cscript` dispatcher (`list`, `which`, `run`, `show`, `edit`, `rm`, `state-dir`, `where`, `register`), a Windows `cscript.cmd` wrapper, an optional `UserPromptSubmit` hook that surfaces matching catalogue entries on every prompt, and a smoke-test script. Verified on macOS and Linux; Windows support is implemented (subprocess dispatch + `cscript.cmd` wrapper + PowerShell language option) and awaits validation by a Windows user. +Ships with a `cscript` dispatcher (`list`, `which`, `run`, `show`, `edit`, `rm`, `state-dir`, `where`, `version`, `mine`, `register`), a Windows `cscript.cmd` wrapper, and a smoke-test script. `cscript mine` ranks repeated catalogue misses from a local `which` invocation log so you can spot tasks worth compiling without paying per-prompt hook latency. Verified on macOS and Linux; Windows support is implemented (subprocess dispatch + `cscript.cmd` wrapper + PowerShell language option) and awaits validation by a Windows user. **Arguments:** None — describe the task in natural language and the skill decides whether to compile it, match an existing script, or skip. diff --git a/skills/cscript/README.md b/skills/cscript/README.md index 4239be8..1fd96fa 100644 --- a/skills/cscript/README.md +++ b/skills/cscript/README.md @@ -29,16 +29,22 @@ cscript edit # open in $EDITOR cscript rm # archive and unregister cscript state-dir # print per-script state dir cscript where # print the data directory +cscript version # print the dispatcher version +cscript mine # surface repeated `which` misses worth compiling ``` -## The catalogue hint hook +## Surfacing candidates from your history -Without help, the AI only consults the catalogue when you invoke `/cscript` explicitly. Casual prompts ("sort my downloads") skip the catalogue and pay LLM tokens to regenerate equivalent work. +Per-prompt hooks are expensive (latency on every turn) and can only catch already-registered scripts — they can never propose new ones. Instead, `cscript` keeps a small local log of every `which` query and lets you mine it on demand: -The skill ships a `UserPromptSubmit` hook (`scripts/cscript-hook`) for Claude Code that closes that loop: on every prompt, it runs `cscript which ""` and, if anything matches, injects a `` block into the agent's context with the candidates. The agent considers reusing one instead of doing it from scratch. +``` +cscript mine # tasks you've asked for 2+ times that the catalogue never matched +cscript mine --min 1 # every miss +``` + +The signal is direct: you (or an agent on your behalf) ran `cscript which ""`, the catalogue returned nothing, and that happened more than once. Those are exactly the tasks worth compiling next. -- Pure stdlib Python (~60ms per prompt). No-op when `cscript` isn't installed, when the prompt is conversational, or when nothing matches. -- Opt-in: the skill asks before wiring it into `~/.claude/settings.json`. +A smarter miner that reads Claude Code session transcripts and shell history is planned but deliberately deferred until the invocation log has enough real data to validate the algorithm. ## Design choices diff --git a/skills/cscript/SKILL.md b/skills/cscript/SKILL.md index b7c3f2b..9576a47 100644 --- a/skills/cscript/SKILL.md +++ b/skills/cscript/SKILL.md @@ -65,26 +65,6 @@ In order, before anything else: 4. **Verify the install worked.** After copying, run `cscript --help` in a fresh shell invocation. If it does not resolve, the chosen directory is not on `PATH` in interactive shells — tell the user how to add it for their shell and stop. Do not proceed until they confirm `cscript --help` works. -5. **Offer to install the catalogue hint hook (Claude Code only).** This is the loop-closer: it makes future prompts trigger a catalogue lookup automatically, even when the user doesn't invoke `/cscript`. Without it, the second-time-doing-X savings only kick in when the user remembers to use the slash command. - - - Source: `scripts/cscript-hook` next to this `SKILL.md`. Stdlib Python; no install dependencies beyond `cscript` itself. - - Install path: copy to `~/.claude/hooks/cscript-hook` (create the dir if missing) and `chmod +x` on POSIX. - - Wire-up: add the hook to `~/.claude/settings.json` under `hooks.UserPromptSubmit`. Merge with any existing entries — do not overwrite. A minimal additive snippet: - - ```json - { - "hooks": { - "UserPromptSubmit": [ - { "hooks": [ { "type": "command", "command": "~/.claude/hooks/cscript-hook" } ] } - ] - } - } - ``` - - - Verify: run `echo '{"hook_event_name":"UserPromptSubmit","prompt":""}' | ~/.claude/hooks/cscript-hook` and confirm it emits a `` block. - - Ask the user before installing the hook. It runs on every prompt; some users prefer to wire that up themselves or skip it entirely. Default to yes if they don't have a strong preference — the loop is much weaker without it. - The dispatcher itself is a uv single-file script — it bootstraps its own Python deps the first time it runs. ### 2. Match against the catalogue @@ -182,8 +162,23 @@ Subcommands: | `cscript rm ` | Archive the file to `scripts/.archive/` and drop its index entry and state directory. | | `cscript state-dir ` | Print (creating if missing) the per-script state directory under the appdata dir. | | `cscript where` | Print the data directory path. | +| `cscript version` | Print the dispatcher version. | +| `cscript mine` | Rank repeated catalogue misses from the `which` log to surface things worth compiling. | | `cscript register …` | Used by this skill at compile time; not normally hand-invoked. | +## Surfacing candidates from history + +`cscript which` appends every query to a local invocation log (`which.log` under the data dir). `cscript mine` reads it back and ranks queries that have been asked 2+ times but never matched anything in the catalogue — direct "I tried to reuse but couldn't" signal. + +Run it on-demand: + +```sh +cscript mine # repeated misses, default threshold 2 +cscript mine --min 1 # every miss +``` + +When you (the agent) see repeated patterns in your conversation that the user hasn't asked to compile yet, suggest `cscript mine` so they can review what's worth stashing. + ## Regeneration When the user asks to "redo", "rewrite", or "regenerate" an existing script (or when running it reveals a bug): diff --git a/skills/cscript/scripts/cscript b/skills/cscript/scripts/cscript index 0e8667f..9fa1d35 100755 --- a/skills/cscript/scripts/cscript +++ b/skills/cscript/scripts/cscript @@ -23,7 +23,7 @@ import sys from platformdirs import user_data_dir -VERSION = "0.2.0" +VERSION = "0.3.0" APP = "cscript" DATA = Path(os.environ.get("CSCRIPT_DATA_DIR") or user_data_dir(APP)) @@ -31,6 +31,7 @@ SCRIPTS = DATA / "scripts" ARCHIVE = SCRIPTS / ".archive" STATE = DATA / "state" INDEX = DATA / "index.json" +WHICH_LOG = DATA / "which.log" EXT = {"bash": ".sh", "python": ".py", "powershell": ".ps1"} @@ -110,10 +111,26 @@ def _query_tokens(query: str) -> list[str]: return [t for t in cleaned.split() if len(t) >= _MIN_QUERY_TOKEN_LEN and t not in _STOPWORDS] +def _log_which(query: str, top_hit: str | None) -> None: + """Append a query to the invocation log. Best-effort; never raises.""" + try: + ensure_dirs() + entry = { + "ts": datetime.now(timezone.utc).isoformat(), + "query": query, + "hit": top_hit, + } + with WHICH_LOG.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry) + "\n") + except OSError: + pass + + def cmd_which(args: argparse.Namespace) -> int: items = load_index() tokens = _query_tokens(args.query) if not tokens: + _log_which(args.query, None) return 1 scored: list[tuple[int, dict]] = [] for item in items: @@ -128,8 +145,10 @@ def cmd_which(args: argparse.Namespace) -> int: if score: scored.append((score, item)) if not scored: + _log_which(args.query, None) return 1 scored.sort(reverse=True, key=lambda x: x[0]) + _log_which(args.query, scored[0][1]["name"]) width = max(len(s[1]["name"]) for s in scored[:5]) for _, item in scored[:5]: print(f" {item['name']:<{width}} {item['description']}") @@ -292,6 +311,86 @@ def cmd_version(_args: argparse.Namespace) -> int: return 0 +def _load_which_log() -> list[dict]: + if not WHICH_LOG.exists(): + return [] + entries: list[dict] = [] + for line in WHICH_LOG.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries + + +def cmd_mine(args: argparse.Namespace) -> int: + """Surface recurring `which` queries that found nothing — script candidates. + + The signal is: the user (or the skill on their behalf) asked the catalogue + for a script N times with effectively the same prompt, and the catalogue + had nothing. That's a direct intent-to-reuse signal — exactly the thing + we want to compile. + """ + entries = _load_which_log() + if not entries: + print( + "No `cscript which` history yet. Mining looks for repeated catalogue\n" + "misses — keep using cscript and try again later.", + file=sys.stderr, + ) + return 0 + + misses = [e for e in entries if not e.get("hit")] if not args.include_hits else entries + + clusters: dict[str, list[str]] = {} + for entry in misses: + query = entry.get("query") or "" + tokens = _query_tokens(query) + if not tokens: + continue + fingerprint = " ".join(sorted(set(tokens))) + clusters.setdefault(fingerprint, []).append(query) + + ranked = sorted( + ((len(qs), fp, qs) for fp, qs in clusters.items() if len(qs) >= args.min), + reverse=True, + ) + + if not ranked: + msg = ( + f"No patterns repeated {args.min}+ times in the `which` log " + f"({len(misses)} entries scanned).\n" + "Keep using cscript and try again in a week, or pass --min 1 to see every miss." + ) + print(msg, file=sys.stderr) + return 0 + + print(f"Found {len(ranked)} repeated catalogue miss(es):\n") + for count, _fp, samples in ranked[: args.limit]: + unique: list[str] = [] + for s in samples: + if s not in unique: + unique.append(s) + print(f" [{count}x] {unique[0]}") + for sample in unique[1:3]: + print(f" ↳ {sample}") + if len(unique) > 3: + print(f" ↳ ...and {len(unique) - 3} more variant(s)") + print() + sys.stdout.flush() + + print( + "These are tasks you've asked for that the catalogue couldn't fulfil.\n" + "Use `/cscript ` (or describe one to an agent that knows this skill)\n" + "to compile any of them.", + file=sys.stderr, + ) + return 0 + + def build_parser() -> argparse.ArgumentParser: summary = (__doc__ or "cscript").splitlines()[0] p = argparse.ArgumentParser(prog="cscript", description=summary) @@ -301,6 +400,19 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("where", help="print the data directory") sub.add_parser("version", help="print the dispatcher version") + pm = sub.add_parser( + "mine", + help="surface recurring `which` misses worth compiling", + description="Aggregate the `cscript which` invocation log into ranked " + "candidate tasks. The signal is: same effective query asked N+ times, " + "catalogue had no match.", + ) + pm.add_argument("--min", type=int, default=2, help="minimum repeats to surface (default: 2)") + pm.add_argument("--limit", type=int, default=20, help="max candidates to print (default: 20)") + pm.add_argument( + "--include-hits", action="store_true", help="also include queries that did match something" + ) + pw = sub.add_parser("which", help="fuzzy match against names/descriptions") pw.add_argument("query") @@ -358,6 +470,7 @@ def main(argv: list[str] | None = None) -> int: "register": cmd_register, "where": cmd_where, "version": cmd_version, + "mine": cmd_mine, } return handlers[args.cmd](args) diff --git a/skills/cscript/scripts/cscript-hook b/skills/cscript/scripts/cscript-hook deleted file mode 100755 index 8490f73..0000000 --- a/skills/cscript/scripts/cscript-hook +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""Claude Code UserPromptSubmit hook for the /cscript skill. - -Reads the user's prompt from the hook JSON payload on stdin, runs it through -`cscript which`, and if any catalogue entries match, injects them as extra -context so the agent considers re-using an existing script instead of -regenerating equivalent work. - -Designed to be a no-op when: -- cscript is not installed on PATH -- the prompt is too short to be a task -- the catalogue has no matches -- stdin isn't a JSON payload (e.g. invoked manually) - -Stdlib only — must start fast since it runs on every prompt. -""" - -from __future__ import annotations - -import json -import shutil -import subprocess -import sys - - -MIN_PROMPT_LEN = 8 -WHICH_TIMEOUT_SECONDS = 3 -HINT_TEMPLATE = """ -The user's cscript catalogue contains entries that may match this prompt. Before -generating new work, consider whether one of these scripts already does the job: - -{matches} - -To use one: `cscript run [args]` (or `cscript show ` to inspect first). -If none fit the request, ignore this hint and proceed normally. -""" - - -def main() -> int: - if not shutil.which("cscript"): - return 0 - - try: - payload = json.load(sys.stdin) - except (json.JSONDecodeError, ValueError): - return 0 - - prompt = (payload.get("prompt") or "").strip() - if len(prompt) < MIN_PROMPT_LEN: - return 0 - - try: - result = subprocess.run( - ["cscript", "which", prompt], - capture_output=True, - text=True, - timeout=WHICH_TIMEOUT_SECONDS, - ) - except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - return 0 - - matches = result.stdout.strip() - if not matches: - return 0 - - print(HINT_TEMPLATE.format(matches=matches)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/cscript/scripts/smoke-test.sh b/skills/cscript/scripts/smoke-test.sh index 119b457..4690a1a 100755 --- a/skills/cscript/scripts/smoke-test.sh +++ b/skills/cscript/scripts/smoke-test.sh @@ -111,5 +111,35 @@ out=$("$CS" list) [[ "$out" == *"hello-again"* ]] || fail "list should still contain hello-again" pass "rm archives script and removes state dir" +# 9. version subcommand +out=$("$CS" version) +[[ -n "$out" ]] || fail "version should print something" +pass "version prints" + +# 10. mine: which calls are logged and mined +# Generate three misses (same effective query) and one hit, then mine. +"$CS" which "convert pdf to html for archiving" >/dev/null 2>&1 || true +"$CS" which "convert pdf to html for archiving" >/dev/null 2>&1 || true +"$CS" which "convert pdf to html for archiving" >/dev/null 2>&1 || true +"$CS" which "hello-again" >/dev/null 2>&1 || true + +[[ -f "$CSCRIPT_DATA_DIR/which.log" ]] || fail "which.log should exist after which calls" +log_lines=$(wc -l < "$CSCRIPT_DATA_DIR/which.log" | tr -d ' ') +[[ "$log_lines" -ge 4 ]] || fail "which.log should have at least 4 entries, got $log_lines" +pass "which appends to invocation log" + +out=$("$CS" mine) +[[ "$out" == *"[3x]"* ]] || fail "mine should show 3x repeat for pdf-to-html query: '$out'" +[[ "$out" == *"convert pdf to html"* ]] || fail "mine should surface the repeated query" +[[ "$out" != *"hello-again"* ]] || fail "mine should exclude hits" +pass "mine ranks repeated misses" + +# 11. mine: empty case message +empty_dir="$(mktemp -d)" +out=$(CSCRIPT_DATA_DIR="$empty_dir" "$CS" mine 2>&1 || true) +[[ "$out" == *"No \`cscript which\` history yet"* ]] || fail "mine empty message missing: '$out'" +rm -rf "$empty_dir" +pass "mine handles empty log" + echo echo "All checks passed."