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

Working area for attacking unsolved historical ciphers, starting from the list maintained on S. Tomokiyo's site Cryptiana.
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):
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)
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.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.CATALOG.md keeps every item from the index page with Tomokiyo's status and the corrections.python3 tools/html2text.py sources/cryptiana/web/mary.htm | less
ciphers/ following ciphers/README.md, or use one that already exists.python3 tools/freq.py ciphers/sp53-16-78/ciphertext.txt
These folders already hold a transcription and notes:
| Folder | What |
|---|---|
| sp53-16-78, sp53-16-79 | Two 1585 letters in the same hand, numeric nomenclator, Mary Queen of Scots circle |
| sp53-22-f52 | Very short "Spanish spy" cipher, alphabet-sized symbol set |
| moray-wood-1568 | Short Scottish diplomatic cipher, said to look simple |
| birago-nevers-1571 | Italian numerical cipher with diacritics, needs tokenising first |
| maurice-rupert-1645 | Royalist code after Naseby |
| intercepted-royalist-1646 | Opening only; full text needs transcribing from DECODE R8623 |
| ormond-arran-1678 | About 20 groups, explicitly written as a test of the recipient |
| stepney-manchester-1702 | Two short runs; archival route more promising than cryptanalysis |
| destaing-gerard-1779 | French naval code, about 600 entries, with cleartext context |
| berthier-napoleon-1812 | Large Napoleonic code, opening only |
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.
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.
hooks/register.tsx 170 lines1import { 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}
170types/index.d.ts 21 lines1export 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