mercury-agent 0.15.0 → 0.16.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 (37) hide show
  1. package/container/Dockerfile +30 -32
  2. package/container/Dockerfile.base +28 -31
  3. package/container/agent-package.json +1 -1
  4. package/docs/configuration.md +86 -0
  5. package/docs/context-architecture.md +20 -0
  6. package/examples/extensions/poster/skill/SKILL.md +56 -2
  7. package/examples/extensions/poster/skill/references/authoring-contract.md +230 -0
  8. package/examples/extensions/poster/skill/references/example-layout.html +54 -0
  9. package/examples/extensions/poster/skill/scripts/lib/audit.mjs +131 -0
  10. package/examples/extensions/poster/skill/scripts/lib/render.mjs +45 -9
  11. package/examples/extensions/poster/skill/scripts/lib/shell.mjs +489 -0
  12. package/examples/extensions/poster/skill/scripts/lib/validate.mjs +275 -0
  13. package/examples/extensions/poster/skill/scripts/poster.mjs +225 -0
  14. package/examples/extensions/poster/skill/templates/feature-grid.mjs +8 -25
  15. package/examples/extensions/poster/skill/templates/schedule-day.mjs +5 -32
  16. package/examples/extensions/poster/tests/poster-compose.test.ts +443 -0
  17. package/package.json +4 -4
  18. package/resources/skills/tasks/SKILL.md +13 -0
  19. package/resources/templates/mercury.example.yaml +8 -0
  20. package/src/adapters/whatsapp.ts +55 -12
  21. package/src/agent/container-entry.ts +189 -3
  22. package/src/agent/container-runner.ts +59 -5
  23. package/src/bridges/whatsapp.ts +6 -4
  24. package/src/cli/mercury.ts +9 -1
  25. package/src/cli/mrctl-http.ts +32 -0
  26. package/src/cli/mrctl.ts +10 -12
  27. package/src/config-file.ts +30 -0
  28. package/src/config.ts +48 -0
  29. package/src/core/handler.ts +38 -5
  30. package/src/core/operator-alerts.ts +442 -0
  31. package/src/core/routes/tasks.ts +7 -2
  32. package/src/core/runtime.ts +50 -5
  33. package/src/core/storage-cleanup.ts +47 -1
  34. package/src/core/system-messages.ts +13 -2
  35. package/src/core/task-output.ts +129 -0
  36. package/src/core/task-time.ts +164 -0
  37. package/src/storage/pi-auth.ts +59 -3
@@ -60,49 +60,47 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
60
60
  ENV CHROMIUM_FLAGS="--no-sandbox"
61
61
 
62
62
  # Install CLIs
63
- RUN bun add -g @earendil-works/pi-coding-agent@~0.79.6
63
+ RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
64
64
 
65
65
  WORKDIR /app
66
66
 
67
67
  COPY container/agent-package.json /app/package.json
68
68
  RUN bun install --production
69
69
 
70
- # Patch pi: skip thinkingConfig for Gemma models (Google API rejects thinkingBudget for them,
71
- # but pi marks gemma-4 as reasoning:true and sends thinkingBudget:0 to disable it)
72
- RUN node <<'EOF'
73
- const fs = require('fs'), path = require('path');
74
- function patch(dir) {
75
- try {
76
- for (const f of fs.readdirSync(dir)) {
77
- const p = path.join(dir, f);
78
- try {
79
- if (fs.statSync(p).isDirectory()) patch(p);
80
- else if (f === 'google.js' && p.includes('@earendil-works/pi-ai')) {
81
- let c = fs.readFileSync(p, 'utf8');
82
- const noThinking = '&& !model.id.startsWith("gemma")';
83
- const p1 = 'if (options.thinking?.enabled && model.reasoning) {';
84
- const p2 = 'else if (model.reasoning && options.thinking && !options.thinking.enabled) {';
85
- if (!c.includes(noThinking)) {
86
- c = c.replace(p1, 'if (options.thinking?.enabled && model.reasoning ' + noThinking + ') {');
87
- c = c.replace(p2, 'else if (model.reasoning && options.thinking && !options.thinking.enabled ' + noThinking + ') {');
88
- fs.writeFileSync(p, c);
89
- console.log('Patched:', p);
90
- }
91
- }
92
- } catch(e) {}
93
- }
94
- } catch(e) {}
95
- }
96
- patch('/home/mercury/.bun');
97
- patch('/app/node_modules');
98
- EOF
70
+ # pi >= 0.84 handles Gemma 4's thinking config itself, so the patch that used to
71
+ # live here is gone. It string-replaced two exact lines in pi-ai's
72
+ # dist/providers/google.js to stop `thinkingBudget: 0` being sent to Gemma
73
+ # models, which the Google API rejects. In 0.84 that file moved to
74
+ # dist/api/google-generative-ai.js and gained `isGemma4Model()`, whose
75
+ # `getDisabledThinkingConfig()` returns `{ thinkingLevel: "MINIMAL" }` instead.
76
+ # Neither string the patch matched still exists.
77
+ #
78
+ # Narrower than what it replaces, deliberately and worth knowing: the patch
79
+ # gated on `model.id.startsWith("gemma")` — every Gemma — while `isGemma4Model`
80
+ # is `/gemma-?4/`, so anything else still falls through to `thinkingBudget: 0`.
81
+ # pi's built-in Google catalog only ships gemma-4 ids, so reaching the gap needs
82
+ # a hand-authored models.json entry for e.g. gemma-3 marked `reasoning: true`.
83
+ # If that turns up, fix it upstream rather than reinstating a source patch.
84
+ #
85
+ # Deleted rather than updated for a second reason: the patch swallowed every
86
+ # error (`catch(e) {}`) and only wrote when its marker was absent, so once pi
87
+ # moved the file it silently did nothing while still reading as protection. A
88
+ # no-op that looks like a safeguard is worse than no safeguard. If a future pi
89
+ # regresses here, reinstate it as a build step that FAILS when its pattern does
90
+ # not match.
99
91
 
