SLOPSHOPPER

lessons

Spots wins and pitfalls in your prompts and has Claude run your win-logger / pitfall-logger skills; session tally in the status line.

newcommandtoaststatusprompt
v0.1.0MITupdated 2026-10-07tyree88/tempered_plugins/lessons
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lessons
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ lessons │ ⏺ Read(src/auth.ts) │ lessons: win-logger skill not found │ ⎿ Read 6 lines │ (/lessons status) │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭────────────────────────────────────────────╮ ⏺ Bash(bun test) │ lessons │ ⎿ 3 pass, 1 fail │ lessons: pitfall-logger skill not found │ │ (/lessons status) │ ● Done. refresh now rejects expired claims and logs an audit event. ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /lessons ⎿ lessons: lessons is on. This session: 0 wins, 0 pitfalls logged. win-logger: missing. pitfall-logger: missing. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Tempered Plugins

This repository holds plugins from Tempered Works for AI coding tools. Five plugins are for Claude Code. One plugin is for Codex.

A Claude Code "mod" is a plugin of function hooks. A function hook is code that Claude Code runs when an event occurs, for example when a tool call ends. The five Claude Code plugins here are mods. They run inside Claude Code, in the terminal and in the desktop Code tab.

What is in this repository

FolderToolWhat it does
ship-state/Claude CodeShows the git, pull request, CI and deploy state of the current repo in one line above the prompt.
timeline/Claude CodeShows a vertical timeline of the work in a side pane: what you asked, what Claude did, and what each subagent is doing.
limit-resume/Claude CodeShows your usage limits and continues a turn after a rate limit resets.
followups/Claude CodeShows 4 options for your next prompt above the prompt box after each answer. You press 1 to 4 to put one in the box.
lessons/Claude CodeFinds wins and pitfalls in your prompts. It then asks Claude to run your own win-logger and pitfall-logger skills. The status line shows how many entries you logged.
multi-harness/CodexGives Codex 6 skills to plan large work in waves and to track it to completion.
.claude-plugin/marketplace.jsonClaude CodeLists the 5 Claude Code plugins so that Claude Code can install them from this repository.

Why these plugins exist

ship-state. During a coding session, you often need to know if your work is pushed, if CI passed, and if production has the change. Without this plugin, you ask Claude, and Claude runs git and gh commands to find out. Each check costs a model turn. ship-state shows the answer on screen at all times and makes no model calls.

timeline. Long work with many steps and many subagents is hard to follow. Without a record, you ask "what is left?" and "what is the current goal?" many times. You also cannot see which model each subagent uses. timeline keeps one record per repo across sessions and shows it as a timeline.

limit-resume. When a session hits a usage limit, the work stops until you type "try again". If you are away, the session stays idle after the limit resets. limit-resume continues the work at the reset time. It also shows your usage before you reach the limit.

followups. Claude Code shows one grey suggestion for your next prompt. That suggestion is often the wrong one. followups shows 4 options in 4 directions: continue the plan, verify the work, take the alternative path, and wrap up. These options cover the usual next moves. You choose one and edit it. followups sends nothing until you press Enter.

lessons. The same mistakes happen again, and good patterns get lost. A skill that logs them is useful, but it often does not run at the right moment. lessons makes the skill run. It reads your prompts for praise, frustration, and repeated requests. When it finds one, it tells Claude to run the matching skill after Claude finishes your request. The skill always asks "Log it? y/n" before it writes. You stay in control.

multi-harness. Large product work needs a plan, branch and pull request gates, tracker updates, QA evidence, and a safe closeout. multi-harness gives Codex a repeatable method for these steps. The method is the same for every product.

Requirements

  • Claude Code with support for function-hook plugins. We tested the plugins on Claude Code 2.1.288 on macOS. All Claude Code plugins pass claude plugin validate.
  • git 2.31 or newer.
  • The GitHub CLI gh, signed in. ship-state and timeline use it for pull request, CI and deploy data. Without gh, they show only local git data.
  • Production deploy data comes from GitHub deployment records. Vercel and most hosting services create these records.

How to install the Claude Code plugins

