@stupify/cli 0.0.16 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.review/CORPUS.md +44 -0
  2. package/.review/CORPUS.template.md +73 -0
  3. package/.review/REVIEW-PROMPT.md +52 -0
  4. package/.review/RUBRIC.md +46 -0
  5. package/LICENSE +1 -1
  6. package/README.md +95 -37
  7. package/package.json +27 -26
  8. package/packs/antirez.md +10 -0
  9. package/packs/anton-kropp.md +10 -0
  10. package/packs/dhh.md +10 -0
  11. package/packs/dtolnay.md +10 -0
  12. package/packs/jarred-sumner.md +9 -0
  13. package/packs/mitchell-hashimoto.md +10 -0
  14. package/packs/rich-harris.md +10 -0
  15. package/packs/simon-willison.md +10 -0
  16. package/packs/sindre-sorhus.md +10 -0
  17. package/packs/tanner-linsley.md +10 -0
  18. package/packs/zod.md +10 -0
  19. package/src/cli.ts +626 -0
  20. package/src/prime-install.test.ts +109 -0
  21. package/src/prime.ts +50 -0
  22. package/src/review-sweep.test.ts +101 -0
  23. package/src/review-sweep.ts +526 -0
  24. package/dist/analysis.d.ts +0 -16
  25. package/dist/analysis.js +0 -168
  26. package/dist/cache.d.ts +0 -2
  27. package/dist/cache.js +0 -57
  28. package/dist/checks.d.ts +0 -4
  29. package/dist/checks.js +0 -228
  30. package/dist/command.d.ts +0 -2
  31. package/dist/command.js +0 -147
  32. package/dist/constants.d.ts +0 -4
  33. package/dist/constants.js +0 -53
  34. package/dist/counter-scout.d.ts +0 -21
  35. package/dist/counter-scout.js +0 -167
  36. package/dist/diff.d.ts +0 -1
  37. package/dist/diff.js +0 -10
  38. package/dist/doctor.d.ts +0 -16
  39. package/dist/doctor.js +0 -143
  40. package/dist/git.d.ts +0 -17
  41. package/dist/git.js +0 -368
  42. package/dist/hooks.d.ts +0 -5
  43. package/dist/hooks.js +0 -135
  44. package/dist/index.d.ts +0 -1
  45. package/dist/index.js +0 -1
  46. package/dist/model.d.ts +0 -11
  47. package/dist/model.js +0 -296
  48. package/dist/prompts.d.ts +0 -8
  49. package/dist/prompts.js +0 -89
  50. package/dist/render.d.ts +0 -6
  51. package/dist/render.js +0 -295
  52. package/dist/repomix-provider.d.ts +0 -12
  53. package/dist/repomix-provider.js +0 -196
  54. package/dist/search-bench.d.ts +0 -1
  55. package/dist/search-bench.js +0 -677
  56. package/dist/search-profile.d.ts +0 -6
  57. package/dist/search-profile.js +0 -73
  58. package/dist/sem-provider.d.ts +0 -2
  59. package/dist/sem-provider.js +0 -255
  60. package/dist/stupify.d.ts +0 -38
  61. package/dist/stupify.js +0 -505
  62. package/dist/trace.d.ts +0 -31
  63. package/dist/trace.js +0 -86
  64. package/dist/types.d.ts +0 -341
  65. package/dist/types.js +0 -6
  66. package/dist/ui.d.ts +0 -34
  67. package/dist/ui.js +0 -143
  68. package/src/analysis.ts +0 -223
  69. package/src/cache.ts +0 -63
  70. package/src/checks.ts +0 -231
  71. package/src/command.ts +0 -173
  72. package/src/constants.ts +0 -56
  73. package/src/counter-scout.ts +0 -195
  74. package/src/diff.ts +0 -9
  75. package/src/doctor.ts +0 -166
  76. package/src/git.ts +0 -380
  77. package/src/hooks.ts +0 -151
  78. package/src/index.ts +0 -1
  79. package/src/model.ts +0 -367
  80. package/src/prompts.ts +0 -100
  81. package/src/render.ts +0 -328
  82. package/src/repomix-provider.ts +0 -219
  83. package/src/search-bench.ts +0 -783
  84. package/src/search-profile.ts +0 -89
  85. package/src/sem-provider.ts +0 -300
  86. package/src/stupify.ts +0 -604
  87. package/src/trace.ts +0 -126
  88. package/src/types.ts +0 -362
  89. package/src/ui.ts +0 -187