100
92
  # Fix ownership of all mercury home dir artifacts before switching user.
101
- # Placed here — after the last step that writes to /home/mercury (the pi patch),
93
+ # Placed here — after the last step that writes to /home/mercury (the global
94
+ # `bun add -g` above),
102
95
  # but before the volatile /app source COPYs below — so this expensive `chown -R`
103
96
  # (it walks the whole Chromium + .bun tree) lands in a stable cached layer.
104
97
  # Editing source files no longer invalidates it or forces the huge layer re-export.
105
- RUN chown -R mercury:mercury /home/mercury
98
+ # `mkdir` here rather than leaving it to Docker: the host mounts the global dir
99
+ # into PI_CODING_AGENT_DIR entry by entry, so Docker would create this dir as
100
+ # the mount parent and own it as root — and pi 0.84's credential store writes
101
+ # an empty auth.json on its first *read*, which then fails EACCES and takes
102
+ # every model leg down with it. Folded into the chown so it shares the layer.
103
+ RUN mkdir -p /home/mercury/.pi/agent && chown -R mercury:mercury /home/mercury
106
104
 
107
105
  COPY src/agent/container-entry.ts /app/src/agent/container-entry.ts
108
106
  COPY src/agent/model-capabilities-core.ts /app/src/agent/model-capabilities-core.ts
@@ -42,42 +42,34 @@ RUN echo '{"args":["--no-sandbox"]}' > /home/mercury/.puppeteerrc.json
42
42
  ENV CHROMIUM_FLAGS="--no-sandbox"
43
43
 
44
44
  # Install CLIs
45
- RUN bun add -g @earendil-works/pi-coding-agent@~0.79.6
45
+ RUN bun add -g @earendil-works/pi-coding-agent@~0.84.1
46
46
 
47
47
  WORKDIR /app
48
48
 
49
49
  COPY container/agent-package.json /app/package.json
50
50
  RUN bun install --production
51
51
 
