diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f52f0..6fcad53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ The version in `plugins/vstack/.claude-plugin/plugin.json` is what your host compares against to decide an update is available. See the release checklist in [`CONTRIBUTING.md`](CONTRIBUTING.md). +## 4.9.0 — 2026-08-05 + +- **Two new ways to mark up a page: Move and Delete.** A toolbar beside the page + holds Comment, Move and Delete — what Annotate draws with, on keys `c`, `m` + and `d`. Neither new tool needs a note: the mark is the instruction, and + anything you type adds to it. Both outline whatever the pointer is over, so + the element the gesture will take is settled before you press. + - **Move** draws an arrow from a thing to where it should go. It records the + element you dropped it on and which side of it — inside, before, or after — + so your agent is told "after the Cancel button" rather than "180px right", + which stops meaning anything the moment the page reflows. + - **Delete** strikes out what should go. Drag across text and exactly those + words are marked for removal; the strike finds them again by their text when + the page is rebuilt, so it stays on them. Click an element instead and the + whole thing is marked. +- **A watcher finds a review whose page lives outside the directory it was + started from.** A review's files sit beside the page under review, and + `watch --all` found them by walking the directory it ran in. A page written to + a temp directory took its files with it, so the watcher walked straight past a + running review and the workspace said Unlinked while a session was in fact + listening. Serving now leaves a pointer where it was run from, and the watcher + follows it. +- **A watcher that covers no review says so.** It reports `UNLINKED` instead of + `LINKED`, because nothing is listening to any workspace at that point whatever + the handshake proved. A handshake also carries the token it printed, so one + watcher's answer no longer brings a different watcher live. + ## 4.8.1 — 2026-08-05 - **The comment composer's send button is readable in dark mode.** Its label was diff --git a/CLAUDE.md b/CLAUDE.md index 4c1dc2c..6166d01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ The layering rule that everything else follows (`plugins/vstack/contracts/README - **Adapters speak hosts.** Only `skills/review/hosts/*.md` may mention host-specific tools (Monitor, Artifact, etc.). A SKILL.md references Host ops (`background`, `watch_stream`, `share`, …); the adapter maps them to tools. -- **Profiles are data.** `hosts/.json` carries UI labels, install steps, and +- **Profiles are data.** `host-profiles/.json` carries UI labels, install steps, and capability flags; servers inject it as `window.__VSTACK_HOST__`, selected by `--host` / `VSTACK_HOST` (default `claude`). Loaded via `lib/host.mjs`. - **On-disk roles are stable:** review threads use `by: "agent" | "reviewer"`. @@ -95,7 +95,7 @@ The layering rule that everything else follows (`plugins/vstack/contracts/README a self-contained HTML page inside the workspace, or reverse-proxies a running app (`--app`) so the workspace shares an origin with what it annotates (that origin-sharing is why comments can attach to elements, not coordinates). CLI - subcommands (`publish`, `claim`, `reply`, `cancelled`, `share`, `status`, + subcommands (`publish`, `claim`, `reply`, `ack`, `share`, `status`, `check`, `watch`) drive the protocol; sentinels and round records live on disk. - `lib/json-bridge.mjs` — the live link for JSON-document pages (user-story-map, plus the experimental spec and phase-build tools): the page POSTs saves and diff --git a/README.md b/README.md index a268d97..d972f94 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ $vstack:review Wireframe a desktop personal task manager with minimal aesthetics - Work in a familiar, Figma-like interface. - Click any element and leave feedback exactly where the problem is hiding. +- Drag a thing to where it belongs, or strike out what should go — no note required. - Stay in the workspace as your agent publishes each update. - Preview desktop, tablet, and mobile layouts before production does it for you. - Compare revisions and identify the exact moment things went wrong. @@ -80,6 +81,18 @@ No archaeology through 200 messages. No screenshot named `final-final-v2-actuall - A local web browser - At least one strong opinion about border radius +## Technical Details + +### Live Link + +Each workspace is linked to one agent session. The link holds while that session is active, its heartbeat is less than 15 seconds old, and every submitted review round has been claimed. + +![The workspace page in a browser tab talks over http and SSE to the review server on 127.0.0.1. The server reads and writes a store on disk holding the state, the versions, the comments, the rounds, and the files that carry the link. The agent session watches and writes the same store.](docs/assets/live-link.svg) + +### Review Lifecycle + +![Your comments are submitted as one review round. The agent claims the round and reads its brief, asking for clarification when a comment is unclear. Comments sent while the round is in progress join it. Publishing is blocked until every comment has been applied, answered, or dismissed, and the published version appears in the same workspace.](docs/assets/review-lifecycle.svg) + ## Contribute Visual Stack is open source and under active development. Expect rough edges, breaking changes, and occasional moments of character development. diff --git a/docs/assets/live-link.svg b/docs/assets/live-link.svg new file mode 100644 index 0000000..42550ea --- /dev/null +++ b/docs/assets/live-link.svg @@ -0,0 +1,65 @@ + + + + LIVE LINK + How the workspace, review server, and agent session connect + + + + BROWSER TAB + Workspace page + The comments, the versions, and the page or app being reviewed, all on one origin. + + + + + http + SSE + + + + + REVIEW ENGINE + + + ONE LOCAL PROCESS, ON 127.0.0.1 + Review server + Serves the workspace, proxies your app, and pushes every change to the tab. + + + + + reads and writes + + + Store on disk + .vstack/local/review/<name>/ + + state.json + the current version and the round in flight + + versions/ + every version, frozen as it was published + + reviews/ + the comments and the brief they became + + rounds/ + what was sent, and what closed it + + handshake · watching · pending + the link, and what waits on the agent + + + + + watches and writes + + + + YOUR AGENT + Agent session + A watcher that turns the store into events, and the commands that answer them. + + The workspace and agent session communicate through the shared store. This allows a review + round to continue after the browser tab closes or the agent session restarts. + diff --git a/docs/assets/review-lifecycle.svg b/docs/assets/review-lifecycle.svg new file mode 100644 index 0000000..7d51393 --- /dev/null +++ b/docs/assets/review-lifecycle.svg @@ -0,0 +1,82 @@ + + + + REVIEW LIFECYCLE + A review round, from submission to the next version + + + + You, in the workspace + + Review server + + Agent session + + + + + + + + + EVERY ROUND + + + + + Send + Your comments are submitted as one review round. + + + + Review + The agent claims the round and reads its brief. + + + + + + + IF A COMMENT IS UNCLEAR + + Question + The agent asks for clarification on that comment. + + + + Reply + Your answer is added to the comment. + + + + + + + IF YOU COMMENT MID-ROUND + + Send again + New comments join the current round. + + + + Review again + The agent reads them at the next checkpoint. + + + + Publish + Blocked until every comment has been + applied, answered, or dismissed. + + + + New version + The published version appears in the same workspace. + + + + Approve + The review server closes after approval. + + + diff --git a/docs/review-wishlist.md b/docs/review-wishlist.md new file mode 100644 index 0000000..b0a8e69 --- /dev/null +++ b/docs/review-wishlist.md @@ -0,0 +1,25 @@ +# Wishlist — the review tool + +Features that have been considered for the review tool and are not being built yet. Each entry says +what it has to do to ship. An entry stays here until its acceptance criteria can be met. + +## Stop a round in flight + +**Status: withdrawn on 5 August 2026**, after an implementation that could not meet criterion 1. + +**What it should do.** The reviewer presses Stop and the agent stops working on that round. + +**Acceptance criteria.** + +1. Stop interrupts the agent's current turn, the way Esc does in the reviewer's own session. + A request the agent has to notice for itself does not qualify. +2. The interruption holds without the agent calling a protocol command. An agent that never calls + `check` still stops. +3. The workspace shows the round as ended once it has ended. + +**What it needs.** A Host op that interrupts the running turn, exposed by every host the plugin +supports — or a host-specific adapter path, with a fallback that says plainly what happens on a host +without the op. `contracts/host.md` has no such op today. + +**Until then.** The reviewer sends again. The brief is the state of the review rather than a diff, +so the next send supersedes the last one. diff --git a/plugins/vstack/.claude-plugin/plugin.json b/plugins/vstack/.claude-plugin/plugin.json index 6c429a9..ee9717f 100644 --- a/plugins/vstack/.claude-plugin/plugin.json +++ b/plugins/vstack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vstack", "displayName": "Visual Stack", - "version": "4.8.1", + "version": "4.9.0", "description": "Stop prompting. Start pointing. Visual Stack adds a Figma-like feedback layer to Claude Code. Create a new screen from a prompt, screenshot, reference site, or design system, or open an app you already have running. Click any element and leave feedback exactly where the problem is hiding, and Claude publishes the next revision into the same workspace. Compare revisions on a timeline, preview desktop, tablet, and mobile layouts, and keep every comment attached to the element, route, and version it refers to. Wireframes are self-contained HTML and review state stays in your project.", "author": { "name": "Cavalry Collective", diff --git a/plugins/vstack/.codex-plugin/plugin.json b/plugins/vstack/.codex-plugin/plugin.json index d6dd387..be35069 100644 --- a/plugins/vstack/.codex-plugin/plugin.json +++ b/plugins/vstack/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vstack", - "version": "4.8.1", + "version": "4.9.0", "description": "Stop prompting. Start pointing. Visual Stack adds a Figma-like feedback layer to Codex. Create a new screen from a prompt, screenshot, reference site, or design system, or open an app you already have running. Click any element and leave feedback exactly where the problem is hiding, and Codex publishes the next revision into the same workspace. Compare revisions on a timeline, preview desktop, tablet, and mobile layouts, and keep every comment attached to the element, route, and version it refers to. Wireframes are self-contained HTML and review state stays in your project.", "author": { "name": "Cavalry Collective", diff --git a/plugins/vstack/contracts/README.md b/plugins/vstack/contracts/README.md index 65ca541..c042e77 100644 --- a/plugins/vstack/contracts/README.md +++ b/plugins/vstack/contracts/README.md @@ -16,7 +16,7 @@ particular agent product. ``` plugins/vstack/ contracts/ ← this directory (the specs) - hosts/ ← profiles that implement Host (claude.json, codex.json, grok.json) + host-profiles/ ← profiles that implement Host (claude.json, codex.json, grok.json) lib/host.mjs ← loads a profile; used by servers skills/review/ SKILL.md ← loop in contract terms (no host-specific tools) @@ -29,10 +29,10 @@ plugins/vstack/ 1. **Engine speaks contracts.** `review-server.mjs`, the workspace, and shared shell never name a product except as data from a Host profile. -2. **Adapters speak hosts.** Only `hosts/*.md` (and the Host profile JSON) may - mention Monitor, Artifact, `monitor`, etc. +2. **Adapters speak hosts.** Only `skills/review/hosts/*.md` (and the Host + profile JSON) may mention Monitor, Artifact, `monitor`, etc. 3. **Profiles are data.** UI labels, install steps, and capability flags come - from `hosts/.json`, injected as `window.__VSTACK_HOST__` and selected by + from `host-profiles/.json`, injected as `window.__VSTACK_HOST__` and selected by `VSTACK_HOST` / `--host`. 4. **On-disk roles are stable.** Review threads use `by: "agent" | "reviewer"`. Older files may still say `"claude"`; readers treat that as `"agent"`. diff --git a/plugins/vstack/contracts/host.md b/plugins/vstack/contracts/host.md index adb9d82..5454219 100644 --- a/plugins/vstack/contracts/host.md +++ b/plugins/vstack/contracts/host.md @@ -4,7 +4,7 @@ A **Host** is the coding-agent product that runs the skill (Claude Code, Codex, Build, …). The review engine does not call into a host. The *agent session* running under a host fulfills these operations by using that host’s tools. -Every Host is described by a **profile** (`hosts/.json`, schema +Every Host is described by a **profile** (`host-profiles/.json`, schema [`host.schema.json`](host.schema.json)). Servers load it; the workspace reads `window.__VSTACK_HOST__`. @@ -46,8 +46,12 @@ node review-server.mjs watch --all --stream - Process must not exit after the first event. - Lines are UTF-8 text, one event per line (see [review-loop.md](review-loop.md)). -- While this process runs, the engine’s `watching` heartbeat is live and the UI - shows **Linked**. +- **The agent must be able to act on a line as it arrives.** +- The engine tests that: the stream opens with a `HANDSHAKE` line naming a + command the agent must run. The `watching` heartbeat starts once it is + answered, and the watcher exits `3` if two minutes pass first. Answering + proves a session is receiving the stream, which is the only claim the UI's + **Linked** state is allowed to make. ### `stop(handle)` — **required** @@ -90,7 +94,7 @@ user provides or skips harvest. When serving a workspace, the server: 1. Resolves Host via `--host ` or env `VSTACK_HOST` (default `claude`). -2. Loads `plugins/vstack/hosts/.json`. +2. Loads `plugins/vstack/host-profiles/.json`. 3. Injects into the page: ```html diff --git a/plugins/vstack/contracts/review-loop.md b/plugins/vstack/contracts/review-loop.md index 56e92e9..d92c4a3 100644 --- a/plugins/vstack/contracts/review-loop.md +++ b/plugins/vstack/contracts/review-loop.md @@ -13,7 +13,7 @@ Host-independent: any Host that fulfills [host.md](host.md) can drive this loop. | --- | --- | | **Engine** | Serves workspace, stores state, freezes versions, emits events | | **Agent** | Applies feedback, publishes versions, replies, fulfills Host ops | -| **Reviewer** | Comments in the browser; Send / Stop / Approve / Share | +| **Reviewer** | Comments in the browser; Send / Approve / Share | --- @@ -47,13 +47,22 @@ a caller never has to pick between the two itself. | `reviews/v/feedback.md` | Markdown brief for the agent | | `reviews/v/feedback.json` | Same, structured | | `rounds/r.json` | Durable membership, revisions, outcomes, and completion record | +| `handshake` | A stream watcher waiting to be told its events are being read. Carries the token it printed; `ack` marks the record answered rather than deleting it, and only the watcher whose token it holds acts on it and clears it | | `pending` | Notification only: review sent, agent must `claim` it | -| `cancel` | Sentinel: reviewer asked to stop the in-flight round | | `approved` | Sentinel: design signed off; engine shutting down | | `share` | Sentinel: reviewer wants a shareable link | | `url` | Present only while `serve` is running | | `watching` | Heartbeat while Host op `watch_stream` is active | +`serve` also records the store it is serving under the directory it was run +from: `/.vstack/local/review/.serving/`, one file per live review, +holding that review's store path. It is written after `url` and removed with it. + +`watch --all` finds a review by walking the directory it was run from **and** by +following those pointers. The pointer is what covers a page that lives outside +that directory, whose store lives outside it too. A pointer whose store has no +`url` is stale, and the reader deletes it. + Every vstack tool keeps its per-machine working files under `.vstack/local//`, resolved by `lib/workdir.mjs`: the enclosing `.vstack` when the artifact already sits in one, otherwise the one beside it. Engines must @@ -83,12 +92,12 @@ Host selection: `--host ` or `VSTACK_HOST=` (affects UI injection only). | Command | Contract | | --- | --- | | `serve --file …` / `serve --app …` | Long-lived via Host `background`. Binds `127.0.0.1`. | +| `ack --file/name … --token ` \| `ack --all --token ` | Answer a stream watcher's handshake. Only this arms the `watching` heartbeat | | `claim --file/name … --round r` | Acknowledge delivery while preserving the durable round ledger | | `publish --file/name … --round r --label … [--addressed ids]` | Validate full round coverage, freeze next version, and mark comments addressed | | `reply --file/name … --round r --comment --text "…"` | Append `{ by: "agent", text, at }`; status → `question` | -| `cancelled --file/name … --round r` | Acknowledge Stop, leave comments open, and close the active round | | `share --file/name … --url ` | Record public URL; clear `share` sentinel | -| `check --file/name …` | Exit `0` continue, `2` stop requested | +| `check --file/name …` | Always exits `0`. Names a queued round nobody has claimed | | `status --file/name …` | Human/debug snapshot | | `watch [--all] [--file …] --stream` | Event stream via Host `watch_stream` | @@ -101,9 +110,12 @@ One line of stdout per event (from `watch --stream`): | Prefix | Meaning | Agent action | | --- | --- | --- | | `WATCHING` | Stream armed | — | +| `HANDSHAKE` | The watcher asking whether anyone receives it | Run the `ack` command it prints, immediately | +| `LINKED` | The handshake was answered and at least one review is covered | — | +| `UNLINKED` | The handshake was answered and no review turned up to cover, so no workspace goes Linked | Start it again via `watch_stream` with `--file` if a review is running elsewhere; a later serve in the same directory is picked up without it | +| `UNWIRED` | The handshake went unanswered; the watcher exits `3` | Start it again via `watch_stream` | | `REVIEW` | `pending` written; round id and path to `feedback.md` | `claim` the round, apply brief, publish/reply | | `REPLIED` | Reviewer answered a question | Continue that comment’s thread | -| `CANCELLED` | Stop requested | Do not publish half-work; run `cancelled --round …`; report | | `SHARE` | Link requested | Host `share` if capable; then `share --url` | | `APPROVED` | Sign-off; server exiting | Confirm; next pipeline stage as skill says | | `OPENED` | Another live store joined `--all` | — | @@ -125,7 +137,6 @@ reviewer comments ──Send──► round record + pending + feedback.md │ │ │◄──── version ready ─────┘ │ - Stop ──► cancel ──► CANCELLED (agent must check during long rounds) Approve ──► approved ──► APPROVED + server exit Share ──► share ──► SHARE ──► share --url ``` @@ -134,10 +145,13 @@ Rules: 1. Only a validated `publish --round … --addressed …` closes comments (reviewer has no resolve). 2. The engine rejects publication unless every round member is addressed, dismissed, or waiting on the reviewer. -3. The engine rejects unknown IDs, changed comment revisions, unclaimed rounds, stale round IDs, and any publish after Stop. -4. Agent must `check` at checkpoints; `cancelled --round …` acknowledges Stop. Do not delete protocol files manually. +3. The engine rejects unknown IDs, changed comment revisions, unclaimed rounds, and stale round IDs. +4. A round in flight cannot be called off. The reviewer's only correction is to send again, which supersedes the brief. Do not delete protocol files manually. 5. Retrying an already completed `publish --round …` is idempotent and creates no extra version. 6. One `watch_stream` per session is enough with `--all`. +7. Presence is proven. A stream watcher writes its `watching` heartbeat from the moment its handshake is answered, so **Linked** means a session is receiving the stream. Default window 120 s (`--handshake-timeout `). +8. Presence is per review, and per watcher. A watcher heartbeats only the stores it covers, and goes live only on an answer carrying its own token — a second watcher's handshake is not an answer to the first. It reports `LINKED` once it covers a review, and `UNLINKED` when none has turned up. +9. Presence is also claim-backed. The engine reports the agent present (workspace **Linked**) only while the `watching` heartbeat is fresh **and** no queued round has sat unclaimed past the claim window (90 s). A stalled round drops presence — a watcher whose events nobody reads must look the same to the reviewer as no watcher at all. --- @@ -148,14 +162,22 @@ Rules: | Field | Meaning | | --- | --- | | `id` | Pass to `--addressed` | -| `note` | Requirement text | +| `kind` | `comment` · `area` · `general` · `move` · `strike` | +| `note` | Requirement text. Empty is valid on `move` and `strike` | | `anchor` | Element identity (tag, id, classes, text, region, selector) | +| `move` | `move` only — `{ target: { …anchor identity, where }, delta }`, `where` is `inside` · `before` · `after` | +| `strike` | `strike` only — `{ scope: 'text' \| 'element', text }` | | `screenSize` | Layout the comment was made at | | `route` | Live only — app path | | `status` | `open` · `question` · `addressed` | | `replies` | `{ by, text, at }[]` | | `reopened` / `wantsRevert` | Returned from Refine / Revert | +A comment carries its requirement in `note`. A `move` and a `strike` carry it in +their own fields instead, so a reader must not treat an empty `note` as an +incomplete comment. `move.target` outranks `move.delta`: the element and side +survive a reflow and the pixel distance does not. + --- ## Share diff --git a/plugins/vstack/experimental/phase-build/assets/build-board.html b/plugins/vstack/experimental/phase-build/assets/build-board.html index 452197b..f44b6d6 100644 --- a/plugins/vstack/experimental/phase-build/assets/build-board.html +++ b/plugins/vstack/experimental/phase-build/assets/build-board.html @@ -186,6 +186,18 @@ .cogmenu[hidden]{display:none} .cogmenu .row{display:flex;align-items:center;gap:10px;justify-content:space-between} .cogmenu .lbl{font-size:12px;color:var(--ink-2);white-space:nowrap} +.cogmenu .about{align-items:flex-start} +.cogmenu .abouts{display:grid;gap:3px;justify-items:end} +.cogmenu .abouts .one{display:flex;gap:8px;align-items:baseline} +/* An author `display` beats the UA rule for [hidden], so every row that is + hidden from script needs its own. A page that never hears a server version + showed "server —" for good without this. */ +.cogmenu .abouts .one[hidden]{display:none} +.cogmenu .abouts em{font-style:normal;font-size:10.5px;letter-spacing:.03em;color:var(--ink-3)} +.cogmenu .abouts b{font:600 11px/1.3 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums} +.cogmenu .stale{font-size:11px;line-height:1.4;color:var(--brand);justify-content:flex-start} +.cogmenu .stale[hidden]{display:none} +.cogmenu .about[hidden]{display:none} /* the live link, stated rather than implied */ .linkdot{display:inline-flex;align-items:center;gap:6px;font:600 10.5px/1 var(--mono); @@ -425,6 +437,17 @@ + + +
@@ -605,6 +628,30 @@ } const hideLink = () => { const el = $('#linkDot'); if (el) el.hidden = true }; + /* ── which version is running ── + The page reports what served it, held from load; the server reports what it + is on now. A tab open across an update shows both and offers the reload. */ + let pageVersion = null, serverVersion = null; + function paintVersions () { + const row = $('#cogAbout'); + if (!row) return; + row.hidden = !pageVersion && !serverVersion; + const put = (id, value) => { const el = $(id); if (el) el.textContent = value || '—' }; + put('#cogVersionPage', pageVersion || serverVersion); + put('#cogVersionServer', serverVersion); + const line = $('#cogServerLine'); + if (line) line.hidden = !serverVersion; + const stale = $('#cogStale'); + if (stale) stale.hidden = !(pageVersion && serverVersion && pageVersion !== serverVersion); + } + /** What the server is on right now, which a page learns from its own payload. */ + function setServerVersion (version) { + const next = version || null; + if (serverVersion === next) return; + serverVersion = next; + paintVersions(); + } + /* ── one live-link client, instead of one per page ── Wires the dot to a server: SSE when the page has an event stream, a plain poll for a server that only answers /ping. Either way the shell owns the @@ -778,6 +825,8 @@ if (opts.wip) wip(true, typeof opts.wip === 'string' ? opts.wip : undefined); name(opts.name, opts.eyebrow); wireSettings(); + pageVersion = (window.__VSTACK_BUILD__ || {}).version || null; + paintVersions(); applyTheme(); applyLang(); updateNotice(); @@ -785,7 +834,7 @@ } const api = { - init, setTheme, setLang, setLink, setWatching, hideLink, name, wip, + init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, get theme () { return theme }, get lang () { return lang }, diff --git a/plugins/vstack/experimental/spec/assets/spec-tree.html b/plugins/vstack/experimental/spec/assets/spec-tree.html index 263ab91..d2d0092 100644 --- a/plugins/vstack/experimental/spec/assets/spec-tree.html +++ b/plugins/vstack/experimental/spec/assets/spec-tree.html @@ -190,6 +190,18 @@ .cogmenu[hidden]{display:none} .cogmenu .row{display:flex;align-items:center;gap:10px;justify-content:space-between} .cogmenu .lbl{font-size:12px;color:var(--ink-2);white-space:nowrap} +.cogmenu .about{align-items:flex-start} +.cogmenu .abouts{display:grid;gap:3px;justify-items:end} +.cogmenu .abouts .one{display:flex;gap:8px;align-items:baseline} +/* An author `display` beats the UA rule for [hidden], so every row that is + hidden from script needs its own. A page that never hears a server version + showed "server —" for good without this. */ +.cogmenu .abouts .one[hidden]{display:none} +.cogmenu .abouts em{font-style:normal;font-size:10.5px;letter-spacing:.03em;color:var(--ink-3)} +.cogmenu .abouts b{font:600 11px/1.3 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums} +.cogmenu .stale{font-size:11px;line-height:1.4;color:var(--brand);justify-content:flex-start} +.cogmenu .stale[hidden]{display:none} +.cogmenu .about[hidden]{display:none} /* the live link, stated rather than implied */ .linkdot{display:inline-flex;align-items:center;gap:6px;font:600 10.5px/1 var(--mono); @@ -542,6 +554,17 @@ + + +
@@ -834,6 +857,30 @@

} const hideLink = () => { const el = $('#linkDot'); if (el) el.hidden = true }; + /* ── which version is running ── + The page reports what served it, held from load; the server reports what it + is on now. A tab open across an update shows both and offers the reload. */ + let pageVersion = null, serverVersion = null; + function paintVersions () { + const row = $('#cogAbout'); + if (!row) return; + row.hidden = !pageVersion && !serverVersion; + const put = (id, value) => { const el = $(id); if (el) el.textContent = value || '—' }; + put('#cogVersionPage', pageVersion || serverVersion); + put('#cogVersionServer', serverVersion); + const line = $('#cogServerLine'); + if (line) line.hidden = !serverVersion; + const stale = $('#cogStale'); + if (stale) stale.hidden = !(pageVersion && serverVersion && pageVersion !== serverVersion); + } + /** What the server is on right now, which a page learns from its own payload. */ + function setServerVersion (version) { + const next = version || null; + if (serverVersion === next) return; + serverVersion = next; + paintVersions(); + } + /* ── one live-link client, instead of one per page ── Wires the dot to a server: SSE when the page has an event stream, a plain poll for a server that only answers /ping. Either way the shell owns the @@ -1007,6 +1054,8 @@

if (opts.wip) wip(true, typeof opts.wip === 'string' ? opts.wip : undefined); name(opts.name, opts.eyebrow); wireSettings(); + pageVersion = (window.__VSTACK_BUILD__ || {}).version || null; + paintVersions(); applyTheme(); applyLang(); updateNotice(); @@ -1014,7 +1063,7 @@

} const api = { - init, setTheme, setLang, setLink, setWatching, hideLink, name, wip, + init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, get theme () { return theme }, get lang () { return lang }, diff --git a/plugins/vstack/experimental/start/assets/chooser.html b/plugins/vstack/experimental/start/assets/chooser.html index e304db4..c64c6da 100644 --- a/plugins/vstack/experimental/start/assets/chooser.html +++ b/plugins/vstack/experimental/start/assets/chooser.html @@ -179,6 +179,18 @@ .cogmenu[hidden]{display:none} .cogmenu .row{display:flex;align-items:center;gap:10px;justify-content:space-between} .cogmenu .lbl{font-size:12px;color:var(--ink-2);white-space:nowrap} +.cogmenu .about{align-items:flex-start} +.cogmenu .abouts{display:grid;gap:3px;justify-items:end} +.cogmenu .abouts .one{display:flex;gap:8px;align-items:baseline} +/* An author `display` beats the UA rule for [hidden], so every row that is + hidden from script needs its own. A page that never hears a server version + showed "server —" for good without this. */ +.cogmenu .abouts .one[hidden]{display:none} +.cogmenu .abouts em{font-style:normal;font-size:10.5px;letter-spacing:.03em;color:var(--ink-3)} +.cogmenu .abouts b{font:600 11px/1.3 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums} +.cogmenu .stale{font-size:11px;line-height:1.4;color:var(--brand);justify-content:flex-start} +.cogmenu .stale[hidden]{display:none} +.cogmenu .about[hidden]{display:none} /* the live link, stated rather than implied */ .linkdot{display:inline-flex;align-items:center;gap:6px;font:600 10.5px/1 var(--mono); @@ -467,6 +479,17 @@ + + +
@@ -702,6 +725,30 @@

} const hideLink = () => { const el = $('#linkDot'); if (el) el.hidden = true }; + /* ── which version is running ── + The page reports what served it, held from load; the server reports what it + is on now. A tab open across an update shows both and offers the reload. */ + let pageVersion = null, serverVersion = null; + function paintVersions () { + const row = $('#cogAbout'); + if (!row) return; + row.hidden = !pageVersion && !serverVersion; + const put = (id, value) => { const el = $(id); if (el) el.textContent = value || '—' }; + put('#cogVersionPage', pageVersion || serverVersion); + put('#cogVersionServer', serverVersion); + const line = $('#cogServerLine'); + if (line) line.hidden = !serverVersion; + const stale = $('#cogStale'); + if (stale) stale.hidden = !(pageVersion && serverVersion && pageVersion !== serverVersion); + } + /** What the server is on right now, which a page learns from its own payload. */ + function setServerVersion (version) { + const next = version || null; + if (serverVersion === next) return; + serverVersion = next; + paintVersions(); + } + /* ── one live-link client, instead of one per page ── Wires the dot to a server: SSE when the page has an event stream, a plain poll for a server that only answers /ping. Either way the shell owns the @@ -875,6 +922,8 @@

if (opts.wip) wip(true, typeof opts.wip === 'string' ? opts.wip : undefined); name(opts.name, opts.eyebrow); wireSettings(); + pageVersion = (window.__VSTACK_BUILD__ || {}).version || null; + paintVersions(); applyTheme(); applyLang(); updateNotice(); @@ -882,7 +931,7 @@

} const api = { - init, setTheme, setLang, setLink, setWatching, hideLink, name, wip, + init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, get theme () { return theme }, get lang () { return lang }, diff --git a/plugins/vstack/hosts/claude.json b/plugins/vstack/host-profiles/claude.json similarity index 100% rename from plugins/vstack/hosts/claude.json rename to plugins/vstack/host-profiles/claude.json diff --git a/plugins/vstack/hosts/codex.json b/plugins/vstack/host-profiles/codex.json similarity index 100% rename from plugins/vstack/hosts/codex.json rename to plugins/vstack/host-profiles/codex.json diff --git a/plugins/vstack/hosts/grok.json b/plugins/vstack/host-profiles/grok.json similarity index 100% rename from plugins/vstack/hosts/grok.json rename to plugins/vstack/host-profiles/grok.json diff --git a/plugins/vstack/lib/host.mjs b/plugins/vstack/lib/host.mjs index 80c97b0..1650f06 100644 --- a/plugins/vstack/lib/host.mjs +++ b/plugins/vstack/lib/host.mjs @@ -1,7 +1,7 @@ /** * host.mjs — load a Host profile (contracts/host.md). * - * Profiles live in plugins/vstack/hosts/.json. Servers inject the profile + * Profiles live in plugins/vstack/host-profiles/.json. Servers inject the profile * into pages as window.__VSTACK_HOST__. Skills never hardcode product names * in the engine; they pass --host / VSTACK_HOST and read the adapter markdown. */ @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url' import { injectHead } from './live-link.mjs' const HERE = path.dirname(fileURLToPath(import.meta.url)) -const HOSTS_DIR = path.join(HERE, '..', 'hosts') +const HOSTS_DIR = path.join(HERE, '..', 'host-profiles') const DEFAULT_ID = 'claude' diff --git a/plugins/vstack/lib/json-bridge.mjs b/plugins/vstack/lib/json-bridge.mjs index 5cb0c44..212c745 100644 --- a/plugins/vstack/lib/json-bridge.mjs +++ b/plugins/vstack/lib/json-bridge.mjs @@ -57,7 +57,7 @@ import path from 'node:path' import http from 'node:http' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' -import { checkForUpdate, dismissUpdate, withUpdate } from './update-check.mjs' +import { checkForUpdate, dismissUpdate, withUpdate, withVersion } from './update-check.mjs' import { loadHost, resolveHostId, withHost } from './host.mjs' import { workDir, findWorkDir, TOOL } from './workdir.mjs' import { writeAtomic, watchingRecently, startHeartbeat, startPresence, openInBrowser } from './live-link.mjs' @@ -237,7 +237,7 @@ function page () { const doc = /^\s*]*>)/i, `$1\n${handle}`) : `\n\n\n${handle}${body}` - return withUpdate(withHost(doc, HOST), update) + return withUpdate(withVersion(withHost(doc, HOST)), update) } const send = (res, code, type, body) => { diff --git a/plugins/vstack/lib/shell/shell.css b/plugins/vstack/lib/shell/shell.css index 29baddd..9aa50f4 100644 --- a/plugins/vstack/lib/shell/shell.css +++ b/plugins/vstack/lib/shell/shell.css @@ -106,6 +106,18 @@ .cogmenu[hidden]{display:none} .cogmenu .row{display:flex;align-items:center;gap:10px;justify-content:space-between} .cogmenu .lbl{font-size:12px;color:var(--ink-2);white-space:nowrap} +.cogmenu .about{align-items:flex-start} +.cogmenu .abouts{display:grid;gap:3px;justify-items:end} +.cogmenu .abouts .one{display:flex;gap:8px;align-items:baseline} +/* An author `display` beats the UA rule for [hidden], so every row that is + hidden from script needs its own. A page that never hears a server version + showed "server —" for good without this. */ +.cogmenu .abouts .one[hidden]{display:none} +.cogmenu .abouts em{font-style:normal;font-size:10.5px;letter-spacing:.03em;color:var(--ink-3)} +.cogmenu .abouts b{font:600 11px/1.3 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums} +.cogmenu .stale{font-size:11px;line-height:1.4;color:var(--brand);justify-content:flex-start} +.cogmenu .stale[hidden]{display:none} +.cogmenu .about[hidden]{display:none} /* the live link, stated rather than implied */ .linkdot{display:inline-flex;align-items:center;gap:6px;font:600 10.5px/1 var(--mono); diff --git a/plugins/vstack/lib/shell/shell.js b/plugins/vstack/lib/shell/shell.js index 6dacad7..1d46b94 100644 --- a/plugins/vstack/lib/shell/shell.js +++ b/plugins/vstack/lib/shell/shell.js @@ -103,6 +103,30 @@ window.VSShell = (function () { } const hideLink = () => { const el = $('#linkDot'); if (el) el.hidden = true }; + /* ── which version is running ── + The page reports what served it, held from load; the server reports what it + is on now. A tab open across an update shows both and offers the reload. */ + let pageVersion = null, serverVersion = null; + function paintVersions () { + const row = $('#cogAbout'); + if (!row) return; + row.hidden = !pageVersion && !serverVersion; + const put = (id, value) => { const el = $(id); if (el) el.textContent = value || '—' }; + put('#cogVersionPage', pageVersion || serverVersion); + put('#cogVersionServer', serverVersion); + const line = $('#cogServerLine'); + if (line) line.hidden = !serverVersion; + const stale = $('#cogStale'); + if (stale) stale.hidden = !(pageVersion && serverVersion && pageVersion !== serverVersion); + } + /** What the server is on right now, which a page learns from its own payload. */ + function setServerVersion (version) { + const next = version || null; + if (serverVersion === next) return; + serverVersion = next; + paintVersions(); + } + /* ── one live-link client, instead of one per page ── Wires the dot to a server: SSE when the page has an event stream, a plain poll for a server that only answers /ping. Either way the shell owns the @@ -276,6 +300,8 @@ window.VSShell = (function () { if (opts.wip) wip(true, typeof opts.wip === 'string' ? opts.wip : undefined); name(opts.name, opts.eyebrow); wireSettings(); + pageVersion = (window.__VSTACK_BUILD__ || {}).version || null; + paintVersions(); applyTheme(); applyLang(); updateNotice(); @@ -283,7 +309,7 @@ window.VSShell = (function () { } const api = { - init, setTheme, setLang, setLink, setWatching, hideLink, name, wip, + init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, get theme () { return theme }, get lang () { return lang }, diff --git a/plugins/vstack/lib/shell/topbar.html b/plugins/vstack/lib/shell/topbar.html index 0039c30..ce1bde4 100644 --- a/plugins/vstack/lib/shell/topbar.html +++ b/plugins/vstack/lib/shell/topbar.html @@ -57,6 +57,17 @@ + + +
diff --git a/plugins/vstack/lib/update-check.mjs b/plugins/vstack/lib/update-check.mjs index 2cd9788..4272bbe 100644 --- a/plugins/vstack/lib/update-check.mjs +++ b/plugins/vstack/lib/update-check.mjs @@ -216,6 +216,18 @@ export async function checkForUpdate (hostProfile = null) { } } +/** The version of the copy that is running. */ +export const currentVersion = () => manifestVersion() + +/** Tell a served page which version served it, so the page can say so without + reaching for anything. A page kept open across an update still reports the + version it loaded with, which is the point. */ +export function withVersion (html) { + const version = manifestVersion() + if (!version) return html + return injectHead(html, `\n`) +} + /** Put the handle into a served page. No-op when there is nothing to say, so callers can apply it unconditionally. */ export function withUpdate (html, info) { diff --git a/plugins/vstack/skills/review/SKILL.md b/plugins/vstack/skills/review/SKILL.md index 39c90b2..0a4bebd 100644 --- a/plugins/vstack/skills/review/SKILL.md +++ b/plugins/vstack/skills/review/SKILL.md @@ -12,9 +12,13 @@ this file** — they live only in the Host adapter. | You are running under… | Load adapter | Set | | --- | --- | --- | -| **Codex** | `hosts/codex.md` | `VSTACK_HOST=codex` (or `--host codex` on `serve`) | -| **Grok** Build / Grok CLI | `hosts/grok.md` | `VSTACK_HOST=grok` (or `--host grok` on `serve`) | -| **Claude Code** | `hosts/claude.md` | `VSTACK_HOST=claude` (default if unset) | +| **Codex** | `skills/review/hosts/codex.md` | `VSTACK_HOST=codex` (or `--host codex` on `serve`) | +| **Grok** Build / Grok CLI | `skills/review/hosts/grok.md` | `VSTACK_HOST=grok` (or `--host grok` on `serve`) | +| **Claude Code** | `skills/review/hosts/claude.md` | `VSTACK_HOST=claude` (default if unset) | + +Adapters live in the `hosts/` directory beside this SKILL.md. Do not read +`plugins/vstack/host-profiles/.json` instead: that JSON is UI data with no +tool mapping. **Read the adapter before §3.** Every `background`, `watch_stream`, `stop`, `share`, and `browser_capture` step is fulfilled exactly as that file says. @@ -118,10 +122,14 @@ The page opens in **its own browser window** on the canvas — own viewport, own | | | |---|---| -| **View / Annotate** | two modes, **space** toggles. **View hides every annotation** so the page is judged as it really is; Annotate brings them back | +| **View / Annotate** | two modes, **esc** toggles. **View hides every annotation** so the page is judged as it really is; Annotate brings them back | | **Click** | a comment pin at that spot | | **Drag** | an area comment over that region — it carries the element that contains the box *and* everything named inside it | | Either way | the note opens **on the canvas** where the mark is. A comment with nothing typed in it is discarded on dismiss | +| **Toolbar** (left of the canvas) | what Annotate draws with: Comment · Move · Delete, keys **c** · **m** · **d**. Picking one in View mode returns to Annotate with it | +| **Move** and **Delete** | both are optional-note: the mark is the instruction, and anything typed adds to it. Both outline whatever the pointer is over, so it is clear which element the gesture will take | +| **Move** | drag a thing to where it should go. Arrives as `kind: move` — the element to move, the element it was dropped on, and which side of it | +| **Delete** | drag across text to strike exactly those words, or click an element to strike everything written inside it. Arrives as `kind: strike` | | **Target** | the note says which element the comment attached to | | **Attached to an element** | a comment belongs to the thing it was made on, not to a coordinate. The mark rides it when the layout moves, and **goes off the page with it** — a comment made inside a modal, tab or step is not drawn while that thing is closed. It stays in the list tagged *not on screen*, and it still reaches you | | Captions | stay hidden — a mark shows its note when it's open, or on hover in Annotate | @@ -138,7 +146,6 @@ The page opens in **its own browser window** on the canvas — own viewport, own | **Addressed** | comments you closed stay in the list in their own section, each offering **Revert** or **Refine** | | **Publish a link to this wireframe** (the ▾ beside Send) | only when Host `capabilities.share` is `artifact`. Asks you to publish **the wireframe** (Host op `share`) and hand the URL back. Hidden on hosts without public share, and in a live review | | **Approve & finish** (the ▾ beside Send) | sign-off. Ends the review, closes the server, and tells you the design is settled — behind a confirm that warns how many comments are being left unapplied | -| **Cancel** | stops the round you're working on. Not a kill: finish what you were mid-way through, then say what you'd already changed | There is no per-comment resolve button: **you** close comments out by addressing them. Approve is the whole-page verdict, not an item-by-item one — one click that means *the design is done*, which is the @@ -158,56 +165,59 @@ accepts a status going backwards when the reviewer deliberately sent it back. ## 5 · Catch the review, and hold up your end of the conversation -After starting the server, start the watcher with Host op **`watch_stream`** (adapter -names the tool). Prefer the streaming form so nothing has to be re-armed: +After starting the server, start the watcher with Host op **`watch_stream`**. Look up the tool for +that op in your adapter — `watch_stream` delivers each line of output to you as it arrives, and it +is a different op from the `background` you used in §3. ```bash node "$SKILL/assets/review-server.mjs" watch --all --stream # or --file ``` -It never exits. Each line of its output is one event, delivered to you as it happens, and the -process keeps running — so there is nothing to re-arm after a round, which is the step that gets -forgotten and leaves a review nobody is reading. `--all` covers every review open in the project, -so a session with a wireframe and a story map up needs one watcher, not one each. +Each line of its output is one event, delivered to you as it happens, and the process keeps running, +so one watcher covers the whole session — `--all` takes in every review open in the project, +including ones opened later, and any you started from this directory whose page lives elsewhere. +Run it from the same directory you started the server from; that is what ties the two together. + +**It opens with a `HANDSHAKE` line naming a command. Run that command straight away.** The watcher +goes live once you answer, the workspace says **Linked**, and the round events start reaching you. +Answering proves the op was fulfilled, since only a session that can run commands can answer. +Answer within two minutes; after that the watcher prints `UNWIRED` and exits, and you start it again +with the tool your adapter names for `watch_stream`. -While it runs the page says **Linked**; with no watcher it says **Unlinked**, in amber, so the -reviewer can see that what they send will sit there. +The page says **Linked** for as long as the watcher is answered and the rounds are being claimed, +and **Unlinked** in amber the rest of the time, so the reviewer always knows which one they have. Each event is one line (full table: `contracts/review-loop.md`): | | What it means | What you do | |---|---|---| +| **`HANDSHAKE`** | the watcher asking whether you can hear it | run the command it prints, now — it is what brings the watcher live | +| **`LINKED`** | the handshake is answered and a review is under the watcher; the workspace says Linked | carry on — the loop is live | +| **`UNLINKED`** | the handshake is answered, but the watcher found no review to cover, so no workspace says Linked | start it again with `--file ` if a review is already running for a page outside this directory. A serve started here after it needs nothing | +| **`UNWIRED`** | the handshake went unanswered and the watcher exited | start it again with the tool your adapter names for `watch_stream` | | **`REVIEW`** | a review landed; the line names its round and brief | `claim` the round, then apply it — the steps below | | **`REPLIED`** | they answered a question you asked | read the thread and carry on with that comment. Nothing else announces this — a reply writes no sentinel | -| **`CANCELLED`** | the reviewer pressed **Stop** | don't publish what you had half-done. Say what you had already changed and what you hadn't, then run `cancelled --round …` | | **`SHARE`** | they want a link to send someone | Host op `share` if capable, then §6; if the Host cannot share publicly, say so and offer a file/bundle instead | | **`APPROVED`** | the design is signed off; the server has closed itself | say it's approved, note any `openComments` deliberately left, and carry on with whatever comes next | | **`CLOSED`** | that review's tab went away | the watcher drops it and keeps watching the rest; it only stops when none are left | -**Use the protocol commands rather than deleting state files.** `claim --round …` consumes `pending`, -`cancelled --round …` acknowledges Stop, and `share --url` clears `share`. The durable round record -remains available for validation, recovery, and idempotent retries. +**Use the protocol commands rather than deleting state files.** `claim --round …` consumes `pending` +and `share --url` clears `share`. The durable round record remains available for validation, +recovery, and idempotent retries. -### Stopping a round in flight +### Checking during a round -While you work, **no waiter is armed** — you are the only thing that can notice the reviewer changing -their mind. The workspace's **Stop** button writes the `cancel` sentinel; nothing in it can interrupt -a turn already running, so a round that never looks is a round that cannot be stopped. +While you work, **no waiter is armed** — nothing will interrupt you, and a round in flight cannot be +called off. If the reviewer changes their mind they send again, and that brief supersedes. -**Check at every checkpoint of a round** — after reading the feedback, between batches of edits, and -always immediately before `publish`: +**Check at the checkpoints of a long round** — after reading the feedback, and before `publish`: ```bash -node "$SKILL/assets/review-server.mjs" check --file "$FILE" || STOP=1 +node "$SKILL/assets/review-server.mjs" check --file "$FILE" ``` -Exit 2 means stop. Then: - -1. **Don't publish.** A half-applied version published as a new one is the worst outcome — the - reviewer now has to review your interrupted work. -2. Leave the file as it is. Say plainly what you had already changed and what you hadn't. -3. Run `node "$SKILL/assets/review-server.mjs" cancelled --file "$FILE" --round `. - The review is still open; only this round ended, and the watcher is still running. +It always exits 0. It exists to name a round sitting in the queue that nobody has claimed: if it does, +claim that round before anything else, because comments are sitting unread. The longer the round, the more it matters: a check costs nothing, and one that never runs makes the button a lie. @@ -233,7 +243,7 @@ On a review landing: --round r17 --label "Filters collapsed, overdue sorts first" --addressed c1f3k2,c9dk1 ``` Only `--addressed` marks a comment done. Publish fails before creating a version if the round was - not claimed, an id is unknown or stale, Stop is outstanding, or any open comment is unaccounted for. + not claimed, an id is unknown or stale, or any open comment is unaccounted for. 7. Leave **`watch_stream` running** and say what changed in a few lines. Then wait — don't ask "shall I continue?", the loop is the point. (Only re-arm if you used one-shot `watch` without `--stream`.) **Closing the browser tab closes the review.** The workspace holds an SSE @@ -303,7 +313,7 @@ node "$SKILL/assets/bundle-artifact.mjs" --file "$FILE" --out review.html ## 7 · Reviewing a UI that already exists Point the same workspace at a running app and everything above still holds — the -modes, the marks, the threads, the timeline, Stop, Approve. Three things differ, +modes, the marks, the threads, the timeline, Approve. Three things differ, and they all follow from the same fact: **what is under review is code, not a file you own.** @@ -406,12 +416,12 @@ route. - State lives in `/.vstack/local/review//` beside the file — versions, reviews, threads, and the sentinels. The page itself stays clean. A live review has nothing to sit beside, so it lands in `.vstack/local/review//` under the directory you started it from. - **Every vstack tool writes under `.vstack/local//`**, so a project grows one dot-directory, not one per engine. `lib/workdir.mjs` resolves it — use that rather than joining the path by hand. One gitignore line covers the lot (`**/.vstack/local/`); the rest of `.vstack/` is the pipeline and belongs in the repo. - The server binds to `127.0.0.1` only. Port 7788 busy usually means a review server is already running — pass `--port`. -- `node "$SKILL/assets/review-server.mjs" status --file "$FILE"` prints the current version, whether a review is waiting, and any stop / sign-off / share request outstanding. -- `check --file "$FILE"` is the same question reduced to an exit code — 0 carry on, 2 stop. Use it inside a round, where `status` is too much output to read repeatedly. +- `node "$SKILL/assets/review-server.mjs" status --file "$FILE"` prints the current version, whether a review is waiting, and any sign-off / share request outstanding. +- `check --file "$FILE"` is the same question in one line, and always exits 0. Use it inside a round, where `status` is too much output to read repeatedly. If it names a round waiting unclaimed, claim that round before anything else — comments are sitting unread. - **Every command takes `--name ` in place of `--file` for a live review** — `publish`, `reply`, `share`, `status`, `check`. The brief tells you which name to use. - Full command reference and troubleshooting: `references/workflow.md`. - Contracts: `plugins/vstack/contracts/` — Host ops and review-loop protocol. -- Host adapters: `hosts/claude.md`, `hosts/codex.md`, `hosts/grok.md`. +- Host adapters: `skills/review/hosts/claude.md`, `skills/review/hosts/codex.md`, `skills/review/hosts/grok.md`. ## State & handoff diff --git a/plugins/vstack/skills/review/assets/review-server.mjs b/plugins/vstack/skills/review/assets/review-server.mjs index e2acb1b..0c29e07 100644 --- a/plugins/vstack/skills/review/assets/review-server.mjs +++ b/plugins/vstack/skills/review/assets/review-server.mjs @@ -21,10 +21,10 @@ * node review-server.mjs claim --file --round r1 * node review-server.mjs publish --file --round r1 --label "…" [--addressed c1,c3] * node review-server.mjs reply --file --round r1 --comment --text "…" - * node review-server.mjs cancelled --file --round r1 + * node review-server.mjs ack --file --token * node review-server.mjs share --file --url * node review-server.mjs status --file - * node review-server.mjs check --file (exit 2 = stop asked) + * node review-server.mjs check --file (names a round nobody has claimed) * node review-server.mjs watch --file (blocks until there is something to do) * * Every command takes `--app ` or `--name ` in place of `--file` when @@ -38,13 +38,17 @@ * versions/v.meta.json label, date, what it addressed * reviews/v/ annotations.json · feedback.json · feedback.md * pending sentinel written on send, watched by the agent + * handshake a stream watcher waiting to be told its events land * rounds/r.json durable round membership and completion record - * cancel sentinel written when the reviewer calls a round off * approved sentinel written on sign-off — the review is over * share sentinel — they want a shareable public link * url the live URL — present only while the server runs * watching heartbeat — an agent session is waiting on this review * + * A serve also leaves a pointer to that store under the directory it was run + * from — `/.vstack/local/review/.serving/` — so `watch --all`, run + * from the same place, finds a review whose page lives somewhere else entirely. + * * Serving opens the workspace in the machine's default browser as soon as it is * up — `--no-open`, or VSTACK_NO_OPEN=1, for a run that should not. * @@ -63,9 +67,9 @@ import https from 'node:https' import zlib from 'node:zlib' import fs from 'node:fs' import path from 'node:path' -import { createHash } from 'node:crypto' +import { createHash, randomBytes } from 'node:crypto' import { fileURLToPath } from 'node:url' -import { checkForUpdate, dismissUpdate, withUpdate } from '../../../lib/update-check.mjs' +import { checkForUpdate, currentVersion, dismissUpdate, withUpdate, withVersion } from '../../../lib/update-check.mjs' import { resolveHostId, loadHost, withHost, AGENT_ROLE, REVIEWER_ROLE } from '../../../lib/host.mjs' import { workDir, subjectDir, toolNames, LOCAL, TOOL } from '../../../lib/workdir.mjs' import { writeAtomic, watchingRecently, startHeartbeat, startPresence, openInBrowser } from '../../../lib/live-link.mjs' @@ -127,7 +131,7 @@ if (LIVE) { console.error(' are rewritten to stay inside the proxy, but bot protection, a login wall or a') console.error(' strict CSRF check can still refuse it. If the site misbehaves, say so.') } -} else if (args._ === 'watch' && (args.all === true || args.all === 'true')) { +} else if (['watch', 'ack'].includes(args._) && (args.all === true || args.all === 'true')) { /* `watch --all` names no subject on purpose — it finds the live ones itself, so a session with several pages open arms one waiter instead of one each. */ DIR = process.cwd(); NAME = 'all'; STORE = workDir(DIR, TOOL.review) @@ -160,7 +164,7 @@ const P = { round: id => path.join(STORE, 'rounds', `${id}.json`), lock: () => path.join(STORE, 'transition.lock'), pending: () => path.join(STORE, 'pending'), - cancel: () => path.join(STORE, 'cancel'), + handshake: () => path.join(STORE, 'handshake'), approved: () => path.join(STORE, 'approved'), share: () => path.join(STORE, 'share'), url: () => path.join(STORE, 'url'), @@ -300,6 +304,9 @@ function commentRevision (comment) { return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16) } +/* `cancelled` is still terminal here although nothing writes it any more: a + store filled before the Stop control was withdrawn can hold one, and reading + it as live would hand the agent a round the reviewer called off. */ function loadActiveRound (state = loadState()) { if (!state.activeRound) return null const round = readJSON(P.round(state.activeRound)) @@ -344,7 +351,6 @@ function nextRound (version, comments, feedback) { round.comments = [...members.values()] round.feedback = feedback round.updatedAt = new Date().toISOString() - if (fs.existsSync(P.cancel())) round.status = 'queued' return saveActiveRound(round) } @@ -375,12 +381,26 @@ function migrateLegacyPending () { return round } +/* "Linked" must mean someone will act on what the reviewer sends, not that a + watch process is alive. The heartbeat proves the process; a round nobody + claims within this window proves its events go unread — a watcher started + with the wrong host op, a dead session, and a killed watcher all look the + same from here. 90s gives an agent mid-turn time to reach the claim. */ +/* A round leaves the queue only by being claimed, so its wait is measured from + when it was created. Nothing else about the round decides this: a heartbeat + with a round nobody has picked up is the state this exists to catch. */ +const CLAIM_STALL_MS = 90_000 +const roundStalled = round => !!round && round.status === 'queued' && + Date.now() - Date.parse(round.createdAt || '') > CLAIM_STALL_MS +const agentListening = () => someoneWatching() && !roundStalled(loadActiveRound()) + function roundSummary (round) { if (!round) return null return { id: round.id, status: round.status, baseVersion: round.baseVersion, comments: (round.comments || []).map(comment => comment.id), createdAt: round.createdAt, claimedAt: round.claimedAt || null, + stalled: roundStalled(round), } } @@ -438,7 +458,6 @@ function cmdPublish (quiet) { else if (requestedRound !== active.id) errors.push(`active round is ${active.id}, not ${requestedRound}`) if (active.status !== 'active') errors.push(`claim ${active.id} before publishing it`) if (args.replace === true || args.replace === 'true') errors.push('--replace cannot complete an active review round') - if (fs.existsSync(P.cancel())) errors.push('the reviewer asked to stop this round') const members = new Map((active.comments || []).map(comment => [comment.id, comment])) for (const id of addressed) if (!members.has(id)) errors.push(`${id} does not belong to ${active.id}`) @@ -467,9 +486,6 @@ function cmdPublish (quiet) { } else if (addressed.length) { console.error('Cannot mark comments addressed without an active review round') process.exit(2) - } else if (fs.existsSync(P.cancel())) { - console.error('Cannot publish: the reviewer asked to stop') - process.exit(2) } // Validation is complete. Nothing above this line mutates a version or a @@ -591,25 +607,6 @@ function cmdClaim () { touch() } -/** Acknowledge that a requested stop was honored. Publish remains blocked until - * this explicit transition closes the active round and clears the request. */ -function cmdCancelled () { - const request = readJSON(P.cancel()) - if (!request) { console.error('No cancel request to acknowledge'); process.exit(2) } - const round = loadActiveRound() - const requested = args.round && args.round !== true ? String(args.round) : null - if (round && (!requested || requested !== round.id)) { - if (!requested) console.error(`Include --round ${round.id}`) - else console.error(`Active round is ${round.id}, not ${requested}`) - process.exit(2) - } - if (round) finishActiveRound(round, 'cancelled', { reason: request.reason || null }) - fs.rmSync(P.pending(), { force: true }) - fs.rmSync(P.cancel(), { force: true }) - console.log(`Cancelled ${round?.id || 'the current review round'} — open comments were left open`) - touch() -} - /** * Hand the published Artifact's URL back to the workspace. It appears under the * ▾ beside Send, tagged with the version it was published from — so a link that @@ -631,28 +628,18 @@ function cmdShare () { } /** - * "Should I still be doing this?" — one cheap call, made between steps of a - * round. Exit 2 means the reviewer pressed Stop while you were working. - * - * This is the whole mechanism behind Stop, and it only works if it is actually - * called: nothing here can interrupt a turn that is already running, so a round - * that never checks cannot be stopped until it ends. + * "Is anything waiting on me?" — one cheap call, made between steps of a round. + * A round sitting in the queue is named here and nothing suppresses it: an agent + * asking and being told nothing, twice, while six comments sat queued is exactly + * how a broken watcher stays broken. Exit is always 0. */ function cmdCheck () { - const req = fs.existsSync(P.cancel()) ? readJSON(P.cancel(), {}) : null - if (!req) { - if (!args.quiet) console.log('carry on') - process.exit(0) - } - console.log('STOP — the reviewer asked you to stop this round.') - console.log(` asked at ${req.at || 'unknown'}`) - if (req.comments?.length) console.log(` in flight ${req.comments.join(', ')}`) - console.log(` reason ${req.reason || '(none given)'}`) - console.log('\nStop where you are. Do not publish a half-applied version. Tell the') - console.log('reviewer what you had already changed and what you left alone, then acknowledge it:') - const round = loadActiveRound() - console.log(` node review-server.mjs cancelled ${SUBJECT}${round ? ` --round ${round.id}` : ''}`) - process.exit(2) + const waiting = loadActiveRound() + if (waiting?.status === 'queued') { + console.log(`carry on — but ${waiting.id} (${(waiting.comments || []).length} comment(s), sent ${waiting.createdAt}) is waiting unclaimed.`) + console.log(`Claim it: node review-server.mjs claim ${SUBJECT} --round ${waiting.id}`) + } else console.log('carry on') + process.exit(0) } /** @@ -677,6 +664,44 @@ const storeFor = f => { } const inStore = (store, name) => path.join(store, name) +/* Where a server records the store it is serving, for the benefit of a watcher + that cannot walk to it. + + A review's store sits beside the page under review, and `watch --all` finds + stores by walking the directory it was run from. A page written outside that + directory — a temp directory is the usual one, since that is where an agent + puts a file it just generated — takes its store with it, and the watcher + walks right past a review that is running. It then heartbeats into nothing + while the workspace says Unlinked, which is the one failure this protocol + must not have. + The directory both processes share is the one the session ran them from, so + the server leaves a pointer there naming its real store. Keyed by the store + path, so a second serve from the same place adds a pointer rather than + overwriting one. */ +const servingDir = from => path.join(workDir(from, TOOL.review), '.serving') +const servingFile = (from, store) => + path.join(servingDir(from), createHash('sha1').update(store).digest('hex').slice(0, 12)) + +/** Stores pointed at from `from`, minus any whose server is gone. */ +function pointedStores (from) { + const found = [] + for (const tool of toolNames(TOOL.review)) { + const dir = path.join(workDir(from, tool), '.serving') + let entries = [] + try { entries = fs.readdirSync(dir) } catch { continue } + for (const name of entries) { + const pointer = path.join(dir, name) + let store = '' + try { store = fs.readFileSync(pointer, 'utf8').trim() } catch { continue } + // The pointer is written after `url` and removed with it, so a pointer + // with no `url` behind it belongs to a server that was killed outright. + if (store && fs.existsSync(path.join(store, 'url'))) found.push(store) + else fs.rmSync(pointer, { force: true }) + } + } + return found +} + /** Every store with a server behind it — `url` exists only while one runs. */ function liveStores (from = process.cwd(), depth = 5) { const found = [] @@ -715,7 +740,48 @@ function liveStores (from = process.cwd(), depth = 5) { } } walk(from, depth) - return found + // A store found both ways is one review, so compare resolved paths. + return [...new Set([...found, ...pointedStores(from)].map(store => path.resolve(store)))] +} + +/* How long a stream watcher waits to be told its events are being read. Long + enough that a session which started it mid-turn still gets there; + `--handshake-timeout ` for a host that needs longer. */ +const HANDSHAKE_MS = Math.max(1, Number(args['handshake-timeout']) || 120) * 1000 +/* Live only once a stream watcher has been answered, or straight away for the + one-shot watch, which proves itself by exiting. */ +let heartbeat = null +const stopBeating = () => { heartbeat?.stop(); heartbeat = null } + +/** + * Answer a stream watcher's handshake. Only a session that can run commands can + * do this, which is exactly what the watcher needs to know about itself. + */ +function cmdAck () { + const waiting = readJSON(P.handshake()) + if (!waiting) { + // Naming the directory it looked in, because the usual reason to find + // nothing is being somewhere else: `--all` resolves the handshake from the + // working directory, and an ack run from a different one reads a file that + // was never there rather than the one the watcher wrote. + console.log('Nothing to answer — no watcher is waiting on a handshake for this review.') + console.log(`Looked in ${STORE}`) + return + } + const token = args.token && args.token !== true ? String(args.token) : null + if (token !== waiting.token) { + console.error('That is not the token the waiting watcher printed — read its HANDSHAKE line again.') + process.exit(2) + } + /* Answered, not gone. A second watcher overwrites the first one's handshake, + so a watcher that read "the file I wrote is missing" as "someone answered + me" would go live on an answer addressed to another process — and the one + nobody answered would heartbeat forever. The token stays on the record, and + only the watcher that owns it clears it. */ + writeJSON(P.handshake(), { ...waiting, answeredAt: new Date().toISOString() }) + // Whether the workspace goes Linked is the watcher's to report: it knows + // which reviews it covers, and this command does not. + console.log('Answered — the watcher is wired to this session. Read its next line.') } /** @@ -731,12 +797,74 @@ function liveStores (from = process.cwd(), depth = 5) { * * node review-server.mjs watch --all --stream */ -async function cmdStream (stores, beatAll, stop, label, all) { +async function cmdStream (stores, label, all, subjectFlags) { const seen = new Map(stores.map(s => [s, { sent: null, flags: new Set(), replies: repliesIn(s) }])) const say = line => { process.stdout.write(line + '\n') } say(`WATCHING ${stores.length} review(s): ${stores.map(label).join(', ')}`) + /* Presence is proven before it is claimed. Nothing here can tell which tool + started this process — every host spawns children the same way — so ask for + the one thing only a live session can do, and run a command back. The + heartbeat starts when that lands, which is what makes the page's Linked + mean a session is receiving this stream. */ + const token = randomBytes(4).toString('hex') + fs.mkdirSync(STORE, { recursive: true }) + writeJSON(P.handshake(), { token, at: new Date().toISOString(), pid: process.pid }) + say(`HANDSHAKE this stream is not live until you answer it. Run now:`) + say(` node "${process.argv[1]}" ack ${subjectFlags} --token ${token}`) + const askedAt = Date.now() + + /* Answered means answered *here*. A second watcher on the same review + overwrites this record, so an answer carrying someone else's token is not + this watcher's to act on — and only the watcher that owns the record clears + it. Without that, the watcher nobody answered goes live too, and heartbeats + long after the answered one has stopped. */ + const mine = () => readJSON(P.handshake())?.token === token + const answered = () => { const record = readJSON(P.handshake()); return record?.token === token && !!record.answeredAt } + + /* The handshake proves a session is reading this stream. It says nothing + about whether the stream reaches the review the reviewer is looking at, and + a watcher covering no store heartbeats into nothing — so LINKED waits for a + store to be under it, and the gap is named rather than papered over. */ + let saidLinked = false, saidUnlinked = false, answeredAt = 0 + const sayLink = () => { + if (saidLinked || !stores.length) return + saidLinked = true + say('LINKED handshake answered — the workspace says Linked from here') + } + /* Arming the watcher before the serve is a supported order, and a review that + turns up a moment later needs no explaining — so the empty case is only + worth reporting once it has had time to stop being empty. */ + const EMPTY_LINK_MS = 15_000 + const sayNoLink = () => { + if (saidLinked || saidUnlinked || Date.now() - answeredAt < EMPTY_LINK_MS) return + saidUnlinked = true + say(`UNLINKED handshake answered, but no live review is visible from ${process.cwd()},`) + say(' so no workspace says Linked. A serve started here is picked up on its') + say(' own; one already running for a page outside this directory is not —') + say(' for that, start this again with the tool your adapter names for') + say(` watch_stream: node "${process.argv[1]}" watch --file --stream`) + } + while (true) { + if (!heartbeat) { + if (answered()) { + fs.rmSync(P.handshake(), { force: true }) + answeredAt = Date.now() + heartbeat = startHeartbeat(() => stores.map(store => inStore(store, 'watching'))) + sayLink() + } else if (Date.now() - askedAt > HANDSHAKE_MS) { + /* Exiting is the point: on a host where a finished background command + re-invokes the session, this delivers itself to whoever started the + watcher. */ + if (mine()) fs.rmSync(P.handshake(), { force: true }) + say('UNWIRED the handshake went unanswered, so these events reach no one.') + say(' Start this again with the Host op watch_stream, using the tool') + say(' your Host adapter names for it.') + return process.exit(3) + } + } else sayNoLink() + for (const store of [...stores]) { const at = n => inStore(store, n) const was = seen.get(store) @@ -750,7 +878,7 @@ async function cmdStream (stores, beatAll, stop, label, all) { } // Each sentinel is announced once per appearance, not once per poll. - for (const [file, what] of [['approved', 'APPROVED '], ['cancel', 'CANCELLED'], ['share', 'SHARE ']]) { + for (const [file, what] of [['approved', 'APPROVED '], ['share', 'SHARE ']]) { if (fs.existsSync(at(file))) { if (!was.flags.has(file)) { say(`${what} ${label(store)} · read ${at(file)}`); was.flags.add(file) } } else was.flags.delete(file) @@ -783,15 +911,17 @@ async function cmdStream (stores, beatAll, stop, label, all) { seen.set(store, { sent: null, flags: new Set(), replies: repliesIn(store) }) say(`OPENED ${label(store)} · now watching ${stores.length} review(s)`) } + // A review that arrives after the handshake is what makes the link real. + if (heartbeat) sayLink() } // With --all, an empty set means "no tab open right now" — keep the // stream and the heartbeat path alive so a later serve can OPENED in. // Without --all, empty means the only subject closed: done. if (!stores.length) { - if (!all) { stop(); say('CLOSED nothing left to watch'); return process.exit(0) } + if (!all) { stopBeating(); say('CLOSED nothing left to watch'); return process.exit(0) } } - beatAll() + heartbeat?.beat() await new Promise(r => setTimeout(r, 1000)) } } @@ -831,12 +961,8 @@ async function cmdWatch () { } const label = store => path.basename(store) - // `stores` shrinks as reviews close; the heartbeat re-reads it every beat. - const hb = startHeartbeat(() => stores.map(store => inStore(store, 'watching'))) - const beatAll = hb.beat - const stop = hb.stop - process.on('SIGINT', () => { stop(); process.exit(130) }) - process.on('SIGTERM', () => { stop(); process.exit(143) }) + process.on('SIGINT', () => { stopBeating(); process.exit(130) }) + process.on('SIGTERM', () => { stopBeating(); process.exit(143) }) touch() // the page hears about it straight away if (args.stream === true || args.stream === 'true') { @@ -845,9 +971,14 @@ async function cmdWatch () { if (!stores.length && all) { process.stdout.write('WATCHING 0 review(s): waiting for a live serve…\n') } - return cmdStream(stores, beatAll, stop, label, all) + // The stream arms its heartbeat only once its handshake is answered. + return cmdStream(stores, label, all, all ? '--all' : SUBJECT) } + /* The one-shot form proves itself by exiting, which is what delivers its + event, so it needs no handshake. `stores` shrinks as reviews close, and the + heartbeat re-reads it every beat. */ + heartbeat = startHeartbeat(() => stores.map(store => inStore(store, 'watching'))) console.log(`watching ${stores.length} review(s): ${stores.map(label).join(', ')}`) /* Exiting IS the wake-up — a running process cannot interrupt an idle agent session, so the only way to be called is to finish. That makes re-arming @@ -856,7 +987,7 @@ async function cmdWatch () { puts it back. Prefer `watch --stream` via Host op watch_stream. */ const rearm = `node "${process.argv[1]}" ${process.argv.slice(2).join(' ')}` const done = (what, store, file) => { - stop() + stopBeating() console.log(`${what} ${label(store)}`) if (file) { try { console.log(fs.readFileSync(file, 'utf8')) } catch {} } console.log(`\nThis one-shot watch has now ended. Either restart it:\n ${rearm}`) @@ -871,7 +1002,6 @@ async function cmdWatch () { for (const store of [...stores]) { const at = n => inStore(store, n) if (fs.existsSync(at('approved'))) return done('APPROVED', store, at('approved')) - if (fs.existsSync(at('cancel'))) return done('CANCELLED', store, at('cancel')) if (fs.existsSync(at('share'))) return done('SHARE', store, at('share')) if (fs.existsSync(at('pending'))) return done('REVIEW', store, at('pending')) if (!fs.existsSync(at('url'))) { @@ -883,7 +1013,7 @@ async function cmdWatch () { if (!stores.length) break await new Promise(r => setTimeout(r, 1000)) } - stop() + stopBeating() console.log('CLOSED — nothing left to watch. Nothing to re-arm.') process.exit(0) } @@ -904,7 +1034,6 @@ function cmdStatus () { versions: listVersions().map(v => `v${v.n}: ${v.label}`), activeRound: roundSummary(loadActiveRound(state)), pendingReview: fs.existsSync(P.pending()) ? readJSON(P.pending(), {}) : null, - cancelRequest: fs.existsSync(P.cancel()) ? readJSON(P.cancel(), {}) : null, approved: fs.existsSync(P.approved()) ? readJSON(P.approved(), {}) : null, shareRequest: fs.existsSync(P.share()) ? readJSON(P.share(), {}) : null, shareUrl: loadState().shareUrl || null, @@ -917,7 +1046,7 @@ const clients = new Set() let reloadTimer = null /* Only when it changes — a heartbeat file ticking every two seconds is not worth a message every two seconds. */ -startPresence(clients, someoneWatching).unref?.() +startPresence(clients, agentListening).unref?.() /* Set once the server is listening, so a request handler can end the review. */ let closeServer = null /* Live-page bookkeeping, so the server can close itself when the tab does. */ @@ -965,6 +1094,9 @@ function payload () { } return { mode: LIVE ? 'live' : 'local', + // What this server is on now. A tab opened before an update still holds the + // version that served it, so the workspace can show both. + version: currentVersion(), name: pageName(), fileName: LIVE ? (APP ? APP.host : state.app || '') : path.basename(FILE), app: appOrigin(), @@ -983,12 +1115,12 @@ function payload () { or a second tab, showed a review where nothing was happening. */ pendingReview: fs.existsSync(P.pending()) ? readJSON(P.pending(), {}) : null, activeReview: roundSummary(activeRound), - cancelRequest: fs.existsSync(P.cancel()) ? readJSON(P.cancel(), {}) : null, /* Whether an agent session is actually waiting on this review. The link dot used to say "Linked" whenever the page could reach this server, which is a fact about the browser and the file server — not about anyone being - there to read what you send. */ - watching: someoneWatching(), + there to read what you send. A live heartbeat with a round sitting + unclaimed is the same lie one layer up, so that drops it too. */ + watching: agentListening(), } } @@ -1197,7 +1329,7 @@ function proxyUpgrade (req, socket, head) { function serveWorkspace (res) { let html = fs.readFileSync(path.join(HERE, 'workspace.html'), 'utf8') if (BASE) html = html.replace(//i, `\n`) - html = withHost(html, HOST_PROFILE) + html = withVersion(withHost(html, HOST_PROFILE)) send(res, 200, withUpdate(html, update), MIME['.html']) } @@ -1225,7 +1357,7 @@ async function handle (req, res) { idleSince = null // Presence rides the same stream: a waiter starting or stopping is news the // page needs, and it is the one change no file write announces. - try { res.write(`event: presence\ndata: ${JSON.stringify({ watching: someoneWatching() })}\n\n`) } catch {} + try { res.write(`event: presence\ndata: ${JSON.stringify({ watching: agentListening() })}\n\n`) } catch {} const ping = setInterval(() => { try { res.write(': ping\n\n') } catch {} }, 25000) req.on('close', () => { clearInterval(ping) @@ -1329,8 +1461,6 @@ font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif;color:#667;backgr feedback: path.join(dir, 'feedback.md'), sentAt: stillOut ? prev.sentAt : new Date().toISOString(), }) - // A new review supersedes any earlier "stop" — they have moved on. - fs.rmSync(P.cancel(), { force: true }) console.log(`\n● ${round.id} sent for v${n} — ${round.comments.length} comment(s) → ${path.join(dir, 'feedback.md')}`) return sendJSON(res, 200, { ok: true, roundId: round.id }) }) @@ -1363,24 +1493,6 @@ font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif;color:#667;backgr touch() return sendJSON(res, 200, { ok: true }) } - if (p === '/api/cancel' && req.method === 'POST') { - const body = JSON.parse(await readBody(req) || '{}') - const n = Number(body.version) || loadState().version - return withStoreLock(() => { - writeJSON(P.cancel(), { - page: FILE || appOrigin(), app: appOrigin(), - name: pageName(), - version: n, - comments: body.comments || [], - reason: body.reason || 'The reviewer cancelled this round.', - at: new Date().toISOString(), - }) - fs.rmSync(P.pending(), { force: true }) - console.log(`\n■ Cancel requested on v${n} — stop, then tell the reviewer what you had already changed`) - touch() - return sendJSON(res, 200, { ok: true }) - }) - } /** * Sign-off. The review is over: write the verdict and close the server, which * removes `url` and ends the waiter — so the same exit that means "tab closed" @@ -1413,7 +1525,6 @@ font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif;color:#667;backgr outcomes: Object.fromEntries((active.comments || []).map(comment => [comment.id, 'left_open_on_approval'])), }) fs.rmSync(P.pending(), { force: true }) - fs.rmSync(P.cancel(), { force: true }) const left = openComments.length console.log(`\n✓ Approved at v${n}${left ? ` — ${left} comment(s) left unapplied` : ''} — the review is closed`) sendJSON(res, 200, { ok: true }) @@ -1465,11 +1576,8 @@ async function cmdServe () { saveState(state) } // Terminal signals belong to the review that raised them. A new one starts - // clean, or the first waiter it arms fires on last week's verdict. An active - // round is recovery, not a new review: preserve its Stop request across a - // server restart so publication cannot slip past it. + // clean, or the first waiter it arms fires on last week's verdict. fs.rmSync(P.approved(), { force: true }) - if (!loadActiveRound()) fs.rmSync(P.cancel(), { force: true }) fs.rmSync(P.share(), { force: true }) const port = Number(args.port || 7788) const server = http.createServer((req, res) => { @@ -1506,6 +1614,7 @@ async function cmdServe () { stops waiting instead of hanging until its timeout. */ const close = why => { try { fs.rmSync(P.url(), { force: true }) } catch {} + try { fs.rmSync(servingFile(process.cwd(), STORE), { force: true }) } catch {} console.log(`closed (${why})`) process.exit(0) } @@ -1548,6 +1657,11 @@ async function cmdServe () { server.listen(port, '127.0.0.1', () => { fs.mkdirSync(STORE, { recursive: true }) fs.writeFileSync(P.url(), url + '\n') + // So `watch --all`, run from here, finds this review wherever the page lives. + try { + fs.mkdirSync(servingDir(process.cwd()), { recursive: true }) + writeAtomic(servingFile(process.cwd(), STORE), STORE + '\n') + } catch {} console.log(`${LIVE ? 'live review' : 'wireframe'} · ${pageName()} · v${loadState().version}`) console.log(` workspace ${url}`) console.log(LIVE ? ` app ${APP.origin} (proxied)` : ` page ${FILE}`) @@ -1563,13 +1677,13 @@ switch (args._) { case 'publish': withStoreLock(() => cmdPublish()); break case 'claim': withStoreLock(cmdClaim); break case 'reply': withStoreLock(cmdReply); break - case 'cancelled': withStoreLock(cmdCancelled); break + case 'ack': withStoreLock(cmdAck); break case 'share': withStoreLock(cmdShare); break case 'status': cmdStatus(); break case 'check': cmdCheck(); break case 'watch': cmdWatch(); break case 'serve': cmdServe(); break default: - console.error(`Unknown command "${args._}". Use: serve | claim | publish | reply | cancelled | share | status | check | watch`) + console.error(`Unknown command "${args._}". Use: serve | claim | publish | reply | ack | share | status | check | watch`) process.exit(1) } diff --git a/plugins/vstack/skills/review/assets/workspace.html b/plugins/vstack/skills/review/assets/workspace.html index aa66cb6..3b23108 100644 --- a/plugins/vstack/skills/review/assets/workspace.html +++ b/plugins/vstack/skills/review/assets/workspace.html @@ -81,8 +81,13 @@ /* The column is what animates: the track collapses to nothing while the panel slides out through it, so the page under review grows into the space instead of the panel simply vanishing from it. */ +/* The clip is load-bearing, not tidiness. A closed track still holds the panel + at its full width (see below), and Safari counts that overhang as page + content it has to fit: it scales the whole workspace to about 85% and leaves + the window's own background down the right and along the bottom. Clipping + here keeps the overhang inside the box that the track belongs to. */ #main{display:grid;grid-template-columns:minmax(0,1fr) var(--pw,var(--panelw));min-height:0; - transition:grid-template-columns .18s ease-out} + overflow:hidden;transition:grid-template-columns .18s ease-out} #canvasArea{grid-column:1;grid-row:1;overflow:hidden} #panel{grid-column:2;grid-row:1;position:relative;z-index:40} @@ -264,6 +269,18 @@ .cogmenu[hidden]{display:none} .cogmenu .row{display:flex;align-items:center;gap:10px;justify-content:space-between} .cogmenu .lbl{font-size:12px;color:var(--ink-2);white-space:nowrap} +.cogmenu .about{align-items:flex-start} +.cogmenu .abouts{display:grid;gap:3px;justify-items:end} +.cogmenu .abouts .one{display:flex;gap:8px;align-items:baseline} +/* An author `display` beats the UA rule for [hidden], so every row that is + hidden from script needs its own. A page that never hears a server version + showed "server —" for good without this. */ +.cogmenu .abouts .one[hidden]{display:none} +.cogmenu .abouts em{font-style:normal;font-size:10.5px;letter-spacing:.03em;color:var(--ink-3)} +.cogmenu .abouts b{font:600 11px/1.3 var(--mono);color:var(--ink);font-variant-numeric:tabular-nums} +.cogmenu .stale{font-size:11px;line-height:1.4;color:var(--brand);justify-content:flex-start} +.cogmenu .stale[hidden]{display:none} +.cogmenu .about[hidden]{display:none} /* the live link, stated rather than implied */ .linkdot{display:inline-flex;align-items:center;gap:6px;font:600 10.5px/1 var(--mono); @@ -375,12 +392,6 @@ two things. */ #hiNew{display:none} body.phase #hiNew{display:inline-flex} -/* Send becomes Stop while a round is out. Not red-on-red: a stop is a quiet - correction, not a second call to action competing with the one it replaces. */ -#btnSend.stop{background:var(--surface);border-color:var(--brand-line);color:var(--brand)} -#btnSend.stop:hover{background:var(--brand);border-color:var(--brand);color:#fff;filter:none} -#btnSend.stop[disabled]{opacity:1;color:var(--ink-3);border-color:var(--line-2)} -.split #btnSend.stop + .caret{border-left-color:var(--brand-line)} .btn .kbd{font:500 10px/1 var(--mono);opacity:.6} .seg .kbd{font:500 10px/1 var(--mono);opacity:.65} /* Icon-only sizes — wider cells, nothing else. Selected is the shell's selected @@ -450,7 +461,8 @@ #stageArea{position:relative;overflow:hidden;background:var(--surface-2); background-image:radial-gradient(circle at 1px 1px,var(--line-2) 1px,transparent 0); background-size:22px 22px} -#canvasWrap{position:absolute;inset:0;overflow:auto} +/* The left inset is the toolbar's strip — see #toolbar below. */ +#canvasWrap{position:absolute;inset:0 0 0 54px;overflow:auto} #canvas{padding:26px;display:flex;justify-content:center;min-height:100%;align-items:flex-start} /* transform-origin is top-left so the negative margins in setZoom() shrink the @@ -517,6 +529,51 @@ #drag{position:absolute;border:2px dashed var(--brand);border-radius:3px;display:none; background:color-mix(in srgb,var(--brand) 8%,transparent);pointer-events:none} +/* ── move: an arrow from a thing to where it should go ── + The mark itself has no size. It sits at the start of the drag and hangs its + parts off that point, so the number and the note stay upright while the shaft + between the two ends turns. */ +.mark.move,.mark.strike{width:0;height:0} +.mark.move .shaft{position:absolute;left:0;top:-1px;height:2px;background:var(--mk); + transform-origin:0 50%;pointer-events:auto;cursor:pointer} +/* Two pixels is nothing to aim at, so the line takes clicks from a band around + itself. */ +.mark.move .shaft::before{content:'';position:absolute;inset:-7px 0} +.mark.move .shaft::after{content:'';position:absolute;right:-1px;top:50%;width:0;height:0; + border:5px solid transparent;border-left:9px solid var(--mk);transform:translateY(-50%)} +.mark.move .tail{position:absolute;left:-3.5px;top:-3.5px;width:7px;height:7px; + border-radius:50%;background:var(--mk)} +/* Where the thing should end up, at the size it is now — the arrow says the + direction and this says what will be sitting there. */ +.mark.move .drop{position:absolute;border:1.5px dashed var(--mk);border-radius:3px;opacity:.75; + background:color-mix(in srgb,var(--mk) 6%,transparent)} + +/* ── delete: struck through and marked for removal ── + One bar per line of struck text. Taking a whole element strikes every line + written in it and draws the element's edge round the lot. */ +.mark.strike .bar{position:absolute;border-radius:2px;pointer-events:auto;cursor:pointer; + background:color-mix(in srgb,var(--mk) 13%,transparent)} +.mark.strike .bar::after{content:'';position:absolute;left:0;right:0;top:50%;height:2px; + margin-top:-1px;background:var(--mk)} +.mark.strike .box{position:absolute;border:1.5px solid var(--mk);border-radius:3px; + background:color-mix(in srgb,var(--mk) 6%,transparent);pointer-events:auto;cursor:pointer} +.mark.strike .box[hidden]{display:none} + +.mark.move .lbl,.mark.strike .lbl{position:absolute;left:9px;top:7px;width:max-content; + max-width:280px;background:var(--mk);color:#fff;padding:3px 7px;border-radius:4px; + font:500 11.5px/1.35 var(--font);white-space:pre-wrap;box-shadow:0 1px 4px rgba(0,0,0,.2)} +/* What Move would pick up if the pointer went down here. Move is the one tool + whose gesture starts on a specific thing rather than at a place, so it says + which thing before the drag rather than after it. */ +#hoverBox{position:absolute;display:none;pointer-events:none;border:1.5px solid var(--brand); + border-radius:3px;background:color-mix(in srgb,var(--brand) 7%,transparent)} +/* The preview drawn while the arrow is being dragged, before there is a mark. */ +#dragArrow{position:absolute;display:none;pointer-events:none} +#dragArrow .shaft{position:absolute;left:0;top:-1px;height:2px;background:var(--brand); + transform-origin:0 50%} +#dragArrow .shaft::after{content:'';position:absolute;right:-1px;top:50%;width:0;height:0; + border:5px solid transparent;border-left:9px solid var(--brand);transform:translateY(-50%)} + /* ── on-canvas composer ── */ #composer{position:absolute;z-index:30;width:280px;background:var(--surface);border:1px solid var(--line-2); border-radius:10px;box-shadow:var(--shadow-pop);display:none;overflow:hidden} @@ -598,16 +655,13 @@ /* ── work in flight ── Progress lives on the comments themselves, so it is obvious *which* ones are - being worked on — and cancel sits next to them, where the doubt is. */ + being worked on. */ #pwork{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid var(--line); background:var(--brand-soft)} #pwork[hidden]{display:none} #pwork .spin{width:12px;height:12px;border-radius:50%;border:2px solid var(--brand-line); border-top-color:var(--brand);animation:spin .7s linear infinite;flex:none} #pwork .ptxt{flex:1;min-width:0;font-size:11.5px;color:var(--ink-2);line-height:1.35} -#pwork .pcancel{font-size:11.5px;font-weight:600;color:var(--brand);white-space:nowrap;flex:none; - border:1px solid var(--brand-line);border-radius:6px;padding:3px 8px;background:var(--surface)} -#pwork .pcancel:hover{background:var(--brand);border-color:var(--brand);color:#fff} @keyframes spin{to{transform:rotate(360deg)}} @keyframes sweep{0%{left:-38%}55%{left:100%}100%{left:100%}} #pfoot{border-top:1px solid var(--line);padding:9px 10px;display:flex;gap:7px;align-items:center} @@ -793,6 +847,40 @@ .banner .btn{height:25px;font-size:11.5px} #workBanner .txt,#shareBanner .txt{min-width:0;overflow:hidden;text-overflow:ellipsis} +/* The tools, down the left edge of the canvas. Same shell as the zoom controls + in the corner below it — both are things you reach for while working on the + page, rather than decisions about the review. + It takes its own strip rather than floating over the canvas: a toolbar that + covers the top-left corner of the page covers the part of a design people + look at first. The strip is there in both modes, so switching to View and + back does not move the page. */ +#toolbar{position:absolute;left:12px;top:12px;display:grid;gap:2px; + background:var(--surface);border:1px solid var(--line-2);border-radius:8px;padding:3px; + box-shadow:var(--shadow-pop);z-index:20} +#toolbar[hidden]{display:none} +#toolbar button{position:relative;width:30px;height:30px;border-radius:5px;color:var(--ink-2); + display:grid;place-items:center} +#toolbar button:hover{background:var(--surface-2);color:var(--ink)} +#toolbar button[aria-pressed=true]{background:var(--ink);color:var(--surface)} +/* In View the pointer is driving the page, so no tool is the one in use. + Picking one here is how you come back to annotating with it. */ +body.viewing #toolbar button[aria-pressed=true]{background:var(--surface-2);color:var(--ink)} +#toolbar svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.6; + stroke-linecap:round;stroke-linejoin:round} +/* Icons alone need their names said. The bubble sits off the right edge, clear + of the page underneath, and answers the keyboard as well as the pointer. */ +#toolbar .tip{position:absolute;left:calc(100% + 8px);top:50%;transform:translateY(-50%); + width:max-content;max-width:196px;text-align:left; + background:var(--ink);color:var(--surface);font:550 11.5px/1.3 var(--font); + padding:6px 9px;border-radius:6px;box-shadow:var(--shadow-pop);pointer-events:none; + opacity:0;visibility:hidden;transition:opacity .1s} +#toolbar button:hover .tip,#toolbar button:focus-visible .tip{opacity:1;visibility:visible} +#toolbar .tip b{font-weight:600} +#toolbar .tip kbd{margin-left:6px;font:600 10px/1 var(--mono);opacity:.55} +/* The gesture, under the name that has already been read. */ +#toolbar .tip em{display:block;margin-top:3px;font-style:normal;font-weight:450; + font-size:11px;line-height:1.4;opacity:.72} + #zoombar{position:absolute;left:12px;bottom:12px;display:flex;gap:2px;align-items:center;background:var(--surface); border:1px solid var(--line-2);border-radius:8px;padding:3px;box-shadow:var(--shadow-pop);z-index:20} #zoombar button{width:26px;height:24px;border-radius:5px;color:var(--ink-2);display:grid;place-items:center;font-size:14px} @@ -902,6 +990,17 @@ + + +
@@ -942,13 +1041,44 @@
-
+
+
+
+
+ +
+ + + + + +
+
@@ -721,6 +744,30 @@

} const hideLink = () => { const el = $('#linkDot'); if (el) el.hidden = true }; + /* ── which version is running ── + The page reports what served it, held from load; the server reports what it + is on now. A tab open across an update shows both and offers the reload. */ + let pageVersion = null, serverVersion = null; + function paintVersions () { + const row = $('#cogAbout'); + if (!row) return; + row.hidden = !pageVersion && !serverVersion; + const put = (id, value) => { const el = $(id); if (el) el.textContent = value || '—' }; + put('#cogVersionPage', pageVersion || serverVersion); + put('#cogVersionServer', serverVersion); + const line = $('#cogServerLine'); + if (line) line.hidden = !serverVersion; + const stale = $('#cogStale'); + if (stale) stale.hidden = !(pageVersion && serverVersion && pageVersion !== serverVersion); + } + /** What the server is on right now, which a page learns from its own payload. */ + function setServerVersion (version) { + const next = version || null; + if (serverVersion === next) return; + serverVersion = next; + paintVersions(); + } + /* ── one live-link client, instead of one per page ── Wires the dot to a server: SSE when the page has an event stream, a plain poll for a server that only answers /ping. Either way the shell owns the @@ -894,6 +941,8 @@

if (opts.wip) wip(true, typeof opts.wip === 'string' ? opts.wip : undefined); name(opts.name, opts.eyebrow); wireSettings(); + pageVersion = (window.__VSTACK_BUILD__ || {}).version || null; + paintVersions(); applyTheme(); applyLang(); updateNotice(); @@ -901,7 +950,7 @@

} const api = { - init, setTheme, setLang, setLink, setWatching, hideLink, name, wip, + init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, get theme () { return theme }, get lang () { return lang },