@@ -0,0 +1,109 @@
1
+ // Guards the hook installer's contract — it writes to the user's Claude Code settings.json, so the invariants
2
+ // that matter are: MERGE (never clobber other hooks/keys), IDEMPOTENT (no duplicate), SURGICAL uninstall
3
+ // (remove only ours), and REFUSE malformed JSON. Driven through the real CLI subprocess against throwaway
4
+ // STUPIFY_HOME + CLAUDE_CONFIG_DIR dirs, so the real ~/.claude is never touched.
5
+ import { expect, test } from 'bun:test'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ const CLI = join(import.meta.dir, 'cli.ts')
12
+
13
+ function env() {
14
+ const home = mkdtempSync(join(tmpdir(), 'stupify-home-'))
15
+ const cfg = mkdtempSync(join(tmpdir(), 'stupify-cc-'))
16
+ return { home, cfg, settings: join(cfg, 'settings.json') }
17
+ }
18
+ const run = (sub: string[], e: { home: string; cfg: string }) =>
19
+ spawnSync('bun', [CLI, ...sub], { env: { ...process.env, STUPIFY_HOME: e.home, CLAUDE_CONFIG_DIR: e.cfg }, encoding: 'utf8' })
20
+ const read = (p: string) => JSON.parse(readFileSync(p, 'utf8'))
21
+ const seeded = JSON.stringify({ theme: 'dark', hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'echo keep' }] }] } })
22
+
23
+ test('--install merges into existing settings without clobbering, and is idempotent', () => {
24
+ const e = env()
25
+ writeFileSync(e.settings, seeded)
26
+ run(['prime', '--install'], e)
27
+ let s = read(e.settings)
28
+ expect(s.theme).toBe('dark') // unrelated key survives
29
+ expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('echo keep') // unrelated hook survives
30
+ expect(s.hooks.SessionStart[0].matcher).toBe('startup')
31
+ expect(s.hooks.SessionStart[0].hooks[0].command).toContain('prime.ts') // points at the copied dep-free engine
32
+ expect(existsSync(join(e.home, 'prime.ts'))).toBe(true) // engine copied
33
+
34
+ run(['prime', '--install'], e) // again
35
+ s = read(e.settings)
36
+ expect(s.hooks.SessionStart).toHaveLength(1) // no duplicate
37
+ rmSync(e.home, { recursive: true, force: true })
38
+ rmSync(e.cfg, { recursive: true, force: true })
39
+ })
40
+
41
+ test('--uninstall removes only our hook + engine, preserving everything else', () => {
42
+ const e = env()
43
+ writeFileSync(e.settings, seeded)
44
+ run(['prime', '--install'], e)
45
+ run(['prime', '--uninstall'], e)
46
+ const s = read(e.settings)
47
+ expect(s.theme).toBe('dark')
48
+ expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('echo keep')
49
+ expect(s.hooks.SessionStart).toBeUndefined() // ours gone; empty array collapsed
50
+ expect(existsSync(join(e.home, 'prime.ts'))).toBe(false) // engine removed
51
+ rmSync(e.home, { recursive: true, force: true })
52
+ rmSync(e.cfg, { recursive: true, force: true })
53
+ })
54
+
55
+ test('--install refuses to clobber a malformed settings.json', () => {
56
+ const e = env()
57
+ writeFileSync(e.settings, 'NOT JSON {')
58
+ const r = run(['prime', '--install'], e)
59
+ expect(readFileSync(e.settings, 'utf8')).toContain('NOT JSON') // left untouched
60
+ expect(r.status).not.toBe(0) // died non-zero rather than overwrite
61
+ rmSync(e.home, { recursive: true, force: true })
62
+ rmSync(e.cfg, { recursive: true, force: true })
63
+ })
64
+
65
+ test('--uninstall on a machine with no settings.json is a clean no-op', () => {
66
+ const e = env()
67
+ const r = run(['prime', '--uninstall'], e)
68
+ expect(r.status).toBe(0)
69
+ expect(existsSync(e.settings)).toBe(false)
70
+ rmSync(e.home, { recursive: true, force: true })
71
+ rmSync(e.cfg, { recursive: true, force: true })
72
+ })
73
+
74
+ test('taste --pack assembles ~/.stupify/.review and nothing else (no reviewer leaks in)', () => {
75
+ const e = env()
76
+ run(['taste', '--pack', 'anton-kropp,zod'], e)
77
+ expect(readFileSync(join(e.home, '.review', 'CORPUS.md'), 'utf8')).toContain('Anton Kropp') // packs assembled
78
+ expect(existsSync(join(e.home, 'config.env'))).toBe(false) // no reviewer config
79
+ expect(existsSync(join(e.home, 'review-sweep.ts'))).toBe(false) // no reviewer engine
80
+ rmSync(e.home, { recursive: true, force: true })
81
+ rmSync(e.cfg, { recursive: true, force: true })
82
+ })
83
+
84
+ test('--install refreshes a stale command on re-install (not just the engine file)', () => {
85
+ const e = env()
86
+ run(['prime', '--install', '--pack', 'zod'], e)
87
+ // simulate bun having moved since first install: rewrite the stored command to a stale path
88
+ // (keep the engine path so the entry is still recognized as ours)
89
+ const s = read(e.settings)
90
+ s.hooks.SessionStart[0].hooks[0].command = `/old/stale/bun ${join(e.home, 'prime.ts')}`
91
+ writeFileSync(e.settings, JSON.stringify(s))
92
+ run(['prime', '--install', '--pack', 'zod'], e)
93
+ const after = read(e.settings)
94
+ expect(after.hooks.SessionStart).toHaveLength(1) // still no duplicate
95
+ expect(after.hooks.SessionStart[0].hooks[0].command).not.toContain('/old/stale/bun') // stale path corrected
96
+ expect(after.hooks.SessionStart[0].hooks[0].command).toContain('prime.ts')
97
+ rmSync(e.home, { recursive: true, force: true })
98
+ rmSync(e.cfg, { recursive: true, force: true })
99
+ })
100
+
101
+ test('prime --install --pack assembles taste AND wires the hook in one step', () => {
102
+ const e = env()
103
+ run(['prime', '--install', '--pack', 'zod'], e)
104
+ expect(readFileSync(join(e.home, '.review', 'CORPUS.md'), 'utf8')).toContain('zod') // taste assembled
105
+ const s = read(e.settings)
106
+ expect(s.hooks.SessionStart[0].hooks[0].command).toContain('prime.ts') // and hook wired
107
+ rmSync(e.home, { recursive: true, force: true })
108
+ rmSync(e.cfg, { recursive: true, force: true })
109
+ })
package/src/prime.ts ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * stupify prime — emit the pre-decided taste (rubric + corpus index) as a Claude Code SessionStart hook
4
+ * payload, so a coding session opens already holding your standard instead of only catching slop in review.
5
+ *
6
+ * Dependency-free (node builtins only) ON PURPOSE: `stupify prime --install` drops a copy of THIS file at
7
+ * ~/.stupify/prime.ts and points the hook at it, so the hook runs fast with no global install and no
8
+ * node_modules. Pure file read — no model, no network. It must NEVER break session start: any miss or error
9
+ * emits nothing and exits 0. stdout is ONLY the JSON payload (a stray byte makes Claude Code drop it).
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+
15
+ const HOME = process.env.STUPIFY_HOME ?? join(homedir(), '.stupify')
16
+
17
+ /** Resolve taste like the reviewer does (the repo you're coding in wins, else the pack taste setup assembled)
18
+ * and build the SessionStart payload. Returns null when no taste is set up — caller emits nothing. */
19
+ export function primePayload(cwd: string = process.cwd(), home: string = HOME): string | null {
20
+ const dir = [join(cwd, '.review'), join(home, '.review')].find(
21
+ (d) => existsSync(join(d, 'RUBRIC.md')) && existsSync(join(d, 'CORPUS.md')),
22
+ )
23
+ if (dir === undefined) return null
24
+ const rubric = readFileSync(join(dir, 'RUBRIC.md'), 'utf8').trim()
25
+ const corpus = readFileSync(join(dir, 'CORPUS.md'), 'utf8').trim()
26
+ const additionalContext = `# Your taste, loaded by stupify — write to this standard
27
+
28
+ You're about to write or change code in this repo. Hold every edit to the standard below BEFORE you write it —
29
+ it's the same taste stupify reviews against, so matching it now is a clean review later.
30
+
31
+ ## What counts as slop here — don't ship it (RUBRIC)
32
+ ${rubric}
33
+
34
+ ## The code yours should look like (CORPUS)
35
+ The links are commit-pinned exemplars — open one only if a finding needs the detail; never paste them in wholesale.
36
+ ${corpus}`
37
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext } })
38
+ }
39
+
40
+ /** Write the payload to stdout, or nothing. Swallows every error: a hook must never disrupt session start. */
41
+ export function emitPrime(): void {
42
+ try {
43
+ const payload = primePayload()
44
+ if (payload !== null) process.stdout.write(payload)
45
+ } catch {
46
+ /* never break session start */
47
+ }
48
+ }
49
+
50
+ if (import.meta.main) emitPrime() // run directly (the installed hook calls `bun ~/.stupify/prime.ts`)
@@ -0,0 +1,101 @@
1
+ // Proof of the cache invariant: the review prompt's PREFIX (instructions + spec + rubric + corpus index) is
2
+ // byte-identical for every PR in a repo, and ONLY the tail (diff target, marker, memory) changes. That stable
3
+ // prefix is what the provider caches across diff threads — if a per-PR token ever leaked into it, the cache
4
+ // would thrash and this test would go red. We render against the repo's own real .review/ (no mocks).
5
+ import { expect, test } from 'bun:test'
6
+ import { join } from 'node:path'
7
+ import { type Config, type Pr, reviewPrompt, stablePrefix } from './review-sweep'
8
+
9
+ const REVIEW_DIR = join(import.meta.dir, '..', '.review') // the real spec/rubric/corpus shipped in this repo
10
+ const THIS_PR = '===== THIS PR' // the boundary between the cached prefix and the per-PR tail
11
+
12
+ const cfg = (): Config => ({
13
+ repoDir: '/tmp/x',
14
+ remote: 'https://github.com/acme/widgets.git',
15
+ slug: 'acme/widgets',
16
+ defaultBranch: 'main',
17
+ reviewDir: REVIEW_DIR,
18
+ homeReviewDir: REVIEW_DIR,
19
+ scope: 'label',
20
+ reviewLabel: 'codex-review',
21
+ diffLineCap: 800,
22
+ dryRun: false,
23
+ maxPrs: 15,
24
+ stateDir: '/tmp/x/state',
25
+ codexEffort: 'high',
26
+ codexProvider: '',
27
+ codexModel: '',
28
+ })
29
+
30
+ const pr = (number: number, sha: string): Pr => ({
31
+ number,
32
+ headRefOid: sha,
33
+ isDraft: false,
34
+ author: { login: 'someone', is_bot: false },
35
+ labels: [{ name: 'codex-review' }],
36
+ })
37
+
38
+ const sha256 = (s: string) => new Bun.CryptoHasher('sha256').update(s).digest('hex')
39
+ const prefixOf = (prompt: string) => prompt.slice(0, prompt.indexOf(THIS_PR))
40
+
41
+ // Three different PRs: different numbers, different head SHAs, and (crucially) one mid-thread with memory —
42
+ // the hardest case, since "continuing a review" must STILL not perturb the prefix.
43
+ const prompts = [
44
+ reviewPrompt(cfg(), pr(1, 'a'.repeat(40)), ''),
45
+ reviewPrompt(cfg(), pr(42, 'b'.repeat(40)), ''),
46
+ reviewPrompt(cfg(), pr(987, 'c'.repeat(40)), 'PRIOR-THREAD: a past review and the author reply'),
47
+ ]
48
+ const prefixes = prompts.map(prefixOf)
49
+
50
+ test('the cached prefix is byte-identical across every PR (incl. mid-thread)', () => {
51
+ const hashes = new Set(prefixes.map(sha256))
52
+ expect(hashes.size).toBe(1) // one and only one prefix hash, no matter the PR
53
+ expect(prefixes[0]).toBe(prefixes[1])
54
+ expect(prefixes[0]).toBe(prefixes[2])
55
+ })
56
+
57
+ test('the prefix equals stablePrefix(cfg) and carries the real taste, not generic weights', () => {
58
+ expect(prefixes[0]?.trimEnd()).toBe(stablePrefix(cfg()).trimEnd())
59
+ expect(prefixes[0]).toContain('===== RUBRIC')
60
+ expect(prefixes[0]).toContain('===== CORPUS')
61
+ })
62
+
63
+ test('NO per-PR token leaks into the cached prefix', () => {
64
+ for (const prefix of prefixes) {
65
+ expect(prefix).not.toContain('gh pr diff') // the diff command lives in the tail
66
+ expect(prefix).not.toContain('a'.repeat(40)) // no head SHA / marker
67
+ expect(prefix).not.toContain('b'.repeat(40))
68
+ expect(prefix).not.toContain('PRIOR-THREAD') // memory lives in the tail
69
+ }
70
+ })
71
+
72
+ test('only the tail changes — per-PR content is present and correct there', () => {
73
+ expect(prompts[0]).not.toBe(prompts[1]) // whole prompts differ...
74
+ expect(prompts[0]).toContain('gh pr diff 1 --repo acme/widgets')
75
+ expect(prompts[1]).toContain('gh pr diff 42 --repo acme/widgets')
76
+ expect(prompts[2]).toContain('gh pr diff 987 --repo acme/widgets')
77
+ expect(prompts[2]).toContain('PRIOR-THREAD') // memory threaded into the tail
78
+ })
79
+
80
+ test('the prefix is large enough to be cache-eligible (well past the ~1024-token floor)', () => {
81
+ const bytes = prefixes[0]?.length ?? 0
82
+ const approxTokens = Math.round(bytes / 4) // ~4 chars/token, the standard rough estimate
83
+ expect(approxTokens).toBeGreaterThan(1024)
84
+
85
+ // Receipt: print the proof so a human sees it, plus the per-100-PR cost model the prefix-cache buys.
86
+ const reads = 100
87
+ const naive = reads // full-price prefix on every run
88
+ const cached = 1 + (reads - 1) * 0.1 // full once, then ~10% cache-read on the rest
89
+ console.log(
90
+ [
91
+ '',
92
+ ' ── cache invariant proof ─────────────────────────────',
93
+ ` prefix sha256 (all PRs): ${sha256(prefixes[0] ?? '')}`,
94
+ ` prefix size: ${bytes} bytes (~${approxTokens} tokens)`,
95
+ ` prefix identical across: ${prefixes.length} distinct PRs (incl. one mid-thread)`,
96
+ ` prefix cost over ${reads} PRs: naive ${naive.toFixed(1)}× vs cached ${cached.toFixed(1)}× → ${Math.round((1 - cached / naive) * 100)}% off the prefix`,
97
+ ' ──────────────────────────────────────────────────────',
98
+ '',
99
+ ].join('\n'),
100
+ )
101
+ })