The variants verb: three live directions, a local picker, pick-and-continue

- scripts/variants/{core,serve,start,await}.mjs: zero-dep picker server
  (loopback only, port walk-up, polling, atomic request queue, idle
  self-shutdown), scaled-iframe grid + single view + keyboard flow,
  chip.js overlay for generated routes, static compare fallback
- references/verbs/variants.md: context detection, per-framework route
  recipes, shared ceremony + direction-plan table, divergence rules,
  the poll ladder, pick-and-continue, riff
This commit is contained in:
Youssef
2026-07-23 13:08:45 +01:00
parent 75c577fd1d
commit 04a9873633
5 changed files with 1106 additions and 0 deletions
@@ -0,0 +1,280 @@
# `hallmark variants`
One brief, three full directions, side by side, in the user's own browser. The user flips between them, picks one, and only the winner ships. A single build is a bet; three structurally distinct builds are a conversation.
## Flow at a glance
1. Detect the mode: greenfield or routes; routes mode asks for the dev server URL.
2. Run SKILL.md Steps 0-1 once; read `.hallmark/log.json` once.
3. Say the direction plan table; end with "Redirect now or I build all three."
4. Build three self-contained directions: parallel subagents or sequential fallback.
5. `start.mjs`; relay the picker URL in one line.
6. Await the pick via the poll ladder; a chat reply always works.
7. Promote the winner: full 58-gate sweep, archive all three, ack, stop, log.
## The machinery
The verb rides on four zero-dependency scripts in `<skill-dir>/scripts/variants/` (`core.mjs` · `serve.mjs` · `start.mjs` · `await.mjs`) and one run directory inside the USER'S project:
```
.hallmark/variants/<run-id>/ run-id: date + letter, e.g. 2026-07-23-a
manifest.json run state (shape below)
v1/ v2/ v3/ greenfield direction folders (index.html + optional css)
requests/ requests/done/ the pick/riff queue
server.log serve.mjs stdout/err
.hallmark/variants/server.json live-server identity: {port, pid, run, startedAt}
```
Second run the same day: increment the letter (`2026-07-23-b`). Respect any existing `.gitignore` treatment of `.hallmark/`.
`manifest.json` (you write it; `start.mjs` only ensures a skeleton exists):
```json
{ "run": "2026-07-23-a", "mode": "greenfield", "brief": "...",
"devServer": null,
"directions": [
{ "n": 1, "title": "The broadsheet", "macrostructure": "Long Document",
"theme": "Newsprint", "nav": "N6", "footer": "Ft2",
"axes": "light / roman-serif / warm", "url": "/frame/1/", "status": "ready" }
],
"picked": null }
```
`mode` is `"greenfield"` or `"routes"`. In routes mode `devServer` holds the user's dev-server origin and each direction's `url` is absolute into it (e.g. `http://localhost:3000/hallmark-v1`). Write `mode`, `brief`, and `devServer` as soon as § 2 resolves; add each direction row when you plan it, flip its `status` to `"ready"` when its files land.
---
## 1 · Trigger and arguments
- `hallmark variants <brief>` : the full run described here. Default depth: **3 full directions**.
- Bare `hallmark variants` : continue an in-flight brief. If this conversation already carries one (a default build was being scoped, the user just answered the Step 1 gate, or the latest run's manifest has `"picked": null`), reuse it and say so in one line. No brief anywhere: ask for it, once, and nothing else.
- **Count override.** "Give me 4" / "five directions" is honored. The divergence rules below stay pairwise; with only three paper bands, directions 4+ relax the theme rule to >= 2 axes distinct against each earlier direction. Say so in the plan. Never offer more than 3 unprompted.
- **`--sketch`** : each direction is hero + one signature section + footer, nothing more. The winner is completed to full depth after the pick. This is the cheap path; offer it unprompted on heavy briefs (see § 9, token budget).
"Show me 3 again" after a finished run: a new run-id, same ceremony reuse, and the archived directions stay where they are.
## 2 · Context detection
Two modes. Decide before the ceremony, state the mode in one line.
**Greenfield** : no project, or a project without a framework dev server. Directions are standalone pages in `v1/ v2/ v3/`; the picker serves them itself at `/frame/<n>/`.
**Routes mode** : a framework with file-based routing AND a running dev server. The directions become throwaway routes inside the user's own app, rendered by their real stack: their fonts load, their components import, the comparison is honest. **Ask for the dev server URL: never guess a port, never start a server yourself.** One line: *"Is your dev server running? Give me its URL (e.g. http://localhost:3000)."* No running server, or no answer: greenfield.
Detect the framework from the Step 0 pre-flight you already ran (`package.json` deps + directory shape): `next` with `app/` = app router · `next` with `pages/` = pages router · `@sveltejs/kit` = SvelteKit · `astro` = Astro · `nuxt` = Nuxt · `@remix-run/*` = Remix.
Route recipes, direction `n`:
| Framework | Route file |
| --- | --- |
| Next.js (app router) | `app/hallmark-v<n>/page.tsx` |
| Next.js (pages router) | `pages/hallmark-v<n>.tsx` |
| SvelteKit | `src/routes/hallmark-v<n>/+page.svelte` |
| Astro | `src/pages/hallmark-v<n>.astro` |
| Nuxt | `pages/hallmark-v<n>.vue` |
| Remix | `app/routes/hallmark-v<n>.tsx` |
**No file router** (CRA, Vite SPA with react-router, anything unrecognized): do not wire router config; touching a routes array is exactly the shared-file edit § 4 forbids. Fall back to greenfield-style standalone sketches built on the pre-flight tokens, and tell the user the winner gets implemented into the real app afterwards.
**Root-layout warning.** If the app's root layout ships its own nav or shell, the directions will render inside it, chrome and all. Say so before building, and offer sketch mode (greenfield frames) when the shell would drown the comparison. A direction's own nav inside the app's nav is a confusing artifact, not a bug; name it once so the user does not think it is one.
## 3 · Shared ceremony
**Do:** run SKILL.md Steps 0-1 exactly ONCE for the whole run: one pre-flight scan, one three-question gate, one genre detection. Read `.hallmark/log.json` once. Three directions never means three interrogations, and "go ahead" at the gate covers all three.
Then state **the direction plan**: a markdown table, said before any code. It replaces three separate Picks blocks; do not also narrate per-direction picks.
| # | Title | Macrostructure | Theme | Axes | Nav | Footer | Enrichment |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | The broadsheet | Long Document | Newsprint | light / roman-serif / warm | N6 | Ft2 | none |
| 2 | The control room | Workbench | Cobalt | mid / grotesk / cool | N13 | Ft5 | E1 Tier-A |
| 3 | The poster wall | Manifesto | Midnight | dark / display-slab / chromatic | N9 | Ft8 | none |
**Divergence rules, hard:**
- Three **different macrostructures from different structural families**: grid-led (Bento, Catalogue, Portfolio Grid, Ecosystem Index) · document-led (Long Document, Letter, Conversational FAQ, Index-First) · poster-led (Manifesto, Marquee Hero, Quote-Led, Type Specimen) · product-led (Workbench, Feature Stack, Stat-Led, Component Playground). One family, one direction. Three shapes from one family is a colour swap wearing three hats.
- Themes **pairwise distinct on ALL THREE Rotation axes** (paper band, display style, accent hue; SKILL.md § Rotation). Three paper bands, three directions: this forces one dark, one mid, one light paper. Non-negotiable; it is what makes the picker grid legible at a glance.
- Three **distinct nav codes** and three **distinct footer codes** (index in [`component-cookbook.md`](../component-cookbook.md)).
- Every tuple also respects the log's last-3 rotation (SKILL.md § Rotation), because the winner gets logged and must not collide with history.
**Titles** are short evocative names ("The broadsheet", "The control room"), never theme names or "Option A". The title is what the user will say back to you in chat, so make each one unmistakable.
**Direction roles:** direction 1 is genre-faithful (what the brief most expects, done properly); direction 2 pulls from an adjacent genre; direction 3 is the wildcard. When Step 1's custom signals fired, the custom route ([`custom-theme.md`](../custom-theme.md)) is allowed for direction 3, run at draft depth: slate, draw, and direction contract, no finish review until it wins.
End the plan with the accountability beat, verbatim: *"Redirect now or I build all three."* A beat, not a blocking question; silence means build.
## 4 · Build recipes
**Parallel path** (harness supports subagents): after the table, spawn one subagent per direction. Each receives the brief + the inferred audience/use/tone + its tuple row + its output target + the **fragment contract**. Briefing template:
```
You are building direction <n> of 3 for a Hallmark variants run.
Brief: <brief>. Inferred: audience <a> · use <u> · tone <t> · genre <g>.
Tuple: "<title>" · <macro> · <theme> · axes <axes> · nav <N#> · footer <Ft#> · enrichment <E# or none>.
Output: <absolute path to v<n>/index.html, or the route file from the recipes table>.
Include this chip tag verbatim: <the § 4 snippet with data-direction="<n>">.
Load ONLY references/macrostructures/<picked file>, the picked component archetype
files, and the universal set (typography · color · layout-and-space · motion ·
copy · anti-patterns). Do not read log.json. Do not append memory. Do not run
the full gate sweep: Core-15 plus contrast gates 40-41 only. Styles fully
self-contained. Stamp the CSS with `direction: <n> of 3 · run: <run-id>`.
```
**Sequential fallback** (no subagents): same table, build v1 then v2 then v3 in one context, universal references loaded once and reused. Same fragment contract per direction, minus the spawning.
**Draft quality bar.** Drafts get an abbreviated pass: the **Core-15 sweep** ([`slop-test.md`](../slop-test.md) § Core-15) plus contrast gates 40-41. ONLY THE WINNER runs the full 58-gate sweep, later, at § 7. Do not spend three full sweeps on two pages that will be archived.
**Self-containment.** Each direction is fully self-contained: its own inline styles or a sibling css file in its folder. No shared `tokens.css` across directions; shared tokens would quietly homogenize the three systems you are trying to keep apart. In routes mode, hang each direction's token block on the route's own root element (a wrapper class), not `:root`, so three simultaneous routes cannot fight each other or leak into the app shell; every colour still references a `var(--*)` per the critical floor. The winner gets properly tokenized at promotion.
**Greenfield target:** `.hallmark/variants/<run-id>/v<n>/index.html`. Sibling assets referenced by relative path (`./style.css`), never root-absolute, because the frame serves under `/frame/<n>/`.
**Routes mode target:** the framework route from the § 2 table. Author the chip script tag into each generated route, exactly this shape (adjust `data-direction`, port, and base):
```html
<script src="http://127.0.0.1:<port>/chip.js" data-direction="2" data-of="3" data-mode="routes" data-base="http://localhost:3000/hallmark-v"></script>
```
Greenfield frames carry the chip too, with `data-mode="greenfield"` and `data-base="http://127.0.0.1:<port>/frame/"`. What the chip does (so you can describe it, not so you can rebuild it):
- Renders a small fixed bottom-center pill: "Direction 2/3", dismissible with an x.
- Arrow buttons and ArrowLeft/ArrowRight flip to the sibling direction via `data-base` + n.
- A Pick and a Riff button POST to `/api/pick` on the chip's own origin.
- On fetch failure the pill swaps to the text *"picker offline: tell your agent - pick 2"*, so the flow survives the server dying.
- Respects `prefers-reduced-motion`; nothing animates beyond opacity.
Assume port 4180 when writing the tags; § 9 covers the walk-up case.
**Routes-mode discipline:** never touch shared stylesheets, config files, or `package.json` during variant generation. Each direction is additive route files only. Write all of a direction's files in **one batch** so the dev server hot-reloads once per direction, not once per file.
## 5 · Serve and hand over
Start the picker (idempotent; safe to re-run):
```
node <skill-dir>/scripts/variants/start.mjs --run <run-dir>
```
`start.mjs` ensures the skeleton + manifest exist, reuses a live server whose `/api/state` identity matches this run, otherwise spawns `serve.mjs` detached (stdout/err to `server.log`), and prints exactly:
```
PICKER http://127.0.0.1:<port>
AWAIT node <abs-path>/await.mjs --run <run-dir> --timeout 540
DRAIN node <abs-path>/await.mjs --run <run-dir> --drain
```
Relay the picker URL to the user in one line: *"Flip with arrows or the number keys; Pick when one clicks; Riff deals a fourth."* If the harness has a browser preview pane, open the picker there too.
What the user sees (built by `core.mjs`, dark neutral shell, system-ui, self-contained):
- An overview grid of the three directions as scaled live iframes: real 1280x800 frames scaled down via transform, non-interactive in the grid.
- Each frame labelled with the direction's title and its macro/theme/nav/footer meta.
- Click or 1/2/3 enters full-size single view; arrows cycle directions.
- P or the Pick button confirms; R or the Riff button deals a fourth, with an optional one-line steer prompt.
- The page binds to 127.0.0.1 only and polls `/api/state` every 2 seconds, so manifest updates (a riff landing) appear without a restart.
**No-server variant:** `node <skill-dir>/scripts/variants/start.mjs --run <run-dir> --static` skips the server, writes `compare.html` into the run dir (self-contained, srcdoc-inlined iframes of v1-v3, keyboard 1/2/3 and arrows, a banner telling the user to reply "pick N" in chat), and prints its path. Reach for it when a long-lived process is unwelcome but node exists.
## 6 · The poll ladder
The pick comes back through `await.mjs`:
```
node <abs-path>/await.mjs --run <run-dir> [--drain] [--timeout <sec>] [--ack <id>] [--note "<line>"] [--stop]
```
Exit codes: **0** = one request claimed (atomic rename to `.working`) and printed as JSON on stdout · **2** = idle/timeout · **1** = error.
Handling on exit 0:
1. Parse the JSON from stdout. A pick looks like:
```json
{ "id": "0001-pick", "action": "pick", "choice": 2 }
```
A riff carries `"action": "riff"` and an optional `"steer"` line instead of `choice`.
2. Dispatch on `action`: `"pick"` → § 7, `"riff"` → § 8.
3. Ack: `--ack <id>` moves the claimed file to `requests/done/`; `--note "<line>"` records what you did with it.
4. A request carrying `"redelivered": true` is an orphaned claim older than 5 minutes (a previous attempt died mid-handle); handle it normally.
On exit 1: read `server.log`, then fall back to chat.
Pick the rung your harness supports:
1. **Claude Code:** blocking wait with a long shell timeout (>= 600000 ms), or run the same command in the background and act when it exits:
```
node <abs-path>/await.mjs --run <run-dir> --timeout 540
```
Between turns, `--drain` prints ALL queued requests as a JSON array (exit 2 when empty); drain at the top of any turn where the user might have clicked meanwhile.
2. **One-shot harnesses (Cursor, Codex):** short loops:
```
node <abs-path>/await.mjs --run <run-dir> --timeout 60
```
After 3 consecutive idle exits, stop polling and ask in chat: *"Reply 1, 2, or 3 (or riff)."*
3. **No node / no scripts installed** (the Cursor `.mdc` install channel ships no scripts): skip the server entirely. Write `compare.html` by hand into the run dir, in the static template shape, and ask for the pick in chat. The hand-written file must be:
- Fully self-contained: each direction's page inlined into an iframe `srcdoc` attribute (escape quotes), no external requests.
- Navigable: keyboard 1/2/3 jumps to a direction, arrow keys cycle, plus visible buttons for mouse users.
- Honest about the channel: a fixed banner reading "Reply in chat: pick 1, 2, or 3 (or riff)". No Pick button that pretends to work.
- Labelled: each frame shows its direction title and macro/theme meta from the plan table.
**THE FLOW NEVER DEAD-ENDS.** A chat reply naming a direction ("2", "pick 2", "the poster one") is a valid pick channel at every rung, always, even while the server is up. After acting on a chat pick, run `--drain` once and ack anything stale so `requests/` ends empty.
## 7 · Pick and continue
On `{"action": "pick", "choice": n}` or the chat equivalent:
**Greenfield:**
1. Copy the winner into place as the normal build output (wherever a default Hallmark build would land for this project).
2. If `--sketch` was used, complete it to full depth first: remaining sections, states, responsive pass.
3. Run the FULL 58-gate sweep + sloplint, fix every FAIL, stamp, emit `tokens.css`, exactly as SKILL.md Steps 6-7 demand. The draft's abbreviated pass counts for nothing here; the winner earns the whole bar.
**Routes mode:** state the file plan first: the standard safety rail, promoting into real targets needs the user's ok. For example:
> Promoting direction 2. Plan: modify `app/page.tsx` (the new design), create `app/tokens.css`, delete `app/hallmark-v1/`, `app/hallmark-v2/`, `app/hallmark-v3/`. Ok?
Then implement the winning direction into the real app as a normal Hallmark build: tokenize into the project's system (real `:root` tokens now, not the draft's scoped block), strip the chip tag, delete the other variant routes.
**Always, both modes:**
- Archive all three directions to `.hallmark/variants/<run-id>/` (never hard-delete; this is what makes "show me the 3 again" free). In routes mode, copy the route files into the run dir before deleting them from the app.
- Ack the request (`--ack <id>`), stop the server (`await.mjs --run <run-dir> --stop`, which POSTs `/api/shutdown` via `server.json` and clears it), and report the cleanup in one line: *"Archived 3 directions to .hallmark/variants/2026-07-23-a/, picker stopped, log updated."*
- Set `"picked": n` in the manifest.
- Append the log entry, winner fields at top level so SKILL.md § Rotation reads it like any other run:
```json
{ "date": "...", "macrostructure": "<winner>", "theme": "<winner>", "nav": "...",
"footer": "...", "enrichment": "...", "brief": "...", "verb": "variants",
"picked": 2,
"variants": [ {"n": 1, "...": "..."}, {"n": 2, "...": "..."}, {"n": 3, "...": "..."} ] }
```
Each `variants[]` row carries that direction's title, macrostructure, theme, nav, footer, and enrichment, so a future run can rotate away from all three, not just the winner.
## 8 · Riff
On `{"action": "riff", "steer": "..."}` (steer optional) or a chat ask ("riff", "deal another", "none of these"):
- Plan direction 4: a macrostructure different from all three, from whichever structural family remains; a theme distinct on as many axes as remain. With only three paper bands, direction 4 relaxes to >= 2 axes distinct against each earlier direction; say so in the one-line plan.
- Honor the steer line as art direction ("warmer", "like 2 but dark"). Where the steer and the divergence default conflict, the steer wins; the user is telling you where the target is.
- Build v4 under the same fragment contract, write `v4/` (or the `hallmark-v4` route with a chip tag reading `data-direction="4" data-of="4"`), append its tuple to `manifest.json` `directions` with `"status": "ready"`, then ack the riff request. The picker's 2-second poll shows the new direction without a restart. Bump the earlier chips' `data-of` only if trivially cheap; otherwise leave them, the arrows still work.
- A second riff repeats the ritual as direction 5. If the user riffs twice without picking, ask what is missing instead of dealing a sixth.
## 9 · Risks and edge notes
- **Iframe-refusing dev servers** (`X-Frame-Options` / CSP `frame-ancestors`): the picker cannot embed the routes and shows open-in-tab cards instead. The chip still works in the real tab, so flipping and picking survive; nothing to fix.
- **File watchers that restart on new files:** batch writes (§ 4). One write per direction, never a file-by-file trickle that restarts the dev server three times.
- **Tailwind content globs** pick up new `hallmark-v*` routes automatically. Fine; no config edit, and config edits are forbidden anyway.
- **Port conflicts:** `serve.mjs` tries 4180 and walks up to 4189. If the printed PICKER port differs from the 4180 you stamped into chip tags, update `src` and `data-base` in each direction once.
- **Stale server from a previous run:** `start.mjs` checks the live server's `/api/state` identity against this run; a matching run is reused, anything else is replaced and `server.json` rewritten. The server also self-shuts after 30 minutes without picker polls, so a forgotten run does not linger.
- **Dev server restarts mid-run** (routes mode): the routes are plain files, so they come back with it; the chip reconnects on its next click. Nothing to do.
- **Abandoned run:** the user walks away without picking. The server self-shuts, the manifest keeps `"picked": null`, and a later bare `hallmark variants` resumes exactly this run (§ 1). Do not delete an unpicked run.
- **Uncommitted variant routes:** the `hallmark-v*` routes are throwaway; if the user mentions committing mid-run, suggest waiting until § 7 deletes them.
- **Token budget:** a variants run costs roughly 2.3x one build. `--sketch` is the cheap path: hero + one signature section + footer per direction, winner completed after the pick. Offer it unprompted when the brief is heavy or the model context is tight.
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
// The agent's side of the hallmark variants bridge. Filesystem queue plus one
// loopback POST for --stop.
//
// node await.mjs --run <run-dir> [--timeout 540]
// Blocks until the picker queues a request (a pick or a riff), claims it
// by atomic rename to .working, prints its JSON to stdout, exits 0.
// Exit 2 = idle timeout (just re-run). Exit 1 = hard error (stderr).
//
// node await.mjs --run <run-dir> --drain
// Claims EVERYTHING queued right now and prints a JSON array, never
// blocks. Exit 2 = nothing waiting.
//
// node await.mjs --run <run-dir> --ack <id> [--note "<line>"]
// Closes a previously claimed request: moves its .working file to
// requests/done/ (with ackedAt and the optional note). Combinable with
// a blocking or --drain call in the same invocation.
//
// node await.mjs --run <run-dir> --stop
// POSTs /api/shutdown to the server recorded in server.json, then
// clears server.json.
//
// A .working file older than 5 minutes is an orphan (crashed agent) and is
// redelivered with "redelivered": true before anything new. Claiming touches
// the file's mtime so the 5-minute clock starts at the claim.
import fs from "node:fs";
import path from "node:path";
import { parseArgs, runPaths, readJsonSafe, statMtime, httpPostJson, nowIso } from "./core.mjs";
const args = parseArgs(process.argv.slice(2));
if (!args.run || args.run === true) { console.error("await.mjs: --run <run-dir> is required"); process.exit(1); }
const P = runPaths(args.run);
const TIMEOUT_MS = Math.max(2, Number(args.timeout ?? 540)) * 1000;
const ORPHAN_MS = 5 * 60 * 1000;
function out(obj, code) {
process.stdout.write(JSON.stringify(obj) + "\n", () => process.exit(code));
}
function listQueued() {
try { return fs.readdirSync(P.REQ).filter((f) => f.endsWith(".json")).sort(); } catch { return []; }
}
function listWorking() {
try { return fs.readdirSync(P.REQ).filter((f) => f.endsWith(".json.working")).sort(); } catch { return []; }
}
function readJson(p) {
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
}
function touch(p) {
try { const now = new Date(); fs.utimesSync(p, now, now); } catch { /* ignore */ }
}
function isOrphan(f) {
const m = statMtime(path.join(P.REQ, f));
return m != null && Date.now() - m >= ORPHAN_MS;
}
// ---- --stop: shut the server down and clear server.json ---------------------
if (args.stop) {
const sj = readJsonSafe(P.SERVER_JSON).json;
let stopped = false;
if (sj?.port) stopped = (await httpPostJson(sj.port, "/api/shutdown"))?.ok === true;
try { fs.rmSync(P.SERVER_JSON, { force: true }); } catch { /* ignore */ }
out({ stopped, port: sj?.port ?? null }, 0);
await new Promise(() => {}); // park until the exit callback fires
}
if (!fs.existsSync(P.REQ)) { console.error(`await.mjs: no requests/ under ${P.RUN}. Run start.mjs first.`); process.exit(1); }
fs.mkdirSync(P.REQ_DONE, { recursive: true });
// ---- fold in the previous ack ----------------------------------------------
if (args.ack) {
const id = String(args.ack);
const working = listWorking().find((f) => f.startsWith(id + "-"));
if (working) {
const p = path.join(P.REQ, working);
const json = readJson(p) ?? { id };
json.ackedAt = nowIso();
if (args.note) json.note = String(args.note).slice(0, 300);
fs.writeFileSync(path.join(P.REQ_DONE, working.replace(/\.working$/, "")), JSON.stringify(json, null, 2) + "\n");
fs.rmSync(p, { force: true });
} else {
// Idempotent: acking something already closed is a warning, not an error.
console.error(`await.mjs: nothing claimed under id ${id} (already acked?)`);
}
if (!args.drain && args.timeout == null) process.exit(0); // pure ack call
}
function claimFile(f) {
const from = path.join(P.REQ, f);
const to = from + ".working";
try { fs.renameSync(from, to); } catch { return null; } // loser of a race
touch(to);
return readJson(to);
}
// ---- drain: claim everything queued, print an array, never block ------------
if (args.drain) {
const batch = [];
for (const w of listWorking()) {
if (!isOrphan(w)) continue;
const p = path.join(P.REQ, w);
const j = readJson(p);
if (j) { j.redelivered = true; touch(p); batch.push(j); }
}
for (const f of listQueued()) {
const j = claimFile(f);
if (j) batch.push(j);
}
out(batch, batch.length ? 0 : 2);
} else {
mainBlocking();
}
// ---- blocking: orphan first, then oldest queued, then wait ------------------
function redeliverOrphan() {
const w = listWorking().find(isOrphan);
if (!w) return false;
const p = path.join(P.REQ, w);
const json = readJson(p);
if (!json) return false;
json.redelivered = true;
touch(p);
out(json, 0);
return true;
}
function claimOne() {
for (const f of listQueued()) {
const json = claimFile(f);
if (!json) continue;
out(json, 0);
return true;
}
return false;
}
function mainBlocking() {
if (redeliverOrphan() || claimOne()) return;
const deadline = Date.now() + TIMEOUT_MS;
let watcher = null;
try { watcher = fs.watch(P.REQ, () => attempt()); } catch { /* poll only */ }
const pollTimer = setInterval(attempt, 2000);
function attempt() {
if (claimOne()) { cleanup(); return; }
if (Date.now() >= deadline) {
cleanup();
out({ type: "idle", waitedMs: TIMEOUT_MS, queued: 0 }, 2);
}
}
function cleanup() {
clearInterval(pollTimer);
try { watcher?.close(); } catch { /* ignore */ }
}
}
+343
View File
@@ -0,0 +1,343 @@
// Shared internals for the hallmark variants bridge (start.mjs, serve.mjs,
// await.mjs). Zero dependencies. Thin fs/http helpers, the run-dir layout,
// the request-queue naming, and the three embedded UI payloads: the picker
// page, the chip.js overlay, and the static compare page.
//
// Not a CLI. Imported by the other three scripts.
import fs from "node:fs";
import path from "node:path";
import http from "node:http";
// ---------------------------------------------------------------------------
// small helpers
export const nowIso = () => new Date().toISOString();
export function readSafe(file) {
try { return fs.readFileSync(file, "utf8"); } catch { return null; }
}
export function atomicWrite(file, content) {
const tmp = file + ".tmp-" + process.pid;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, file);
}
export function readJsonSafe(file) {
const raw = readSafe(file);
if (raw == null) return { json: null, error: "missing" };
try { return { json: JSON.parse(raw), error: null }; }
catch {
// One brief retry: we may have caught a writer mid-rename.
try { return { json: JSON.parse(fs.readFileSync(file, "utf8")), error: null }; }
catch (e2) { return { json: null, error: String(e2).slice(0, 120) }; }
}
}
export function statMtime(p) {
try { return fs.statSync(p).mtimeMs; } catch { return null; }
}
/** --flag value pairs; a flag followed by another flag (or nothing) is true. */
export function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
if (!argv[i].startsWith("--")) continue;
const next = argv[i + 1];
out[argv[i].slice(2)] = next == null || next.startsWith("--") ? true : argv[++i];
}
return out;
}
export function escapeAttr(s) {
return String(s ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ---------------------------------------------------------------------------
// run layout: .hallmark/variants/<run-id>/ inside the user project.
// server.json is a per-project singleton, one level up from the run dir.
export function runPaths(runDir) {
const RUN = path.resolve(String(runDir));
const REQ = path.join(RUN, "requests");
return {
RUN,
RUN_ID: path.basename(RUN),
REQ,
REQ_DONE: path.join(REQ, "done"),
MANIFEST: path.join(RUN, "manifest.json"),
SERVER_JSON: path.join(path.dirname(RUN), "server.json"),
LOG: path.join(RUN, "server.log"),
};
}
export function defaultManifest(runId) {
return { run: runId, mode: "greenfield", brief: "", devServer: null, directions: [], picked: null };
}
/** Next zero-padded queue id, scanning requests/ AND requests/done/. */
export function nextRequestId(P) {
let max = 0;
for (const dir of [P.REQ, P.REQ_DONE]) {
let files = [];
try { files = fs.readdirSync(dir); } catch { /* not created yet */ }
for (const f of files) {
const m = f.match(/^(\d+)-/);
if (m) max = Math.max(max, parseInt(m[1], 10));
}
}
return String(max + 1).padStart(4, "0");
}
export const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".ico": "image/x-icon",
".json": "application/json; charset=utf-8",
".woff2": "font/woff2",
".txt": "text/plain; charset=utf-8",
};
// ---------------------------------------------------------------------------
// loopback http (start.mjs identity checks, await.mjs --stop)
export function httpGetJson(port, pathname, timeoutMs = 900) {
return new Promise((resolve) => {
const req = http.get({ host: "127.0.0.1", port, path: pathname, timeout: timeoutMs }, (res) => {
let buf = "";
res.on("data", (c) => (buf += c));
res.on("end", () => { try { resolve(JSON.parse(buf)); } catch { resolve(null); } });
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
});
}
export function httpPostJson(port, pathname, body = {}, timeoutMs = 1500) {
return new Promise((resolve) => {
const data = JSON.stringify(body);
const req = http.request(
{ host: "127.0.0.1", port, path: pathname, method: "POST", timeout: timeoutMs,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } },
(res) => {
let buf = "";
res.on("data", (c) => (buf += c));
res.on("end", () => { try { resolve(JSON.parse(buf)); } catch { resolve({ ok: (res.statusCode ?? 500) < 300 }); } });
},
);
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end(data);
});
}
// ---------------------------------------------------------------------------
// chip.js: the overlay a generated variant route embeds via
// <script src="http://127.0.0.1:<port>/chip.js" data-direction="2"
// data-of="3" data-mode="routes" data-base="http://localhost:3000/hallmark-v"></script>
// Greenfield: data-mode="greenfield", data-base="http://127.0.0.1:<port>/frame/"
// (greenfield hrefs get a trailing slash; routes hrefs do not). The chip skips
// rendering when the page is inside an iframe (the picker frames these pages
// and carries its own controls).
export function buildChipJs() {
return '(function(){\n' +
'"use strict";\n' +
'if(window.self!==window.top)return;\n' +
'var s=document.currentScript;if(!s)return;\n' +
'var origin;try{origin=new URL(s.src).origin}catch(e){return}\n' +
'var n=parseInt(s.getAttribute("data-direction")||"1",10);\n' +
'var total=parseInt(s.getAttribute("data-of")||"3",10);\n' +
'var mode=s.getAttribute("data-mode")||"greenfield";\n' +
'var base=s.getAttribute("data-base")||"";\n' +
'var reduce=false;try{reduce=window.matchMedia("(prefers-reduced-motion: reduce)").matches}catch(e){}\n' +
'function urlFor(k){return base+k+(mode==="greenfield"?"/":"")}\n' +
'function go(d){var k=n+d;if(k<1)k=total;if(k>total)k=1;location.href=urlFor(k)}\n' +
'var pill=document.createElement("div");\n' +
'pill.setAttribute("data-hallmark-chip","");\n' +
'pill.style.cssText="position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:2147483647;display:flex;align-items:center;gap:2px;background:rgba(17,17,19,.94);color:#e9e7e2;font:12px/1 system-ui,sans-serif;border-radius:999px;padding:5px 7px;box-shadow:0 10px 30px -10px rgba(0,0,0,.55);opacity:0;"+(reduce?"":"transition:opacity .2s ease;");\n' +
'function btn(label,title,fn,pad){var b=document.createElement("button");b.type="button";b.textContent=label;b.title=title;b.style.cssText="font:inherit;border:0;background:none;color:inherit;cursor:pointer;padding:6px "+(pad||10)+"px;border-radius:999px;";b.addEventListener("mouseenter",function(){b.style.background="rgba(255,255,255,.14)"});b.addEventListener("mouseleave",function(){b.style.background=b.getAttribute("data-bg")||"none"});b.addEventListener("click",fn);pill.appendChild(b);return b}\n' +
'btn("\\u2039","previous direction (ArrowLeft)",function(){go(-1)});\n' +
'var label=document.createElement("span");label.textContent="Direction "+n+"/"+total;label.style.cssText="padding:6px 5px;white-space:nowrap;letter-spacing:.02em;";pill.appendChild(label);\n' +
'btn("\\u203a","next direction (ArrowRight)",function(){go(1)});\n' +
'var pickBtn=btn("Pick","keep this direction",function(){post({action:"pick",choice:n},"Picked "+n+" \\u2713")});\n' +
'pickBtn.style.background="rgba(255,255,255,.12)";pickBtn.setAttribute("data-bg","rgba(255,255,255,.12)");pickBtn.style.fontWeight="600";pickBtn.style.margin="0 2px";\n' +
'btn("Riff","ask for one more direction",function(){var steer=window.prompt("Optional steer for the new direction (leave blank for a free riff):","");if(steer===null)return;post({action:"riff",choice:n,steer:steer.trim()},"Riff queued \\u2713")});\n' +
'btn("\\u00d7","dismiss",function(){pill.remove();document.removeEventListener("keydown",onKey,true)},8);\n' +
'function post(body,ok){fetch(origin+"/api/pick",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(r){if(!r.ok)throw new Error("bad status");label.textContent=ok}).catch(function(){pill.textContent="picker offline: tell your agent - pick "+n;pill.style.padding="9px 14px"})}\n' +
'function onKey(e){var el=document.activeElement;if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA"||el.isContentEditable))return;if(e.key==="ArrowLeft"){e.preventDefault();go(-1)}else if(e.key==="ArrowRight"){e.preventDefault();go(1)}}\n' +
'document.addEventListener("keydown",onKey,true);\n' +
'function mount(){(document.body||document.documentElement).appendChild(pill);requestAnimationFrame(function(){pill.style.opacity="1"})}\n' +
'if(document.body)mount();else document.addEventListener("DOMContentLoaded",mount);\n' +
'})();\n';
}
// ---------------------------------------------------------------------------
// shared shell CSS for the two host pages (dark neutral, system-ui, no
// external fonts, fully self-contained)
const SHELL_CSS = [
':root{--bg:#141416;--card:#1d1d21;--line:#2c2c31;--fg:#e9e7e2;--mut:#8f8d86;--acc:#6ea8fe;--ok:#3ecf8e;--bad:#e2705e;--mono:ui-monospace,"SF Mono",Menlo,monospace;--tw:408px;--th:255px;--scale:0.31875}',
'*{box-sizing:border-box;margin:0}',
'body{background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}',
'header{display:flex;align-items:baseline;gap:12px;padding:16px 24px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}',
'header b{font-size:15px;letter-spacing:.01em}',
'header span{font-family:var(--mono);font-size:11px;color:var(--mut)}',
'#note{margin-left:auto}#note.ok{color:var(--ok)}#note.bad{color:var(--bad)}',
'button{font:inherit;color:inherit;cursor:pointer}',
].join("\n");
// ---------------------------------------------------------------------------
// picker page (served at /). A static shell: it polls /api/state every 2s and
// renders the manifest's directions, so agent-side manifest edits (a riff's
// 4th direction, status flips, the pick landing) appear without a reload.
// Grid of scaled 1280x800 iframes -> click or 1..9 for single view -> arrows
// cycle, P or Pick posts {action:"pick"}, R or Riff posts {action:"riff"}.
export function buildPickerPage() {
return '<!doctype html>\n<html lang="en">\n<head>\n' +
'<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
'<title>hallmark · pick a direction</title>\n' +
'<style>\n' + SHELL_CSS + '\n' +
'.grid{display:grid;grid-template-columns:repeat(auto-fill,var(--tw));justify-content:center;gap:26px 22px;padding:26px 24px 60px}\n' +
'.card{background:none;border:0;padding:0;text-align:left}\n' +
'.thumb{width:var(--tw);height:var(--th);overflow:hidden;position:relative;border:1px solid var(--line);border-radius:10px;background:#fff}\n' +
'.card:hover .thumb,.card:focus-visible .thumb{border-color:var(--acc)}\n' +
'.thumb iframe{width:1280px;height:800px;border:0;transform:scale(var(--scale));transform-origin:top left;pointer-events:none}\n' +
'.thumb .ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--card);color:var(--mut);font-family:var(--mono);font-size:12px}\n' +
'.meta{display:flex;flex-direction:column;gap:3px;padding:10px 2px 0}\n' +
'.meta .t{font-weight:650;font-size:14px}.meta .t i{font-style:normal;color:var(--mut);font-family:var(--mono);font-size:12px;margin-right:8px}\n' +
'.meta .m{font-family:var(--mono);font-size:11px;color:var(--mut)}\n' +
'.empty{padding:80px 24px;text-align:center;color:var(--mut);font-family:var(--mono);font-size:13px}\n' +
'#single{position:fixed;inset:0;background:var(--bg);display:none;flex-direction:column;z-index:10}#single.on{display:flex}\n' +
'#sbar{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--line)}\n' +
'#sbar button{font-size:12px;border:1px solid var(--line);background:var(--card);border-radius:999px;padding:6px 13px}\n' +
'#sbar button:hover{border-color:var(--acc)}\n' +
'#sbar .pick{background:var(--acc);border-color:var(--acc);color:#0d1420;font-weight:650}\n' +
'#sbar .t{font-weight:650;font-size:13px}#sbar .m{font-family:var(--mono);font-size:11px;color:var(--mut)}\n' +
'#sbar .keys{margin-left:auto;font-family:var(--mono);font-size:10.5px;color:var(--mut)}\n' +
'#sframe{flex:1;border:0;width:100%;background:#fff}\n' +
'</style>\n</head>\n<body>\n' +
'<header><b>hallmark variants</b><span id="run"></span><span id="note"></span></header>\n' +
'<main class="grid" id="grid"></main>\n' +
'<div class="empty" id="empty" hidden>waiting for directions... the agent is still writing them (this page refreshes itself)</div>\n' +
'<div id="single">\n<div id="sbar">\n' +
'<button id="back" title="back to the grid (Esc)"> grid</button>\n' +
'<button id="prev" title="previous (ArrowLeft)"></button><button id="next" title="next (ArrowRight)"></button>\n' +
'<span class="t" id="st"></span><span class="m" id="sm"></span>\n' +
'<span class="keys">arrows flip · P pick · R riff · Esc grid</span>\n' +
'<button class="pick" id="pickBtn">Pick this (P)</button>\n<button id="riffBtn">Riff (R)</button>\n' +
'</div>\n<iframe id="sframe" title="direction preview"></iframe>\n</div>\n' +
'<script>\n(function(){\n' +
'var st=null,lastJson="",view="grid",dirs=[],picked=null;\n' +
'function $(id){return document.getElementById(id)}\n' +
'function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;")}\n' +
'function note(msg,cls){var n=$("note");n.textContent=msg||"";n.className=cls||""}\n' +
'function metaOf(d){return [d.macrostructure,d.theme,d.nav,d.footer].filter(Boolean).join(" · ")}\n' +
'function poll(){fetch("/api/state").then(function(r){return r.json()}).then(function(j){\n' +
' var s=JSON.stringify(j.manifest);if(s===lastJson)return;lastJson=s;st=j;\n' +
' dirs=((j.manifest&&j.manifest.directions)||[]).slice().sort(function(a,b){return a.n-b.n});\n' +
' picked=j.manifest?j.manifest.picked:null;render();\n' +
'}).catch(function(){note("server offline","bad")})}\n' +
'function render(){\n' +
' $("run").textContent=st.run+" · "+((st.manifest&&st.manifest.mode)||"");\n' +
' if(picked)note("picked: direction "+picked+" ✓ - back to your chat","ok");else note("");\n' +
' var g=$("grid");g.innerHTML="";$("empty").hidden=dirs.length>0;\n' +
' dirs.forEach(function(d){\n' +
' var ready=!d.status||d.status==="ready";\n' +
' var b=document.createElement("button");b.className="card";\n' +
' b.innerHTML=\'<div class="thumb">\'+(ready?\'<iframe loading="lazy" scrolling="no" tabindex="-1" src="\'+esc(d.url)+\'"></iframe>\':\'<div class="ph">building...</div>\')+\'</div>\'+\n' +
' \'<div class="meta"><span class="t"><i>\'+d.n+\'</i>\'+esc(d.title||("Direction "+d.n))+(picked===d.n?" ✓":"")+\'</span><span class="m">\'+esc(metaOf(d))+\'</span>\'+(d.axes?\'<span class="m">\'+esc(d.axes)+\'</span>\':"")+\'</div>\';\n' +
' b.addEventListener("click",function(){openDir(d.n)});g.appendChild(b);\n' +
' });\n' +
' if(view!=="grid")syncSingle();\n' +
'}\n' +
'function dirByN(n){for(var i=0;i<dirs.length;i++)if(dirs[i].n===n)return dirs[i];return null}\n' +
'function openDir(n){if(!dirByN(n))return;view=n;$("single").classList.add("on");syncSingle()}\n' +
'function closeSingle(){view="grid";$("single").classList.remove("on")}\n' +
'function cycle(step){var idx=0;for(var i=0;i<dirs.length;i++)if(dirs[i].n===view)idx=i;idx=(idx+step+dirs.length)%dirs.length;view=dirs[idx].n;syncSingle()}\n' +
'function syncSingle(){var d=dirByN(view);if(!d){closeSingle();return}\n' +
' $("st").textContent=d.n+" · "+(d.title||"");$("sm").textContent=metaOf(d)+(d.axes?" · "+d.axes:"");\n' +
' var f=$("sframe");if(f.getAttribute("data-src")!==d.url){f.setAttribute("data-src",d.url);f.src=d.url}\n' +
' $("pickBtn").textContent=picked===d.n?"Picked ✓":"Pick this (P)";\n' +
'}\n' +
'function api(body,ok){fetch("/api/pick",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})\n' +
' .then(function(r){if(!r.ok)throw new Error("bad status");note(ok,"ok")})\n' +
' .catch(function(){note("request failed - is the server up?","bad")})}\n' +
'function pick(){if(view==="grid")return;api({action:"pick",choice:view},"picked: direction "+view+" ✓ - back to your chat");$("pickBtn").textContent="Picked ✓"}\n' +
'function riff(){var steer=window.prompt("Optional steer for the new direction (leave blank for a free riff):","");if(steer===null)return;\n' +
' var body={action:"riff",steer:steer.trim()};if(view!=="grid")body.choice=view;\n' +
' api(body,"riff queued ✓ - a new direction will appear here")}\n' +
'$("back").addEventListener("click",closeSingle);\n' +
'$("prev").addEventListener("click",function(){cycle(-1)});\n' +
'$("next").addEventListener("click",function(){cycle(1)});\n' +
'$("pickBtn").addEventListener("click",pick);\n' +
'$("riffBtn").addEventListener("click",riff);\n' +
'document.addEventListener("keydown",function(e){\n' +
' var el=document.activeElement;if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA"||el.isContentEditable))return;\n' +
' var k=parseInt(e.key,10);\n' +
' if(view==="grid"){if(k>=1&&k<=dirs.length)openDir(dirs[k-1].n);return}\n' +
' if(e.key==="Escape")closeSingle();\n' +
' else if(e.key==="ArrowLeft")cycle(-1);\n' +
' else if(e.key==="ArrowRight")cycle(1);\n' +
' else if(e.key==="p"||e.key==="P")pick();\n' +
' else if(e.key==="r"||e.key==="R")riff();\n' +
' else if(k>=1&&k<=dirs.length)openDir(dirs[k-1].n);\n' +
'});\n' +
'setInterval(poll,2000);poll();\n' +
'})();\n</script>\n</body>\n</html>\n';
}
// ---------------------------------------------------------------------------
// static compare page (start.mjs --static): no server, srcdoc-inlined docs of
// the greenfield directions, one visible at a time, keys 1..N and arrows, and
// a banner telling the user to reply "pick N" in chat.
// items: [{ n, title, meta, doc }]
export function buildComparePage({ runId, brief, items }) {
const frames = items.map((t, i) =>
'<iframe id="f' + i + '" title="' + escapeAttr(t.title) + '" sandbox="allow-scripts" srcdoc="' + escapeAttr(t.doc) + '"></iframe>').join("\n");
const meta = JSON.stringify(items.map((t) => ({ n: t.n, title: t.title, meta: t.meta })));
return '<!doctype html>\n<html lang="en">\n<head>\n' +
'<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
'<title>hallmark · compare · ' + escapeAttr(runId) + '</title>\n' +
'<style>\n' + SHELL_CSS + '\n' +
'.banner{padding:9px 24px;background:#26261f;color:#e8dfba;font-family:var(--mono);font-size:11.5px;border-bottom:1px solid var(--line)}\n' +
'main{padding:14px 18px 90px;max-width:1440px;margin:0 auto}\n' +
'iframe{display:none;width:100%;height:calc(100vh - 190px);min-height:420px;border:1px solid var(--line);border-radius:10px;background:#fff}\n' +
'iframe.on{display:block}\n' +
'.pill{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:4px;background:rgba(17,17,19,.94);border-radius:999px;padding:6px 8px;box-shadow:0 10px 30px -10px rgba(0,0,0,.55);max-width:94vw;flex-wrap:wrap;justify-content:center}\n' +
'.pill button{border:0;background:none;color:#d9d4ca;font-family:var(--mono);font-size:12px;padding:6px 11px;border-radius:999px;white-space:nowrap}\n' +
'.pill button:hover{background:rgba(255,255,255,.12)}.pill button.on{background:var(--acc);color:#0d1420}\n' +
'</style>\n</head>\n<body>\n' +
'<div class="banner">static compare (no server running) · to choose, reply in your chat: "pick 2" · keys 1-' + items.length + ' and arrows flip</div>\n' +
'<header><b>hallmark variants</b><span>' + escapeAttr(runId) + (brief ? ' · ' + escapeAttr(String(brief).slice(0, 90)) : '') + '</span><span id="note"></span></header>\n' +
'<main>\n' + frames + '\n</main>\n' +
'<div class="pill" id="pill"></div>\n' +
'<script>\n(function(){\n' +
'var items=' + meta + ',cur=0;\n' +
'var pill=document.getElementById("pill"),note=document.getElementById("note");\n' +
'function show(i){cur=(i%items.length+items.length)%items.length;\n' +
' for(var k=0;k<items.length;k++)document.getElementById("f"+k).classList.toggle("on",k===cur);\n' +
' note.textContent=items[cur].title+(items[cur].meta?" · "+items[cur].meta:"");render()}\n' +
'function render(){var h=\'<button data-go="-1" aria-label="previous"></button>\';\n' +
' for(var i=0;i<items.length;i++)h+=\'<button data-i="\'+i+\'" class="\'+(i===cur?"on":"")+\'">\'+items[i].n+" · "+items[i].title+"</button>";\n' +
' h+=\'<button data-go="1" aria-label="next"></button>\';pill.innerHTML=h}\n' +
'pill.addEventListener("click",function(e){var b=e.target.closest("button");if(!b)return;\n' +
' if(b.dataset.i!=null)show(+b.dataset.i);else if(b.dataset.go)show(cur+ +b.dataset.go)});\n' +
'document.addEventListener("keydown",function(e){\n' +
' var el=document.activeElement;if(el&&(el.tagName==="INPUT"||el.tagName==="TEXTAREA"||el.isContentEditable))return;\n' +
' if(e.key==="ArrowLeft")show(cur-1);else if(e.key==="ArrowRight")show(cur+1);\n' +
' else{var k=parseInt(e.key,10);if(k>=1&&k<=items.length)show(k-1)}});\n' +
'show(0);\n' +
'})();\n</script>\n</body>\n</html>\n';
}
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
// hallmark variants picker server. Zero dependencies, binds 127.0.0.1 only.
// Internal: start.mjs spawns this detached; run it by hand only to debug.
//
// node serve.mjs --run <run-dir> [--port 4180]
//
// Serves the picker page at /, the run's greenfield direction folders at
// /frame/<n>/, the overlay chip at /chip.js (CORS *, so routes-mode pages on
// the user's dev server can load it), and a tiny queue API: POST /api/pick
// writes requests/NNNN-<action>.json which the agent claims via await.mjs.
// The manifest is re-read from disk on every request, so agent edits show up
// on the picker's next 2s poll with no restart.
//
// Port: tries 4180, walks up to 4189 on EADDRINUSE. Writes
// .hallmark/variants/server.json ({port, pid, run, startedAt}) next to the
// run dir on listen. Self-shuts-down after 30 min without /api/state polls.
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import {
parseArgs, runPaths, readJsonSafe, atomicWrite, nowIso, defaultManifest,
nextRequestId, MIME, buildPickerPage, buildChipJs,
} from "./core.mjs";
const args = parseArgs(process.argv.slice(2));
if (!args.run || args.run === true) { console.error("serve.mjs: --run <run-dir> is required"); process.exit(1); }
const P = runPaths(args.run);
const PORT_WANTED = Number(args.port ?? 4180);
const PORT_MAX = 4189;
const IDLE_MS = 30 * 60 * 1000;
fs.mkdirSync(P.REQ_DONE, { recursive: true });
const PICKER_HTML = buildPickerPage();
const CHIP_JS = buildChipJs();
let boundPort = null;
let lastPoll = Date.now();
// ---------------------------------------------------------------------------
// manifest: always fresh from disk (the agent edits it between polls)
function readManifest() {
const { json } = readJsonSafe(P.MANIFEST);
return json && typeof json === "object" && !Array.isArray(json) ? json : defaultManifest(P.RUN_ID);
}
// ---------------------------------------------------------------------------
// queue
function createRequest(action, fields) {
const id = nextRequestId(P);
const req = { id, action, ...fields, createdAt: nowIso() };
atomicWrite(path.join(P.REQ, `${id}-${action}.json`), JSON.stringify(req, null, 2) + "\n");
console.log(`[${nowIso()}] queued ${id}-${action}.json`);
return id;
}
// ---------------------------------------------------------------------------
// http plumbing
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
function send(res, code, body, headers = {}) {
res.writeHead(code, { "Cache-Control": "no-store", ...headers });
res.end(body);
}
function sendJson(res, code, obj, headers = {}) {
send(res, code, JSON.stringify(obj), { "Content-Type": "application/json", ...headers });
}
async function readBody(req) {
let buf = "";
for await (const chunk of req) {
buf += chunk;
if (buf.length > 1_000_000) throw new Error("body too large");
}
return buf ? JSON.parse(buf) : {};
}
// ---------------------------------------------------------------------------
// /frame/<n>/* : static-serve v<n>/ from the run dir (greenfield mode only)
function serveFrame(res, pathname) {
const manifest = readManifest();
if (manifest.mode !== "greenfield") return send(res, 404, "frames are greenfield-only; this run previews routes on the dev server");
const rest = pathname.slice("/frame/".length);
const slash = rest.indexOf("/");
const n = Number(slash === -1 ? rest : rest.slice(0, slash));
if (!Number.isInteger(n) || n < 1 || n > 99) return send(res, 404, "expected /frame/<n>/");
if (slash === -1) return send(res, 302, "", { Location: `/frame/${n}/` });
let rel;
try { rel = decodeURIComponent(rest.slice(slash + 1)); } catch { return send(res, 400, "bad path"); }
const dir = path.join(P.RUN, "v" + n);
let file = path.normalize(path.join(dir, rel));
if (file !== dir && !file.startsWith(dir + path.sep)) return send(res, 403, "no");
let stat = null;
try { stat = fs.statSync(file); } catch { /* fall through */ }
if (stat?.isDirectory()) { file = path.join(file, "index.html"); stat = null; }
if (rel === "" || rel.endsWith("/")) file = path.normalize(path.join(dir, rel, "index.html"));
let body;
try { body = fs.readFileSync(file); } catch { return send(res, 404, "not found"); }
return send(res, 200, body, { "Content-Type": MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream" });
}
// ---------------------------------------------------------------------------
// server
const server = http.createServer(async (req, res) => {
const host = (req.headers.host ?? "").split(":")[0];
if (host !== "127.0.0.1" && host !== "localhost") return send(res, 403, "local only");
const url = new URL(req.url, "http://127.0.0.1");
const p = url.pathname;
try {
if (req.method === "OPTIONS") {
if (p === "/api/pick") return send(res, 204, "", CORS);
return send(res, 404, "not found");
}
if (req.method === "GET") {
if (p === "/") return send(res, 200, PICKER_HTML, { "Content-Type": MIME[".html"] });
if (p === "/favicon.ico") return send(res, 204, "");
if (p === "/api/state") {
lastPoll = Date.now();
const manifest = readManifest();
return sendJson(res, 200, { run: P.RUN_ID, runDir: P.RUN, mode: manifest.mode, port: boundPort, manifest });
}
if (p === "/chip.js") {
return send(res, 200, CHIP_JS, { "Content-Type": MIME[".js"], "Access-Control-Allow-Origin": "*" });
}
if (p.startsWith("/frame/")) return serveFrame(res, p);
return send(res, 404, "not found");
}
if (req.method === "POST") {
if (p === "/api/pick") {
const body = await readBody(req);
const action = body?.action;
if (action !== "pick" && action !== "riff") return sendJson(res, 400, { error: 'action must be "pick" or "riff"' }, CORS);
const fields = {};
if (action === "pick") {
const choice = Number(body.choice);
if (!Number.isInteger(choice) || choice < 1) return sendJson(res, 400, { error: "pick needs a direction number in choice" }, CORS);
fields.choice = choice;
} else {
if (body.choice != null && Number.isInteger(Number(body.choice))) fields.choice = Number(body.choice);
fields.steer = String(body.steer ?? "").slice(0, 500);
}
const id = createRequest(action, fields);
return sendJson(res, 200, { ok: true, id }, CORS);
}
if (p === "/api/shutdown") {
sendJson(res, 200, { ok: true });
console.log(`[${nowIso()}] shutdown requested`);
setTimeout(shutdown, 50);
return;
}
return send(res, 404, "not found");
}
return send(res, 405, "method not allowed");
} catch (e) {
return sendJson(res, 500, { error: String(e?.message ?? e) });
}
});
function cleanup() {
const { json } = readJsonSafe(P.SERVER_JSON);
if (json?.pid === process.pid) {
try { fs.rmSync(P.SERVER_JSON, { force: true }); } catch { /* ignore */ }
}
}
function shutdown(code = 0) {
cleanup();
try { server.close(); } catch { /* ignore */ }
process.exit(code);
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
setInterval(() => {
if (Date.now() - lastPoll > IDLE_MS) {
console.log(`[${nowIso()}] idle for 30 min, shutting down`);
shutdown(0);
}
}, 60_000);
function listen(port) {
server.once("error", (e) => {
if (e.code === "EADDRINUSE" && port < PORT_MAX) listen(port + 1);
else { console.error("listen failed:", e.message); process.exit(1); }
});
server.listen(port, "127.0.0.1", () => {
boundPort = port;
atomicWrite(P.SERVER_JSON, JSON.stringify({ port, pid: process.pid, run: P.RUN_ID, startedAt: Date.now() }, null, 2) + "\n");
console.log(`[${nowIso()}] hallmark variants picker on http://127.0.0.1:${port} run=${P.RUN}`);
});
}
listen(PORT_WANTED);
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
// Boot the hallmark variants picker: ensure the run skeleton, start (or
// reuse) the detached server, print the picker URL and the exact next
// commands. Safe to run any number of times.
//
// node start.mjs --run <run-dir> [--static]
//
// Prints exactly:
// PICKER http://127.0.0.1:<port>
// AWAIT node <abs>/await.mjs --run <run-dir> --timeout 540
// DRAIN node <abs>/await.mjs --run <run-dir> --drain
//
// --static: no server. Writes compare.html into the run dir (srcdoc-inlined
// v1..v3 with local CSS folded in and the chip script stripped) and prints
// its absolute path instead.
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
parseArgs, runPaths, readJsonSafe, readSafe, defaultManifest,
buildComparePage, httpGetJson, httpPostJson,
} from "./core.mjs";
const args = parseArgs(process.argv.slice(2));
if (!args.run || args.run === true) { console.error("start.mjs: --run <run-dir> is required"); process.exit(1); }
const HERE = path.dirname(fileURLToPath(import.meta.url));
const P = runPaths(args.run);
// ---- run skeleton -----------------------------------------------------------
for (const d of ["v1", "v2", "v3", "requests/done"]) fs.mkdirSync(path.join(P.RUN, d), { recursive: true });
if (!fs.existsSync(P.MANIFEST)) {
fs.writeFileSync(P.MANIFEST, JSON.stringify(defaultManifest(P.RUN_ID), null, 2) + "\n");
}
// ---- --static: a serverless compare page ------------------------------------
if (args.static) {
const manifest = readJsonSafe(P.MANIFEST).json ?? defaultManifest(P.RUN_ID);
const ns = manifest.directions?.length ? manifest.directions.map((d) => d.n) : [1, 2, 3];
const items = [];
for (const n of ns) {
const dir = path.join(P.RUN, "v" + n);
let doc = readSafe(path.join(dir, "index.html"));
if (doc == null) continue;
doc = inlineLocalCss(doc, dir)
.replace(/<script\b[^>]*src=["'][^"']*\/chip\.js[^"']*["'][^>]*>\s*<\/script>/gi, "");
const d = manifest.directions?.find((x) => x.n === n);
items.push({
n,
title: d?.title ?? "Direction " + n,
meta: d ? [d.macrostructure, d.theme, d.nav, d.footer].filter(Boolean).join(" · ") : "",
doc,
});
}
if (!items.length) { console.error(`start.mjs: no v<n>/index.html found under ${P.RUN}`); process.exit(1); }
const out = path.join(P.RUN, "compare.html");
fs.writeFileSync(out, buildComparePage({ runId: P.RUN_ID, brief: manifest.brief ?? "", items }));
console.log(out);
process.exit(0);
}
/** Fold <link rel="stylesheet"> files that live inside the direction folder
* into <style> blocks so the srcdoc is self-contained. */
function inlineLocalCss(html, dir) {
return html.replace(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi, (tag) => {
const href = tag.match(/href=["']([^"']+)["']/i)?.[1];
if (!href || /^(https?:)?\/\//i.test(href)) return tag;
const file = path.normalize(path.join(dir, href.split("?")[0]));
if (file !== dir && !file.startsWith(dir + path.sep)) return tag;
const css = readSafe(file);
return css == null ? tag : "<style>\n" + css + "\n</style>";
});
}
// ---- server: reuse a live one whose identity matches this run ---------------
const existing = readJsonSafe(P.SERVER_JSON).json;
let live = null;
if (existing?.port) {
const st = await httpGetJson(existing.port, "/api/state");
if (st && st.run === P.RUN_ID && path.resolve(st.runDir ?? "") === P.RUN) {
live = { port: existing.port };
} else {
// A server for another run of THIS project is superseded; a stranger
// (another project's server squatting the port after a stale file) is
// left alone and we simply walk to the next port.
const ours = st?.runDir && path.dirname(path.resolve(st.runDir)) === path.dirname(P.RUN);
if (st && ours) await httpPostJson(existing.port, "/api/shutdown");
try { fs.rmSync(P.SERVER_JSON, { force: true }); } catch { /* ignore */ }
}
}
if (!live) {
const logFd = fs.openSync(P.LOG, "a");
const child = spawn(process.execPath, [path.join(HERE, "serve.mjs"), "--run", P.RUN], {
detached: true,
stdio: ["ignore", logFd, logFd],
});
child.unref();
const deadline = Date.now() + 8000;
while (Date.now() < deadline && !live) {
await new Promise((r) => setTimeout(r, 250));
const sj = readJsonSafe(P.SERVER_JSON).json;
if (sj?.port) {
const st = await httpGetJson(sj.port, "/api/state");
if (st && st.run === P.RUN_ID && path.resolve(st.runDir ?? "") === P.RUN) live = { port: sj.port };
}
}
if (!live) {
console.error(`variants: server did not come up; check ${P.LOG}`);
process.exit(1);
}
}
const awaitPath = path.join(HERE, "await.mjs");
console.log(`PICKER http://127.0.0.1:${live.port}`);
console.log(`AWAIT node ${awaitPath} --run ${P.RUN} --timeout 540`);
console.log(`DRAIN node ${awaitPath} --run ${P.RUN} --drain`);