slidemind 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -1
- package/README.md +1 -0
- package/bin/slidemind.mjs +16 -0
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +7 -6
- package/src/builders/build.mjs +52 -10
- package/src/builders/gate-server.mjs +223 -0
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +747 -0
- package/src/builders/plan.mjs +575 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +152 -11
- package/src/builders/review.mjs +136 -70
package/CLAUDE.md
CHANGED
|
@@ -78,7 +78,7 @@ slidemind/
|
|
|
78
78
|
│ READ from the bundle's workflow/search.md — owned by the add-in.
|
|
79
79
|
├── src/ implementation of the slidemind CLI
|
|
80
80
|
│ ├── bridge/ add-in local-bridge: client.mjs, bundle.mjs (materialize), build-and-push.mjs (server render; native-slide splicing), engine-download.mjs + recolor.mjs (image/icon fetch + recolour)
|
|
81
|
-
│ ├── builders/ build.mjs (HTML preview), review.mjs (spatial preview-review gate + local server), resolve-skill.mjs (→ bundle), lint.mjs (runs the add-in's engine)
|
|
81
|
+
│ ├── builders/ build.mjs (HTML preview), review.mjs (spatial preview-review gate + local server; stable per-deck port, graceful fallback, tab reuse across rounds), plan.mjs (plan sign-off gate) + plan-overlay.js + plan-md.mjs (markdown→HTML) + gate-server.mjs (shared gate primitives), resolve-skill.mjs (→ bundle), lint.mjs (runs the add-in's engine)
|
|
82
82
|
│ └── installers/ per-harness installers: claude.mjs, codex.mjs, opencode.mjs, lib.mjs
|
|
83
83
|
├── archive/ retired code/assets (local exporter, icon library, brand design files) — NOT shipped
|
|
84
84
|
├── decks/ the user's working decks; sources are slides/*.jsx, design via .bundle-ref.json
|
|
@@ -99,6 +99,7 @@ slidemind presentations # list PowerPoint windows registered with the
|
|
|
99
99
|
slidemind read [presId] # pull the current content of an open presentation (--mode summary|raw) (HOST REQUIRED)
|
|
100
100
|
slidemind download-bundle [deck] # materialize the active corporate design's bundle locally
|
|
101
101
|
slidemind new <name> # scaffold ./decks/<name>/ (auto-binds to the open design when unambiguous + bundle already materialized; --to/--no-bind)
|
|
102
|
+
slidemind plan <deck> # plan sign-off gate on <deck>/plan.md; markdown editor + live preview + overall feedback, blocks for approve/comments (host-free)
|
|
102
103
|
slidemind build <deck> # build self-contained HTML preview
|
|
103
104
|
slidemind review <deck> # spatial preview-review gate; blocks for approve/comments (host-free)
|
|
104
105
|
slidemind build-pptx <deck> # render native .pptx via the add-in (HOST REQUIRED)
|
|
@@ -146,6 +147,7 @@ Both skills are **brand-agnostic workflow guidance** and must not duplicate any
|
|
|
146
147
|
```bash
|
|
147
148
|
slidemind ready <deck> # do this first: connection ladder + resolve the open design + materialize its bundle + bind the deck (host required)
|
|
148
149
|
slidemind download-bundle <deck> # lower-level: materialize-only (no connection ladder, no bind) — `ready` now covers this
|
|
150
|
+
slidemind plan <deck> # plan sign-off gate on <deck>/plan.md; blocks until approve/comments (host-free)
|
|
149
151
|
slidemind build <deck> # HTML preview (reads the materialized bundle)
|
|
150
152
|
slidemind build <deck> --open # HTML + open in browser
|
|
151
153
|
slidemind review <deck> # spatial preview-review gate; blocks until approve/comments (host-free)
|
package/README.md
CHANGED
|
@@ -197,6 +197,7 @@ slidemind ready [deck] One-shot session start: connect + resolve the o
|
|
|
197
197
|
--to <presId> --force --connect-only --json
|
|
198
198
|
slidemind new <name> Scaffold ./decks/<name>/ (auto-binds to the open design when available)
|
|
199
199
|
--to <presId> --no-bind
|
|
200
|
+
slidemind plan <deck> Plan sign-off gate on <deck>/plan.md; blocks for approve/comments (offline)
|
|
200
201
|
slidemind build <deck> Build a self-contained HTML preview (offline)
|
|
201
202
|
slidemind review <deck> Spatial preview review; blocks for approve/comments (offline)
|
|
202
203
|
slidemind build-pptx <deck> Render a native .pptx via the add-in (host required)
|
package/bin/slidemind.mjs
CHANGED
|
@@ -56,6 +56,10 @@ function printHelp() {
|
|
|
56
56
|
` slidemind new <name> Scaffold ./decks/<name>/ (auto-binds to the open design)\n` +
|
|
57
57
|
` --to <presId> bind to a specific open window\n` +
|
|
58
58
|
` --no-bind skip the auto-bind\n` +
|
|
59
|
+
` slidemind plan <deck> Plan sign-off gate on <deck>/plan.md: opens it in the browser\n` +
|
|
60
|
+
` as a markdown editor + live preview + overall feedback box,\n` +
|
|
61
|
+
` blocks until the user decides, prints DECISION: approved | comments\n` +
|
|
62
|
+
` --port <n> bind a fixed port (default: ephemeral)\n` +
|
|
59
63
|
` slidemind build <deck> Build self-contained HTML\n` +
|
|
60
64
|
` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
|
|
61
65
|
` approves or leaves comments (host-free)\n` +
|
|
@@ -229,6 +233,15 @@ async function cmdBuild(rest) {
|
|
|
229
233
|
]);
|
|
230
234
|
}
|
|
231
235
|
|
|
236
|
+
async function cmdPlan(rest) {
|
|
237
|
+
const { deck, passthrough } = splitDeckArg(rest, "plan");
|
|
238
|
+
await runNode(path.join(BUILDERS_DIR, "plan.mjs"), [
|
|
239
|
+
"--deck",
|
|
240
|
+
resolveDeckArg(deck),
|
|
241
|
+
...passthrough,
|
|
242
|
+
]);
|
|
243
|
+
}
|
|
244
|
+
|
|
232
245
|
async function cmdReview(rest) {
|
|
233
246
|
const { deck, passthrough } = splitDeckArg(rest, "review");
|
|
234
247
|
await runNode(path.join(BUILDERS_DIR, "review.mjs"), [
|
|
@@ -2256,6 +2269,9 @@ async function main(rawArgv) {
|
|
|
2256
2269
|
case "build":
|
|
2257
2270
|
await cmdBuild(rest);
|
|
2258
2271
|
return 0;
|
|
2272
|
+
case "plan":
|
|
2273
|
+
await cmdPlan(rest);
|
|
2274
|
+
return 0;
|
|
2259
2275
|
case "review":
|
|
2260
2276
|
await cmdReview(rest);
|
|
2261
2277
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@ The moment this skill activates on an authoring task, **create a todo list of th
|
|
|
21
21
|
|
|
22
22
|
1. `slidemind ready` — session preflight (*First step* below)
|
|
23
23
|
2. Clarify with the user — the content **and the output target** (*Intake → plan* below; needs no host, so it can run while `ready` executes)
|
|
24
|
-
3. Plan sign-off
|
|
24
|
+
3. Plan sign-off — `slidemind plan <deck>` (decks; single slides stay in chat)
|
|
25
25
|
4. Author the slides
|
|
26
26
|
5. Draft check — `slidemind view-draft`
|
|
27
27
|
6. Review gate — `slidemind review`, the moment the deck builds clean
|
|
@@ -74,7 +74,7 @@ Everything about how a deck is authored, styled, and rendered is shaped by the c
|
|
|
74
74
|
- **`ambiguous_design`** — several windows with **different** corporate designs are open, so `ready` can't choose. The CLI lists the candidates (each with its `env` and window `title`/`fileName`, plus a design fingerprint); if the deck was last built against one of them it's marked `suggested`. **Ask the user which design to build against, naming the windows by `title`/`fileName` (and `env`) — never by raw id** — and mention the suggested one if present ("this deck was last built against X — continue with it?"). Then re-run `slidemind ready --to <presId>` for the window they pick.
|
|
75
75
|
- **Connection blockers** (`not_paired`, `all_unreachable`, `cli_update_required`, `no_window`, `not_render_ready`) — relay the CLI's `nextAction` verbatim (pair / open PowerPoint / click the pane / run `slidemind update`). Pairing is walked through in *Pairing the CLI with the add-in* above. (`no_window` only ever surfaces for an **unbound** deck or deckless `ready` — a bound deck rides the pane-free rung above instead.)
|
|
76
76
|
|
|
77
|
-
**The session spine** (= the stage todos you created up front): `slidemind ready` → read `workflow/flow.md` + `design.md` from `bundleDir` → intake/plan (below) → `slidemind new <slug>` (scaffolds **and** auto-binds the deck to the open design — zero extra download, because `ready` already materialized it) → author → `view-draft` → `review` (mandatory, the moment the deck builds clean) → deliver (`push` / `build-pptx`). `slidemind new` best-effort binds when the open design is unambiguous and the bundle is already materialized; if it can't (ambiguous, or no host), it scaffolds anyway and hints to run `slidemind ready <slug>`.
|
|
77
|
+
**The session spine** (= the stage todos you created up front): `slidemind ready` → read `workflow/flow.md` + `design.md` from `bundleDir` → intake/plan (below) → `slidemind new <slug>` (scaffolds **and** auto-binds the deck to the open design — zero extra download, because `ready` already materialized it) → `slidemind plan <slug>` (plan sign-off gate for decks; single slides stay in chat) → author → `view-draft` → `review` (mandatory, the moment the deck builds clean) → deliver (`push` / `build-pptx`). `slidemind new` best-effort binds when the open design is unambiguous and the bundle is already materialized; if it can't (ambiguous, or no host), it scaffolds anyway and hints to run `slidemind ready <slug>`.
|
|
78
78
|
|
|
79
79
|
**Backgrounded — or closed — PowerPoint is expected to work.** Do not pre-emptively ask the user to focus PowerPoint or the task pane, and never ask them to keep it foregrounded while something runs. The render itself (`build-pptx`, the render half of `push`) **never needs a window** for a bound deck — it captures the slides with a local headless browser and assembles the `.pptx` server-side over the pane-free brand route, streams its own `rendering slide n/m…` progress, and survives arbitrarily long authoring sessions (the old "author 30 minutes and the add-in is gone" failure no longer applies). Only the pane-touching ops (the insert half of `push`, engine search/download, `read`) need a live pane, and the CLI handles that itself: it waits up to ~2 min, prints one *"click the slidemind pane"* line when user help is genuinely needed, and retries safely. If you see that line, relay it once and keep waiting — do not rebuild, re-plan, or babysit. If a `push` can't deliver (pane never woke, or no window at all), the rendered `.pptx` was saved next to the deck (`paneSuspended` + `fallbackFile` in the result) — nothing is lost; re-run the push once a window exists. Reserve the full "open PowerPoint…" instruction for when a command actually reports it as the blocker.
|
|
80
80
|
|
|
@@ -87,7 +87,7 @@ This is your mapping of `workflow/flow.md` stages 1–2 (*clarify* + *plan sign-
|
|
|
87
87
|
- **Read scope from the wording**, don't ask redundantly: single slide ("make a slide about X"), small set ("add three slides on Y"), or whole deck ("a deck for Friday"). Only ask "one slide, a small set, or a full deck?" when truly ambiguous.
|
|
88
88
|
- **Interview just enough** to pin: audience/goal, the content and its sources (none / pasted / local files / existing deck / web research), and the slide outline (each slide's role + a layout *job* like cover/agenda/section/quote/closing/body — the concrete brand layout is chosen later against the bundle).
|
|
89
89
|
- **Lock the output target — it decides your deliver command.** Into the open PowerPoint (`slidemind push`), or a `.pptx` file (`slidemind build-pptx`) — or an HTML preview / a mix. Infer it when the wording is clear ("add a slide to this deck" → push; "make me a deck I can send around" → a file) and ask only when genuinely ambiguous — but **always confirm the concrete target before delivering** (which window, or which output path).
|
|
90
|
-
- **Replay and get sign-off.** Single slides / tiny sets → a compact in-chat plan. Decks → persist `decks/<slug>/plan.md` (content direction, narrative red thread, sources, per-slide role + layout job — *not* visual shape, *not* brand specifics).
|
|
90
|
+
- **Replay and get sign-off.** Single slides / tiny sets → a compact in-chat plan (no gate). Decks → persist `decks/<slug>/plan.md` (content direction, narrative red thread, sources, per-slide role + layout job — *not* visual shape, *not* brand specifics), then run `slidemind plan <deck>`: it opens the plan in a markdown editor with a live rendered preview; the user edits `plan.md` directly and/or leaves overall feedback, and it **blocks until the user decides** — give the shell call a **generous timeout** (the gate waits up to 30 min; if your harness kills the call early, just re-run it — the user's open tab reconnects). Outcomes: `DECISION: approved` → proceed to author; `DECISION: comments` → apply the feedback to `plan.md` and **re-run the gate in the same turn** — after a comments round the user's tab sits on a waiting spinner that only the next `slidemind plan` run releases, so never end your turn, never claim to be "polling", and never send the user back to the browser between rounds (that deadlocks: the tab waits for you while you wait for the tab); `DECISION: pending` → the gate timed out with no answer — not approval, not rejection: don't author; wrap up telling the user the plan still awaits their sign-off and re-run `slidemind plan <deck>` on their go-ahead. Loop until approved. A `plan.md` **approved in a prior gate round and unchanged since** counts as approved (skip straight to authoring); any new or revised plan must pass the gate. When the gate reports the user edited `plan.md` directly, re-read it — the edited text *is* the approved plan; author from it.
|
|
91
91
|
|
|
92
92
|
Keep planning about intent and content. The bundle is already materialized by `slidemind ready`, so `design.md` is there if you need to sanity-check feasibility — but **don't dive into brand/layout/composition detail until create/edit.**
|
|
93
93
|
|
|
@@ -99,9 +99,10 @@ Keep planning about intent and content. The bundle is already materialized by `s
|
|
|
99
99
|
2. **Read, then author.** Read the create-stage references together — `workflow/create.md` (the procedure), `workflow/composition.md` (the composition baseline), `workflow/layouts.md` (palette + working area), and `workflow/authoring.md` (the substrate rules) — plus the deck's `design.md` and the specific `layouts/*.jsx` the plan uses. (That is the read set `workflow/create.md` → Context loading prescribes; follow it as written — `workflow/flow.md` you already read after `ready`.) Then write `decks/<slug>/slides/NN-<topic>.jsx` — zero-padded numeric prefix from the first file on (play order is filename order here; see the slide-file row in the command mapping), one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe.
|
|
100
100
|
3. **Imagery from the engine.** Per the `### Brand Image Sources` block in `design.md`, search the brand engine and place results. The engine search/reuse mechanics live in **`slidemind-search`** — use it to find and fetch imagery; the create-only icon-recolour detail is in *Recolouring engine icons* below. To pull in an **existing whole slide** (not just an image), use `slidemind-search`'s `add-slide` — it folds the slide into `decks/<slug>/slides/` as a native `.pptx` that coexists with your JSX in play order and survives re-push.
|
|
101
101
|
4. **Look at your own draft first (you, the agent).** This is the **draft check** in `workflow/build-and-export.md` — your pre-check, *before* any human sees the deck. Run `slidemind view-draft <deck>`: it renders each authored `slides/*.jsx` to a per-slide PNG and hands the images back to you — **nothing is pushed, no `.pptx` is written, no review gate opens**. Read every image the way the audience will, fix anything visibly wrong **in the JSX** (re-express, never delete), and view again until each slide is sound. One round usually suffices. It runs locally against the materialized bundle, so it needs no awake pane and no open host once the bundle has been downloaded once. Two faithful limits to know: a slide on a **purpose-built (Mode A) layout** (cover/divider/agenda) and a **reused native `.pptx`** slide have no freeform region to capture, so they come back as a status line, not an image — judge those at the full-deck `review`/`build` step below. Do this self-check before the human review gate; don't push a draft you haven't looked at.
|
|
102
|
-
5. **The review gate — the moment the deck builds clean.** This is the **review gate** in `workflow/build-and-export.md`; the CLI's gate command is `slidemind review <deck>`. Run it the instant the authored set builds clean — before any deliver, without asking whether the user wants a review. It builds the HTML preview first (the same lint the render enforces, so a gate that *opens* is a clean build; while lint errors remain it exits non-zero — fix and re-run), then opens the browser with a spatial comment overlay and **blocks until the user decides** (host-free — no open PowerPoint needed for the gate). The verdict prints to stdout:
|
|
102
|
+
5. **The review gate — the moment the deck builds clean.** This is the **review gate** in `workflow/build-and-export.md`; the CLI's gate command is `slidemind review <deck>`. Run it the instant the authored set builds clean — before any deliver, without asking whether the user wants a review. It builds the HTML preview first (the same lint the render enforces, so a gate that *opens* is a clean build; while lint errors remain it exits non-zero — fix and re-run), then opens the browser with a spatial comment overlay and **blocks until the user decides** (host-free — no open PowerPoint needed for the gate). **Blocking is the point: give the shell call a generous timeout (the gate waits up to 30 min) and let it sit — a long quiet wait means the user is reviewing, not that something hung. Never cancel it; if your harness kills the call early, just re-run it (the user's open tab reconnects).** The verdict prints to stdout:
|
|
103
103
|
- `DECISION: approved` → proceed to the deliver the user chose at intake: `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file).
|
|
104
|
-
- `DECISION: comments` → the user left spatially-anchored notes, each citing a `slides/<file>.jsx:line:col`, the source line, and the click position (plus a readonly-layout note where the click landed on a layout slot). **Edit the cited files in place at the given line
|
|
104
|
+
- `DECISION: comments` → the user left spatially-anchored notes, each citing a `slides/<file>.jsx:line:col`, the source line, and the click position (plus a readonly-layout note where the click landed on a layout slot). **Edit the cited files in place at the given line and re-run `slidemind review <deck>` in the same turn** — after a comments round the user's tab waits for the next round, so never end your turn, never claim to be "polling", and never send the user back to the browser between rounds. The prior round shows as read-only ghost pins so the user can confirm each note was addressed. Loop until approved. Nothing is exported on a comments round.
|
|
105
|
+
- `DECISION: pending` → the gate timed out with no answer (the user stepped away). Not approval, not rejection — **do not deliver**. Wrap up your turn: tell the user the preview is still waiting for their review and that on their go-ahead you'll re-run `slidemind review <deck>` (their tab reconnects).
|
|
105
106
|
|
|
106
107
|
`slidemind review` is the only step that pauses; `push`/`build-pptx` never pause — they render pane-free (local capture + server-side assembly, with live per-slide progress). **CLI exports happen out of the user's sight, so the flow invariant — *the user sees every change before the task is done* — makes re-review mandatory here: every deliver is preceded by an approved review of exactly what it delivers. After any later slide change, the deck re-enters `slidemind review` before it can be pushed or exported again — never deliver an unreviewed change.** If several presentations are open, confirm **which one to push into** first (it can differ from the design source `slidemind ready` locked in). After approval, run the push without asking; it works while PowerPoint is backgrounded, and the CLI itself waits + retries if the pane is needed (see *First step* above). If a push still times out, the rendered `.pptx` is saved next to the deck — re-run `push` after the user clicks the pane, without rebuilding.
|
|
107
108
|
|
|
@@ -127,7 +128,7 @@ The bundle's `workflow/*.md` speak the harness-neutral verbs defined in `workflo
|
|
|
127
128
|
|---|---|
|
|
128
129
|
| (session setup — a CLI-only stage before the spine) | `slidemind ready [deck]` — the one-shot session start: connection ladder + open-design resolution + bundle materialize + deck bind. **Run it first, every session** (see *First step* above). `slidemind download-bundle` is the lower-level materialize-only command `ready` now covers — you rarely call it directly |
|
|
129
130
|
| **track the stages** | your harness's task tracker — explicitly create the stage todo list before your first command and keep it current (see *Create a todo list* above) |
|
|
130
|
-
| **clarify** / **plan sign-off** |
|
|
131
|
+
| **clarify** / **plan sign-off** | intake stays in chat with the user (above); for decks, persist `decks/<slug>/plan.md`, then run `slidemind plan <deck>` — the same blocking `DECISION:` gate contract as `slidemind review`, on the plan (markdown editor + live preview + overall feedback; loop until approved) |
|
|
131
132
|
| **read** / **list** the workspace | read the files from the materialized bundle dir (`<bundle>/…`) and your `decks/<slug>/` |
|
|
132
133
|
| **create / update a slide file** | write `decks/<slug>/slides/NN-<topic>.jsx` on disk with a **zero-padded numeric prefix** (`01-cover.jsx`, `02-agenda.jsx`, …) — over the CLI, "the ordered set you hand to build & push" is expressed as **numeric-aware filename order**; there is no ordered-list channel, so reordering = renaming files |
|
|
133
134
|
| brand reference samples (studied before composing) | **Read** 3 random images from the materialized bundle's `samples/` dir — the Read tool renders them (see *The create flow* step 1) |
|
package/src/builders/build.mjs
CHANGED
|
@@ -917,17 +917,59 @@ export async function buildDeck(deckDir, opts = {}) {
|
|
|
917
917
|
return { out, slideCount: slideFiles.length };
|
|
918
918
|
}
|
|
919
919
|
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
920
|
+
// Detect whether this Linux box is really WSL, so we can hand the target to the
|
|
921
|
+
// Windows-side opener instead of a (usually absent) Linux one. Any read/parse
|
|
922
|
+
// failure just means "not WSL".
|
|
923
|
+
function isWsl() {
|
|
924
|
+
if (process.platform !== "linux") return false;
|
|
925
|
+
try {
|
|
926
|
+
return /microsoft/i.test(fs.readFileSync("/proc/version", "utf8"));
|
|
927
|
+
} catch {
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Ordered list of opener candidates to try for the current platform. The
|
|
933
|
+
// platform openers (open / start / xdg-open / wslview) accept both a local file
|
|
934
|
+
// path AND an http URL, so review.mjs reuses this to launch the local review
|
|
935
|
+
// server URL and view-draft passes png paths.
|
|
936
|
+
function openerChain(target) {
|
|
937
|
+
if (process.platform === "darwin") return [{ cmd: "open", args: [target] }];
|
|
938
|
+
if (process.platform === "win32")
|
|
939
|
+
return [{ cmd: "cmd", args: ["/c", "start", "", target] }];
|
|
940
|
+
if (isWsl())
|
|
941
|
+
return [
|
|
942
|
+
{ cmd: "wslview", args: [target] },
|
|
943
|
+
{ cmd: "powershell.exe", args: ["-NoProfile", "-Command", "start", target] },
|
|
944
|
+
];
|
|
945
|
+
return [{ cmd: "xdg-open", args: [target] }];
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Open a local file path OR an http URL in the user's default browser, trying
|
|
949
|
+
// each platform opener in turn. This is fire-and-forget and must NEVER crash:
|
|
950
|
+
// if the opener binary is missing (e.g. a headless Linux box with no xdg-open)
|
|
951
|
+
// the spawned child would otherwise emit an unhandled 'error' and take down the
|
|
952
|
+
// whole CLI. We degrade gracefully — on exhausting the chain we just print the
|
|
953
|
+
// target for the user to open manually.
|
|
923
954
|
export function openInBrowser(target) {
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
955
|
+
const chain = openerChain(target);
|
|
956
|
+
const tryAt = (i) => {
|
|
957
|
+
if (i >= chain.length) {
|
|
958
|
+
process.stderr.write(`couldn't open a browser — open ${target} manually\n`);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const { cmd, args } = chain[i];
|
|
962
|
+
let child;
|
|
963
|
+
try {
|
|
964
|
+
child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
965
|
+
} catch {
|
|
966
|
+
tryAt(i + 1);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
child.on("error", () => tryAt(i + 1));
|
|
970
|
+
child.unref();
|
|
971
|
+
};
|
|
972
|
+
tryAt(0);
|
|
931
973
|
}
|
|
932
974
|
|
|
933
975
|
function parseArgs(argv) {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Shared low-level primitives for the CLI's blocking review gates (the spatial
|
|
2
|
+
// preview gate in review.mjs and the plan sign-off gate in plan.mjs).
|
|
3
|
+
//
|
|
4
|
+
// These were lifted out of review.mjs so both gates share one implementation of
|
|
5
|
+
// the MIME table, the traversal-guarded static responder, the decision promise +
|
|
6
|
+
// timeout wrapper, and the port + tab-reuse machinery: a deterministic per-deck
|
|
7
|
+
// per-gate port (so a tab left waiting from the previous round reconnects to the
|
|
8
|
+
// same URL), a bind that never fails on a taken port (falls back to an
|
|
9
|
+
// OS-assigned one with a stderr notice), and a heartbeat endpoint each gate
|
|
10
|
+
// serves under its own service marker so a waiting tab only ever reloads into
|
|
11
|
+
// its OWN gate's next round — never into the other gate or a foreign app.
|
|
12
|
+
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
// Tab-reuse timing contract, shared by both gates and injected into their
|
|
18
|
+
// overlays as CFG.pollMs / CFG.pollTtlMs:
|
|
19
|
+
// GRACE_MS > POLL_MS so a waiting tab always lands one poll inside the
|
|
20
|
+
// open-browser grace window;
|
|
21
|
+
// POLL_TTL_MS > the gates' 30-min decision timeout so an abandoned tab stops
|
|
22
|
+
// polling only after the server side has certainly given up.
|
|
23
|
+
export const GRACE_MS = 1500;
|
|
24
|
+
export const POLL_MS = 1000;
|
|
25
|
+
export const POLL_TTL_MS = 35 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
const PORT_BASE = 40000;
|
|
28
|
+
const PORT_SPAN = 1000; // preferred ports live in 40000..40999
|
|
29
|
+
|
|
30
|
+
// Extension → content-type table (lifted verbatim from review.mjs).
|
|
31
|
+
export const MIME = {
|
|
32
|
+
".html": "text/html; charset=utf-8",
|
|
33
|
+
".css": "text/css; charset=utf-8",
|
|
34
|
+
".js": "text/javascript; charset=utf-8",
|
|
35
|
+
".json": "application/json; charset=utf-8",
|
|
36
|
+
".svg": "image/svg+xml",
|
|
37
|
+
".png": "image/png",
|
|
38
|
+
".jpg": "image/jpeg",
|
|
39
|
+
".jpeg": "image/jpeg",
|
|
40
|
+
".gif": "image/gif",
|
|
41
|
+
".webp": "image/webp",
|
|
42
|
+
".woff": "font/woff",
|
|
43
|
+
".woff2": "font/woff2",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Traversal-guarded static responder, generalized to MULTIPLE roots. `pathname`
|
|
47
|
+
// is expected already URL-decoded (review.mjs decodes once at the top of its
|
|
48
|
+
// request handler and passes the decoded value in). Each root is tried in order:
|
|
49
|
+
// the pathname is resolved within the root and required to stay inside it (via
|
|
50
|
+
// path.sep so the boundary check is correct on Windows). The first root that
|
|
51
|
+
// holds a matching file serves it (200 + MIME, default application/octet-stream)
|
|
52
|
+
// and returns true. If no root matches — file missing everywhere, or a traversal
|
|
53
|
+
// attempt escaped every root — returns false WITHOUT writing a response, leaving
|
|
54
|
+
// the caller to send its own 404.
|
|
55
|
+
export function serveStaticFile({ roots, pathname, res }) {
|
|
56
|
+
const rel = String(pathname).replace(/^\/+/, "");
|
|
57
|
+
for (const root of roots || []) {
|
|
58
|
+
if (!root) continue;
|
|
59
|
+
const base = path.resolve(root);
|
|
60
|
+
const resolved = path.resolve(base, rel);
|
|
61
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
62
|
+
continue; // escaped this root — a later root might still legitimately match
|
|
63
|
+
}
|
|
64
|
+
let data;
|
|
65
|
+
try {
|
|
66
|
+
const st = fs.statSync(resolved);
|
|
67
|
+
if (!st.isFile()) continue;
|
|
68
|
+
data = fs.readFileSync(resolved);
|
|
69
|
+
} catch {
|
|
70
|
+
continue; // missing here — try the next root
|
|
71
|
+
}
|
|
72
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
73
|
+
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
|
74
|
+
res.end(data);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The decision promise + timeout wrapper both gates block on. `resolve(value)`
|
|
81
|
+
// settles the internal promise once (a Promise never re-settles, so repeat calls
|
|
82
|
+
// are no-ops). `waitForDecision({ timeoutMs })` returns the promise directly when
|
|
83
|
+
// no/falsy timeout is given, otherwise races it against a setTimeout that rejects
|
|
84
|
+
// with `timeoutMessage`. The timer is cleared the moment the decision wins so the
|
|
85
|
+
// process can exit instead of being held open by a pending timeout.
|
|
86
|
+
//
|
|
87
|
+
// A timeout rejection carries `code: "GATE_TIMEOUT"` so gates can tell "the user
|
|
88
|
+
// never answered" (an expected outcome with its own DECISION: pending contract)
|
|
89
|
+
// apart from a real failure.
|
|
90
|
+
export function createDecisionGate({
|
|
91
|
+
timeoutMessage = "review timed out — no decision received",
|
|
92
|
+
} = {}) {
|
|
93
|
+
let resolveDecision;
|
|
94
|
+
const decisionPromise = new Promise((res) => {
|
|
95
|
+
resolveDecision = res;
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
resolve(value) {
|
|
99
|
+
resolveDecision(value);
|
|
100
|
+
},
|
|
101
|
+
waitForDecision({ timeoutMs } = {}) {
|
|
102
|
+
if (!timeoutMs) return decisionPromise;
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const t = setTimeout(() => {
|
|
105
|
+
const err = new Error(timeoutMessage);
|
|
106
|
+
err.code = "GATE_TIMEOUT";
|
|
107
|
+
reject(err);
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
decisionPromise.then((d) => {
|
|
110
|
+
clearTimeout(t);
|
|
111
|
+
resolve(d);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Deterministic per-deck, per-gate preferred port so a tab left waiting from
|
|
119
|
+
// the previous round can reconnect to the same URL. Stateless — derived purely
|
|
120
|
+
// from the resolved deck path plus the gate name. The gate name is the salt:
|
|
121
|
+
// the preview and plan gates of the SAME deck get independent ports, so their
|
|
122
|
+
// waiting tabs can never sit on each other's URL.
|
|
123
|
+
export function preferredGatePort(deckDir, gateName) {
|
|
124
|
+
const key = `${path.resolve(deckDir)}\u0000${gateName}`;
|
|
125
|
+
const n = crypto.createHash("sha1").update(key).digest().readUInt32BE(0);
|
|
126
|
+
return PORT_BASE + (n % PORT_SPAN);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Bind `server` to `port`, falling back to an OS-assigned port when the
|
|
130
|
+
// preferred one is taken or blocked. An http.Server is reusable after a failed
|
|
131
|
+
// listen, so we retry listen(0) on the SAME instance. onFallback runs once,
|
|
132
|
+
// before the retry, so the caller can warn on stderr. EADDRINUSE: port already
|
|
133
|
+
// bound. EACCES: Windows excluded/reserved port range. Anything else (or a
|
|
134
|
+
// fallback-attempt failure) is a real error and rejects.
|
|
135
|
+
export function listenWithFallback(server, port, host, onFallback) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const onFirstError = (err) => {
|
|
138
|
+
server.removeListener("listening", onFirstListening);
|
|
139
|
+
if (err && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
|
|
140
|
+
onFallback(port, err);
|
|
141
|
+
const onFallbackError = (err2) => {
|
|
142
|
+
server.removeListener("listening", onFallbackListening);
|
|
143
|
+
reject(err2);
|
|
144
|
+
};
|
|
145
|
+
const onFallbackListening = () => {
|
|
146
|
+
server.removeListener("error", onFallbackError);
|
|
147
|
+
resolve();
|
|
148
|
+
};
|
|
149
|
+
server.once("error", onFallbackError);
|
|
150
|
+
server.once("listening", onFallbackListening);
|
|
151
|
+
server.listen(0, host);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
reject(err);
|
|
155
|
+
};
|
|
156
|
+
const onFirstListening = () => {
|
|
157
|
+
server.removeListener("error", onFirstError);
|
|
158
|
+
resolve();
|
|
159
|
+
};
|
|
160
|
+
server.once("error", onFirstError);
|
|
161
|
+
server.once("listening", onFirstListening);
|
|
162
|
+
server.listen(port, host);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Per-run heartbeat state for one gate server. The gate mounts `respond` under
|
|
167
|
+
// its own heartbeat path; a waiting tab polls it and reloads only when BOTH the
|
|
168
|
+
// service marker matches its own gate (a foreign app or the other gate on this
|
|
169
|
+
// port must never trigger a reload) AND the round nonce differs from the one it
|
|
170
|
+
// was served with (same nonce = this run's own orphan — never self-reload).
|
|
171
|
+
// `waitForHeartbeat(ms)` is the server side of the open-browser grace window.
|
|
172
|
+
export function createHeartbeat({ service }) {
|
|
173
|
+
const round = crypto.randomUUID();
|
|
174
|
+
let seen = false;
|
|
175
|
+
const waiters = [];
|
|
176
|
+
return {
|
|
177
|
+
round,
|
|
178
|
+
respond(res) {
|
|
179
|
+
seen = true;
|
|
180
|
+
while (waiters.length) waiters.shift()();
|
|
181
|
+
res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
|
|
182
|
+
res.end(JSON.stringify({ ok: true, service, round }));
|
|
183
|
+
},
|
|
184
|
+
waitForHeartbeat(ms) {
|
|
185
|
+
if (seen) return Promise.resolve(true);
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
188
|
+
waiters.push(() => {
|
|
189
|
+
clearTimeout(t);
|
|
190
|
+
resolve(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Print the gate URL, then decide whether to open a browser. If a tab left
|
|
198
|
+
// waiting from a previous comments round pings the gate's heartbeat within the
|
|
199
|
+
// grace window, reuse it (it reloads itself) instead of opening a duplicate.
|
|
200
|
+
// Returns true if an existing tab was reused. `open` is required — gates pass
|
|
201
|
+
// their browser opener so this module stays dependency-light and tests inject
|
|
202
|
+
// a fake. The message strings are per-gate; defaults speak the preview gate's.
|
|
203
|
+
export async function serveAndOpen(server, {
|
|
204
|
+
open,
|
|
205
|
+
graceMs = GRACE_MS,
|
|
206
|
+
readyLabel = "Preview review",
|
|
207
|
+
reuseNoun = "review tab",
|
|
208
|
+
openedLine = "Opened your browser — leave comments or approve, then return here.",
|
|
209
|
+
waitLine = null,
|
|
210
|
+
} = {}) {
|
|
211
|
+
process.stderr.write(`${readyLabel} ready at ${server.url}\n`); // URL always printed first
|
|
212
|
+
const reused = await server.waitForHeartbeat(graceMs);
|
|
213
|
+
if (reused) {
|
|
214
|
+
process.stderr.write(`Reusing your open ${reuseNoun} — no new browser window.\n`);
|
|
215
|
+
} else {
|
|
216
|
+
open(server.url);
|
|
217
|
+
process.stderr.write(`${openedLine}\n`);
|
|
218
|
+
}
|
|
219
|
+
// Spoken to the AGENT running this command: blocking here is the designed
|
|
220
|
+
// behavior, not a hang — don't kill the call because it sits quiet.
|
|
221
|
+
if (waitLine) process.stderr.write(`${waitLine}\n`);
|
|
222
|
+
return reused;
|
|
223
|
+
}
|