@timqi/pier 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
package/docs/deploy.md ADDED
@@ -0,0 +1,307 @@
1
+ # Running Pier as a service (Linux, systemd)
2
+
3
+ Pier is one Node process that binds the loopback. Running it in a terminal is
4
+ a complete installation — nothing below is required to *use* Pier. What systemd
5
+ adds is the thing a terminal cannot: scheduled tasks fire and IM channels stay
6
+ connected while nobody is logged in.
7
+
8
+ That is also why this document is Linux-only. A laptop closes its lid; the
9
+ machine you want always-on is a server, and on that machine systemd is already
10
+ there.
11
+
12
+ ## The short version
13
+
14
+ ```sh
15
+ npm install -g @timqi/pier
16
+ pier service install
17
+ journalctl --user -u pier -e # the password, printed once
18
+ ```
19
+
20
+ That writes the unit below, enables linger, and starts the service. The rest of
21
+ this page is what it wrote and why — read it before widening the bind, and when
22
+ you want the unit to say something different.
23
+
24
+ ## Prerequisites
25
+
26
+ - Node 24 or newer (`node:sqlite` is used unflagged).
27
+ - The `sqlite3` CLI, for the off-machine backup and password steps below. Pier
28
+ itself does not need it.
29
+ - A checkout, built once:
30
+
31
+ ```sh
32
+ git clone git@github.com:timqi/pier.git ~/pier
33
+ cd ~/pier && npm ci && npm run build
34
+ ```
35
+
36
+ The unit below runs the build output, so a deploy is always "update the
37
+ checkout, rebuild, restart" — never "run from source".
38
+
39
+ ## The unit
40
+
41
+ A **user** unit, not a system one: Pier runs as you, reads your Pi
42
+ configuration, and drives sessions in your own directories. Running it as root
43
+ or as a dedicated system user means an agent that cannot touch the files you
44
+ wanted it to work on.
45
+
46
+ `~/.config/systemd/user/pier.service` — what `pier service install` generates,
47
+ with your node and your entry point filled in:
48
+
49
+ ```ini
50
+ [Unit]
51
+ Description=Pier — agent workspace
52
+ Documentation=https://github.com/timqi/pier
53
+ After=network-online.target
54
+ Wants=network-online.target
55
+
56
+ [Service]
57
+ Type=simple
58
+ WorkingDirectory=%h/pier
59
+ # An absolute path on purpose: systemd starts with a minimal PATH, so a node
60
+ # installed by nvm/fnm/asdf is not on it. `command -v node` gives you this.
61
+ ExecStart=/usr/bin/node dist/main.js
62
+ Environment=NODE_ENV=production
63
+ # Loopback by default. Put a reverse proxy in front before widening this —
64
+ # whoever reaches this port can drive an agent that runs a shell.
65
+ Environment=HOST=127.0.0.1
66
+ Environment=PORT=3141
67
+ # Where the database, the boards and the generated password hash live.
68
+ Environment=PIER_HOME=%h/.pier
69
+ Restart=always
70
+ RestartSec=2
71
+ # The journal is where the first-run password is printed, so keep it readable.
72
+ StandardOutput=journal
73
+ StandardError=journal
74
+ # Otherwise every line is tagged "node"; this makes `journalctl -t pier` work.
75
+ SyslogIdentifier=pier
76
+
77
+ [Install]
78
+ WantedBy=default.target
79
+ ```
80
+
81
+ Enable it, and tell logind to keep your user manager alive after you log out —
82
+ without lingering, every scheduled task stops when your SSH session ends:
83
+
84
+ ```sh
85
+ loginctl enable-linger "$USER"
86
+ systemctl --user daemon-reload
87
+ systemctl --user enable --now pier
88
+ ```
89
+
90
+ ## Memory limits
91
+
92
+ Worth setting: an agent runs whatever a turn decided to run, so the failure to
93
+ plan for is a build or a test suite eating the machine, not Pier itself
94
+ leaking. A cgroup limit turns "the box OOMs and sshd dies with it" into "the
95
+ heaviest process inside this unit is killed".
96
+
97
+ User units can do this without root as long as the memory controller is
98
+ delegated to your user manager, which it is by default on current systemd:
99
+
100
+ ```sh
101
+ systemctl show "user@$(id -u).service" -p DelegateControllers
102
+ # DelegateControllers=cpu memory pids ← memory listed means these work
103
+ ```
104
+
105
+ `~/.config/systemd/user/pier.service.d/limits.conf` — a drop-in, so the unit
106
+ above stays about what Pier is and this file is about what it may consume:
107
+
108
+ The limit is on the **whole unit**: `node`, every Pi subagent, and every
109
+ command a turn ran, added together — plus the page cache those processes
110
+ touched, which is why the soft ceiling should be the one that bites. So size it
111
+ as a share of the machine, not as "how much should Pier need". Percentages are
112
+ relative to installed physical memory, which also keeps this file portable:
113
+
114
+ ```ini
115
+ [Service]
116
+ # Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
117
+ # instead of killing anything. This is the one that should bite first.
118
+ MemoryHigh=60%
119
+ # Hard ceiling: the kernel OOM-kills *inside this cgroup*. The number exists to
120
+ # protect everything outside it — the OS, sshd, your other services — so what
121
+ # it should leave behind is a few GB for them, not a small share for Pier.
122
+ MemoryMax=75%
123
+ # Swapping an agent is worse than failing it — the machine stops responding
124
+ # long before the limit is reached.
125
+ MemorySwapMax=0
126
+ # A runaway command an agent ran can fork as well as allocate.
127
+ TasksMax=512
128
+ # The unit's own processes are the preferred victims if the *machine* still
129
+ # runs out, e.g. before these limits are tuned. Works with no cgroup limit at
130
+ # all, which makes it the cheapest half of this file.
131
+ OOMScoreAdjust=200
132
+ # A child being OOM-killed must not take the service with it: the turn that
133
+ # ran it fails, Pier keeps serving. (Delegated units default to this; set
134
+ # explicitly because the default depends on system configuration.)
135
+ OOMPolicy=continue
136
+ ```
137
+
138
+ On a dedicated 4–8 GB VPS those percentages land around 2.5–6 GB, which is
139
+ roughly what one agent doing ordinary work needs. On a big shared box, prefer
140
+ absolute values sized to what you are willing to lose to a runaway build —
141
+ `MemoryHigh=8G` / `MemoryMax=12G` on 32 GB, say — because 75% of a large
142
+ machine is no longer a meaningful ceiling.
143
+
144
+ Pick the numbers by measuring, not by guessing — Pier idles in the hundreds of
145
+ MB, and what varies is what the agent runs:
146
+
147
+ ```sh
148
+ systemctl --user show pier -p MemoryCurrent -p MemoryPeak
149
+ systemd-cgtop --depth=3 "user.slice/user-$(id -u).slice"
150
+ ```
151
+
152
+ A limit that fires during ordinary work is worse than none: the turn dies with
153
+ a signal and the cause is a kernel message nobody reads. Set it to protect the
154
+ machine, then raise it the first time it kills something legitimate.
155
+
156
+ ## Logs
157
+
158
+ Pier writes to stdout and stderr and to nothing else — no log file, no
159
+ rotation, no log configuration. Under the unit above that *is* the log:
160
+ journald stamps the time, keeps the history and rotates it, which is why
161
+ nothing in Pier reimplements any of that.
162
+
163
+ ```sh
164
+ journalctl --user -u pier -f # follow
165
+ journalctl --user -u pier -p warning # only what went wrong
166
+ journalctl --user -u pier --since -1h | grep 'tasks:' # one area
167
+ ```
168
+
169
+ Every line is `area: message` — `core`, `agent`, `tasks`, `slack`, `telegram`,
170
+ `channels`, `web`, `auth`, `boards`, `client`, `pier` — so an area is a grep
171
+ and a level is a `-p`. The level reaches journald as a syslog priority prefix, which Pier
172
+ emits only when systemd says the output is a journal (`$JOURNAL_STREAM`); run
173
+ in a terminal, the same lines carry a timestamp and a level word instead.
174
+
175
+ What is logged by default: process start and shutdown, sessions opening,
176
+ conversation → session routing, every turn ending, every task run queued and
177
+ settled, callback and message delivery failures with their retries, adapters
178
+ starting (and failing to start or stop), dropped inbound messages, failed
179
+ logins, and any request that threw. A watch probe that matched nothing is the
180
+ one routine event kept at `debug` — it fires on every interval.
181
+
182
+ `client:` is the browser's half, posted back by the workbench (`ui/report.ts`)
183
+ and written into this same stream: script errors, unhandled rejections and a
184
+ dead SSE stream, each with the view and the user agent. It comes from signed-in
185
+ tabs only — the route sits behind the password like the rest of `/api`, so
186
+ nobody who cannot already reach Pier can write into this journal. The person sees the
187
+ same sentence in the chat pane, so "I clicked and nothing happened" has a line
188
+ on both ends:
189
+
190
+ ```sh
191
+ journalctl --user -u pier | grep 'client:'
192
+ ```
193
+
194
+ ```sh
195
+ systemctl --user set-environment PIER_LOG=debug # + per-message tracing
196
+ systemctl --user restart pier
197
+ ```
198
+
199
+ `PIER_LOG` takes `debug`, `info` (default), `warn`, `error` or `silent`. Prefer
200
+ turning `debug` on for a session and back off: it logs one line per inbound
201
+ message and per tool call.
202
+
203
+ ## First login
204
+
205
+ Pier generates a password on an empty database and prints it once, so the
206
+ journal is where you read it:
207
+
208
+ ```sh
209
+ journalctl --user -u pier | grep -A2 'no password'
210
+ ```
211
+
212
+ Only its scrypt hash is stored. If you lose it, drop the row and restart — a
213
+ new password is generated and printed:
214
+
215
+ ```sh
216
+ sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
217
+ systemctl --user restart pier
218
+ ```
219
+
220
+ Changing the password invalidates every session cookie: the cookies are signed
221
+ with the stored hash.
222
+
223
+ ## Updating
224
+
225
+ Manually, which is also exactly what an automated update has to do:
226
+
227
+ ```sh
228
+ cd ~/pier
229
+ git fetch --tags && git checkout v0.2.0 # a tag, not a branch
230
+ npm ci && npm run build
231
+ systemctl --user restart pier
232
+ ```
233
+
234
+ A newer Pier brings its own schema up on the next start: the migrations run in
235
+ one transaction before the port opens, and the version they leave behind is
236
+ stamped in the database. Before touching an existing database it snapshots it
237
+ to `~/.pier/db/pier.db.v<N>.bak` (`N` = the schema it was at), so the step you
238
+ cannot forget is the one you never take. **Upgrades only.** Start an older Pier
239
+ on a database a newer one has migrated and it refuses to run rather than write
240
+ tables it does not understand — the way back down is that `.bak`:
241
+
242
+ ```sh
243
+ systemctl --user stop pier
244
+ cd ~/.pier/db && rm -f pier.db pier.db-wal pier.db-shm && cp pier.db.v1.bak pier.db
245
+ # then check out the Pier that speaks schema 1 again
246
+ ```
247
+
248
+ The three newest snapshots are kept and older ones removed as later upgrades
249
+ supersede them; each is a full copy of the database, so size grows with the
250
+ database, not with the number of releases. They sit next to the database and
251
+ therefore protect against a bad upgrade, not against a lost disk — an
252
+ off-machine copy is still yours to take.
253
+
254
+ ### Can it update itself?
255
+
256
+ Yes, with one caveat that decides the shape: **the updater must not be a child
257
+ of the service it restarts.** `systemctl --user restart pier` kills everything
258
+ in `pier.service`'s cgroup, so an update script spawned by Pier dies halfway
259
+ through — sometimes after unpacking and before restarting, which is the one
260
+ outcome worse than not updating.
261
+
262
+ So the update runs as its own unit. `~/.config/systemd/user/pier-update.service`:
263
+
264
+ ```ini
265
+ [Unit]
266
+ Description=Update Pier to the latest tag
267
+
268
+ [Service]
269
+ Type=oneshot
270
+ WorkingDirectory=%h/pier
271
+ ExecStart=/bin/sh -lc 'git fetch --tags && git checkout "$(git describe --tags --abbrev=0 origin/main)" && npm ci && npm run build'
272
+ ExecStartPost=/bin/sh -lc 'systemctl --user restart pier'
273
+ ```
274
+
275
+ Pier can then trigger an update with a single call that survives its own
276
+ restart, because the work happens in a different cgroup:
277
+
278
+ ```sh
279
+ systemctl --user start pier-update.service
280
+ ```
281
+
282
+ Deliberately **not** a `systemd.timer`. An unattended update is a machine that
283
+ rewrites its own code from the network while holding your API keys, and it
284
+ interrupts whatever session was mid-turn to do it. The intended shape is that
285
+ Pier notices a newer release and says so (not built yet), while starting the
286
+ update stays a decision someone makes. `Restart=always` above is what makes
287
+ that decision cheap — the service comes back on its own.
288
+
289
+ ## Remote access
290
+
291
+ The unit binds the loopback, and that is the intended posture. To reach it from
292
+ elsewhere, pick a tunnel rather than a wider bind:
293
+
294
+ - `ssh -L 3141:localhost:3141 server` — nothing to configure, nothing exposed.
295
+ - Tailscale, or Cloudflare Tunnel — no open port, and TLS terminates outside.
296
+ - A reverse proxy (Caddy, nginx) if you want a real hostname. Terminate TLS
297
+ there and pass `X-Forwarded-For`; Pier's login throttle counts per forwarded
298
+ client, and its session cookie is marked `Secure` when the proxy reports
299
+ `X-Forwarded-Proto: https`.
300
+
301
+ ## Backups
302
+
303
+ Two paths hold everything: `~/.pier/db/pier.db` (tasks, channels, the chat →
304
+ session map, workbench state, settings, the password hash) and
305
+ `~/.pier/boards/`. Copy the database with `sqlite3 ... "VACUUM INTO '…'"`
306
+ rather than `cp`, which under WAL can miss the most recent commits. Pi's own session history lives under its
307
+ config directory (`PI_CODING_AGENT_DIR`, `~/.pi/agent` by default).
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@timqi/pier",
3
+ "version": "0.0.1",
4
+ "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": "github:timqi/pier",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "type": "module",
11
+ "bin": {
12
+ "pier": "dist/cli.js"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "!dist/**/*.test.js",
17
+ "skills",
18
+ "docs/deploy.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "provenance": true
23
+ },
24
+ "scripts": {
25
+ "dev": "npm run build:web && tsx watch src/main.ts",
26
+ "dev:web": "vite",
27
+ "build": "tsc && npm run build:web && npm run build:assets",
28
+ "build:assets": "mkdir -p dist/boards dist/web && cp src/boards/pier.css dist/boards/ && rm -rf dist/web/public && cp -r src/web/public dist/web/public",
29
+ "prepack": "npm run build",
30
+ "build:web": "vite build",
31
+ "check": "tsc --noEmit && tsc -p tsconfig.web.json --noEmit",
32
+ "lint": "oxlint src",
33
+ "test": "vitest run --passWithNoTests"
34
+ },
35
+ "devDependencies": {
36
+ "@tailwindcss/typography": "^0.5.20",
37
+ "@tailwindcss/vite": "^4.3.3",
38
+ "oxlint": "^1.79.0",
39
+ "tailwindcss": "^4.3.3",
40
+ "tsx": "^4.23.12",
41
+ "typescript": "^7.0.2",
42
+ "vite": "^8.2.1",
43
+ "vitest": "^4.1.11"
44
+ },
45
+ "dependencies": {
46
+ "@earendil-works/pi-coding-agent": "^0.84.2",
47
+ "@hono/node-server": "^2.1.1",
48
+ "croner": "^10.0.1",
49
+ "dompurify": "^3.4.14",
50
+ "highlight.js": "^11.12.0",
51
+ "hono": "^4.13.3",
52
+ "marked": "^18.0.10",
53
+ "typebox": "^1.3.7"
54
+ }
55
+ }
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: pier-boards
3
+ description: Publish a Board — a folder of static HTML Pier serves at a stable URL — to present a report, digest, dashboard or handover note to the user. Read before building any page-shaped deliverable, or before editing an existing board.
4
+ ---
5
+
6
+ # Building a Pier board
7
+
8
+ A **board** is a directory in the boards folder `<pier>/AGENTS.md` names under
9
+ "This Pier instance" — use that path verbatim; `~/.pier` is only the default.
10
+ Pier serves `<board>/site/` and nothing else. Boards outlive sessions: any
11
+ session may read or rewrite any board, and closing this one changes nothing.
12
+
13
+ ## Create one
14
+
15
+ ```
16
+ <boards folder>/weekly-digest/
17
+ board.json
18
+ site/index.html
19
+ ```
20
+
21
+ ```json
22
+ {
23
+ "title": "Weekly digest — infra",
24
+ "description": "What changed in infra this week and what needs a decision.",
25
+ "sessions": ["<your session id>"],
26
+ "public": false
27
+ }
28
+ ```
29
+
30
+ Those four fields are the whole manifest.
31
+
32
+ - `slug`: `[a-z0-9][a-z0-9-]{0,63}`, and it is the URL — short and stable.
33
+ - `description` is the Console list entry: write it for someone who has
34
+ forgotten this conversation.
35
+ - `sessions`: append your own id, never replace — other ids are provenance too.
36
+
37
+ ## Publish, then hand over the link
38
+
39
+ `"public": true` serves the board at `/p/<slug>/` **with no password**. Set it
40
+ only if the user asked for a public or shareable board *in this request*;
41
+ otherwise leave it `false`, say the board is private, and mention that Console →
42
+ Boards flips it. Never publish credentials, internal paths, personal data or
43
+ anything the user has not seen.
44
+
45
+ The message announcing the board carries **one clickable link** — a bare path is
46
+ not something a person can open, and a board nobody reached was not delivered.
47
+ `<pier>/AGENTS.md` gives you the address, so there is nothing to look up:
48
+
49
+ | The user asked for | Send |
50
+ | --- | --- |
51
+ | a board, nothing about sharing | `[Weekly digest](https://pier.example.com/boards/weekly-digest/)` — behind the Pier password |
52
+ | a **public** board | `[Weekly digest](https://pier.example.com/p/weekly-digest/)` — no password |
53
+
54
+ Never both: the pair invites pasting the password-free URL of a board that was
55
+ never meant to leave the workspace, and `/p/<slug>/` 404s unless the manifest
56
+ says `"public": true`. No address configured? Give the path, say Console →
57
+ Settings turns it into a link, and never guess a host.
58
+
59
+ ## Writing the page
60
+
61
+ A board is a **presentation**, not a text file: someone opens it to get an
62
+ answer fast. Link the shipped stylesheet and write plain semantic HTML — no
63
+ build, no npm, no framework:
64
+
65
+ ```html
66
+ <!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="utf-8">
70
+ <meta name="viewport" content="width=device-width, initial-scale=1">
71
+ <title>Weekly digest — infra</title>
72
+ <link rel="stylesheet" href="/boards/_assets/pier.css">
73
+ </head>
74
+ <body>
75
+ <div class="hero">
76
+ <h1>Weekly digest — infra</h1>
77
+ <p class="lede">Throughput is up, but two migrations need a decision before Friday.</p>
78
+ <p class="muted">18 Feb 2026 · covers 12 repos</p>
79
+ </div>
80
+
81
+ <div class="grid">
82
+ <div class="card"><span class="kpi good">12</span> PRs merged <span class="muted">+3 vs last week</span></div>
83
+ <div class="card"><span class="kpi warn">2</span> awaiting decision</div>
84
+ <div class="card"><span class="kpi">98.9%</span> uptime <span class="muted">30 d</span></div>
85
+ </div>
86
+
87
+ <div class="callout warn">
88
+ <strong>Needs you:</strong> the payments migration blocks two teams — approve or defer by Friday.
89
+ </div>
90
+
91
+ <h2>Payments is the only degraded service</h2>
92
+ <table>
93
+ <thead><tr><th>Service</th><th>Status</th><th class="num">p95</th><th>Error budget</th></tr></thead>
94
+ <tbody>
95
+ <tr><td><strong>payments</strong></td><td><span class="tag bad">degraded</span></td><td class="num">910 ms</td>
96
+ <td><span class="bar bad" style="--v:18%"></span></td></tr>
97
+ <tr><td>api</td><td><span class="tag good">healthy</span></td><td class="num">142 ms</td>
98
+ <td><span class="bar good" style="--v:82%"></span></td></tr>
99
+ </tbody>
100
+ </table>
101
+
102
+ <details><summary>All 12 merged PRs</summary>
103
+ <table><thead><tr><th>PR</th><th>Author</th><th>Merged</th></tr></thead>
104
+ <tbody><tr><td>#412</td><td>ana</td><td>Mon</td></tr></tbody></table>
105
+ </details>
106
+
107
+ <footer>Written by Pier · data as of 18 Feb 09:00 · ask for an update to refresh</footer>
108
+ </body>
109
+ </html>
110
+ ```
111
+
112
+ What makes it read as designed rather than generated:
113
+
114
+ - **The lede is the verdict, not the topic.** "Two migrations need a decision by
115
+ Friday" answers; "This digest covers infra activity" restates the prompt. A
116
+ reader who stops after the hero must still leave with the point.
117
+ - **Headings are findings.** "Payments is the only degraded service", not
118
+ "Services". A heading that could top any report — Overview, Summary, Details —
119
+ says nothing about this one.
120
+ - **Every number carries its unit and its baseline**: "142 ms p95, was 120",
121
+ "12 of 40". A bare number is decoration; so is fake precision (98.8724% →
122
+ 98.9%).
123
+ - **End at the last useful block.** No closing summary, no filler section. The
124
+ footer holds provenance: data as-of, source, how to refresh.
125
+
126
+ ## What `pier.css` gives you
127
+
128
+ Headings, paragraphs, lists, tables, `pre`/`code`, `blockquote`, `details` and
129
+ `footer` are styled with **no classes at all**, dark mode included. The page
130
+ sizes itself: a wide desktop canvas (62rem, 76rem on a very large screen) that
131
+ reflows to one column on a phone, prose held to a readable measure while tables,
132
+ `.grid`, `.split` and `.hero` use the full width — don't add a `max-width` of
133
+ your own. Zebra striping, tables that scroll inside themselves, and `<details>`
134
+ that print open are free. On top of that:
135
+
136
+ | Class | Use it for |
137
+ | --- | --- |
138
+ | `.lede` | the one-sentence answer under the title |
139
+ | `.hero` | the opening block: title + lede + date/scope, on a tinted panel |
140
+ | `.grid` + `.card` | a KPI row (auto-fits to one column on a phone) |
141
+ | `.kpi` | the headline number inside a card |
142
+ | `.card` + `.good/.warn/.bad/.info` | a status card: accent edge and tinted fill |
143
+ | `.callout` (`.good` `.warn` `.bad`) | the takeaway or the ask |
144
+ | `.tag` (`.good` `.warn` `.bad` `.info`) | status pills in tables and lists |
145
+ | `.good` `.warn` `.bad` | colour on a number or a word |
146
+ | `.num` | right-aligned, tabular numeric table cells |
147
+ | `.bar` (`.good` `.warn` `.bad`) | share-of-total inside a table: `style="--v:62%"` |
148
+ | `.split` | two columns that stack on a phone: before/after, text + aside |
149
+ | `.muted` | secondary text: dates, deltas, units, scope |
150
+
151
+ Need something it lacks? A `<style>` block or your own CSS file inside `site/`
152
+ is normal, and so is a custom colour or a hand-written layout when the content
153
+ calls for one.
154
+
155
+ ## Pick the form from the content
156
+
157
+ | What you have | How to present it |
158
+ | --- | --- |
159
+ | The single most important fact | `.hero` lede, or one `.kpi` on its own |
160
+ | 2–4 headline metrics | `.grid` of `.card`s, each with `.kpi` + a `.muted` delta |
161
+ | Something the reader must act on | `.callout warn` near the top, naming the deadline |
162
+ | Items with a state | table with `.tag` pills, worst rows first |
163
+ | Ranked or compared numbers | table, `.num` columns, `.bar` for share of total |
164
+ | Progress toward a goal | `.bar` per row, or `.kpi` + "of 40 done" in `.muted` |
165
+ | Two alternatives | `.split` with a `.card` each, verdict in the lede above |
166
+ | A sequence of events | ordered list, date in `.muted` at the start of each item |
167
+ | A trend over time | first/last + delta in words; inline SVG only if the shape *is* the news |
168
+ | 200 rows | aggregate, show the 5–10 that matter, rest in `<details>` with the count in its summary |
169
+ | 1–2 data points | a sentence or a `.callout` — a one-row table is a table costume |
170
+ | Long raw output, logs, code | `<details>` at the bottom, or `<pre><code>` trimmed to the lines that matter |
171
+ | Nothing to report | say so in the lede ("all 14 checks green") and stop — short is finished, not thin |
172
+
173
+ Vary the forms: never two blocks of the same kind in a row, and prefer number →
174
+ ask → proof → fold over six paragraphs. A status or decision board earns one
175
+ screenful before the first `<details>`; only a handover or postmortem earns a
176
+ long scroll. Colour is part of the message — green healthy/done, amber
177
+ attention/pending, red broken/blocked, `.info` neutral emphasis — on the numbers
178
+ and status cells that carry the point, never on ordinary prose; that contrast is
179
+ what makes it read as signal.
180
+
181
+ ## Rules
182
+
183
+ - **Static and self-contained.** Everything the page needs lives under `site/`
184
+ with relative paths. No CDN, no external fonts, no analytics, no `fetch()` —
185
+ a published board is served under a CSP that blocks all of it, so an external
186
+ reference is a broken page, not a slow one.
187
+ - **Content, not an app.** Interaction is `<details>` and anchors; the page
188
+ stays readable with JavaScript off.
189
+ - **Show, don't narrate.** A sentence describing numbers should have been a KPI
190
+ row or a table. Prose is for judgement — what it means, what to do.
191
+ - **Structure before graphics.** A chart is inline SVG (no library) and only
192
+ when the *shape* of the data is the message. A three-row table beats any
193
+ picture of three numbers; no chart beats a decorative one.
194
+ - **Only real data.** Every figure traces to something you saw this session, and
195
+ a gap is shown as a gap ("no data since Tue", "~3 weeks", "n=3") — never
196
+ smoothed into a clean number.
197
+ - **Rewrite in place.** Updating a board means editing its files, not creating
198
+ `weekly-digest-v2`. The URL is the point.
199
+ - Top-down — what this is → the answer → detail → raw data in `<details>` —
200
+ prose in the user's language, short headings, no emoji chrome.
201
+
202
+ ## If a board needs a build
203
+
204
+ Pier ships no toolchain and the default is no build. If one is genuinely needed
205
+ (a bundled charting library, a component layout), you own it: keep sources
206
+ outside `site/` (e.g. `<board>/src/`), emit into `site/`, and write a
207
+ `<board>/README.md` a future session can follow cold — install command, build
208
+ command, output path, where the data came from. Never leave `site/`
209
+ inconsistent with its sources; on an existing board look for that README first
210
+ and rebuild, because hand-patching `site/` is lost on the next build.