52
- # Patch pi: skip thinkingConfig for Gemma models (Google API rejects thinkingBudget for them,
53
- # but pi marks gemma-4 as reasoning:true and sends thinkingBudget:0 to disable it)
54
- RUN node <<'EOF'
55
- const fs = require('fs'), path = require('path');
56
- function patch(dir) {
57
- try {
58
- for (const f of fs.readdirSync(dir)) {
59
- const p = path.join(dir, f);
60
- try {
61
- if (fs.statSync(p).isDirectory()) patch(p);
62
- else if (f === 'google.js' && p.includes('@earendil-works/pi-ai')) {
63
- let c = fs.readFileSync(p, 'utf8');
64
- const noThinking = '&& !model.id.startsWith("gemma")';
65
- const p1 = 'if (options.thinking?.enabled && model.reasoning) {';
66
- const p2 = 'else if (model.reasoning && options.thinking && !options.thinking.enabled) {';
67
- if (!c.includes(noThinking)) {
68
- c = c.replace(p1, 'if (options.thinking?.enabled && model.reasoning ' + noThinking + ') {');
69
- c = c.replace(p2, 'else if (model.reasoning && options.thinking && !options.thinking.enabled ' + noThinking + ') {');
70
- fs.writeFileSync(p, c);
71
- console.log('Patched:', p);
72
- }
73
- }
74
- } catch(e) {}
75
- }
76
- } catch(e) {}
77
- }
78
- patch('/home/mercury/.bun');
79
- patch('/app/node_modules');
80
- EOF
52
+ # pi >= 0.84 handles Gemma 4's thinking config itself, so the patch that used to
53
+ # live here is gone. It string-replaced two exact lines in pi-ai's
54
+ # dist/providers/google.js to stop `thinkingBudget: 0` being sent to Gemma
55
+ # models, which the Google API rejects. In 0.84 that file moved to
56
+ # dist/api/google-generative-ai.js and gained `isGemma4Model()`, whose
57
+ # `getDisabledThinkingConfig()` returns `{ thinkingLevel: "MINIMAL" }` instead.
58
+ # Neither string the patch matched still exists.
59
+ #
60
+ # Narrower than what it replaces, deliberately and worth knowing: the patch
61
+ # gated on `model.id.startsWith("gemma")` — every Gemma — while `isGemma4Model`
62
+ # is `/gemma-?4/`, so anything else still falls through to `thinkingBudget: 0`.
63
+ # pi's built-in Google catalog only ships gemma-4 ids, so reaching the gap needs
64
+ # a hand-authored models.json entry for e.g. gemma-3 marked `reasoning: true`.
65
+ # If that turns up, fix it upstream rather than reinstating a source patch.
66
+ #
67
+ # Deleted rather than updated for a second reason: the patch swallowed every
68
+ # error (`catch(e) {}`) and only wrote when its marker was absent, so once pi
69
+ # moved the file it silently did nothing while still reading as protection. A
70
+ # no-op that looks like a safeguard is worse than no safeguard. If a future pi
71
+ # regresses here, reinstate it as a build step that FAILS when its pattern does
72
+ # not match.
81
73
 
82
74
  COPY src/agent/container-entry.ts /app/src/agent/container-entry.ts
83
75
  COPY src/agent/model-capabilities-core.ts /app/src/agent/model-capabilities-core.ts
@@ -102,7 +94,12 @@ RUN echo '#!/bin/sh\nbun run /app/src/cli/mrctl.ts "$@"' > /usr/local/bin/mrctl
102
94
  chmod +x /usr/local/bin/mrctl
103
95
 
104
96
  # Fix ownership of all mercury home dir artifacts before switching user
105
- RUN chown -R mercury:mercury /home/mercury
97
+ # `mkdir` here rather than leaving it to Docker: the host mounts the global dir
98
+ # into PI_CODING_AGENT_DIR entry by entry, so Docker would create this dir as
99
+ # the mount parent and own it as root — and pi 0.84's credential store writes
100
+ # an empty auth.json on its first *read*, which then fails EACCES and takes
101
+ # every model leg down with it. Folded into the chown so it shares the layer.
102
+ RUN mkdir -p /home/mercury/.pi/agent && chown -R mercury:mercury /home/mercury
106
103
 
107
104
  USER mercury
108
105
 
@@ -3,6 +3,6 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@earendil-works/pi-coding-agent": "~0.79.6"
6
+ "@earendil-works/pi-coding-agent": "~0.84.1"
7
7
  }
8
8
  }
@@ -39,6 +39,41 @@ context:
39
39
 
40
40
  Per-space overrides via `mrctl config set context.<key> <value>` always win over YAML defaults; YAML re-reads on restart do not overwrite an existing space row.
41
41
 
42
+ ## Operator alerts (`alerts:`)
43
+
44
+ When a provider OAuth credential can no longer be refreshed, every space stops
45
+ answering until someone re-authenticates on the host. Mercury DMs the configured
46
+ operators so the outage does not wait for a human to read the logs.
47
+
48
+ ```yaml
49
+ alerts:
50
+ enabled: true # default: true
51
+ cooldown_ms: 21600000 # 60000 – 604800000 (default: 6h)
52
+ ```
53
+
54
+ Env equivalents: **`MERCURY_ALERTS_ENABLED`**, **`MERCURY_ALERTS_COOLDOWN_MS`**.
55
+
56
+ - **Recipients** are the ids already listed in `permissions.admins` (`MERCURY_ADMINS`)
57
+ and `dm_auto_space.admin_ids` (`MERCURY_DM_AUTO_SPACE_ADMIN_IDS`)
58
+ — there is no separate alert-recipient key. An id is alerted only when it
59
+ resolves to an existing space **with a linked conversation**, i.e. a chat
60
+ Mercury can actually send into; an alert never creates a space. With no
61
+ configured admin, or none that has ever messaged the bot, the alert has
62
+ nowhere to go — Mercury logs that fact at ERROR and leaves the cooldown
63
+ unclaimed, so the next failure tries again.
64
+ - **Channel** is the ordinary adapter outbox, the same path scheduled-task
65
+ failures use. It carries no agent run and no LLM, so it still works when the
66
+ agent's own credential is what died.
67
+ - **`cooldown_ms`** is the minimum gap between identical alerts, per credential
68
+ and per reason. It is deliberately long: the alert names a manual recovery
69
+ step, and repeating it while an operator performs it adds nothing. The state is
70
+ stored in the database, so a crash-looping service does not re-alert on every
71
+ restart. A successful credential load clears it — if the credential dies again
72
+ later, that is news and alerts immediately.
73
+ - **Transient failures** (HTTP 408/429/5xx from the token endpoint) alert only
74
+ after three in a row; a single blip heals itself. A rejected refresh token, or
75
+ a cause that could not be classified, alerts on the first occurrence.
76
+
42
77
  ## Ambient group context
