SLOPSHOPPER

whetstone

Engineering-craft skills that sharpen the edge: red-green TDD, flaky-test audit, pre-decision doubt review, merge-conflict resolution, skill linting, and a…

new
A shopper browsing a rack in a slop shop
README

whetstone

Engineering-craft skills that sharpen the edge before you cut. Six small, composable skills — each carries a deterministic self-verify, so "done" is a checkable fact.

Docs live at the repository root, which is not shipped to the install path — these are absolute so they resolve from the plugin cache too:

  • README — install, commands, self-verify table
  • ARCHITECTURE.md — priorities, tenets, verdict grammar, testing standard
  • RESEARCH.md — decisions and their rejected alternatives

Local to this plugin:

Source 3 files
hooks/register.ts 29 lines
1import type { Register } from 'claude-code'
2import { flaggedUnits } from './claim-check.ts'
3
4const MAX_SHOWN = 10
5const MAX_REASON_CHARS = 2000
6
7function claimReason(reply: string): string | undefined {
8  const flagged = flaggedUnits(reply).map(([number, unit]) => `line ${number}: "${unit}"`)
9  if (!flagged.length) return undefined
10  const shown = flagged.slice(0, MAX_SHOWN)
11  if (flagged.length > MAX_SHOWN) shown.push(`… and ${flagged.length - MAX_SHOWN} more`)
12  const body = [...shown.join('\n')].slice(0, MAX_REASON_CHARS).join('')
13  return (
14    'Unbacked claim in the reply:\n' +
15    `${body}\n` +
16    'Name the exit code, cite the source, or label it advisory / model-judgment / opt-in — or drop the blocks / enforces / gates / denies / prevents / refuses verb.'
17  )
18}
19
20export const register: Register = (on) => {
21  on('classic.Stop', async ($, e, next) => {
22    const result = await next(e)
23    if (e.stop_hook_active) return result
24    const reason = claimReason(e.last_assistant_message ?? '')
25    if (!reason) return result
26    return { ...result, additionalContext: [...(result.additionalContext ?? []), reason] }
27  })
28}
29
hooks/claim-check.ts 62 lines
1import { splitLines, strip, unicodeRegex } from './text.ts'
2
3const CLAIM = unicodeRegex(
4  '(?:`[^`]+`' +
5    '|\\b(?:the|this)\\s+(?:runner|hook|script|gate|check(?:er)?|guard|linter)\\b)' +
6    '\\s+(?:\\w[\\w-]*\\s+){0,3}?' +
7    '(?:hard-)?(?:blocks|enforces|gates|denies|prevents|refuses)\\b' +
8    '(?!\\s+\\w+ed\\b)',
9  'i',
10)
11
12const BACKED = unicodeRegex(
13  '\\bexits?\\s+\\d+\\b|\\bexit\\s+cod\\w*\\b|\\breturn(?:s|ing)?\\s+exit\\b' +
14    '|\\bnon-zero\\b|\\bexit-code\\b|https?://|#\\d{2,}' +
15    '|(?<![\\w./~-])(?:[\\w.~-]*/[\\w./~-]*' +
16    '|[\\w.~-]*\\.(?:[cm]?[jt]sx?|py|sh|zsh|fish|md|json|jsonc|toml|ya?ml|tmpl|rs|go|rb|lua|css|html|txt)):\\d+\\b',
17  'i',
18)
19
20const LABELLED = unicodeRegex(
21  '\\badvisory\\b|\\bmodel[\\s-]judge?ment\\b|\\bopt-in\\b|\\bnag\\b' +
22    '|\\bnon-blocking\\b|\\bnever blocks\\b|\\bblocks nothing\\b|\\bdoes not block\\b' +
23    '|\\bnot a gate\\b' +
24    '|\\bcode\\s*[—–-]\\s*`[^`]+`',
25  'i',
26)
27
28const SENTENCE_END = unicodeRegex('(?<=[.!?])\\s+')
29const SHOWN_MAX = 110
30
31function units(text: string): Array<[number, string]> {
32  const found: Array<[number, string]> = []
33  splitLines(text).forEach((line, index) => {
34    const stripped = strip(line)
35    if (!stripped) return
36    if (stripped.startsWith('|')) {
37      found.push([index + 1, stripped])
38      return
39    }
40    for (const piece of stripped.split(SENTENCE_END)) {
41      if (strip(piece)) found.push([index + 1, strip(piece)])
42    }
43  })
44  return found
45}
46
47function shown(unit: string): string {
48  const points = [...unit]
49  if (points.length <= SHOWN_MAX) return unit
50  return points.slice(0, SHOWN_MAX - 3).join('') + '...'
51}
52
53export function flaggedUnits(text: string): [number, string][] {
54  return units(text)
55    .filter(([, unit]) => CLAIM.test(unit) && !BACKED.test(unit) && !LABELLED.test(unit))
56    .map(([number, unit]) => [number, shown(unit)])
57}
58
59export function scanText(text: string, label: string): string[] {
60  return flaggedUnits(text).map(([number, unit]) => `${label}:${number}: ${unit}`)
61}
62
hooks/text.ts 74 lines
1const WORD_SET = '\\p{L}\\p{N}_'
2const SPACE_SET = '\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000'
3const DIGIT_SET = '\\p{Nd}'
4const WORD = `[${WORD_SET}]`
5const BOUNDARY = `(?:(?<=${WORD})(?!${WORD})|(?<!${WORD})(?=${WORD}))`
6const LINE_BREAK = /\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/
7const DECIMAL = /^\p{Nd}$/u
8const INTEGER = /^[+-]?\p{Nd}+(?:_\p{Nd}+)*$/u
9const EDGE_SPACE = new RegExp(`^[${SPACE_SET}]+|[${SPACE_SET}]+$`, 'gu')
10
11const IN_CLASS: Record<string, string> = { w: WORD_SET, s: SPACE_SET, d: DIGIT_SET }
12const OUTSIDE: Record<string, string> = {
13  w: WORD,
14  s: `[${SPACE_SET}]`,
15  d: `[${DIGIT_SET}]`,
16  b: BOUNDARY,
17}
18
19export function unicodeRegex(source: string, flags = ''): RegExp {
20  let out = ''
21  let inClass = false
22  for (let i = 0; i < source.length; i++) {
23    const char = source[i] ?? ''
24    if (char === '\\') {
25      const next = source[i + 1] ?? ''
26      i++
27      const table = inClass ? IN_CLASS : OUTSIDE
28      out += table[next] ?? `\\${next}`
29      continue
30    }
31    if (char === '[' && !inClass) inClass = true
32    else if (char === ']' && inClass) inClass = false
33    out += char
34  }
35  return new RegExp(out, `${flags}u`)
36}
37
38export function strip(text: string): string {
39  return text.replace(EDGE_SPACE, '')
40}
41
42export function splitLines(text: string): string[] {
43  if (!text) return []
44  const lines = text.split(LINE_BREAK)
45  if (lines[lines.length - 1] === '') lines.pop()
46  return lines
47}
48
49function digitValue(char: string): number {
50  let point = char.codePointAt(0) ?? 0
51  const original = point
52  while (DECIMAL.test(String.fromCodePoint(point - 1))) point -= 1
53  return (original - point) % 10
54}
55
56export function pythonInt(raw: string): number | undefined {
57  const text = strip(raw)
58  if (!INTEGER.test(text)) return undefined
59  const sign = text.startsWith('-') ? -1 : 1
60  const digits = [...text.replace(/^[+-]/, '').replaceAll('_', '')]
61  return sign * digits.reduce((total, char) => total * 10 + digitValue(char), 0)
62}
63
64export function byCodePoint(a: string, b: string): number {
65  const left = [...a]
66  const right = [...b]
67  for (let i = 0; i < Math.min(left.length, right.length); i++) {
68    const x = left[i]?.codePointAt(0) ?? 0
69    const y = right[i]?.codePointAt(0) ?? 0
70    if (x !== y) return x - y
71  }
72  return left.length - right.length
73}
74