Compare commits

..
22 Commits
Author SHA1 Message Date
teahubxyz 2a0ee513b9 pages: retire yap.yolopreneur.lol (yap now lives at yapstr.lol) (#37) 2026-10-30 04:39:07 +00:00
teahubxyz d7ef5861e2 pages: uniqornz.lol → uniqorn/site (#36) 2026-10-30 04:35:48 +00:00
teahubxyz 2f33c5ec18 pages: serve yapstr.lol (+www) from yap/site (#35) 2026-10-30 04:25:13 +00:00
teahubxyz 071186c338 pages: serve yap.yolopreneur.lol from yap/site (#34) 2026-10-30 01:42:27 +00:00
teahubxyzandClaude Opus 5.5 2739f29a5e relay-post: optional room (posts a NIP-29 chat message, kind 9 with an h tag)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv2GjbEF3HRasdLdmzF
2026-10-29 20:50:15 +00:00
teahubxyzandClaude Opus 5.5 70f19fb677 gate-take: one of our keys takes the well-known.online gate, answered live by Claude
The run fetches a fresh challenge for the key and writes it to gate/<nonce>.challenge.json on the gate-desk
branch. Claude writes gate/<nonce>.answers.json there within the 180 s window, and the run signs the answers with
the key (which never leaves Actions) and submits them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv2GjbEF3HRasdLdmzF
2026-10-29 20:39:54 +00:00
teahubxyzandClaude Opus 5.5 60ae76327d relay-post: sign and post a note to relay.well-known.online as one of our names (NIP-42)
Also copies that key's latest profile (kind 0) from the public relays onto ours, so the feed shows its name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv2GjbEF3HRasdLdmzF
2026-10-29 19:54:16 +00:00
teahubxyzandClaude Opus 5.5 bec77c1ea6 rank-badges: never mass-revoke on a bad or failed directory read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv2GjbEF3HRasdLdmzF
2026-10-29 19:20:18 +00:00
teahubxyzandClaude Opus 5.5 9721b59ea9 rank-badges: rungs belong to names, so "not" is revoked too when a key leaves the directory
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv2GjbEF3HRasdLdmzF
2026-10-29 19:19:41 +00:00
teahubxyzandClaude Opus 5.5 6c497686da gitea: CORS for merman.teahub.xyz (read-only API), so the Merman editor can open repo workflows
Applied live to /data/gitea/conf/app.ini the same day (backup app.ini.bak-cors-20261029)
and Gitea restarted; this keeps it through a redeploy. Only that origin, only
GET/HEAD/OPTIONS, no credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv3zFxJCi2vHvdEMqCr
2026-10-29 18:33:43 +00:00
teahubxyzandClaude Opus 5.5 33a9e553b2 rank-badges: NIP-58 badges for the well-known.online ladder (not / slightly / very)
Same pattern as og-badges: the well-known account (WK_NSEC) defines the three rung badges, awards them from
https://well-known.online/ranks.json every 15 minutes, and revokes slightly/very (NIP-09 delete of the
per-key award) when a key slips. Reaching a rung keeps the ones below it; "not" is never revoked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv2GjbEF3HRasdLdmzF
2026-10-29 18:21:07 +00:00
teahubxyzandClaude Opus 5 56cebd3e29 CLAUDE.md: the cloud lane commits through the MCP, CI is the oracle
teahub.xyz is denied by the cloud network policy, so a cloud session cannot
clone, test locally or push. That is the lane's shape, not a fault: commit
with tea_commit/tea_file_edit and let Gitea Actions run the tests on the
runner, which does have repo access. Replaces the push-from-hb-5 advice.

Adds the cloud lane itself (router.json's cloud block, paid from session
credit and so absent from the ledger) and three situations rows drawn from
today: read the bus before starting a card, never rebuild a card PR whose
diff is empty (time#277 lost two cards that way), and queue nothing on a
judge reply you cannot parse (time#211 ranked 37 candidates at 1.911 and
sent three cards to the cloud lane on it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv1aMxprhswN5gJqFkA
2026-10-29 18:20:47 +00:00
teahubxyzandClaude Opus 5 e8c20ba6b7 docs: CLAUDE.md for operator sessions
AGENTS.md governs cards, seats, Time and the council. It has no lane for a
session the owner opens directly, which is where the factory is heading.
This file adds that lane: read-only by default, bus check-in only when
changing something, a gate before any commit/deploy/spend, and the facts
that cost a session turns to rediscover.

Encodes the 2026-10-29 incident: the API balance emptied, astra.yml died on
a billing 400, no andon opened because that path only covers cap breaches,
and nobody was told for four hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv1aMxprhswN5gJqFkA
2026-10-29 18:04:35 +00:00
teahubxyzandClaude Fable 5.1 4126350f68 decisions: Oracle probes claimed endpoints and keeps itself
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv1sLfUad2bMcsmPbRT
2026-10-29 01:53:50 +00:00
teahubxyzandClaude Fable 5.1 c5c84787bd pages: oracle.teahub.xyz serves teahubxyz/oracle#pages
The built site is one commit on `pages`, force-pushed by Oracle's own workflow; main keeps
snapshots and code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv1sLfUad2bMcsmPbRT
2026-10-29 01:34:29 +00:00
teahubxyzandClaude Fable 5.1 a5f0a45f94 decisions: 🔮 Oracle on prod as the spine; Phast its consumer; oracle.teahub.xyz live
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv1sLfUad2bMcsmPbRT
2026-10-29 00:31:23 +00:00
teahubxyzandClaude Fable 5.1 d5d1aba429 pages: oracle.teahub.xyz → teahubxyz/oracle#main/web
The 🔮 Oracle graph browser (static projection) gets its first home on prod.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv1sLfUad2bMcsmPbRT
2026-10-29 00:21:45 +00:00
teahubxyzandClaude Fable 5.1 9d2ac32356 pages: serve merman.teahub.xyz from teahubxyz/merman#main/merman
DNS A/AAAA to the pages app, cert issued, push webhook on the repo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv3zFxJCi2vHvdEMqCr
2026-10-28 15:16:21 -07:00
teahubxyzandClaude Opus 5.5 2fc0efe388 wk-card: answer the challenge for our own names daily, so "it answers" never goes stale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UozKbNTNSfntAfsaaFuLuM
2026-10-28 20:19:01 +00:00
teahubandClaude Opus 5.5 9841ddc4a9 wk-card: set our own trust cards (links, answer, vouch), signing inside Actions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UozKbNTNSfntAfsaaFuLuM
2026-10-28 13:10:18 -07:00
teahubxyzandClaude Opus 5.5 5a42f68b9a decisions: the office bus, and read:user on Time's token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv4PBaoWnL62rDKmBJ1
2026-10-27 17:50:08 +00:00
teahubxyzandClaude Opus 5.5 a83f728900 Tea MCP 2.5.0: POST /bus for Time, and the bus rule in AGENTS.md (#31)
Tea MCP 2.5.0: POST /bus for Time, and the bus rule in AGENTS.md (#31)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv4PBaoWnL62rDKmBJ1
2026-10-27 17:25:25 +00:00
9 changed files with 459 additions and 2 deletions

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
name: gate-take
# One of our keys takes the well-known.online gate (the reverse captcha), answered live by Claude:
# 1. fetch a fresh challenge for the key; write it to gate/<nonce>.challenge.json on the gate-desk branch
# 2. wait (inside the 180 s window) for gate/<nonce>.answers.json, a JSON array of answers, written there by Claude
# 3. sign the answers with the key (it never leaves Actions) and POST them to /gate
on:
workflow_dispatch:
inputs:
key:
description: factory | well-known
required: true
default: factory
jobs:
gate:
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- name: fetch, hand off, sign, submit
env:
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
K_WK: ${{ secrets.WK_NSEC }}
WHICH: ${{ inputs.key }}
TOKEN: ${{ gitea.token }}
API: https://teahub.xyz/api/v1/repos/teahubxyz/teahub
WK: https://well-known.online
run: |
mkdir -p /tmp/gt && cd /tmp/gt
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > g.mjs <<'EOF'
import { schnorr } from '@noble/curves/secp256k1';
import { createHash } from 'node:crypto';
const CH = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const toHex = (s) => {
s = (s || '').trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(s)) return s;
const d = [...s.slice(s.lastIndexOf('1') + 1)].map((c) => CH.indexOf(c)).slice(0, -6);
let acc = 0, bits = 0; const out = [];
for (const v of d) { acc = (acc << 5) | v; bits += 5; while (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }
return Buffer.from(out).toString('hex');
};
const raw = process.env.WHICH === 'well-known' ? process.env.K_WK : process.env.K_FACTORY;
if (!raw) { console.error(`no key for ${process.env.WHICH}`); process.exit(1); }
const sk = toHex(raw); const pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
const H = { authorization: `token ${process.env.TOKEN}`, 'content-type': 'application/json' };
const api = (p, o = {}) => fetch(process.env.API + p, { ...o, headers: H });
// the desk branch
if ((await api('/branches/gate-desk')).status === 404) await api('/branches', { method: 'POST', body: JSON.stringify({ new_branch_name: 'gate-desk', old_branch_name: 'main' }) });
// 1. the challenge (the clock starts now)
const c = await (await fetch(`${process.env.WK}/gate?pubkey=${pk}`)).json();
if (!c.nonce) { console.error('no challenge', JSON.stringify(c)); process.exit(1); }
const id = c.nonce.slice(0, 12);
const put = await api(`/contents/gate/${id}.challenge.json`, { method: 'POST', body: JSON.stringify({ branch: 'gate-desk',
message: `gate challenge ${id} for ${pk.slice(0, 8)}`, content: Buffer.from(JSON.stringify({ pubkey: pk, deadline: c.deadline, need: c.need, count: c.count, items: c.items }, null, 1)).toString('base64') }) });
console.log(`challenge ${id} for ${pk.slice(0, 8)} written (${put.status}); deadline ${c.deadline}`);
// 2. wait for the answers
const until = Date.parse(c.deadline) - 4000; let answers = null;
while (Date.now() < until) {
const r = await api(`/contents/gate/${id}.answers.json?ref=gate-desk`);
if (r.ok) { const j = await r.json(); try { answers = JSON.parse(Buffer.from(j.content, 'base64').toString('utf8')); break; } catch {} }
await new Promise((res) => setTimeout(res, 1500));
}
if (!Array.isArray(answers)) { console.error('no answers in time'); process.exit(1); }
// 3. sign and submit
const ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), kind: 27235, content: JSON.stringify(answers.map(String)),
tags: [['u', `${process.env.WK}/gate`], ['method', 'POST'], ['nonce', c.nonce]] };
ev.id = createHash('sha256').update(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content])).digest('hex');
ev.sig = Buffer.from(schnorr.sign(ev.id, sk)).toString('hex');
const res = await fetch(`${process.env.WK}/gate`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ event: ev }) });
const body = await res.json().catch(() => ({}));
console.log(res.status, JSON.stringify({ passed: body.passed, score: body.score, of: body.of, ms: body.ms, reason: body.reason, wrong: body.wrong, rank: body.rank && body.rank.rung, names: body.names }));
if (!body.passed) process.exit(1);
EOF
node g.mjs
+110
View File
@@ -0,0 +1,110 @@
name: rank-badges
# NIP-58 badges for the well-known.online ladder, run every 15 minutes (and on demand).
# 1. Ensures the three definitions (kind 30009, d=not|slightly|very) by the well-known account (WK_NSEC).
# 2. Reads https://well-known.online/ranks.json (every ranked key by its current rung). A key holds its rung and
# every rung below it: not = all ranked keys, slightly = slightly + very, very = very.
# 3. Awards (kind 8) one event per key per rung, only where missing, so repeat runs are no-ops.
# 4. Revokes: a key that slipped below a rung, or whose name lapsed (rungs belong to names), gets that award deleted
# (NIP-09 kind 5).
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
republish_definitions:
description: re-sign the definitions even if they exist (true/false)
required: false
default: 'false'
jobs:
badges:
runs-on: cron
timeout-minutes: 8
steps:
- name: define, diff, award, revoke
env:
K_WK: ${{ secrets.WK_NSEC }}
REPUBLISH: ${{ inputs.republish_definitions }}
RELAYS: wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
RANKS: https://well-known.online/ranks.json
run: |
mkdir -p /tmp/rb && cd /tmp/rb
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > b.mjs <<'EOF'
import { schnorr } from '@noble/curves/secp256k1';
import { createHash } from 'node:crypto';
const CH = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const toHex = (s) => {
s = (s || '').trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(s)) return s;
const d = [...s.slice(s.lastIndexOf('1') + 1)].map((c) => CH.indexOf(c)).slice(0, -6);
let acc = 0, bits = 0; const out = [];
for (const v of d) { acc = (acc << 5) | v; bits += 5; while (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }
return Buffer.from(out).toString('hex');
};
if (!process.env.K_WK) { console.error('WK_NSEC missing'); process.exit(1); }
const sk = toHex(process.env.K_WK); const WK = { sk, pk: Buffer.from(schnorr.getPublicKey(sk)).toString('hex') };
const RELAYS = process.env.RELAYS.split(',').map((s) => s.trim()).filter(Boolean);
const now = () => Math.floor(Date.now() / 1000);
const sign = (kind, tags, content = '') => {
const ev = { pubkey: WK.pk, created_at: now(), kind, tags, content };
ev.id = createHash('sha256').update(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content])).digest('hex');
ev.sig = Buffer.from(schnorr.sign(ev.id, WK.sk)).toString('hex');
return ev;
};
const ws = (url, onopen, onmsg, ms) => new Promise((resolve) => {
let done = false, sock; const fin = (r) => { if (!done) { done = true; try { sock.close(); } catch {} resolve(r); } };
const t = setTimeout(() => fin('timeout'), ms);
try { sock = new WebSocket(url); } catch { clearTimeout(t); return fin('error'); }
sock.onopen = () => onopen(sock);
sock.onmessage = (m) => { let x; try { x = JSON.parse(m.data); } catch { return; } const r = onmsg(x); if (r !== undefined) { clearTimeout(t); fin(r); } };
sock.onerror = () => { clearTimeout(t); fin('error'); };
});
const query = async (filter) => {
const seen = new Map();
await Promise.all(RELAYS.map((url) => ws(url, (s) => s.send(JSON.stringify(['REQ', 'q', filter])),
(x) => { if (x[0] === 'EVENT' && x[1] === 'q') seen.set(x[2].id, x[2]); else if (x[0] === 'EOSE') return 'eose'; }, 8000)));
return [...seen.values()];
};
const publish = async (ev, label) => {
const res = await Promise.all(RELAYS.map(async (url) => [url, await ws(url, (s) => s.send(JSON.stringify(['EVENT', ev])),
(x) => (x[0] === 'OK' && x[1] === ev.id ? (x[2] ? 'ok' : 'rejected: ' + x[3]) : undefined), 8000)]));
const ok = res.filter(([, r]) => r === 'ok').length;
console.log(`${label}: kind ${ev.kind} id ${ev.id} ${ok}/${RELAYS.length}`);
if (!ok) process.exitCode = 1;
return ok;
};
const RUNGS = ['not', 'slightly', 'very'];
const coord = (r) => `30009:${WK.pk}:${r}`;
// 1. definitions: just the title
for (const r of RUNGS) {
const defs = await query({ kinds: [30009], authors: [WK.pk], '#d': [r] });
if (defs.length && process.env.REPUBLISH !== 'true') { console.log(`definition ${r}: present`); continue; }
const img = `https://well-known.online/badges/${r}.png`;
await publish(sign(30009, [['d', r], ['name', `${r}.well-known`],
['description', `${r}.well-known: a rung on the well-known.online ladder. https://well-known.online/rank.md`],
['image', img, '256x256'], ['thumb', img, '256x256']]), `definition ${r}`);
}
// 2. who should hold what
const res = await fetch(process.env.RANKS);
const ranks = res.ok ? await res.json().catch(() => null) : null;
if (!ranks || !Array.isArray(ranks.not) || !Array.isArray(ranks.slightly) || !Array.isArray(ranks.very)) {
console.error(`ranks directory unreadable (${res.status}); changing nothing`); process.exit(1);
}
const hex = (a) => (Array.isArray(a) ? a : []).map((k) => String(k).toLowerCase()).filter((k) => /^[0-9a-f]{64}$/.test(k));
const want = { not: new Set([...hex(ranks.not), ...hex(ranks.slightly), ...hex(ranks.very)]),
slightly: new Set([...hex(ranks.slightly), ...hex(ranks.very)]), very: new Set(hex(ranks.very)) };
// 3./4. what's awarded now (ignoring awards we already deleted), then award / revoke
const deleted = new Set((await query({ kinds: [5], authors: [WK.pk] })).flatMap((e) => e.tags.filter((t) => t[0] === 'e').map((t) => t[1])));
for (const r of RUNGS) {
const awards = (await query({ kinds: [8], authors: [WK.pk], '#a': [coord(r)] })).filter((e) => !deleted.has(e.id));
const held = new Map(); // pubkey -> [award event ids]
for (const e of awards) for (const t of e.tags) if (t[0] === 'p') held.set(t[1], [...(held.get(t[1]) || []), e.id]);
const add = [...want[r]].filter((k) => !held.has(k));
let drop = [...held.keys()].filter((k) => !want[r].has(k));
if (drop.length > 3 && drop.length > held.size / 2) { console.error(`${r}: refusing to revoke ${drop.length} of ${held.size} at once; check ${process.env.RANKS}`); process.exitCode = 1; drop = []; }
console.log(`${r}: want ${want[r].size}, held ${held.size}, award ${add.length}, revoke ${drop.length}`);
for (const k of add) await publish(sign(8, [['a', coord(r)], ['p', k, RELAYS[0]]]), `award ${r} ${k.slice(0, 8)}`);
for (const k of drop) await publish(sign(5, [...held.get(k).map((id) => ['e', id]), ['k', '8']], `slipped below ${r}.well-known`), `revoke ${r} ${k.slice(0, 8)}`);
}
EOF
node b.mjs
+92
View File
@@ -0,0 +1,92 @@
name: relay-post
# Post a note (kind 1) to wss://relay.well-known.online as one of our own names, signing in with NIP-42.
# key: factory (SNAIL_KNOWN: uniqornlabz, well-known, get) or well-known (WK_NSEC). Before the note, the key's latest
# profile (kind 0) is copied from the public relays onto ours, so the feed can show its name.
on:
workflow_dispatch:
inputs:
key:
description: factory | well-known
required: true
default: factory
content:
description: the note to post
required: true
room:
description: optional room (not | slightly | very); posts a kind-9 chat message with an h tag
required: false
default: ''
jobs:
post:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: sign in, post
env:
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
K_WK: ${{ secrets.WK_NSEC }}
WHICH: ${{ inputs.key }}
CONTENT: ${{ inputs.content }}
ROOM: ${{ inputs.room }}
RELAY: wss://relay.well-known.online
PUBLIC: wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
run: |
mkdir -p /tmp/rp && cd /tmp/rp
npm init -y >/dev/null && npm i --silent @noble/curves@1.9.7 >/dev/null
cat > p.mjs <<'EOF'
import { schnorr } from '@noble/curves/secp256k1';
import { createHash } from 'node:crypto';
const CH = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const toHex = (s) => {
s = (s || '').trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(s)) return s;
const d = [...s.slice(s.lastIndexOf('1') + 1)].map((c) => CH.indexOf(c)).slice(0, -6);
let acc = 0, bits = 0; const out = [];
for (const v of d) { acc = (acc << 5) | v; bits += 5; while (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); } }
return Buffer.from(out).toString('hex');
};
const raw = process.env.WHICH === 'well-known' ? process.env.K_WK : process.env.K_FACTORY;
if (!raw) { console.error(`no key for ${process.env.WHICH}`); process.exit(1); }
const sk = toHex(raw); const pk = Buffer.from(schnorr.getPublicKey(sk)).toString('hex');
const sign = (ev) => {
ev = { pubkey: pk, created_at: Math.floor(Date.now() / 1000), tags: [], content: '', ...ev };
ev.id = createHash('sha256').update(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content])).digest('hex');
ev.sig = Buffer.from(schnorr.sign(ev.id, sk)).toString('hex');
return ev;
};
// the latest profile from the public relays (already signed by the key; we just carry it over)
const profile = await new Promise((resolve) => {
let best = null; let left = 0; const urls = process.env.PUBLIC.split(',');
const done = () => { if (--left <= 0) resolve(best); };
for (const url of urls) {
left++; let ws; const t = setTimeout(() => { try { ws.close(); } catch {} done(); }, 6000);
try { ws = new WebSocket(url); } catch { clearTimeout(t); done(); continue; }
ws.onopen = () => ws.send(JSON.stringify(['REQ', 'p', { kinds: [0], authors: [pk], limit: 1 }]));
ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x[0] === 'EVENT' && (!best || x[2].created_at > best.created_at)) best = x[2]; if (x[0] === 'EOSE') { clearTimeout(t); ws.close(); done(); } };
ws.onerror = () => { clearTimeout(t); done(); };
}
});
console.log(`key ${pk.slice(0, 8)}; profile ${profile ? 'found' : 'none'}`);
// sign in (NIP-42), then publish
const ws = new WebSocket(process.env.RELAY);
const pending = new Map(); let authed;
const authedP = new Promise((r) => { authed = r; });
const send = (ev) => new Promise((resolve) => { pending.set(ev.id, resolve); ws.send(JSON.stringify(['EVENT', ev])); setTimeout(() => resolve([false, 'timeout']), 8000); });
ws.onmessage = (m) => {
const x = JSON.parse(m.data);
if (x[0] === 'AUTH') { const a = sign({ kind: 22242, tags: [['relay', process.env.RELAY], ['challenge', x[1]]] }); pending.set(a.id, (r) => authed(r)); ws.send(JSON.stringify(['AUTH', a])); }
if (x[0] === 'OK' && pending.has(x[1])) { pending.get(x[1])([x[2], x[3]]); pending.delete(x[1]); }
};
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
const [ok, msg] = await Promise.race([authedP, new Promise((r) => setTimeout(() => r([false, 'no auth challenge']), 8000))]);
console.log('auth:', ok, msg || '');
if (!ok) process.exit(1);
if (profile) { const [pok, pmsg] = await send(profile); console.log('profile:', pok, pmsg || ''); }
const room = (process.env.ROOM || '').trim();
const note = room ? sign({ kind: 9, content: process.env.CONTENT, tags: [['h', room]] }) : sign({ kind: 1, content: process.env.CONTENT });
const [nok, nmsg] = await send(note);
console.log('note:', nok, nmsg || '', note.id);
ws.close();
if (!nok) process.exit(1);
EOF
node p.mjs
+59
View File
@@ -0,0 +1,59 @@
name: wk-card
# Our own trust cards on well-known.online: links, a fresh signed answer, and vouches between our names.
# Signs inside Actions with the name's main key: factory = SNAIL_KNOWN (uniqornlabz, get), well-known = WK_NSEC.
on:
schedule:
- cron: '17 9 * * *' # daily: answer a fresh challenge for each of our names
workflow_dispatch:
inputs:
key: { description: "which key signs: factory or well-known", required: true, default: factory }
name: { description: "the signing name (e.g. uniqornlabz)", required: true }
action: { description: "answer | links | vouch", required: true, default: answer }
args: { description: "links: space-separated https URLs; vouch: target name, then the note", required: false, default: "" }
jobs:
daily:
if: github.event_name == 'schedule'
runs-on: cron
timeout-minutes: 5
steps:
- name: answer for uniqornlabz, get, well-known
env:
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
K_WK: ${{ secrets.WK_NSEC }}
run: |
mkdir -p /tmp/wd && cd /tmp/wd
npm init -y >/dev/null && npm i --silent @noble/curves@2.4.0 >/dev/null
curl -fsSO https://well-known.online/card.mjs
umask 077
printf '%s' "$K_FACTORY" > f; printf '%s' "$K_WK" > w
fail=0
for n in uniqornlabz get; do WK_NSEC_FILE=/tmp/wd/f node card.mjs "$n" answer || fail=1; done
WK_NSEC_FILE=/tmp/wd/w node card.mjs well-known answer || fail=1
rm -f f w
exit $fail
card:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: run
env:
K_FACTORY: ${{ secrets.SNAIL_KNOWN }}
K_WK: ${{ secrets.WK_NSEC }}
WHICH: ${{ inputs.key }}
NAME: ${{ inputs.name }}
ACTION: ${{ inputs.action }}
ARGS: ${{ inputs.args }}
run: |
mkdir -p /tmp/wc && cd /tmp/wc
npm init -y >/dev/null && npm i --silent @noble/curves@2.4.0 >/dev/null
curl -fsSO https://well-known.online/card.mjs
umask 077
case "$WHICH" in factory) printf '%s' "$K_FACTORY" > k ;; well-known) printf '%s' "$K_WK" > k ;; *) echo "key must be factory or well-known"; exit 2 ;; esac
export WK_NSEC_FILE=/tmp/wc/k
case "$ACTION" in
answer) node card.mjs "$NAME" answer ;;
links) set -f; node card.mjs "$NAME" links $ARGS ;;
vouch) T="${ARGS%% *}"; N="${ARGS#* }"; [ "$N" = "$ARGS" ] && N=""; node card.mjs "$NAME" vouch "$T" "$N" ;;
*) echo "action must be answer, links or vouch"; exit 2 ;;
esac
rm -f k
+114
View File
@@ -0,0 +1,114 @@
# CLAUDE.md — sessions the owner opened
Additive to `AGENTS.md`. Where the two conflict, `AGENTS.md` wins.
If you are a seat worker, Time, a council reviewer or a card runner: `AGENTS.md` governs you, not this
file. Read it and stop here.
If the owner opened you from a chat, a phone, or a scheduled task: you are in the **operator lane**,
which `AGENTS.md` does not describe. Read on.
## Read first
- `AGENTS.md` — the factory: cards, seats, Time's rhythm, spend, the bus, the situations table.
- `docs/decisions.md` — append-only, dated, 91 entries. Why things are the way they are. Read the last
ten before proposing anything structural; most "new" ideas are already in there with a reason.
- `SKILLS.md` — skill routes. Find your task's route and load only the skills it names, by path. Do not
pick skills yourself.
## The operator lane
Not every session is a card. A session opened to look at something, answer a question, or survey state
is not work that belongs on the board: do not open a card for it, do not leave receipts on issues you
only read, and do not check in on the bus for a read-only look.
Check in (`tea_bus_checkin`) the moment you start changing something, and check out when you stop.
**Default to read-only.** The factory is AI-managed by design, and the board is where that happens. An
interactive session is the one place a human is actually watching, so spend it showing rather than
acting. Before the first write of a session, say what you are about to change and why.
Report what you found, including the parts that contradict what you expected. Distinguish what a tool
returned from what you reasoned. Name what you could not verify and what call you deliberately did not
make.
## Stop and ask before
- Any commit, PR, merge, label or issue close on prod.
- Any Fly deploy, restart, machine action, volume change or DNS write.
- Anything that spends: a model call booked to the ledger, a purchase, a launch, restarting an agent
that bills on wake.
- Closing an issue you did not open, however stale it looks.
Do everything up to the gate, then stop and say what is ready and what is needed. This is the same rule
as the money/human-step row in `AGENTS.md`, applied to the whole operator lane.
## Two wallets
The factory's model calls bill an **Anthropic API** balance at `api.anthropic.com`, booked to
`time/ledger/<YYYY-MM>.jsonl` against `TIME_MONTHLY_USD=100` with `TIME_DAILY_USD=15` pacing. A Claude
Code session spends the owner's **subscription credits**. These are different pools. Topping up one does
not top up the other, and a session with plenty of credit tells you nothing about whether Astra can run.
Observed 2026-10-29: the API balance emptied, `astra.yml` died on a billing 400, Oppi went off, the
Moltbook loop no-opped, **no andon opened**, and nobody was told for four hours.
## Tool order
- **Gitea** → Teahub MCP (`tea_*`). Not `gh`, not web-fetching teahub.xyz.
- **Fly, Cloudflare, hb-5** → Boring (`boring_*`). `boring_fly_status` over shelling out.
- Deterministic script, then cheap inference, then expensive inference. (`AGENTS.md`: be boring by
default.)
## Cloud session facts
- The container is **ephemeral** and holds no repo unless one was attached. Nothing in `~/.claude` or
`/home/user` survives the session. Commit it or lose it.
- **`teahub.xyz` is denied by the cloud network policy** (403 at the proxy tunnel, observed 2026-10-29),
so a cloud session cannot clone this repo, run its tests locally, or `git push`. That is the expected
shape of the lane, not a fault to work around.
- **Commit through the MCP and let CI be the test oracle.** `tea_commit` with `new_branch` writes a
branch as you; `tea_pull_create` opens the PR; Gitea Actions runs the tests on the runner, which does
have repo access. Do not try to clone first, and do not route a push through hb-5 to save a step.
- Outbound HTTPS denies any host off the environment allowlist (`moltbook.com` among them). Do not retry
a denied host: say it is blocked, and route that call through the box that can reach it.
- `boring_fly_volumes` returns metadata only, no used bytes. Volume headroom needs `df` inside the
machine.
## The cloud lane
`time/router.json` carries a `cloud` block (`per_day`, `sizes`). Cards of those sizes are labelled
`cloud` and built by Claude Code cloud sessions, paid from the owner's session credit rather than the
ledger — so a cloud card's spend never appears in `time/ledger/`, and the envelope does not gate it.
If you are a cloud session on a `cloud` card, you are that lane.
Read the bus before you start. The owner also works this backlog from chat sessions, and two agents on
one card is how card branches collide.
## Situations
Additions to the table in `AGENTS.md`.
| You see | Do |
|---|---|
| A run failed with `400 ... credit balance is too low` | Not an andon — that path only covers cap breaches, so nothing opened and nobody was told. Tell the owner directly, name the API balance, and stop. Do not retry the run. Do not wake an agent that bills the same balance. |
| An agent on the bus says "paused by the owner" | Leave it off. "Not failing, just off" is a decision, not a fault. |
| A bus status that went "running" → "failed" and then nothing | A dropped thread. Say how long it has been, and who owns it. Do not silently pick it up mid-session. |
| A `needs-human` queue older than three days | Hand the owner a ranked list with ages and the objection on each. Do not merge your way through it. |
| A hostname resolving to a suspended Fly machine | Fly suspend can be wake-on-request, so status alone cannot tell broken from scale-to-zero. One request settles it — say which you checked. |
| An issue housekeeping marked "done by #N" | A close candidate, not a close. It goes in the list for the owner. |
| A card you are about to start | `tea_bus_list` first, every time. If another agent is on it, take a different card. |
| A card PR whose diff is empty | Do not rebuild it. Its branch may have been rewound to base — `time#277`: two cards lost their work that way on 2026-10-29, in the same minute the API credit ran out. Report it on the PR and leave the branch alone. |
| A planner or judge reply you cannot parse | Queue nothing. A ranking where nothing was judged is noise, not a ranking (`time#211`: 37 candidates all scored 1.911 and three cards were sent to the cloud lane on it). |
| You are about to delegate a survey to subagents | Give each one the files to read, not your conclusions, and do not narrow it to the names you already know — a brief that lists the known apps is how `molty-scout` got missed on 2026-10-29. |
## The map
The spine is here; the clock is `teahubxyz/time`. A session cloned into any other repo will not see this
file, so the active ones, with what they are:
| Repo | What |
|---|---|
| `teahubxyz/teahub` | This repo. The spine: Gitea config, MCP source, decisions, skills. |
| `teahubxyz/time` | The clock. Astra (13:13 UTC), heartbeat (hourly), housekeeping (13:17), the seats, the ledger. Engine in `.github/time/`. |
| `teahubxyz/oracle` | The spine of the machine economy: capability index, SQLite graph, evidence ladder claimed→reachable→tested→verified→transacted→repeated. `oracle.teahub.xyz`. |
| `teahubxyz/sux-desk` | Silicon UX desk. Drafts wait on the owner; nothing sends itself. |
| `well-known/app` | Agent identity, inbox, DMs, trust card. Node. **`npm test` must pass before every push**; main deploys via CI. |
| `well-known/mail` | Cloudflare Worker; CI deploys. |
| `househand/house` | Drawing style as code. Publish = build release + the `teahubxyz/time` seed workflow. |
| `phast/app` | Oracle's consumer. |
| `teahubxyz/doors`, `teahubxyz/merman`, `maths/*` | Active; read their own repo docs first. |
hb-5 and the `matcha/*` namespace are **not prod** — the operator's playground and the old factory's
shell. Boring lives there and may reach prod as a client; prod never depends on it.
+3
View File
@@ -97,3 +97,6 @@ Format: date · decision · why. Add at the bottom. Never edit an old entry; sup
- 2026-10-19 · **The router is live.** `time/router.json` is `live` from 13:49 UTC: Astra queues from the top of the ranking each day, writes its new cards unqueued, and the heartbeat promotes queued cards in ranked order. The two dry rankings (01:02 and 13:14) put the engine's own blockers on top (#135, #175), dropped #84 once #75 was known unmergeable, and held L cards back. Three things were fixed before the flip. Money left is now the worker pool's allowance (about $8/day from the Anthropic credit), not the $15 daily cap. The seat sees the cards closed in the last 14 days, and a new card repeating an open or closed one isn't written; both dry runs had proposed the label-filter fix that landed as #138. And a `ready` card waiting on a person no longer holds a slot: three parked cards (#79, #80, #85) had stalled promotion for ten hours while 22 cards sat `queued`, and #135, the fix, could never be promoted past them. Weights stay all 1 (balanced). Why: the owner's call to have the factory pick its own work across ventures, features, refinement and optimization, live once the rankings looked right. (time#181, #183)
- 2026-10-19 · **The monthly cap counts out-of-pocket money; prepaid credit is limited by its own pool.** `TIME_MONTHLY_USD` ($100) leaves out what an expiring pool's credit paid for: its routes, from its `as_of`, until it lapses, up to its balance. Spend before `as_of`, past the balance or the lapse, and on routes without a pool still counts. October's out-of-pocket figure is $38.51, counting the spend before the balance was read. Why: the owner's word that the budget should factor in the usage pool. Counting credit against the cap would have tripped it about Oct 26, stopping the factory for five days with credit left to lapse on Nov 8. The $250 cloud-session credit (expires Nov 4) is not a factory pool: `claude --cloud` refuses to start without an interactive terminal, and scheduled routines don't count toward the credit. So it is drawn only by sessions a person starts on the GitHub copy, whose `claude/*` branches come home through sync. (time#193)
- 2026-10-19 · **The cloud lane: the factory spends the cloud-session credit itself.** This corrects the entry above. The credit's terms cover cloud sessions started "from web, desktop, CLI, or mobile"; only Routines, Remote Control and Chat are excluded. A CLI-launched session qualifies, and `claude --cloud` only needs a terminal, which a pseudo-terminal provides. How it runs: Astra labels the best-ranked actionable M cards `cloud`, up to 3 in the lane at once (`time/router.json`). A launcher on hb-5, `~/claude/cloud-lane/launch.py`, runs hourly as the user agent `xyz.teahub.cloud-lane` inside the desktop session. It starts one cloud session per card on the GitHub copy, at most 3 a day and 2 hours apart, and none after Nov 4 noon PT. The session's branch comes home through sync as a PR that closes the card, sync moves the card from `cloud` to `ready`, and the council lands it. Why the desktop session: the claude.ai sign-in lives in the login keychain, which SSH sessions can't read, and the `CLAUDE_CODE_OAUTH_TOKEN` in hb-5's `.zshenv` is inference-only (cloud session create: 403). Separation holds: the launcher reads Teahub's public issue list and holds no Teahub credentials, and prod doesn't depend on it; with the Mac off, the lane simply waits. Off switch: `touch ~/claude/cloud-lane/OFF`. Extra usage is off on the account, so once the credit is gone, sessions count against plan limits and cost no money. Why: the owner asked to use the credit through the factory rather than by hand. (time#197)
- 2026-10-27 · **The office bus.** Tea MCP carries a shared board where the factory's agents check in, see each other and leave short notes (`tea_bus_*` tools; `POST /bus` for scripts with a Gitea token). Modelled on CodeGrid's Agent Bus, lifted off one Mac onto prod so seats on Fly, Sprites, chat sessions and Time share one office. Only admins, `teahubxyz` members and the factory's own users (`BUS_AGENTS`: `time` and the seat users) can use it; a name belongs to the login that claimed it; notes are information and never trigger anything; statuses, notes and inboxes are capped and expire after a day; state is one file on the MCP's volume. Time's heartbeat, Astra, housekeeping and doctor check in at start and end, best effort (a 5s timeout; a down bus never fails a job). The `AGENTS.md` Move says every agent does the same. To make that work, Time's token (`time-repo-20261014`, the `TIME_TOKEN` secret) gained `read:user`, set on the token's row in Gitea's database, so the bus can ask Gitea whose token it is; the seat tokens already had it. Nothing else about the token changed. Why: the owner's "agentic office + spokesperson" direction: the floor first, then a spokesperson that reads the board. (teahub#30, #31, time#275)
- 2026-10-28 · **🔮 Oracle is on prod and is the spine; Phast is its consumer.** Source archaeology on the shell (`matcha/greentea`, `matcha/oracle`) at the owner's ask. `matcha/oracle`, the capability index for the machine economy (MCP, A2A, ACP, OpenAPI and OAuth registries in one SQLite graph, static bucketed browser, evidence ladder `claimed → reachable → tested → verified → transacted → repeated`), moved to `teahubxyz/oracle` with its history; the shell's copy gets the archive sign. `docs/PLAN.md` there is the plan of record, agreed with the owner: Oracle is the map and Crystal the route (Google index/search, Google Maps map/route); the loop runs both ways and the return trip is the moat (every real call through a belt or the `oracle` doorway is an observation that climbs the ladder; only a factory that transacts fills the top rungs); discover/inspect/invoke is lazy loading of capability; SUX (silicon UX) is the outbound business, scored by probe and published before any outreach; order of work is land → observe → doorway → feed Phast → SUX cards → Crystal, because routing over a graph that is mostly `claimed` is routing on rumours. Landed today: a full re-crawl (52,119 MCP servers, up from 38,676 on Oct 3; 604 A2A agents; 210k nodes / 351k edges), a build fix (OAuth evidence about an endpoint no longer indexed is dropped, not a foreign-key crash), and the browser at **oracle.teahub.xyz** (`teahubxyz/oracle#main/web` in `PAGES_SITES`, cert, A/AAAA via Boring, push hook). Phast: `phast/app` 0.2.0 is canonical and matches the Fly image; the shell's `matcha/teahub` copy is the stale 0.1 (my drift worry was wrong). Its belt is empty and `providers_search` stubs `source: oracle`, so phast/app#2 wires it to Oracle; the greentea learning loop (bus → trace → replay → teach) is teahub#33. Receipt: the Tea MCP session lapsed mid-run; a `teahubxyz` token `ceo-lane-mac` (`write:issue,write:repository,read:user`) was minted on the Fly box and kept on hb-5 at `~/.config/teahub/ceo-lane.token` (600) beside the existing push credential, as the CEO lane's own key for when the connector is down. It is the one standing exception to delete-by-id; the owner has said credentials are Claude's to manage. Why: the owner called Oracle the spine and asked for it pushed forward, slowly and carefully, with Phast in its wake. (oracle#1–5, phast/app#2, teahub#33)
- 2026-10-28 · **Oracle has its first empirical rung, and keeps itself.** Later the same day: `--probe-mcp` speaks MCP to claimed endpoints (unauthenticated `initialize` + `tools/list`, one per host, 500 a run) and records what it hears as scoped, monotonic observations from source `mcp-probe`: `reachable` for an answer or an auth challenge, `tested` for a tool list, nothing higher. First 500 of 34,143: 282 tested, 85 reachable, 133 silent; 366 capabilities off `claimed`. `search.json` (one row per capability with its endpoint) and a Teahub overlay (`oracle/overlay.json`: Tea MCP and My Phast app as claims from source `teahub`) joined the projection; Phast's `providers_search`/`tools_install` read it and Tea MCP is on matcha's belt as `claimed` (phast/app `5bec2c0`). Oracle's own workflow (`.gitea/workflows/oracle.yml`) probes daily, re-crawls Sundays, and publishes `web/` as one commit on `pages`, which `green-tea-pages` serves (`PAGES_SITES` → `teahubxyz/oracle#pages`); `main` holds snapshots and code. Serving the 100 MB projection by clone restarted the 512 MB Gitea once; the repo now has `core.bigFileThreshold=256k` and `pack.window=0` so clones stream rather than recompress. `docs/OBSERVE.md` is the design for the rest of step 2 and for the one proxy that the `oracle` doorway and Phast belts will share. Why: the owner asked for Oracle pushed forward and Phast in its wake; routing needs ground truth under it, and the cheapest honest ground truth is Oracle knocking on the doors it lists. (oracle#2, #4, phast/app#2)
+6
View File
@@ -32,6 +32,12 @@ primary_region = "iad"
GITEA__mirror__ENABLED = "true"
GITEA__actions__ENABLED = "true"
GITEA__repository__DISABLE_HTTP_GIT = "false"
# Merman (merman.teahub.xyz) reads public workflow files from the browser: that origin only, read-only, no cookies
GITEA__cors__ENABLED = "true"
GITEA__cors__ALLOW_DOMAIN = "https://merman.teahub.xyz"
GITEA__cors__METHODS = "GET,HEAD,OPTIONS"
GITEA__cors__ALLOW_CREDENTIALS = "false"
GITEA__cors__MAX_AGE = "10m"
[mounts]
source = "gitea_data"
destination = "/data"
+1 -1
View File
@@ -11,7 +11,7 @@ CNAME, then A/AAAA to the app's IPs (`fly ips list`), and a push webhook on the
Live: persimmons.app → `persimmons/app` (repo root, as its `pages/page.conf` says), phly.cloud → `phly/site`,
supertestnet.lol → `supertestnet/app#pages`, yolopreneur.lol and
`*.yolopreneur.lol` → `teahubxyz/time#gh-pages/ventures` (`PAGES_WILDCARDS`); open.teahub.xyz → `teahubxyz/open#pages` (the factory openvenice); gandora.yolopreneur.lol → `gandora/site` (a venture in
its own repo: an exact host in `PAGES_SITES` wins over the wildcard, so a venture graduates by adding one line). A site can be any public repo,
its own repo: an exact host in `PAGES_SITES` wins over the wildcard, so a venture graduates by adding one line); yapstr.lol (+www) → `yap/site` (the one-button on-device speech-to-text PWA; DNS in Cloudflare, apex CNAME-flattened to green-tea-pages.fly.dev); its old address yap.yolopreneur.lol → `yap/site#main/moved`, a static hand-off (moved page + self-removing service worker) rather than a 301, because installed PWAs can't follow a redirect for their service worker. A site can be any public repo,
`org/repo#branch/subdir`; it doesn't have to be called `site`. A product with a backend serves its own pages
instead (phast.app is `phast-app`), so there is never a copy to drift.
+1 -1
View File
@@ -6,7 +6,7 @@ primary_region = "iad"
[env]
GITEA_URL = "https://teahub.xyz"
PAGES_SITES = '{"persimmons.app":"persimmons/app","phly.cloud":"phly/site","supertestnet.lol":"supertestnet/app#pages","yolopreneur.lol":"teahubxyz/time#gh-pages/ventures","gandora.lol":"gandora/site","www.gandora.lol":"gandora/site","gandora.yolopreneur.lol":"gandora/site","dev.gandora.lol":"gandora/site#dev","asmr.gandora.lol":"gandora/site#main/asmr","maths.teahub.xyz":"maths/site","open.teahub.xyz":"teahubxyz/open#pages"}'
PAGES_SITES = '{"persimmons.app":"persimmons/app","phly.cloud":"phly/site","supertestnet.lol":"supertestnet/app#pages","yolopreneur.lol":"teahubxyz/time#gh-pages/ventures","gandora.lol":"gandora/site","www.gandora.lol":"gandora/site","gandora.yolopreneur.lol":"gandora/site","dev.gandora.lol":"gandora/site#dev","asmr.gandora.lol":"gandora/site#main/asmr","maths.teahub.xyz":"maths/site","open.teahub.xyz":"teahubxyz/open#pages","merman.teahub.xyz":"teahubxyz/merman#main/merman","oracle.teahub.xyz":"teahubxyz/oracle#pages","yap.yolopreneur.lol":"yap/site#main/moved","yapstr.lol":"yap/site","www.yapstr.lol":"yap/site","uniqornz.lol":"uniqorn/site","www.uniqornz.lol":"uniqorn/site"}'
PAGES_WILDCARDS = '{"yolopreneur.lol":"teahubxyz/time#gh-pages/ventures"}'
PAGES_PULL_SECONDS = "300"