43
78
 
44
79
  In linked group chats, messages that don't trigger the bot are stored as **ambient context** (author-attributed) so it can answer questions about conversation it overheard. Every message in a linked group becomes a row, bounded on both ends:
@@ -74,6 +109,57 @@ Container env passthrough: all — these vars reach every space's container and
74
109
 
75
110
  For secrets that only host-side hooks and jobs need, prefer `mercury.env({ from: "…", hostOnly: true })`, which keeps them out of containers in either mode. For credentials the agent should never hold at all, use a host-side capability handler (`mercury.capability()`), which runs the privileged call on the host and returns only the result.
76
111
 
112
+ ## Agent run traces (`agent.trace_runs`)
113
+
114
+ Captures pi's raw output from inside each agent container, with arrival timestamps, and copies it to `<dataDir>/traces/` before the container is reaped:
115
+
116
+ ```yaml
117
+ agent:
118
+ trace_runs: false # false (default) | true
119
+ ```
120
+
121
+ Env: `MERCURY_CONTAINER_TRACE_RUNS`.
122
+
123
+ Agent containers run with `--rm`, so pi's session logs die with the container. That is fine until a run misbehaves — a turn that hits `container_timeout_ms` is killed and leaves **nothing** to show where its time went. Tracing exists for exactly that question.
124
+
125
+ Each harvested file is `<dataDir>/traces/<container-name>.jsonl`, one JSON object per line:
126
+
127
+ ```json
128
+ {"atMs":0,"stream":"meta","data":"{\"piSpawnedAtIso\":\"2026-08-13T09:15:02.104Z\"}"}
129
+ {"atMs":812,"stream":"stdout","data":"{\"type\":\"message_start\"…"}
130
+ {"atMs":47310,"stream":"stdout","data":"{\"type\":\"tool_call\"…"}
131
+ ```
132
+
133
+ - `atMs` — milliseconds since pi was spawned. **A long gap between consecutive lines is the finding**: it says which call the run was sitting inside.
134
+ - `stream` — `stdout`, `stderr`, or `meta`.
135
+ - A trace with no closing `meta` line carrying `exitCode` means the container was **killed** rather than exiting — itself a diagnosis. A `meta` line carrying `spawnError` instead means pi never started, which looks similar but is a different fault.
136
+
137
+ > **One file can hold several segments.** A run with a model chain, or one that retries a retryable failure, invokes pi more than once, and every invocation appends to the same file. Each one opens with its own `piSpawnedAtIso` `meta` line and **restarts `atMs` from zero**.
138
+ >
139
+ > So: split the file on `meta` lines carrying `piSpawnedAtIso`, and treat each segment separately. Comparing `atMs` across a boundary yields a negative gap and hides however long the earlier leg took — which matters, because a retry fires on exactly the misbehaving runs a trace is opened for. Concatenating `stdout` reconstructs pi's raw output byte for byte **within a segment**; glued across segments it parses as neither.
140
+
141
+ Writes go through `appendFileSync` rather than a buffered stream, deliberately: a buffered write loses whatever is in userspace when the process is SIGKILLed, which is the case the trace is for.
142
+
143
+ To find where a slow run actually spent its time — the largest gap between consecutive lines, and what the run was doing when it stalled:
144
+
145
+ ```bash
146
+ bun -e 'const ls=require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map(l=>JSON.parse(l));
147
+ let prev=0,seg=0,w={gapMs:0};for(const l of ls){
148
+ if(l.stream==="meta"&&String(l.data).includes("piSpawnedAtIso")){seg++;prev=0;continue}
149
+ const g=l.atMs-prev;if(g>w.gapMs)w={gapMs:g,seg,atMs:l.atMs,after:String(l.data).slice(0,160)};prev=l.atMs}
150
+ console.log(w)' .mercury/traces/<container-name>.jsonl
151
+ ```
152
+
153
+ The `prev=0` reset at each `meta` line is what keeps a multi-leg run honest — without it the boundary produces a negative gap and the earlier leg's stall never wins.
154
+
155
+ `after` is the output that arrived *when the gap ended*, so the call that hung is the one immediately before it.
156
+
157
+ > ⚠️ **A trace contains the full prompt and the full reply in clear text** — real conversation content, including anything group members said. Treat it like `log_level: debug`: switch it on for a bounded window, capture the run you need, and switch it back.
158
+
159
+ `<dataDir>/traces/` is swept by the storage cleanup: a trace file is deleted once it is older than **`MERCURY_TRACE_TTL_DAYS`** (default `7`). The sweep runs whether or not `agent.trace_runs` is currently on — otherwise switching tracing off would strand that window's traces on disk permanently, which is the case the retention exists for. Shorten it if traces are being captured on a host where the conversation content is sensitive; deleting files by hand is still fine at any time.
160
+
161
+ > ⚠️ **Changing this setting needs an agent image rebuild, not just a restart.** The capture half lives in `src/agent/container-entry.ts`, which is baked into the image (`container/Dockerfile`). A restart alone deploys the host-side half and leaves tracing silently doing nothing.
162
+
77
163
  ## Extension config defaults (`extensions:`)