Use one of these 2 methods.

Method 1: install from the marketplace. Run these commands in a Claude Code session:

/plugin marketplace add tyree88/tempered_plugins
/plugin install ship-state@tempered-plugins
/plugin install timeline@tempered-plugins
/plugin install limit-resume@tempered-plugins
/plugin install followups@tempered-plugins
/plugin install lessons@tempered-plugins

Install only the plugins that you want.

Method 2: load the folders directly. Clone this repository. Then add the plugin folders to the env block of ~/.claude/settings.json. Separate the folders with :.

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/tempered_plugins/ship-state:/path/to/tempered_plugins/timeline:/path/to/tempered_plugins/followups:/path/to/tempered_plugins/lessons" } }

New sessions load the plugins. Sessions that are already open do not.

How to use ship-state

ship-state needs no action. It starts with each session.

  1. Look at the band above the prompt. It shows the current repo and branch.
  2. Read the state from left to right:
app ⎇ feat/waitlist  ·  3 dirty  ·  ↑2 ↓0  ·  PR #312  ·  CI ⏳ 4/5  ·  prod = HEAD ✓ 2h ago
PartMeaning
3 dirty3 files have changes that are not committed.
↑2 ↓02 commits are not pushed. 0 commits are not pulled.
PR #312The branch has open pull request 312.
CI ⏳ 4/54 of 5 CI checks are complete.
prod = HEAD ✓Production runs the current commit.
  1. After a push or a merge, wait for the toast. ship-state shows "CI ✓", "CI ✗" with the failed check names, or "Live on prod".

ship-state follows Claude when Claude changes to another repo or worktree. It reads local git data every 20 seconds. It reads GitHub data every 5 minutes. After a push or merge, it reads GitHub data every 20 seconds for 10 minutes.

The band of ship-state stacks with the bands of other plugins, such as followups.

How to use timeline

  1. Type /timeline to open or close the pane. The pane also opens by itself when Claude logs the first task of a session.
  2. Read the pane from top to bottom. The newest entry is at the bottom.
  3. Read the left side for your requests and decisions. Claude writes each one as a one-line summary.
  4. Read the right side for the work:
  5. A task card shows a title, a progress bar such as ██████░░░░ 2/3, how Claude did the step, and the next step.
  6. Lines that start with ↳ show commits, pushes, pull requests and CI results for the active task.
  7. A subagent card shows the agent type, the model, the status, and the elapsed time. It also shows the current tool call (now:), the next step (next:), the number of tool calls, the tokens, and the result.
  8. Look for ⚠ no model set: inherited on a subagent card. This warning means that the subagent uses the same model as the main session, because nothing set a model for it.
  9. Hold the pointer on a card in the desktop app to see more detail.
  10. If the timeline has more than 40 entries, use ◀ older and newer ▶ to move between pages.

How timeline works:

  • The plugin adds a tool, mcp__timeline__log. It also adds an instruction of about 100 tokens that tells Claude when to log. Claude logs once for each change of direction, and once at the start, each step, the end, or a block of each task.
  • Each subagent gets a shorter instruction to log its own progress.
  • The log tool needs no permission prompt.
  • timeline stores the history per repo in ~/.claude/timelines/<repo>-<hash>/. Each session writes only its own files. Worktrees of a repo share one timeline.
  • If 2 sessions work in the same repo, each pane shows the entries of the other session within 10 seconds.
  • The desktop app draws the timeline as an image, with colors for light mode and dark mode. The terminal draws it as text. If the terminal is narrower than 70 columns, the text uses 1 column.

Usage cost: each logged entry costs approximately 40 to 80 output tokens. The instruction costs approximately 100 tokens in each session.

Privacy: the timeline files contain Claude's one-line summaries, the first 2000 characters of each subagent prompt, and commit subjects. The files stay on your computer.

Status: timeline is built and reviewed. Live testing is in progress.

How to use followups

