SLOPSHOPPER

page-contract

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…

newguardprocess
A shopper browsing a rack in a slop shop
README

Topic

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 feed showing proposed sessions, hearts, and comments

Availability view showing timeslots, availability totals, and voting controls

What It Does

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:

  • Propose. Hosts draft topics in a rich-text editor and submit them; admins review and publish (or the forum lets hosts publish directly), with pre-publish feedback in a private drafting thread.
  • Vote. Electors ❤️ the topics they want. Votes are weighted — someone who ❤️s everything counts for less per ❤️ than someone who chooses carefully — and hosts and admins see scores under four different normalisations, from raw totals to one-vote-each.
  • Discuss. Threaded public comments, an optional host-only thread, and @mentions with in-app notifications and email digests.
  • Schedule. Admins define a weekly pattern and term dates; slots are generated from the cross product. Electors mark availability once as a weekly pattern (with per-slot overrides), and hosts use per-topic availability lenses to find, claim, and confirm slots for sessions — which members can subscribe to as an ICS calendar feed.

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.

Quick Start

Prerequisites:

  • Node.js 20 or newer
  • Docker, or another PostgreSQL 16 instance
  • Clerk application keys for authentication
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:

  • Web: http://localhost:3000
  • API: http://localhost:4000
  • GraphQL: http://localhost:4000/graphql

npm 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.

Docs

  • Product: what the product does, for whom, and its current status and gaps.
  • Architecture: apps, packages, API surfaces, auth flow, data model, and runtime boundaries.
  • Deployment: local/dev/prod environments, Clerk, DigitalOcean, GitHub Actions, secrets, and cron.
  • docs/execution-journal: one entry per notable change — the project's history lives there, not in the docs above.
  • CLAUDE.md: working instructions for AI coding agents.

Scripts

CommandDescription
npm run devRun API and web together
npm run dev:api / npm run dev:webRun one app
npm run typecheckType-check every workspace
npm run testRun unit tests
npm run test:e2eRun Playwright anonymous browser smoke tests
npm run lintLint every workspace (web's own Next config, then lint:node)
npm run lint:nodeLint apps/api, packages/*, tests, and scripts with the root eslint.config.mjs
npm run format / npm run format:checkPrettier write / check (defaults; YAML and Markdown are exempt)
npm run buildBuild all workspaces
npm run db:generateGenerate a SQL migration from the schema
npm run db:migrateApply migrations
npm run db:seedSeed the local dev database from dev-sample-data.md
npm run clerk:seed-dev-usersCreate/update Clerk dev users for the sample people
npm run db:studioOpen Drizzle Studio
npm run db:up / npm run db:downStart or stop local Postgres

Testing

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.

Source 2 files
hooks/register.ts 141 lines
1import 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}
141
hooks/check.ts 104 lines
1// 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