78
164
 
79
165
  Deployment-wide defaults for extension config keys, applied to **every space** (including auto-created DM spaces) at read time:
@@ -68,6 +68,12 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
68
68
 
69
69
  ```
70
70
  <caller id="…" name="…" role="…" space="…" />
71
+ <run_budget> ← wall-clock window for this run
72
+ <started_at>…</started_at>
73
+ <killed_at>…</killed_at> ← host kills the container here
74
+ <begin_final_answer_by>…</begin_final_answer_by>
75
+ <total_budget>…</total_budget>
76
+ </run_budget>
71
77
  <episodic_memory>…</episodic_memory> ← MEMORY.md (if present)
72
78
  <active_episodes>…</active_episodes> ← relevance-scored episode snippets
73
79
  <history> ← sliding window from DB
@@ -85,6 +91,20 @@ The session boundary (`chat_state.min_message_id`) excludes messages older than
85
91
  [user prompt text]
86
92
  ```
87
93
 
94
+ `<run_budget>` carries the wall clock the host enforces. `containerTimeoutMs`
95
+ kills the container at `killed_at`, and before this block existed the agent had
96
+ no way to learn that — the first and only signal was SIGKILL, too late to pace
97
+ the work or save partial findings. `begin_final_answer_by` reserves a
98
+ proportional slice of the budget (20%, floored at 30s, capped at 5min) for
99
+ composing and sending the answer. The block is omitted entirely when the host
100
+ supplies no window, or an incoherent one — a missing budget beats one claiming
101
+ the run is already over.
102
+
103
+ The matching `## Run budget` instructions live in the system prompt. They are
104
+ gated more tightly than the block itself: a toolless model gets the block (the
105
+ kill time is useful context regardless) but not the instructions, which all
106
+ assume a shell to run `date` in and a file tool to save findings with.
107
+
88
108
  ---
89
109
 
90
110
  ## Why Not a Pi Session File?
@@ -5,12 +5,16 @@ description: Generate styled marketing posters, flyers and day-programme graphic
5
5
 
6
6
  # Poster CLI
7
7
 
8
- Two commands, two layers. Keep them separate — that separation is the whole point.
8
+ Two layers. Keep them separate — that separation is the whole point.
9
9
 
10
10
  | Layer | Command | Nature | Cost |
11
11
  |---|---|---|---|
12
12
  | 1 — art | `poster art` | probabilistic, calls an image model | per image |
13
- | 2 — typography | `poster render` | deterministic, Chromium + HTML | free |
13
+ | 2 — typography | `poster render` / `poster compose` | deterministic, Chromium + HTML | free |
14
+
15
+ Layer 2 has two paths. `render` fills a fixed template; `compose` renders a
16
+ layout you author yourself. Both go through the same document shell, so both
17
+ carry the same Hebrew guarantee.
14
18
 
15
19
  ## The rule that matters
16
20
 
@@ -149,6 +153,56 @@ right — layer 2 is free.
149
153
  does not reliably obey `--reserve`, and flipping the composition costs nothing where
150
154
  re-rolling a generation costs money and may come back worse.
151
155
 