followups needs no action. It starts with each session.

  1. Wait for Claude to finish an answer. A few seconds later, 4 options appear in the band above the prompt box. Each option has its own row: 1: … to 4: ….
  2. Read the options. Each option goes in a different direction:
  3. Continue the plan.
  4. Verify or test the work.
  5. Take the alternative path, or ask an open question.
  6. Wrap up or commit.
  7. Press 1 to 4 when the prompt box is empty. You can also click an option. The text of the option goes into the prompt box.
  8. Edit the text, or press Enter to send it. followups never sends anything by itself.
  9. To ignore the options, type your own message.
  10. To turn the plugin off or on, type /followups off or /followups on. Type /followups status to see the current setting and the last error, for example a refused model call.

When the band shows, a digit that you type in an empty prompt box picks an option. To start a message with a digit, type a space first.

The band hides while the prompt box has text, while a turn runs, and while a survey uses the band. It comes back when the prompt box is empty.

followups hides the built-in grey suggestion of Claude Code, but only after its own band has drawn once. In a surface without the band, the built-in suggestion stays.

followups makes no options for subagent turns, interrupted turns, errors, and empty answers. If you send a prompt before Haiku answers, followups drops the old reply. After /clear or a resume, followups removes the old options.

Usage cost: followups makes one Haiku call for each answered turn. A call costs approximately 2,000 input tokens and 150 output tokens. followups adds nothing to the context of the main model.

Privacy: the first 1,500 characters of your last prompt and the last 4,000 characters of the answer go to Haiku. The call uses the API client of Claude Code.

How to use lessons

lessons needs 2 skills of your own. Name them win-logger and pitfall-logger. This repository does not include them. The skills decide where an entry goes, for example a Notion database. Each skill always asks "Log it? y/n" before it writes.

  1. Add the 2 skills to Claude Code. Any skills with these names work.
  2. Work as usual. lessons watches only the prompts that you send yourself: in the terminal, in Remote Control, in your own Slack ping, and in the Claude desktop app. It ignores prompts from plugins, notifications, other sessions, and subagents.
  3. Send a prompt that shows a win or a pitfall. The table below lists the signals.
  4. Claude finishes your request first. Then Claude runs the matching skill. lessons adds a short note to your prompt to cause this. You do not see the note.
  5. Read the draft that the skill shows. Answer y to log it. Answer n to skip it.
  6. Look at the status line. 🌱 2 · ⚠ 1 means 2 wins and 1 pitfall are logged in this session. lessons counts a row when you answer y to a draft. The count resets after /clear or a resume.
  7. To turn the plugin off or on, type /lessons off or /lessons on. Type /lessons status to see the setting, the counts, and where each skill was found.
KindSignals
WinPraise, for example "perfect", "nailed it", "love this", "this is great", "exactly what I wanted". Or an ask: "log this win", "log this as a win", "add this to learnings", "remember this worked".
PitfallFrustration, for example "I already told you", "no, I said", "still wrong", "still failing", "this is the third time", "why did you change…", "not what I asked". Or shouting: several words in all capitals. Or the same request sent again: it has high word overlap with one of your last 10 prompts. Or an ask: "log this", "add this to pitfalls", "remember this lesson".

lessons avoids common false signals. These prompts do not trigger it: "exactly 3 retries", "pixel perfect", "log this error to sentry", "why did you choose zod?", "the second time I click it throws". File names such as README or CHANGELOG do not trigger it. HTTP method names do not trigger it.

lessons adds at most 1 note of each kind for each 5 prompts.

lessons looks for the skills in 2 places:

  • The skills loaded in the session. The name can also be anthropic-skills:<name>.
  • On macOS, the synced-skills folder of the Claude desktop app.

If a skill is missing, lessons shows a toast at most once a day. /lessons status shows "missing" for that skill.

Usage cost: lessons makes no model calls. A note costs approximately 40 tokens. lessons adds a note only to a prompt that matches.

Privacy: your last 10 prompts stay in memory only, for the repeat check. lessons does not write them to disk.

How to use limit-resume

