SLOPSHOPPER

cipher-lab-usage

Usage across the cipher-lab accounts: live workers, ledger spend and last rate-limit reading per account

newpanecommandstatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cipher-lab-usage
│ ┃ cipher-lab-jobs ✕ › fix the failing auth test and add an audit log call │ ┃ Loading jobs... │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /usage │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cipher-lab-jobs
Loading jobs...
Pane · cipher-lab-usage
Loading usage bars...
README

cipher-lab

Working area for attacking unsolved historical ciphers, starting from the list maintained on S. Tomokiyo's site Cryptiana.

What counts as a result

The metric, restated by the owner on 23 September 2026: the number of unique solves. A unique solve is a reading a separate verifier has classed N3 or better (CLAUDE.md rule 10) and graded depth D2 or better -- at least one passage that says something, not isolated letters or words (rule 4a, owner 4 Oct 2026; tools/depth_check.py). Nothing else is sacred: language, period, holder and historical weight only matter as far as they change how many such readings the project produces. In practice that makes recovery the lane that scales and cryptanalysis the exception. Corrected the same night: the public DECODE catalogue is already worked daily by dbourdeau/cyphersolver, so the lane is the catalogues DECODE does not cover (TNA Discovery, BL Archives and Manuscripts, Gallica inventories, NRS, county offices: 493 items on 23 Sept 2026). Under this metric an unread letter whose key sits in the same file is not dropped as an "edition": it is the cheapest unique solve there is, provided the verifier finds no printed text (rule 10). The unread axis still records the fact.

Found-solved is not garbage (owner, 23 September 2026). A check-solved or verifier verdict of found-solved records who did not know, in three grades, and the contribution it leaves: F0 the specialist edition or database already links the item to its print or decipherment (no contribution; e.g. Orange 1572, cited in the Huygens database); F1 the plaintext is in print but the specialist catalogue or edition does not link this manuscript to it, or gets its date or sender wrong (a contribution to the holding library and the edition; e.g. Dupuy 468, whose Latin cipher original nobody had linked to the 1896 print); F2 the plaintext is in print but no key or mapping of this ciphertext was ever published (a contribution to the list keeper; e.g. Bowes 1583). F1 and F2 are counted beside unique solves in CONTRIBUTIONS.md.

Three kinds, all wanted, always labelled as which they are (CLAUDE.md rule 10):

  1. Recovery. A key found in another box reads a text nobody has read. Hamilton 1650 and Stepney 1702 are this lane.
  2. Cryptanalysis. A text read without its key. Rare; needs size (NLS MS 20769 if it is a cipher) or a key that fails to fit (Hamilton if the NRS sheets do not cover it).
  3. Contribution. Work handed to the people who hold or study the material: machine-readable keys, verified readings, catalogue corrections, and honest negatives, offered to the archive, to Tomokiyo's list, to DECODE and to the solver repositories. The Eckert keys and audit are this lane.

Layout

STATUS.md the status board: every target, its state, the next action and whose it is status.json the same data, machine-readable; source for the published dashboard dashboard.html one-page board rendered from status.json by tools/build_dashboard.py CATALOG.md every item from Cryptiana's index with status, shelfmark and source page LANDSCAPE.md who is solving what (Sept 2026), corrected statuses, where the open ground is LESSONS.md how the current solvers work, distilled from their repositories CLAUDE.md the rules every session and agent follows here QUEUE.md ranked list of what to attempt next, refreshed by the scout workflow .claude/workflows/ named workflows: check-solved (six-source sweep before any campaign), scout (harvest, filter out the solved, score, file into QUEUE.md) ciphers/<name>/ one folder per cipher being worked on: ciphertext.txt + NOTES.md sources/cryptiana/ unmodified snapshot of the Cryptiana pages (19 September 2026) tools/html2text.py render a saved page as plain text tools/freq.py token frequency, index of coincidence, bigrams for a ciphertext tools/refresh-sources.ps1 re-download the index page and show what changed (PowerShell)

