Holds every write to Ed's questions page to its contract (claude/QUESTIONS-PAGE.md), stamps the coordinator's lastActive, and flags answers left without…

Topic helps a community decide what it wants to talk about — and when. Hosts propose topics they could run a session on; electors signal what they want with weighted ❤️s; everyone shares their availability; and the forum turns the most-wanted topics into a schedule of sessions.
A Newspeak House x Sparkle Bureaucracy production.


Topic is a multi-tenant web app: each forum is an independent workspace with its own members, roles, topics, theme, and settings. The heart of the product is a decision loop:
Around the loop: five visibility levels from fully public to deactivated, per-forum theming (colours, fonts, dark palette, custom role labels), a People page with markdown bios and per-forum person pages, stable topic permalinks, an activity timeline, analytics tables, and an admin add-person flow that pre-creates accounts so a new member's first sign-in lands in a ready-made profile.
docs/PRODUCT.md is the full product tour.
A note on names: the product was built as "Timetable", and code identifiers — packages, database tables, internal routes — keep that name. A "timetable" in code is a forum in the UI. See docs/ARCHITECTURE.md for where the boundary sits.
Prerequisites:
npm install
cp .env.example .env
cp .env.example apps/web/.env.local
npm run db:up
npm run db:migrate
npm run db:seed
# Optional, after setting real Clerk development keys in .env:
npm run clerk:seed-dev-users
npm run dev
Local URLs:
http://localhost:3000http://localhost:4000http://localhost:4000/graphqlnpm run db:seed builds a fully populated sample forum from dev-sample-data.md, including deterministic local dev users. To sign in as one of them, run npm run clerk:seed-dev-users (against a Clerk development instance — the script refuses production keys) and use the OTP code 424242 with any +clerk_test email. Seeding details, including how to map a sample person to a real Clerk account, are in docs/DEPLOYMENT.md.
| Command | Description |
|---|---|
npm run dev | Run API and web together |
npm run dev:api / npm run dev:web | Run one app |
npm run typecheck | Type-check every workspace |
npm run test | Run unit tests |
npm run test:e2e | Run Playwright anonymous browser smoke tests |
npm run lint | Lint every workspace (web's own Next config, then lint:node) |
npm run lint:node | Lint apps/api, packages/*, tests, and scripts with the root eslint.config.mjs |
npm run format / npm run format:check | Prettier write / check (defaults; YAML and Markdown are exempt) |
npm run build | Build all workspaces |
npm run db:generate | Generate a SQL migration from the schema |
npm run db:migrate | Apply migrations |
npm run db:seed | Seed the local dev database from dev-sample-data.md |
npm run clerk:seed-dev-users | Create/update Clerk dev users for the sample people |
npm run db:studio | Open Drizzle Studio |
npm run db:up / npm run db:down | Start or stop local Postgres |
Pull requests must keep the full verification path green (CI enforces it):
npm run build && npm run typecheck && npm run lint && \
npm run format:check && npm run test && npm run test:e2e
plus npm run db:migrate when schema or migrations change. Tests are Vitest (packages/shared, apps/api, apps/web) and one Playwright smoke suite (tests/e2e/); the e2e suite always starts its own web server on port 3100 (override with PLAYWRIGHT_PORT), so it runs alongside a dev stack holding :3000. Known coverage gaps are listed in docs/PRODUCT.md.
hooks/register.ts 141 lines1import type { Register } from 'claude-code'
2import { checkWrite, isPage } from './check'
3
4// page-contract: Ed's questions page (claude/QUESTIONS-PAGE.md in edsaperia/dev-ops), kept by code.
5// 1. Every write to the page is checked against the contract before it runs, and refused with the
6// reasons when it breaks it (2026-09-30 to 10-02: items with kind "decision" or "final", items
7// with no status, times in the future — each hid something from Ed).
8// 2. A coordinator's lastActive is stamped at the end of every turn that did work.
9// 3. Answers of the coordinator's project left without handledAt are named to it, at most hourly.
10// 4. A coordinator other than dev-ops's is told when dev-ops main moves, so it re-reads the rules.
11// The session counts as a coordinator once it writes coordinators/<project> on the page, or, after a
12// restart has wiped what the mod knew, once its own session link is found in coordinators/<project>
13// (2026-10-03: dev-ops and Topic both stopped being stamped after a restart, and Ed got a false nudge).
14
15type Doc = Record<string, unknown>
16type Write = { op: string; collection?: unknown; doc_id?: unknown; data?: unknown; file_path?: unknown }
17
18const HANDLED_GRACE_MS = 10 * 60e3
19const REMIND_EVERY_MS = 60 * 60e3
20const RULES_REPO = 'https://github.com/edsaperia/dev-ops.git'
21
22let project = '' // learned from this session's own write to coordinators/<project>
23let pageUrl = ''
24let worked = false // this turn made a tool call of its own
25let inside = false // the mod's own tool calls, which are neither work nor checked twice
26let remindedAt = 0
27let rulesSha = ''
28let touched = false // this turn used the page
29let lookedAt = 0 // when the mod last looked for this session's own link on the page
30const LOOK_EVERY_MS = 60 * 60e3
31
32const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
33
34// The documents an ArtifactData read printed, one JSON object per line between its BEGIN and END marks.
35function docsOf(text: unknown): Array<{ id: string; data: Doc; version: number }> {
36 if (typeof text !== 'string') return []
37 const out: Array<{ id: string; data: Doc; version: number }> = []
38 for (const line of text.split('\n')) {
39 const s = line.trim()
40 if (!s.startsWith('{"id"')) continue
41 try { out.push(JSON.parse(s)) } catch { /* not a document line */ }
42 }
43 return out
44}
45
46export const register: Register = on => {
47 on('tool.call', { tool: 'ArtifactData' }, async ($, e, next) => {
48 if (inside) return next(e)
49 worked = true
50 const a = e as unknown as { action: string; url?: string; collection?: string; doc_id?: string; data?: Doc; file_path?: string; writes?: Write[] }
51 if (!isPage(a.url)) return next(e)
52 touched = true
53 if (!pageUrl) pageUrl = a.url as string
54 const now = await $.clock.now()
55 const writes: Write[] = a.action === 'batch' ? (a.writes ?? []) : [{ op: a.action, collection: a.collection, doc_id: a.doc_id, data: a.data, file_path: a.file_path }]
56 const why: string[] = []
57 for (const w of writes) {
58 let data = w.data
59 if (data === undefined && typeof w.file_path === 'string') {
60 try { data = JSON.parse(await $.fs.read(w.file_path)) } catch { data = undefined }
61 }
62 why.push(...checkWrite(w.op, w.collection, w.doc_id, data, now))
63 if (w.collection === 'coordinators' && (w.op === 'set' || w.op === 'update') && typeof w.doc_id === 'string') {
64 project = w.doc_id
65 pageUrl = a.url as string
66 }
67 }
68 if (why.length) {
69 return {
70 deny: `page-contract: this write breaks the questions page's contract (claude/QUESTIONS-PAGE.md in edsaperia/dev-ops), so nothing was written:\n- ${why.join('\n- ')}\nFix the item and write it again.`,
71 }
72 }
73 return next(e)
74 })
75
76 on('tool.call', ($, e, next) => {
77 if (!inside) worked = true
78 return next(e)
79 })
80
81 on('turn.start', async ($, e, next) => {
82 worked = false
83 touched = false
84 if (project && project !== 'dev-ops') {
85 try {
86 const r = await $.process.run(['git', 'ls-remote', RULES_REPO, 'refs/heads/main'], { timeoutMs: 15000 })
87 const sha = r.exitCode === 0 ? r.stdout.split(/\s/)[0] : ''
88 if (sha && rulesSha && sha !== rulesSha) {
89 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `page-contract: edsaperia/dev-ops main moved (${rulesSha.slice(0, 7)} → ${sha.slice(0, 7)}) since this session last looked. Before coordinator work, re-read CONVENTIONS.md and claude/QUESTIONS-PAGE.md from dev-ops main with the GitHub tool (not the container's checkout) and follow what they say now.` }] } })
90 }
91 if (sha) rulesSha = sha
92 } catch { /* no network: say nothing */ }
93 }
94 return next(e)
95 })
96
97 on('turn.complete', async ($, e, next) => {
98 const done = await next(e)
99 if (!pageUrl || !worked || (e as { agentId?: string }).agentId) return done
100 inside = true
101 try {
102 const now = await $.clock.now()
103 // 1. A session that used the page but has not told the mod its project (a restart wipes it):
104 // find this session's own link among the coordinators, at most hourly.
105 if (!project && touched && now - lookedAt > LOOK_EVERY_MS) {
106 lookedAt = now
107 const mine = String(await $.env.get('CLAUDE_CODE_REMOTE_SESSION_ID') ?? '').replace(/^(cse|session)_/, '')
108 if (mine) {
109 const list = await $.tool.call({ tool: 'ArtifactData', action: 'list', url: pageUrl, collection: 'coordinators' } as never)
110 const hit = docsOf((list as { text?: string }).text).find(d => String(d.data.sessionUrl ?? '').endsWith('session_' + mine))
111 if (hit) project = hit.id
112 }
113 }
114 if (!project) return done
115 // 2. lastActive, pinned to the version just read.
116 const got = await $.tool.call({ tool: 'ArtifactData', action: 'get', url: pageUrl, collection: 'coordinators', doc_id: project } as never)
117 const me = docsOf((got as { text?: string }).text).find(d => d.id === project)
118 if (me) {
119 await $.tool.call({ tool: 'ArtifactData', action: 'update', url: pageUrl, collection: 'coordinators', doc_id: project, data: { lastActive: iso(now) }, if_version: me.version } as never)
120 }
121 // 3. Answers older than the grace period with no handledAt (or one older than the answer).
122 if (now - remindedAt > REMIND_EVERY_MS) {
123 const q = await $.tool.call({ tool: 'ArtifactData', action: 'query', url: pageUrl, collection: 'questions', query: { where: [['project', '==', project], ['status', '==', 'answered']], limit: 1000 } } as never)
124 const late = docsOf((q as { text?: string }).text).filter(d => {
125 const at = Date.parse(String((d.data.answer as Doc | undefined)?.at ?? ''))
126 const handled = Date.parse(String(d.data.handledAt ?? ''))
127 return at && now - at > HANDLED_GRACE_MS && (!handled || handled < at)
128 })
129 if (late.length) {
130 remindedAt = now
131 const ids = late.slice(0, 12).map(d => d.id).join(', ') + (late.length > 12 ? ` and ${late.length - 12} more` : '')
132 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `page-contract: ${late.length} of ${project}'s answered items on Ed's page have no handledAt (answered over 10 minutes ago): ${ids}. Set handledAt (real clock, if_version pinned) on each you have acted on; act on any you have not.` }] } })
133 }
134 }
135 } catch { /* bookkeeping never breaks a turn */ } finally {
136 inside = false
137 }
138 return done
139 })
140}
141hooks/check.ts 104 lines1// The questions page's data contract (claude/QUESTIONS-PAGE.md in edsaperia/dev-ops), as code.
2// Pure functions: given a write and the time, the reasons it breaks the contract ([] when it holds).
3
4// The page: its claude.ai link and its artifact id. Writes to any other artifact are not checked.
5export const PAGE_IDS = ['FoSoRQxMVh8cZocFpMP6KW', '77dc0e00-9f35-45ee-af49-0632428ebbf5']
6export const isPage = (url: unknown) => typeof url === 'string' && PAGE_IDS.some(id => url.includes(id))
7
8// A time may run ahead of this host's clock by this much (clock skew), never more.
9const SKEW_MS = 2 * 60e3
10
11type Doc = Record<string, unknown>
12const isObj = (v: unknown): v is Doc => !!v && typeof v === 'object' && !Array.isArray(v)
13const isStr = (v: unknown): v is string => typeof v === 'string'
14const isHttps = (v: unknown) => isStr(v) && /^https:\/\//.test(v)
15
16function time(field: string, v: unknown, now: number, why: string[]) {
17 if (v === undefined) return
18 const t = isStr(v) ? Date.parse(v) : NaN
19 if (Number.isNaN(t)) why.push(`${field} is not an ISO time (write it with the real clock, e.g. \`date -u +%FT%TZ\`)`)
20 else if (t > now + SKEW_MS) why.push(`${field} ${v} is in the future (now is ${new Date(now).toISOString()}); write the real clock, never a rounded-up time`)
21}
22
23function links(field: string, v: unknown, why: string[], needText: boolean) {
24 if (v === undefined) return
25 if (!Array.isArray(v)) { why.push(`${field} must be an array`); return }
26 v.forEach((l, i) => {
27 if (!isObj(l) || !isStr(l.label)) why.push(`${field}[${i}] must be an object with a string label`)
28 else if (needText ? !isStr(l.text) : !isHttps(l.url)) why.push(needText ? `${field}[${i}] needs a string text` : `${field}[${i}].url must start with https://`)
29 else if (needText && l.url !== undefined && !isHttps(l.url)) why.push(`${field}[${i}].url must start with https://`)
30 })
31}
32
33// One item of the questions collection: `whole` for a set (every coordinator field must be there),
34// otherwise an update, where only the fields present are checked.
35export function checkQuestion(d: Doc, now: number, whole: boolean): string[] {
36 const why: string[] = []
37 const kind = d.kind
38 if (kind !== undefined && kind !== 'question' && kind !== 'update' && kind !== 'task')
39 why.push(`kind ${JSON.stringify(kind)} is not "question", "update" or "task" (a decision taken on Ed's behalf is an "update"; a builder's FINAL is an "update")`)
40 if (d.status !== undefined && !['open', 'answered', 'withdrawn'].includes(d.status as string))
41 why.push(`status ${JSON.stringify(d.status)} is not "open", "answered" or "withdrawn"`)
42 if (whole) {
43 if (d.status === undefined) why.push('status is missing: a new item is written with "status": "open", or Ed never sees it')
44 if (!isStr(d.title) || !d.title) why.push('title is missing (a string: the item in one line)')
45 if (!isStr(d.context)) why.push(isStr(d.body) ? 'the text goes in "context", not "body"' : 'context is missing (a string: what Ed needs to answer from his phone)')
46 if (!isStr(d.project) || !d.project) why.push('project is missing')
47 if (!isStr(d.askedBy)) why.push('askedBy is missing (e.g. "draft coordinator (cloud)", "builder (PR #12)")')
48 if (d.asked === undefined) why.push(`asked is missing${d.createdAt !== undefined ? ' ("asked", not "createdAt")' : ''}: write the real clock, now ${new Date(now).toISOString()}`)
49 } else {
50 if (d.title !== undefined && (!isStr(d.title) || !d.title)) why.push('title must be a non-empty string')
51 if (d.context !== undefined && !isStr(d.context)) why.push('context must be a string')
52 }
53 time('asked', d.asked, now, why)
54 time('handledAt', d.handledAt, now, why)
55 const effKind = kind ?? (whole ? 'question' : undefined)
56 if (d.options !== undefined || (whole && effKind === 'question')) {
57 const o = d.options
58 if (!Array.isArray(o)) why.push('options must be an array of {key, label, description}')
59 else {
60 if (effKind === 'question' && o.length === 0) why.push('a question needs options (an OK-only item is kind "update"; a Done-only item is kind "task")')
61 o.forEach((x, i) => {
62 if (!isObj(x) || !isStr(x.key) || !x.key || !isStr(x.label)) why.push(`options[${i}] must be {key, label, description}, not ${JSON.stringify(x)}`)
63 })
64 }
65 }
66 links('links', d.links, why, false)
67 links('copy', d.copy, why, true)
68 return why
69}
70
71// One in-flight item: a set needs what the page draws; times are real.
72export function checkFlight(d: Doc, now: number, whole: boolean): string[] {
73 const why: string[] = []
74 if (whole) {
75 if (!isStr(d.project) || !d.project) why.push('project is missing')
76 if (!isStr(d.title) || !d.title) why.push('title is missing')
77 }
78 if (d.status !== undefined && d.status !== 'active' && d.status !== 'done') why.push(`status ${JSON.stringify(d.status)} is not "active" or "done"`)
79 for (const f of ['startedAt', 'updatedAt']) time(f, d[f], now, why)
80 if (d.expectBy !== undefined && d.expectBy !== null && Number.isNaN(Date.parse(String(d.expectBy)))) why.push('expectBy is not an ISO time')
81 links('links', d.links, why, false)
82 return why
83}
84
85export function checkCoordinator(d: Doc, now: number): string[] {
86 const why: string[] = []
87 time('lastActive', d.lastActive, now, why)
88 if (d.sessionUrl !== undefined && !isHttps(d.sessionUrl)) why.push('sessionUrl must start with https://')
89 return why
90}
91
92// One write as the ArtifactData tool takes it (a set or update; a batch is checked entry by entry).
93export function checkWrite(op: string, collection: unknown, docId: unknown, data: unknown, now: number): string[] {
94 if (op !== 'set' && op !== 'update') return []
95 if (!isObj(data)) return []
96 const whole = op === 'set'
97 const where = `${collection}/${docId}`
98 const why = collection === 'questions' ? checkQuestion(data, now, whole)
99 : collection === 'inflight' ? checkFlight(data, now, whole)
100 : collection === 'coordinators' ? checkCoordinator(data, now)
101 : []
102 return why.map(w => `${where}: ${w}`)
103}
104