limit-resume needs no action. It starts with each session.

  1. Read the status line below the prompt. It shows your usage, for example 5h 62% · 7d 41%. 5h is the 5-hour window. 7d is the 7-day window.
  2. If the 5-hour usage reaches 85%, a toast tells you. Commit your work at this point.
  3. If a turn stops because of a usage limit, the status line shows when the work continues. At the reset time plus 1 minute, limit-resume sends "Continue from where you left off".
  4. If a turn stops because of a temporary API error, limit-resume tries again after 1, 2, 4, and 8 minutes, then every 15 minutes. It stops after 12 tries.
  5. To cancel a pending continue, type any message.
  6. To turn the plugin off or on, type /autoresume off or /autoresume on. Type /autoresume status to see the current setting.

Claude Code has a built-in setting, autoContinueAtUsageLimit, that also continues after a usage limit. Do not use the built-in setting and limit-resume together for usage limits. If you do, the turn gets 2 "continue" messages.

How to use multi-harness

multi-harness is a Codex plugin. Its manifest is multi-harness/.codex-plugin/plugin.json, and its plugin name is platform-orchestrator. It is not in the Claude Code marketplace file.

  1. Install the multi-harness folder with the Codex plugin installer.
  2. Ask Codex to use Platform Orchestrator on a backlog. For example: "Use Platform Orchestrator to turn this backlog into shippable waves, branch and PR gates, tracker updates, and verification evidence."
  3. Use the templates in multi-harness/assets/templates/ for wave plans, tracker updates, QA evidence, and pull request closeout.

The 6 skills are:

SkillUse
platform-wave-orchestratorDivide a backlog into agent lanes and implementation waves.
platform-agent-patternsChoose how agents work together on a wave.
platform-pr-closeoutGate and close branches and pull requests.
platform-tracker-syncUpdate GitHub and Notion trackers.
platform-qa-evidenceCollect QA evidence for each change.
platform-safety-reviewReview work that touches sensitive data, regulated text, or trust and safety limits.

To check the folder structure, run python3 multi-harness/scripts/check_plugin_structure.py.

Development

Each Claude Code plugin has checks for its logic. Node 23 or newer runs the .ts check files directly.

node limit-resume/check.ts
node ship-state/check.ts
node followups/checks/ask.check.ts
node lessons/checks/detect.check.ts
bash timeline/checks/run.sh

To run the behavior test of followups, run claude plugin test followups. It runs 8 cases on the terminal and desktop surfaces.

To run the behavior test of lessons, run claude plugin test lessons. It runs 9 cases.

To type-check timeline, do these 2 steps:

  1. In a Claude Code session in this repository, run /plugin-types .claude/types. This command writes the Claude Code type declarations.
  2. Run tsc -p timeline/tsconfig.check.json.

License

MIT. See LICENSE. Copyright 2026 Tempered Works LLC.