Start here

  1. Read LANDSCAPE.md. Most of Cryptiana's list was swept by two AI-driven projects in the week before this snapshot, and the section "Where the open ground is" says what is left.
  2. Read ONBOARDING.md (start here if you are new), BUDGETS.md (per-person plan limits), LESSONS.md, LEDGER.md (one row per worker, outcome and cost), ROOM.md (the workers' shared channel: one-line signals and flags), .claude/briefs/ (role templates, edited by the weekly retrospective) before writing any solver. The short version: search for a printed solution, get the page image, look for a sibling letter with a decipherment, find the structure by hand, and never report a negative without a matched control.
  3. CATALOG.md keeps every item from the index page with Tomokiyo's status and the corrections.
  4. Read the background page named in the catalogue:

python3 tools/html2text.py sources/cryptiana/web/mary.htm | less

  1. Make a folder under ciphers/ following ciphers/README.md, or use one that already exists.
  2. Get a feel for the ciphertext:

python3 tools/freq.py ciphers/sp53-16-78/ciphertext.txt

Ready-made starting points

These folders already hold a transcription and notes:

FolderWhat
sp53-16-78, sp53-16-79Two 1585 letters in the same hand, numeric nomenclator, Mary Queen of Scots circle
sp53-22-f52Very short "Spanish spy" cipher, alphabet-sized symbol set
moray-wood-1568Short Scottish diplomatic cipher, said to look simple
birago-nevers-1571Italian numerical cipher with diacritics, needs tokenising first
maurice-rupert-1645Royalist code after Naseby
intercepted-royalist-1646Opening only; full text needs transcribing from DECODE R8623
ormond-arran-1678About 20 groups, explicitly written as a test of the recipient
stepney-manchester-1702Two short runs; archival route more promising than cryptanalysis
destaing-gerard-1779French naval code, about 600 entries, with cleartext context
berthier-napoleon-1812Large Napoleonic code, opening only

Sources and credit

All pages under sources/ are copyright S. Tomokiyo and are kept only as a working reference. Cite the site (https://cryptiana.web.fc2.com/code/) and the named solvers when using anything from it.

Licence

MIT (LICENSE, added by the owner 26 Sept 2026): readings, keys, notes and tools may be reused with attribution to the repository. Cite the sources named in "Sources and credit" above for anything taken from them.

Source 2 files
hooks/register.tsx 170 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Board, Usage } from '../types'
5
6// Usage bars across the cipher-lab accounts (owner, 3 Oct 2026). Every session that loads this posts its own
7// account's real 5-hour / 7-day bars (account-wide: every session and subagent on the login is in them) to
8// USAGE.tsv on origin/main, at most once per account per 15 min, and shows every account's latest bars.
9
10const PANE = 'cipher-lab-usage'
11const REPO = '/home/user/cipher-lab'
12const EVERY_MS = 5 * 60 * 1000
13const usage = atom({ plugin: 'cipher-lab-usage', key: 'usage' } as const, null as Usage | null)
14const error = atom({ plugin: 'cipher-lab-usage', key: 'error' } as const, '')
15const board = atom({ plugin: 'cipher-lab-usage', key: 'board' } as const, null as Board | null)
16const JOBS = 'cipher-lab-jobs'
17const lastPost = atom({ plugin: 'cipher-lab-usage', key: 'lastPost' } as const, '')
18
19const pct = (p: number | null) => (p === null ? '--' : `${Math.round(p)}%`)
20const bar = (p: number | null, w = 10) => {
21  if (p === null) return '·'.repeat(w)
22  const n = Math.max(0, Math.min(w, Math.round((p / 100) * w)))
23  return '█'.repeat(n) + '░'.repeat(w - n)
24}
25
26export const statusLine = (u: Usage) =>
27  u.accounts
28    .filter(a => a.five_pct !== null || a.seven_pct !== null)
29    .map(a => `a${a.account} 5h ${pct(a.five_pct)} 7d ${pct(a.seven_pct)}`)
30    .join(' · ') || 'usage: no bars posted yet'
31
32const postOwn = async ($: any) => {
33  const u = await $.session.usage()
34  const five = u.rateLimits.find((r: any) => r.kind === 'five_hour')
35  const seven = u.rateLimits.find((r: any) => r.kind === 'seven_day')
36  if (!five && !seven) return 'no rate-limit windows on this session'
37  const argv = ['python3', 'tools/account_usage.py', '--post', 'auto', '--source', 'mod', '--push']
38  if (five) argv.push('--five', String(five.percentUsed), '--five-resets', five.resetsAt ?? '')
39  if (seven) argv.push('--seven', String(seven.percentUsed), '--seven-resets', seven.resetsAt ?? '')
40  const r = await $.process.run(argv, { cwd: REPO, timeoutMs: 90000 })
41  return (r.stdout || r.stderr || '').trim().slice(0, 200)
42}
43
44const note = async ($: any, line: string) => {
45  await $.process.run(['python3', '-c', 'import sys,time;open(sys.argv[1],"a").write(time.strftime("%H:%M:%S ")+sys.argv[2]+"\\n")', '/tmp/cipher-lab-usage-mod.log', line], { timeoutMs: 10000 })
46}
47
48const refresh = async ($: any, alsoPost: boolean) => {
49  if (alsoPost) {
50    try {
51      const u = await $.session.usage()
52      await note($, 'rateLimits=' + JSON.stringify(u.rateLimits))
53      const said = await postOwn($)
54      await note($, 'post: ' + said)
55      await update($, lastPost, () => said)
56    } catch (err) {
57      await note($, 'post failed: ' + String(err))
58      await update($, lastPost, () => `post failed: ${String(err).slice(0, 150)}`)
59    }
60  } else {
61    await $.process.run(['git', 'fetch', '-q', 'origin', 'main'], { cwd: REPO, timeoutMs: 60000 })
62  }
63  const r = await $.process.run(['python3', 'tools/account_usage.py', '--json', '--ref', 'origin/main'], {
64    cwd: REPO,
65    timeoutMs: 60000,
66  })
67  if (r.exitCode !== 0) {
68    await update($, error, () => (r.stderr || 'account_usage.py failed').slice(0, 300))
69    return
70  }
71  const u = JSON.parse(r.stdout) as Usage
72  await update($, usage, () => u)
73  const b = await $.process.run(['python3', 'tools/jobs_board.py', '--json', '--ref', 'origin/main', '--hours', '6'], {
74    cwd: REPO,
75    timeoutMs: 60000,
76  })
77  if (b.exitCode === 0) await update($, board, () => JSON.parse(b.stdout) as Board)
78  await update($, error, () => '')
79  $.ui.status(statusLine(u))
80}
81
82export const register: Register = on => {
83  on('session.start', async ($, e, next) => {
84    const started = await next(e)
85    await $.command.register({ name: 'usage', description: 'Usage bars across the cipher-lab accounts' })
86    await $.command.register({ name: 'jobs', description: 'Jobs across the cipher-lab accounts, by project' })
87    void refresh($, true)
88    $.clock.every(EVERY_MS, () => void refresh($, true))
89    return started
90  })
91
92  on('command.run', { command: 'usage' }, async $ => {
93    await refresh($, true)
94    await $.ui.open({ id: PANE, title: 'Usage bars, all accounts' })
95    const u = await read($, usage)
96    return { text: u ? statusLine(u) : 'Usage pane opened.' }
97  })
98
99  on('command.run', { command: 'jobs' }, async $ => {
100    await refresh($, false)
101    await $.ui.open({ id: JOBS, title: 'Jobs by project, all accounts' })
102    const b = await read($, board)
103    const running = b ? b.projects.reduce((n, g) => n + (g.counts.running ?? 0), 0) : 0
104    return { text: b ? `${running} jobs running across ${b.projects.length} projects.` : 'Jobs pane opened.' }
105  })
106
107  on('ui.render', { component: 'Pane', requestId: JOBS }, async ($, e) => {
108    const { Box, Text } = $.ui.resolve(e)
109    const b = await read($, board)
110    if (!b) return <Text dimColor>Loading jobs...</Text>
111    const mark = { running: '▶', queued: '·', stale: '!', done: '✓' } as const
112    return (
113      <Box flexDirection="column">
114        <Text bold>Jobs by project, {b.utc} UTC (done in the last {b.hours} h)</Text>
115        {b.projects.map(g => (
116          <Box flexDirection="column" marginTop={1}>
117            <Text bold>
118              {g.project}  ·  {g.counts.running ?? 0} running · {g.counts.queued ?? 0} queued · {g.counts.done ?? 0} done
119              {g.counts.stale ? ` · ${g.counts.stale} stale` : ''}
120            </Text>
121            {g.jobs.slice(0, 8).map(j => (
122              <Text dimColor={j.state === 'done'} color={j.state === 'stale' ? 'yellow' : undefined}>
123                {mark[j.state]} {j.id}  ({j.account === 'next free' ? 'next free' : `acct ${j.account}`}
124                {j.start || j.end ? `, ${j.end || j.start} UTC` : ''}) {(j.state === 'done' ? j.summary : j.text).slice(0, 70)}
125              </Text>
126            ))}
127          </Box>
128        ))}
129        <Box marginTop={1}>
130          <Text dimColor>▶ running · queued ✓ done ! stale (claim over 6 h, no done). From ROOM.md + WORK-QUEUE.tsv.</Text>
131        </Box>
132      </Box>
133    )
134  })
135
136  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
137    const { Box, Text } = $.ui.resolve(e)
138    const u = await read($, usage)
139    const err = await read($, error)
140    const said = await read($, lastPost)
141    if (!u) return <Text dimColor>{err || 'Loading usage bars...'}</Text>
142    return (
143      <Box flexDirection="column">
144        <Text bold>Usage bars, {u.utc} UTC</Text>
145        {u.accounts.map(a => (
146          <Box flexDirection="column" marginTop={1}>
147            <Text bold>
148              Account {a.account} · {a.live} live
149              {a.age_min === null ? ' · no reading yet' : ` · read ${a.age_min} min ago`}
150            </Text>
151            <Text color={(a.five_pct ?? 0) >= 80 ? 'yellow' : undefined}>
152              5-hour {bar(a.five_pct)} {pct(a.five_pct)}
153              {a.five_resets ? `  resets ${a.five_resets.slice(11, 16)} UTC` : ''}
154            </Text>
155            <Text color={(a.seven_pct ?? 0) >= 80 ? 'yellow' : undefined}>
156              7-day  {bar(a.seven_pct)} {pct(a.seven_pct)}
157              {a.seven_resets ? `  resets ${a.seven_resets.slice(0, 16).replace('T', ' ')} UTC` : ''}
158            </Text>
159          </Box>
160        ))}
161        <Box marginTop={1}>
162          <Text dimColor>Each account's bars come from any of its sessions running this mod. Refresh every 5 min.</Text>
163        </Box>
164        {said ? <Text dimColor>this session: {said}</Text> : null}
165        {err ? <Text color="red">{err}</Text> : null}
166      </Box>
167    )
168  })
169}
170
types/index.d.ts 21 lines
1export type AccountRow = {
2  account: string
3  live: number
4  live_roles: string[]
5  five_pct: number | null
6  five_resets: string
7  seven_pct: number | null
8  seven_resets: string
9  age_min: number | null
10}
11export type Usage = { utc: string; accounts: AccountRow[] }
12export type Job = { id: string; account: string; text: string; state: 'running' | 'queued' | 'stale' | 'done'; start: string; end: string; summary: string; project: string }
13export type ProjectGroup = { project: string; counts: Record<string, number>; jobs: Job[] }
14export type Board = { utc: string; hours: number; projects: ProjectGroup[] }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'cipher-lab-usage': { usage: Usage | null; error: string; lastPost: string; board: Board | null }
19  }
20}
21