@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.
- package/.review/CORPUS.md +44 -0
- package/.review/CORPUS.template.md +73 -0
- package/.review/REVIEW-PROMPT.md +52 -0
- package/.review/RUBRIC.md +46 -0
- package/LICENSE +1 -1
- package/README.md +95 -37
- package/package.json +27 -26
- package/packs/antirez.md +10 -0
- package/packs/anton-kropp.md +10 -0
- package/packs/dhh.md +10 -0
- package/packs/dtolnay.md +10 -0
- package/packs/jarred-sumner.md +9 -0
- package/packs/mitchell-hashimoto.md +10 -0
- package/packs/rich-harris.md +10 -0
- package/packs/simon-willison.md +10 -0
- package/packs/sindre-sorhus.md +10 -0
- package/packs/tanner-linsley.md +10 -0
- package/packs/zod.md +10 -0
- package/src/cli.ts +626 -0
- package/src/prime-install.test.ts +109 -0
- package/src/prime.ts +50 -0
- package/src/review-sweep.test.ts +101 -0
- package/src/review-sweep.ts +526 -0
- package/dist/analysis.d.ts +0 -16
- package/dist/analysis.js +0 -168
- package/dist/cache.d.ts +0 -2
- package/dist/cache.js +0 -57
- package/dist/checks.d.ts +0 -4
- package/dist/checks.js +0 -228
- package/dist/command.d.ts +0 -2
- package/dist/command.js +0 -147
- package/dist/constants.d.ts +0 -4
- package/dist/constants.js +0 -53
- package/dist/counter-scout.d.ts +0 -21
- package/dist/counter-scout.js +0 -167
- package/dist/diff.d.ts +0 -1
- package/dist/diff.js +0 -10
- package/dist/doctor.d.ts +0 -16
- package/dist/doctor.js +0 -143
- package/dist/git.d.ts +0 -17
- package/dist/git.js +0 -368
- package/dist/hooks.d.ts +0 -5
- package/dist/hooks.js +0 -135
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/model.d.ts +0 -11
- package/dist/model.js +0 -296
- package/dist/prompts.d.ts +0 -8
- package/dist/prompts.js +0 -89
- package/dist/render.d.ts +0 -6
- package/dist/render.js +0 -295
- package/dist/repomix-provider.d.ts +0 -12
- package/dist/repomix-provider.js +0 -196
- package/dist/search-bench.d.ts +0 -1
- package/dist/search-bench.js +0 -677
- package/dist/search-profile.d.ts +0 -6
- package/dist/search-profile.js +0 -73
- package/dist/sem-provider.d.ts +0 -2
- package/dist/sem-provider.js +0 -255
- package/dist/stupify.d.ts +0 -38
- package/dist/stupify.js +0 -505
- package/dist/trace.d.ts +0 -31
- package/dist/trace.js +0 -86
- package/dist/types.d.ts +0 -341
- package/dist/types.js +0 -6
- package/dist/ui.d.ts +0 -34
- package/dist/ui.js +0 -143
- package/src/analysis.ts +0 -223
- package/src/cache.ts +0 -63
- package/src/checks.ts +0 -231
- package/src/command.ts +0 -173
- package/src/constants.ts +0 -56
- package/src/counter-scout.ts +0 -195
- package/src/diff.ts +0 -9
- package/src/doctor.ts +0 -166
- package/src/git.ts +0 -380
- package/src/hooks.ts +0 -151
- package/src/index.ts +0 -1
- package/src/model.ts +0 -367
- package/src/prompts.ts +0 -100
- package/src/render.ts +0 -328
- package/src/repomix-provider.ts +0 -219
- package/src/search-bench.ts +0 -783
- package/src/search-profile.ts +0 -89
- package/src/sem-provider.ts +0 -300
- package/src/stupify.ts +0 -604
- package/src/trace.ts +0 -126
- package/src/types.ts +0 -362
- 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
|
+
})
|