Source 3 files
hooks/register.ts 159 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, PromptOrigin, Register } from 'claude-code'
3
4import type { Tally } from '../types'
5import { countDrafts, detect, isYes, type Drafts, type Kind } from './detect'
6
7const ZERO: Tally = { win: 0, pitfall: 0 }
8const tally = atom({ plugin: 'lessons', key: 'tally' } as const, ZERO)
9const SKILL: Record<Kind, string> = { win: 'win-logger', pitfall: 'pitfall-logger' }
10const COOLDOWN = 5 // typed prompts between two nudges of one kind
11const KEEP = 10 // typed prompts remembered for the repeat check
12const SYNCED = 'Library/Application Support/Claude/local-agent-mode-sessions/skills-plugin' // the desktop app's synced skills
13
14type Source = { ref: string; how: 'loaded' | 'file' } | null
15const NONE: Drafts = { win: 0, pitfall: 0 }
16const NEVER: Record<Kind, number> = { win: -Infinity, pitfall: -Infinity }
17
18// Session memory. Module scope: the loader allows $ only in top-level functions.
19const s = {
20  recent: [] as string[],
21  pending: NONE,
22  lastNudge: { ...NEVER },
23  promptIndex: 0,
24  skills: { win: null, pitfall: null } as Record<Kind, Source>,
25}
26
27async function isOn($: Engine) {
28  return (await $.store.get('enabled')) !== false
29}
30
31function show($: Engine, t: Tally) {
32  $.ui.status(t.win || t.pitfall ? `🌱 ${t.win} · ⚠ ${t.pitfall}` : undefined)
33}
34
35// <home>/<SYNCED>/<id>/<id>/skills/<name>/SKILL.md, newest first.
36async function findFile($: Engine, name: string) {
37  const home = await $.env.get('HOME')
38  if (!home) return undefined
39  const root = `${home}/${SYNCED}`
40  const found: { path: string; at: number }[] = []
41  try {
42    for (const a of await $.fs.list(root)) {
43      if (a.kind !== 'dir') continue
44      for (const b of await $.fs.list(`${root}/${a.name}`)) {
45        if (b.kind !== 'dir') continue
46        const path = `${root}/${a.name}/${b.name}/skills/${name}/SKILL.md`
47        if (await $.fs.exists(path)) found.push({ path, at: (await $.fs.stat(path)).mtimeMs })
48      }
49    }
50  } catch {
51    return undefined // no desktop app folder (another OS, or no access)
52  }
53  return found.sort((x, y) => y.at - x.at)[0]?.path
54}
55
56async function resolve($: Engine, kind: Kind): Promise<Source> {
57  const name = SKILL[kind]
58  const cmd = (await $.command.list()).find(c => c.name === name || c.name.endsWith(`:${name}`))
59  if (cmd) return { ref: cmd.name, how: 'loaded' }
60  const path = await findFile($, name)
61  if (path) return { ref: path, how: 'file' }
62  await warnMissing($, name)
63  return null
64}
65
66// Once per skill per day, never while off: an install without the skills would toast at every start and reload.
67async function warnMissing($: Engine, name: string) {
68  if (!(await isOn($))) return
69  const key = `warned:${name}`
70  const today = new Date(await $.clock.now()).toISOString().slice(0, 10)
71  if ((await $.store.get(key)) === today) return
72  await $.store.set(key, today)
73  $.ui.toast(`lessons: ${name} skill not found (/lessons status)`)
74}
75
76async function resolveAll($: Engine) {
77  try {
78    s.skills = { win: await resolve($, 'win'), pitfall: await resolve($, 'pitfall') }
79  } catch {
80    // a refused command.list or env read leaves skills unresolved; /lessons status says missing
81  }
82}
83
84const note = (kind: Kind, reason: string, src: NonNullable<Source>) =>
85  `[lessons] ${kind} signal (${reason}). Finish the request first. ` +
86  (src.how === 'loaded' ? `Then follow the ${src.ref} skill` : `Then read ${src.ref} and follow it`) +
87  ': it drafts the entry and asks "Log it? y/n". Never write without a yes.'
88
89const where = (src: Source) => (!src ? 'missing' : src.how === 'loaded' ? `loaded as ${src.ref}` : `file ${src.ref}`)
90
91// The person's own words: the terminal, Remote Control, the owner's Slack ping, or the desktop app (an SDK host).
92async function isPerson($: Engine, origin: PromptOrigin) {
93  return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'slack-ping' ||
94    (origin.kind === 'sdk' && (await $.session.surfaces()).includes('desktop'))
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    await $.command.register({
100      name: 'lessons',
101      description: 'lessons: win/pitfall logging nudges on or off',
102      argumentHint: 'on | off | status',
103    })
104    void resolveAll($) // never hold the session's start for a folder walk
105    show($, await read($, tally))
106    return next(e)
107  })
108
109  on('command.run', { command: 'lessons' }, async ($, e) => {
110    const arg = e.args.trim()
111    if (arg === 'on' || arg === 'off') await $.store.set('enabled', arg === 'on')
112    const t = await read($, tally)
113    const state = (await isOn($)) ? 'on' : 'off'
114    return {
115      text: `lessons is ${state}. This session: ${t.win} wins, ${t.pitfall} pitfalls logged. win-logger: ${where(s.skills.win)}. pitfall-logger: ${where(s.skills.pitfall)}.`,
116    }
117  })
118
119  // Only what the person types counts: the terminal, Remote Control, Slack, or the desktop app.
120  on('prompt.submit', async ($, e, next) => {
121    if (!(await isPerson($, e.origin))) return next(e)
122    s.promptIndex += 1
123
124    if ((s.pending.win || s.pending.pitfall) && isYes(e.text)) {
125      const add = s.pending
126      await update($, tally, t => ({ win: t.win + add.win, pitfall: t.pitfall + add.pitfall }))
127      show($, await read($, tally))
128    }
129    s.pending = NONE
130
131    const hit = (await isOn($)) ? detect(e.text, s.recent) : null
132    s.recent = [...s.recent, e.text].slice(-KEEP)
133    const src = hit ? s.skills[hit.kind] : null
134    if (!hit || !src || s.promptIndex - s.lastNudge[hit.kind] < COOLDOWN) return next(e)
135    s.lastNudge[hit.kind] = s.promptIndex
136    return next({ ...e, context: [...(e.context ?? []), note(hit.kind, hit.reason, src)] })
137  })
138
139  on('turn.complete', async ($, e, next) => {
140    const result = await next(e)
141    if (e.agentId || e.reason !== 'answer') return result
142    const d = countDrafts(e.answer)
143    if (d.win || d.pitfall) s.pending = d // a notification or peer turn in between must not wipe drafts awaiting y/n
144    return result
145  })
146
147  // /clear and resume: a new conversation starts a new tally.
148  on('session.end', async ($, e, next) => {
149    if (e.reason === 'clear' || e.reason === 'resume') {
150      s.recent = []
151      s.pending = NONE
152      s.lastNudge = { ...NEVER }
153      await update($, tally, () => ZERO)
154      $.ui.status(undefined)
155    }
156    return next(e)
157  })
158}
159
hooks/detect.ts 74 lines
1export type Kind = 'win' | 'pitfall'
2export type Reason = 'explicit' | 'frustration' | 'repeated correction' | 'praise'
3export type Hit = { kind: Kind; reason: Reason }
4export type Drafts = { win: number; pitfall: number }
5
6// Bare "log this" counts only as a whole ask (end of clause), so "log this error to sentry" is not one; also "log this as a win/pitfall", "add this to pitfalls/learnings".
7const LOG_PITFALL =
8  /\b(log this(?=(?:\s+(?:please|pls))?\s*(?:[.!,;:]|$))|log this (?:as an? )?(?:pitfall|lesson|mistake)|(?:log|add) (?:this |that |it )?(?:\w+ )?(?:to|in) (?:the )?pitfalls|remember this lesson)\b/i
9const LOG_WIN = /\b(log this (?:as an? )?win|(?:log|add) (?:this |that |it )?(?:\w+ )?(?:to|in) (?:the )?learnings|remember this worked)\b/i
10// Excludes: "why did you choose X?" (a rationale question), "the second time I click it..." (only "this is the second time" complains), "run it again" (only a sentence-leading "again, use X" corrects).
11const FRUSTRATION =
12  /\b(i (?:(?:already|just) (?:told|said|asked)|told you|asked you (?:to|not))|no,? i (?:said|meant)|still (?:wrong|broken|not (?:working|right|fixed)|fails|failing|crashing|erroring|doesn['’]?t work|isn['’]?t working)|(?:this is|that['’]?s|it['’]?s|for) the (second|third|2nd|3rd|fourth) time|why (did|would) you(?! (?:choose|pick|decide|opt|select|prefer|recommend|use|go (?:with|for))\b)|not what i (?:asked|wanted|said|meant)|ugh+)\b|(?:^\s*|[.!?]\s+|\bbut\s+)again\b[,.!]?\s+(?:i|please|no|use|do)\b/i
13// Excludes: "exactly 3 retries" (only a standalone "exactly" or "exactly what I wanted" praises), "pixel perfect", "love it if/when/to ...".
14const PRAISE =
15  /(?<!pixel[ -])\b(perfect|nailed it|love (this|it)\b(?! (?:if|when|to)\b)|this is great|that['’]?s great|yes,? (this|that) is what i wanted|exactly what i (?:wanted|needed|meant))\b|^\W*(?:yes,?\s+)?exactly\s*(?:[.!,]|$)/i
16const THANKS_ONLY = /^\s*(thanks|thank you|ok|okay|cool|nice)[\s.!]*$/i
17const NEGATION = /^(not|never|isnt|wasnt|dont|doesnt|didnt|arent)$|n['’]t$/
18// Excludes: REST verbs and file names, and caps words that are under 40% of all words (pasted logs, SQL, env names).
19const ACRONYMS = new Set(['JSON', 'HTML', 'HTTP', 'HTTPS', 'README', 'TODO', 'YAML', 'TOML', 'UUID', 'CORS', 'CRUD', 'NULL', 'TRUE', 'FALSE', 'ASAP', 'NOTE', 'POST', 'PATCH', 'DELETE', 'HEAD', 'CHANGELOG', 'LICENSE'])
20const STOP = new Set(['the', 'and', 'for', 'you', 'this', 'that', 'with', 'are', 'was', 'can', 'please', 'just', 'not', 'but', 'use', 'all', 'any', 'from', 'into', 'have', 'has', 'its', 'our', 'your'])
21const OVERLAP = 0.8
22const MIN_WORDS = 5
23
24const shouting = (text: string) => {
25  if (!/[a-z]/.test(text)) return 0
26  const caps = (text.match(/\b[A-Z]{4,}\b/g) ?? []).filter(w => !ACRONYMS.has(w)).length
27  const all = (text.match(/[A-Za-z]+/g) ?? []).length
28  return caps / all >= 0.4 ? caps : 0
29}
30
31// Short tokens with a digit are kept, so "page 2" and "page 3" differ.
32export const words = (text: string) =>
33  new Set(text.toLowerCase().split(/[^a-z0-9]+/).filter(w => (w.length >= 3 || /\d/.test(w)) && !STOP.has(w)))
34
35const overlap = (a: Set<string>, b: Set<string>) => {
36  let shared = 0
37  for (const w of a) if (b.has(w)) shared += 1
38  return shared / (a.size + b.size - shared)
39}
40
41const isPraise = (text: string) => {
42  const m = PRAISE.exec(text)
43  if (!m || THANKS_ONLY.test(text)) return false
44  const before = text.slice(0, m.index).toLowerCase().split(/\s+/).filter(Boolean).slice(-2)
45  // A trailing "?" vetoes praise only when the praise is not the opening words ("perfect, can you now add tests?" is a win).
46  if (text.trim().endsWith('?') && text.slice(0, m.index).trim().length >= 4) return false
47  return !before.some(w => NEGATION.test(w.replace(/[^a-z'’]/g, '')))
48}
49
50// One typed prompt → a win/pitfall signal or null. Explicit asks first; then pitfall rules beat praise.
51export function detect(text: string, recent: readonly string[]): Hit | null {
52  if (LOG_PITFALL.test(text)) return { kind: 'pitfall', reason: 'explicit' }
53  if (LOG_WIN.test(text)) return { kind: 'win', reason: 'explicit' }
54  if (FRUSTRATION.test(text) || shouting(text) >= 2) return { kind: 'pitfall', reason: 'frustration' }
55  const mine = words(text)
56  if (mine.size >= MIN_WORDS && recent.some(r => {
57    const theirs = words(r)
58    return theirs.size >= MIN_WORDS && overlap(mine, theirs) >= OVERLAP
59  })) return { kind: 'pitfall', reason: 'repeated correction' }
60  if (isPraise(text)) return { kind: 'win', reason: 'praise' }
61  return null
62}
63
64export const isYes = (text: string) => /^\s*(y|yes)\b/i.test(text)
65
66// Drafts the skills showed in one answer; only when they asked "Log it? y/n".
67export function countDrafts(answer: string): Drafts {
68  if (!answer.includes('Log it? y/n')) return { win: 0, pitfall: 0 }
69  return {
70    win: (answer.match(/Win draft/g) ?? []).length,
71    pitfall: (answer.match(/Pitfall draft/g) ?? []).length,
72  }
73}
74
types/index.d.ts 8 lines
1export type Tally = { win: number; pitfall: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    lessons: { tally: Tally }
6  }
7}
8