156
+ ## `poster compose` — author the layout yourself
157
+
158
+ ```
159
+ poster compose --layout <file.html> --assets <file.json> -o <file>
160
+ [--aspect ...] [--size ...] [--dir rtl|ltr] [--workspace <dir>]
161
+ [--audit-json <file>] [--strict-warn] [--dump-html <file>]
162
+
163
+ poster contract # the authoring rules, printed
164
+ ```
165
+
166
+ You write a **fragment** — `<style>` blocks plus markup — and the pipeline wraps
167
+ it in the shell it owns: doctype, `dir="rtl"`, the embedded font palette, the
168
+ canvas, and `#poster-stage` (a box at exactly the poster's size that your markup
169
+ sits inside). Then it validates the fragment, measures the rendered result, and
170
+ **refuses to write a poster that is clipped or off-stage**.
171
+
172
+ ```bash
173
+ poster contract # read this first — it lists the families and rules
174
+ poster compose --layout work/layout.html --assets work/assets.json \
175
+ --aspect 3:2 -o outbox/poster.png
176
+ ```
177
+
178
+ The skill directory is read-only. Write `layout.html` and `assets.json` into the
179
+ **workspace** (`work/…`), not next to the templates, or the write fails.
180
+
181
+ Images are referenced by token, never by path — `<img src="asset:background">`,
182
+ resolved from `{ "background": "work/bg.png" }`. A path or URL in your markup is
183
+ rejected.
184
+
185
+ Exit codes: `0` written · `1` the command was wrong · `2` the layout was
186
+ rejected. On `2`, read the report, fix the layout, re-run — it costs nothing.
187
+ After two failed attempts, fall back to `poster render` with the nearest
188
+ template and say that you did.
189
+
190
+ Full rules, the checked-automatically table, and the surface-landing techniques:
191
+ `references/authoring-contract.md`.
192
+
193
+ ### Which path to use
194
+
195
+ | Use | When |
196
+ |---|---|
197
+ | `render --template schedule-day` | the content is time-ordered and a hero + timed-rows card fits it |
198
+ | `render --template feature-grid` | the content is feature-ordered — a challenge, a workshop, a launch |
199
+ | `compose` | the content does not fit either shape, or the art has a specific surface (a hanging sign, a chalkboard, a ribbon) that the copy should sit on, or the caller asked for a particular look |
200
+
201
+ Default to a template when one genuinely fits — it is one Chromium pass instead
202
+ of two, and it is already known to hold its content. Reach for `compose` when
203
+ the template would fight the content or waste the art. "Make it look designed,
204
+ not filled in" is a `compose` request.
205
+
152
206
  ## Typography rules
153
207
 
154
208
  Apply these unless asked otherwise.
