Entry recommender
Skill product-on-purpose/writing-style-catalog/skills/entry-recommender
Recommend a voice, tone, style, and format combination from the catalog's stable entries for a described writing situation, then compose the prompt in the same step. Accepts optional pre-fixed axis values; never recommends draft entries; reports low confidence rather than force-picking when nothing fits.From its SKILL.md
npx -y skills add product-on-purpose/writing-style-catalog --skill entry-recommenderAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
20.8 KB, ~4.7k tokens by cl100k_base, as published. Nobody here has run it
Entry Recommender
Take a described writing situation and recommend a voice/tone/style/format combination from the catalog's stable entries, with a defensible reason per axis quoting the entry's own language, then compose the final prompt in the same step. This is the fast interactive path for a user who does not yet know which of the 97 stable entries (52 of them Format) fits their situation - writing-instruction-builder composes from axis values you already know you want; this skill finds them first.
Full spec: docs/internal/entry-recommender-spec.md. Implementation plan (this file implements Phases 3-6; Phases 1-2 are scripts/recommend.py): docs/internal/release-plans/entry-recommender-implementation-plan.md.
Prerequisites
- The
writing-style-catalogplugin is present:taxonomy/(the entries),skills/entry-recommender/scripts/recommend.py(the scorer), andskills/writing-instruction-builder/scripts/build-instruction.py(the composer, reused here rather than reimplemented) all exist at the plugin root. - If a prerequisite is missing, say so and stop. Do not invent entries, scores, or field language.
Usage
/writing-style-catalog:entry-recommender <situation description> [voice=<id>] [tone=<id>] [style=<id>] [format=<id>] [--recommend-only]
The situation description is free text - what you need to write and for whom. Any axis=id tokens in the input are pre-fixed axis values; strip them out. Everything else in the input is the situation description, and it reaches the script only through Step 1's temp-file mechanism - never as a --situation shell argument (that is Step 1's first failure mode).
voice=,tone=,style=,format=- optional. An axis with a fixed value is never recommended, re-justified, or second-guessed (AC-2).--recommend-only- optional. Suppresses composition; returns just the four picks, justifications, and any conflict/low-confidence notes, for a user who wants to review before composing (AC-5).
The Process
Order is load-bearing: score before reading full fields (Step 1 before Step 2), pick before conflict-checking (Step 2 before Step 3), resolve before composing (Step 3 before Step 4).
Step 1 - Score the candidate pool. The situation description is arbitrary user text - it can and will contain quotes, apostrophes, backslashes, or newlines ("explaining my team's Q3 numbers" already has one) - and it can be sensitive (HR matters, incident details, customer or product information). Four failure modes to avoid, not one, two, or three:
- Never build a shell command by embedding that text directly into a
--situation "..."string - real command injection, not theoretical; an ordinary apostrophe is enough to break it with no ill intent. - Never hand-substitute it into a JSON template without properly escaping it first ("explaining why "quality" matters" breaks
{"situation": "<text>"}the instant you paste it in unescaped - confirmed, this produces invalid JSON from completely ordinary text, no attacker required). - Never pipe it through a shell heredoc, even a quoted one - the delimiter that closes a heredoc is plain text matched against the body; if the situation text itself contains that exact line (a fixed, predictable delimiter is guessable by anyone who has read this file, since it is public), the heredoc ends early and whatever follows is interpreted as a new, separate shell command, silently reintroducing the exact injection this is supposed to prevent. The script's
--stdinflag is this same trap by another name when fed via a heredoc or here-string - do not use--stdinfor situation text either. - Never rely on yourself to remember a separate delete-the-file step afterward as the only cleanup - if this run is interrupted, errors out earlier than expected, or the step is simply skipped, sensitive situation text is left sitting on disk. Use
--ephemeral-input-file(below), which deletes the file as part of the script's own execution, not as a separate instruction you have to remember and successfully reach.
Do all of this correctly, in order:
-
Apply real JSON string escaping to the situation text (and to
topic/audienceif given): replace\with\\,"with\", newline with\n, carriage return with\r, tab with\t. This is a mechanical, well-defined transformation, not a judgment call - do not skip it or assume the text happens to be simple enough not to need it. -
Use the Write tool (never a shell command,
echo, or heredoc) to write the escaped JSON payload to a new file. The location MUST be inside the OS/system temp directory specifically (Python'stempfile.gettempdir()- on this session, that is the same root your scratchpad directory is nested under) - NOT merely "somewhere that feels temporary," and NEVER inside this project's working directory. Step 3's flag checks this exact condition and refuses to touch (read OR delete) a path outside it, so if you write somewhere else, the file will be left behind with an error telling you to delete it yourself - avoid that by writing to the right place the first time. The filename MUST end in.entry-recommender-input.json(any prefix is fine, for examplesituation-<something>.entry-recommender-input.json) - Step 3's flag also refuses to touch a file that is not named this way, on purpose, so a path mistake fails loudly instead of silently deleting the wrong file:{"situation": "<the escaped situation text>", "voice": "<id, if fixed>", "tone": "<id, if fixed>"}Omit any key for an axis that is not fixed. The Write tool writes file content directly - it does not involve a shell at all, so nothing here is a quoting or injection concern; the only remaining requirement is that the JSON string itself is valid, which step 1 above ensures.
-
Run the scorer against that file with
--ephemeral-input-file, not--input-file- the path is short, assistant-chosen, and safe to put in a shell command, unlike the situation text itself. This flag deletes the file itself, guaranteed, as soon as it has been read (you do not need a separate cleanup step, and the file is gone even if the JSON turns out to be malformed or scoring fails), but only if the path is outside this repo, inside the system temp directory, AND correctly named per step 2 - otherwise it raises an error instead of reading or deleting anything:python "${CLAUDE_SKILL_DIR}/scripts/recommend.py" --ephemeral-input-file <path-to-the-temp-file> --json
This returns, per non-fixed axis, a tiered short_list containing EVERY candidate that clears above_threshold (C1 guarantee - no qualifying candidate is ever hidden). The list has two groups:
- Read tier (first
short_list_sizerows that areabove_threshold: true, default 6): full rows withscore,distinct_matches,matched_tokens,one_liner,when_to_use,tells,when_not_to_use. These are the top qualifying candidates. Step 2 reads and picks from these directly. - Lean rows (everything else - carries
"fields": "fetch"as a sentinel and physically lackswhen_to_use,tells, andwhen_not_to_use): two mutually exclusive sub-groups appear here:- Lean triage (
above_threshold: true, when qualifying count exceedsshort_list_size): qualifying candidates beyond the read tier. You MUST fetch a lean triage row's full fields via--fetch-manybefore it can be picked - never pick from a lean row alone. - Lean padding (
above_threshold: false, when qualifying count is less thanshort_list_size): non-qualifying near-miss rows present for context only. These are NOT pick candidates. Fetch a lean padding row only if you intend to cite it in an AC-7 low-confidence justification (see Step 2 below).
- Lean triage (
Also: candidate_count, and no full_ranked in default output (use --debug if you need it for score diagnosis - it is not part of the documented workflow). For diagnosing why a score is what it is, --verbose prints a per-candidate trace to stderr: every situation token's IDF weight, each candidate's per-field matches with their weighted contribution, and for a rejected candidate which gate it failed. --debug shows the scores; --verbose shows how they were arrived at. stdout stays clean JSON under both. A fixed axis reports {"fixed": id, "valid": true/false} instead - if valid is false, stop and tell the user the fixed value does not exist in the stable catalog rather than silently ignoring it (Phase 4 Step 3).
From this point on, every id this skill puts into a shell command (in Step 3 and Step 4 below) is either one this step's JSON output already validated (a fixed axis reported valid: true) or one this skill itself picked from short_list (which only ever contains real stable catalog ids, matching the strict kebab-case id pattern every entry is validated against). Never interpolate the raw, not-yet-validated situation text or an unvalidated fixed-axis claim into any later shell command either - route it through Step 1's temp-file mechanism first.
Step 2 - Read, pick, and justify each non-fixed axis (AC-1, AC-3, AC-7). The score decides who makes the short list. It does not decide who wins.
-
Read the read-tier candidates (rows carrying full
when_to_use/tells/when_not_to_use) in full. The score is a cheap keyword/facet heuristic - it tells you who is worth reading, not who is right. Compare the WHOLE read tier's language against the situation before picking. Do not default to the top score and use your read only as a rubber stamp. -
Triage the lean rows (those carrying
"fields": "fetch"). There are two kinds:- Lean triage rows (
above_threshold: true): qualifying candidates beyond the read tier. For each, scan itsone_linerandmatched_tokens. A lean triage row whose one_liner or matched tokens look genuinely competitive with the read tier MUST be fetched before it can be picked - never pick from a lean row alone. - Lean padding rows (
above_threshold: false): non-qualifying near-misses. Do not fetch these unless you need to cite one in an AC-7 low-confidence justification (see below). They are not pick candidates and do not need to be triaged.
Fetch lean triage candidates you intend to consider in one call:
python "${CLAUDE_SKILL_DIR}/scripts/recommend.py" --fetch-many <axis> <id> <id> ... --jsonTypical situations need 0-4 fetches per axis (lean triage rows only). A situation where many lean triage rows look competitive is a signal to re-read the read tier more carefully, not to fetch everything. An axis where the read tier alone already contains a strong fit needs zero fetches - which is the common case.
- Lean triage rows (
-
Apply the pick rules over everything you have now read in full:
-
Also read
when_not_to_usebefore picking, not justwhen_to_use/tells- a positive-only read is not the full read. Scoring only looks at positive fields, so a candidate can clear the short list onwhen_to_use/tellsoverlap while its ownwhen_not_to_useexplicitly names the situation as a poor fit. Treat a genuine match there as disqualifying for that candidate. -
Confirmed repeatedly in testing: the top-scoring candidate is very often not the best actual pick. A lower-scoring candidate can describe the situation almost verbatim while the top score turns out to be pulling in the wrong direction (for example, scoring high on "the reasoning is settled, not relitigated" language when the situation explicitly asks the reader to trust the reasoning) or coincidentally matching a word used in an unrelated sense.
Low confidence (AC-7) has two independent triggers, and either one alone is enough:
- No candidate clears the score threshold (
above_thresholdisfalsefor every short-listed candidate) - the deterministic side of AC-7. - A candidate clears the score threshold, but does not survive your own read. If nothing in the short list is genuinely supported by its own
when_to_use/tellslanguage for this specific situation, that is low confidence too, even though the score cleared the bar.
If low confidence applies to an axis, do not force-pick. What the near-miss row looks like depends on which trigger fired:
-
Score-based trigger (no candidate is
above_threshold): the near-miss is a lean padding row - it physically lackswhen_to_use,tells, andwhen_not_to_useinline. Before writing the justification, fetch the near-miss you intend to cite via--fetch-many(one call):python "${CLAUDE_SKILL_DIR}/scripts/recommend.py" --fetch-many <axis> <near-miss-id> --json -
Read-based trigger (a qualifying candidate survived scoring but failed your read): that near-miss was already in the read tier or was fetched during triaging, so its fields are already available. No additional fetch is needed.
In either case, record the near-miss id and a one-line reason it does not genuinely fit, quoting or closely paraphrasing the entry's own field language to make the "why not" credible - the same discipline AC-3 requires for picks, applied here to the rejection. Move on after: this axis will be blank in the composed output (Step 4). If every short-listed candidate scores identically (including a tie at zero), say so plainly rather than presenting an arbitrary tie-broken id as if it were meaningfully closer.
Otherwise, pick the short-listed candidate whose
when_to_use/tellslanguage most specifically matches the situation, and write a one-line justification that names the actual phrase or concept that matched - quote or closely paraphrase the entry's own field, never invented reasoning (AC-3). -
Step 3 - Check and resolve conflicts across the complete final set (AC-4). This runs on every invocation, including when every axis was freshly recommended and nothing is fixed - two independently-picked entries can conflict with each other exactly as a fixed-and-recommended pair can.
-
Call the composer's conflict check on the complete final four-axis set (whatever Step 2 picked, plus any fixed values, plus a blank for any low-confidence axis) - this is the same call as Step 4's compose, so in practice you will often do this and Step 4 together in one call and inspect the
conflictsfield first:python "${CLAUDE_SKILL_DIR}/../writing-instruction-builder/scripts/build-instruction.py" --voice <id-or-omit> --tone <id-or-omit> --style <id-or-omit> --format <id-or-omit> --jsonA blank axis (from Step 2's low-confidence path) is simply omitted from the flags -
writing-instruction-builderalready supports composing with a blank axis, and this skill reuses that rather than inventing new behavior. -
If
conflictsis empty, there is nothing to resolve - proceed to Step 4. -
If a conflict is found, classify it: does it involve at least one axis you recommended in Step 2 (recommender-controlled), or is it strictly between two axes the user fixed?
- At least one side recommender-controlled: re-pick that axis, excluding the conflicting candidate - walk the rest of that axis's
short_list. There is no separate "widen tofull_ranked" step:short_listalready contains every candidate that clearsabove_thresholdfor that axis, however many there are (see Step 1) - nothing is hidden. A replacement candidate in the triage tier (a lean row carrying"fields": "fetch") must be fetched via--fetch-manybefore the two-condition re-pick test runs - condition 2 is a read of the candidate's ownwhen_to_use/tells/when_not_to_use, which the lean row physically lacks. A replacement must clear TWO conditions, not one: (1) non-conflicting; AND (2) genuinely relevant on your own read of its full fields, exactly the Step 2 judgment call, applied again here -above_threshold: trueis necessary (every short-listed candidate already has it) but not sufficient by itself. A candidate that fails condition 2 is not an acceptable resolution; skip it and keep walking the short list. If BOTH conflicting axes are recommender-controlled, try the lower-precedence one first (format, then style, then tone, then voice - the reverse of the compose precedence order) but if its short list is exhausted with no resolution, try the same walk on the other axis before giving up. - Both sides user-fixed: you have no agency over either value. Skip straight to the warning path below.
- Every recommender-controlled axis involved has had its entire short list searched with no compatible-and-relevant candidate found: only now is this the warning path. This should be rare - it means every candidate that genuinely clears the relevance bar on that axis also conflicts.
- At least one side recommender-controlled: re-pick that axis, excluding the conflicting candidate - walk the rest of that axis's
-
When a re-pick resolves the conflict, re-run Step 2's justification for the new candidate alone, then append the reason it was chosen over the higher-scoring original (for example "picked over X because X conflicts with the fixed voice"). Re-run the Step 3 conflict check against the updated set before moving on - a resolution can only be trusted once the check comes back clean.
-
When resolution was not possible (both fallback cases above), proceed to compose anyway with the conflict named alongside the output - this is
writing-instruction-builder's own verified behavior (warn, never block), reused here as the fallback for a conflict this skill genuinely could not avoid, not the default response to one it created and could have fixed itself.
Step 4 - Compose or recommend-only (AC-5). By default, call the composer on the final set (recommended + fixed values, any low-confidence axis passed as blank) exactly as in Step 3's call, and use its instruction as the output. Attach two kinds of notes alongside the composed prompt, never instead of it:
- A conflict note, only if Step 3 ended in the warning path (both-fixed, or no compatible-and-relevant candidate found after exhausting every recommender-controlled axis involved).
- A low-confidence note for every axis Step 2 left blank, naming the near-miss and why it did not genuinely fit.
If --recommend-only was passed, skip the compose call entirely and return the structured picks instead: axis, value (or blank), justification, any resolution note from Step 3, any conflict or low-confidence note.
Constraints
Non-negotiable.
- Never recommend a draft entry (AC-6).
recommend.pyalready filters tostable/reference-qualitybefore scoring - do not work around this with a manual override, and do not recommend an entry by name from memory instead of from the script's output. - Every justification cites the entry's own field language (AC-3). Quote or closely paraphrase
when_to_use,tells, orone_liner- never invent a reason disconnected from what the entry's own fields say, including for a candidate picked during conflict resolution. - Report low confidence honestly, on either trigger (AC-7). A score clearing the threshold is not sufficient on its own if your own read of the field language does not support it. Do not force-pick the least-bad option to avoid an empty axis.
- Reuse the composer and its conflict check; never reimplement them. Both live in
skills/writing-instruction-builder/scripts/build-instruction.pyand are called via subprocess with--json, the same cross-skill patternstyle-profilealready uses. Do not hand-roll theavoid_with/pairs_well_withsymmetric-conflict rule or the voice-tone-style-format compose precedence a second time. - No catalog mutation. This skill only reads
taxonomy/,taxonomy.json, andexamples/; it never writes to any of them. - No em-dashes or en-dashes in any output, prose, or file you write. Use " - ". A pre-commit hook enforces this on committed files.
Examples
Full recommendation, nothing fixed:
/writing-style-catalog:entry-recommender I need to tell my engineering team that a feature we committed to is getting cut this quarter, and I want them to trust the reasoning, not just accept the decision.
Partial recommendation, voice already decided:
/writing-style-catalog:entry-recommender explaining a database migration decision to the team voice=pragmatic-architect
Recommendation only, no composed prompt:
/writing-style-catalog:entry-recommender a public apology for a service outage --recommend-only
See site/src/content/docs/guides/recommend-entries.md for a detailed walkthrough with real example outputs, including the low-confidence and conflict-resolution paths.
What ships with it: 2 files
59.5 KB alongside SKILL.md, 1 of them executable
scripts/
- recommend.pyruns55.6 KB
- README.md3.8 KB