@@ -0,0 +1,230 @@
1
+ # Authoring contract — `poster compose`
2
+
3
+ Read this before authoring a layout. `poster contract` prints the short version
4
+ at the terminal; this is the long one, with the reasoning.
5
+
6
+ ## What you write, and what you must not
7
+
8
+ You write a **fragment**: one or more `<style>` blocks plus the markup that goes
9
+ inside the poster. You do not write a document.
10
+
11
+ The pipeline supplies — and you cannot override:
12
+
13
+ - `<!doctype html>`, `<html dir="rtl" lang="he">`, `<meta charset="utf-8">`
14
+ - every `@font-face` in the bundled palette, base64-inlined
15
+ - `html, body` sized to the exact canvas, `overflow: hidden`
16
+ - `#poster-stage` — a `position: relative` box at exactly the poster's
17
+ dimensions. Your markup goes inside it. Position against it.
18
+ - a `.ltr` utility class
19
+ - the audit script (present only in the measuring pass, never in the shot one)
20
+
21
+ This split is not bureaucracy. It is the reason the extension exists: a layout
22
+ **cannot** forget to embed the font or set the wrong text direction, because
23
+ neither is in the part you write. Get the composition wrong and you get an ugly
24
+ poster; get font embedding wrong and you get a poster full of empty boxes where
25
+ the Hebrew should be, which is the failure this pipeline was built to make
26
+ impossible.
27
+
28
+ ## Fonts
29
+
30
+ Four Hebrew faces, plus the Latin fallback. Run `poster fonts --list` for what
31
+ each is for.
32
+
33
+ | Family | Face | Use for |
34
+ |---|---|---|
35
+ | `PosterSans` | Rubik | body copy, rows, anything neutral |
36
+ | `PosterDisplay` | Suez One | big headlines; very heavy |
37
+ | `PosterHand` | Amatic SC | taglines, asides — hand-drawn and thin |
38
+ | `PosterSerif` | Frank Ruhl Libre | editorial or formal |
39
+ | `PosterLatin` | Rubik Latin | digits and Latin words fall through to it already |
40
+
41
+ **Naming any other family is an error**, checked before anything renders. Not a
42
+ style preference — a face that is not embedded renders as tofu boxes or silently
43
+ drops the glyphs, and nothing downstream would notice.
44
+
45
+ Use `font-family`. The `font` shorthand is refused, because its family position
46
+ cannot be extracted reliably enough to check, and a check that is sometimes
47
+ wrong is worse than a rule that is always clear.
48
+
49
+ `PosterHand` has a much smaller x-height than the others. If you use it, raise
50
+ the font size ~40% over what you would give `PosterSans`.
51
+
52
+ **Mix faces deliberately.** One family across a whole poster reads as a slide,
53
+ not a design. Heavy display headline + hand-drawn aside + neutral sans body is
54
+ the combination that makes these look designed.
55
+
56
+ ## Images
57
+
58
+ Reference every image by **token**, never by path:
59
+
60
+ ```html
61
+ <img src="asset:background" alt="">
62
+ <div style="background: url(asset:dish) center/cover"></div>
63
+ ```
64
+
65
+ Tokens resolve from the `--assets` JSON map, which the caller writes:
66
+
67
+ ```json
68
+ { "background": "work/bg.png", "dish": "work/dish.png" }
69
+ ```
70
+
71
+ This applies wherever an image can appear, `image-set("…")` and `image("…")`
72
+ included — those take a bare quoted string with no `url()` wrapper, and they are
73
+ checked the same way.
74
+
75
+ A filesystem path, a URL, or a `data:` URI in your markup is an error. The
76
+ indirection is deliberate: it means a layout has no way to name a file, so
77
+ `<img src="../../.env">` fails as an unresolvable token rather than being read
78
+ and base64-inlined into an image someone then shares. Paths come from the
79
+ caller and are confined to the workspace.
80
+
81
+ ## Hebrew and numerals
82
+
83
+ The page is RTL. Never reorder Hebrew strings by hand — the browser's bidi
84
+ algorithm handles it, and hand-reordering is how text ends up backwards.
85
+
86
+ **Wrap every digit run in `class="ltr"`:**
87
+
88
+ ```html
89
+ <div>יציאה <span class="ltr">12:30</span> מהמלון</div>
90
+ ```
91
+
92
+ Without it `12:30` can render as `30:12`. This is checked and warns; the warning
93
+ is printed but does not block output; pass `--strict-warn` if you want it to.
94
+
95
+ One nuance worth knowing if you are tempted to write your own isolation: setting
96
+ `unicode-bidi: isolate` does *not* satisfy this. Chromium's own stylesheet
97
+ already computes `isolate` for every block element, so it distinguishes nothing.
98
+ What matters is `direction: ltr`, which is what `.ltr` sets.
99
+
100
+ ## Composition — physical sides, not logical ones
101
+
102
+ Use `left` / `right`, **not** `inset-inline-start` / `inset-inline-end`.
103
+
104
+ Under `dir="rtl"` the logical properties mirror the entire composition, which
105
+ desyncs the layout from wherever the art layer actually left its blank space.
106
+ Which side holds the hero is a *design* decision about the specific background
107
+ you were given; it is not a consequence of the text direction. Text inside each
108
+ block still flows RTL regardless.
109
+
110
+ ## Landing text on a surface
111
+
112
+ A flat absolutely-positioned box renders perfect Hebrew and still looks pasted
113
+ on. What makes a poster look designed is copy that sits *on* something in the
114
+ art:
115
+
116
+ ```css
117
+ .title { transform: rotate(-2.5deg); } /* match a tilted sign */
118
+ .ribbon { transform: rotate(1.5deg) skewX(-3deg); } /* follow a ribbon */
119
+ .chalk { mix-blend-mode: multiply; } /* sink into a chalkboard */
120
+ .on-photo { text-shadow: 0 2px 6px rgba(0,0,0,.5); } /* mandatory over photography */
121
+ ```
122
+
123
+ `transform` also **moves** a block, which is how you land a headline on a
124
+ surface the model put somewhere unexpected:
125
+ `transform: translateY(120px) rotate(-1deg)`.
126
+
127
+ Iterate freely. Layer 2 is deterministic and free — only the art call costs
128
+ money, and re-rendering never touches it.
129
+
130
+ ## What is checked automatically
131
+
132
+ Static, before any render:
133
+
134
+ | Rule | Rejects |
135
+ |---|---|
136
+ | `forbidden-element` | `script` `iframe` `object` `embed` `link` `base` `form` `input` `meta` `html` `head` `body`; also `id="poster-stage"`, which is the pipeline's |
137
+ | `forbidden-at-rule` | `@import`, `@font-face`, `@charset`, `@namespace` |
138
+ | `event-handler` | any `on*` attribute |
139
+ | `external-url` | any URL that is not `asset:<token>` or `#fragment` |
140
+ | `unknown-font` | a family outside the palette; the `font` shorthand |
141
+ | `unresolved-asset` | a token with no entry in the assets map |
142
+ | `asset-escape` | an assets path resolving outside the workspace |
143
+
144
+ Measured in a real browser, after fonts have loaded:
145
+
146
+ | Rule | Level | Means |
147
+ |---|---|---|
148
+ | `clipped` | error | text overflows an element whose `overflow` is hidden |
149
+ | `offstage` | error | an element's box falls outside `#poster-stage` |
150
+ | `tofu` | error | a Hebrew character no bundled face covers — it renders as an empty box |
151
+ | `numerals-not-isolated` | warning | a digit run that could reorder |
152
+ | `audit-failed` | error | the check could not run — never treated as a pass |
153
+
154
+ **Errors block output entirely.** No PNG is written, not even a partial one.
155
+ Warnings print and proceed — `--strict-warn` promotes them to blocking. Nothing overrides an error.
156
+
157
+ `clipped` only fires where overflow is actually hidden — text that spills
158
+ visibly is a design choice, and the words are still there.
159
+
160
+ `tofu` is the one that protects the whole premise. It draws each Hebrew character
161
+ twice — once in your font stack, once with the bundled faces stripped out — and
162
+ flags any character where the two are pixel-identical, meaning no bundled face
163
+ contributed and a fallback drew it. On this image no fallback covers Hebrew at
164
+ all, so that is an empty box in the poster. Known gap: standalone combining marks
165
+ (nikud) are not reliably caught, because the dotted-circle placeholder is itself
166
+ in the subset. Proofread vowelised text by eye.
167
+
168
+ > **A clean audit does not mean the poster is good.** These checks catch *broken* —
169
+ > text that is cut off, boxes off the canvas, glyphs that did not render. They say
170
+ > nothing about whether the composition is attractive, whether the copy is legible
171
+ > against the art behind it, or whether the type sits convincingly on a surface.
172
+ > Look at the PNG before you send it, and never report a green audit as "the
173
+ > poster looks good" — that is not what was measured.
174
+
175
+ ## When it rejects your layout
176
+
177
+ The report names the element. Fix the layout and re-run: exit code **2** means
178
+ the layout was rejected, **1** means the command itself was wrong (retrying
179
+ that unchanged will fail identically).
180
+
181
+ For `clipped`, cut content or grow the box — do not shrink type below
182
+ legibility. For `offstage`, the report gives both rects; the element is outside
183
+ a stage of the size it names.
184
+
185
+ Two authoring attempts. If the second is still rejected, fall back to
186
+ `poster render` with the nearest template and say that you did — a template
187
+ poster that ships beats a bespoke one that does not.
188
+
189
+ ## Where files go
190
+
191
+ The skill directory is mounted **read-only**. Write `layout.html` and
192
+ `assets.json` into your workspace:
193
+
194
+ ```bash
195
+ poster compose --layout work/layout.html --assets work/assets.json \
196
+ --aspect 3:2 -o outbox/poster.png
197
+ ```
198
+
199
+ Writing next to the templates fails with `EROFS`.
200
+
201
+ ## Worked examples
202
+
203
+ The two templates are the reference for what good looks like in this system —
204
+ read one before authoring your own:
205
+
206
+ - `skill/templates/schedule-day.mjs` — time-ordered: hero, taped card of timed
207
+ rows, footer. Note the LTR pin on `.row-time` and the physical-sides comment.
208
+ - `skill/templates/feature-grid.mjs` — feature-ordered: dark hero band with a
209
+ contrast scrim, torn-paper split, icon columns, cards, CTA.
210
+
211
+ They emit fragments through the same shell you do, so anything they do is
212
+ available to you.
213
+
214
+ A worked **freeform** layout ships alongside this file:
215
+
216
+ - `references/example-layout.html` — an itinerary whose title is painted into
217
+ the grain of a wooden sign in the plate (`mix-blend-mode: multiply`, rotated to
218
+ match its hang), with the timed rows in the open corner. It renders clean, and
219
+ it is the shape of thing no fixed template can produce: nothing in a template
220
+ knows there is a sign in the art, let alone where.
221
+
222
+ Run it against your own background to see the mechanics:
223
+
224
+ ```bash
225
+ poster compose --layout references/example-layout.html \
226
+ --assets work/assets.json --aspect 3:2 -o work/try.png
227
+ ```
228
+
229
+ (Copy it into your workspace first if you want to edit it — the skill directory
230
+ is read-only.)