diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3888834..716f9b6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,13 +1,22 @@ { "name": "mochi", "description": "A browser companion for AI assistants. Bundled MCP tools for Chrome automation, persistent project memory (Continuum chain), and an in-page hint modal — pair with the Mochi Chrome extension.", - "version": "0.3.0", + "version": "0.10.0", "author": { "name": "Jonayed Ahamed", "email": "dev.jonayed@gmail.com" }, "homepage": "https://github.com/DevZonayed/Mochi", - "keywords": ["browser", "automation", "qa", "testing", "memory", "continuum", "mcp", "chrome"], + "keywords": [ + "browser", + "automation", + "qa", + "testing", + "memory", + "continuum", + "mcp", + "chrome" + ], "commands": [ "./plugins/continuum/commands/checkpoint.md", "./plugins/continuum/commands/dream.md", @@ -15,7 +24,18 @@ "./plugins/continuum/commands/recall.md", "./plugins/continuum/commands/rename.md", "./plugins/continuum/commands/render.md", - "./plugins/continuum/commands/status.md" + "./plugins/continuum/commands/status.md", + "./plugins/continuum/commands/telemetry.md", + "./plugins/continuum/commands/review-session.md", + "./plugins/continuum/commands/insights.md", + "./plugins/qa/commands/qa.md", + "./plugins/qa/commands/design-qa.md", + "./plugins/qa/commands/playbook.md", + "./plugins/qa/commands/schedule-playbook.md", + "./plugins/qa/commands/unschedule-playbook.md" + ], + "agents": [ + "./plugins/qa/agents/qa-tester.md" ], "hooks": "./plugins/continuum/hooks/hooks.json" } diff --git a/.continuum/.gitignore b/.continuum/.gitignore index 8afb129..c41d04d 100644 --- a/.continuum/.gitignore +++ b/.continuum/.gitignore @@ -16,3 +16,9 @@ feedback/ # Chain archives are recoverable but large. Keep them local by default; # remove this line if you want to commit them for cross-device sync. archive/ +verification/ +runs/ +uploads/ +.env-provenance.json +comms/* +!comms/config.json diff --git a/.continuum/STATE.md b/.continuum/STATE.md index a3f35d9..2a03633 100644 --- a/.continuum/STATE.md +++ b/.continuum/STATE.md @@ -1,51 +1,65 @@ # Super-Tester (mochi plugin) — baseline -**Status:** v0.3.0 of the `mochi` Claude Code plugin. Source repo + the plugin -distribution. Active development. +**Status:** `mochi` Claude Code plugin (v0.6.x). Source repo + plugin distribution. +Active development. **NEW: "mochi comms" feature complete on branch +`DevZonayed/improve-mochi-plugin` (merge-ready; pending real-WhatsApp QR acceptance).** -**Stack:** Node 22+, ESM. esbuild bundles `server/src/index.js` → -`server/dist/server.bundle.mjs` (one self-contained file, ~760KB, no native -deps). Plugin distributed via GitHub marketplace install. +**Stack:** Node 22+, ESM. esbuild bundles each MCP server into one self-contained +`.mjs` (no native deps, zero-install). Plugin distributed via GitHub marketplace install. **What the plugin bundles:** -- `browser` MCP — 39 tools for Chrome automation (server/, Mochi extension) -- `continuum` MCP — `recall` tool + 7 slash commands + 7 hooks for chain memory +- `browser` MCP — Chrome automation (server/, Mochi extension) +- `continuum` MCP — `recall` tool + slash commands + hooks for chain memory +- `comms` MCP — per-repo WhatsApp sync & recall (Baileys), tools + `mcp__plugin_mochi_comms__*`; bundled to `server/dist/comms.bundle.mjs` - In-page send-hint modal (⌘⇧M) with DOM element picker + screenshot context **Active decisions:** - Plugin name: `mochi` (in `mochi` marketplace; repo dir stays `Super-Tester`). -- Browser MCP tools surface as `mcp__plugin_mochi_browser__*` (underscores, - not colons — `claude mcp list` shows `plugin:mochi:browser` but the tool - namespace uses `plugin_mochi_browser`). -- All persistence in `.continuum/` (per-project): chain, archive, screenshots, - feedback, selectors, workflows, runs. No SQLite anywhere. -- Server bundled with esbuild; `dist/server.bundle.mjs` committed to repo - by `.github/workflows/build.yml` on every push to Master. -- Install: `/plugin marketplace add DevZonayed/Super-Tester` then - `/plugin install mochi@mochi`. Chrome extension loaded manually from - `~/.claude/plugins/cache/mochi/mochi/0.3.0/extension`. +- Browser MCP tools surface as `mcp__plugin_mochi_browser__*` (underscores, not colons). +- All persistence in `.continuum/` (per-project). No SQLite anywhere. +- Servers bundled with esbuild; `dist/*.bundle.mjs` committed; CI rebuilds on push to Master. +- **comms:** channel-agnostic provider interface; WhatsApp first (Baileys @6.7.23). It is + mochi's OWN bundled MCP — NOT the external Docker wa-mcp. v1 = WhatsApp only, session-scoped + capture, sync+recall+manual-import, media metadata-only. Deferred to v2 (spec §13): always-on + daemon, sending, media bytes, Telegram (gramjs). +- **comms storage:** file-based under `.continuum/comms/` — `config.json` committed (per-repo + intent + JID allowlist); `auth/`, `store/`, `media/`, `state.json`, `config.local.json` + gitignored. Per-repo allowlist enforced AT the MCP boundary (capture-drop + read-filter + + send-refuse + path-traversal guard). +- **comms onboarding:** `session_start.js` single-emit init gate asks once per repo + ("any channel?"), remembers "no" forever, fires on the first session; `/mochi:comms-setup` + drives QR/pairing login + allowlist pick. **Do NOT:** -- Re-introduce `better-sqlite3` or any native module — bundle-ability and - zero-install were the explicit goals (resolved 2026-05-18). -- Add a project-level `.mcp.json` outside the plugin's own — duplicates the - plugin's `browser` server and causes Chrome debugger conflicts. -- Edit `~/.claude.json` MCP entries manually for this plugin — the plugin's - `.mcp.json` handles registration. Manual edits cause duplicates. -- Refer to the plugin as "super-tester" in user-facing strings (the brand is - `mochi`); internal artifacts like `SUPER_TESTER_*` env vars and the repo - directory name are unchanged for backward-compat / git-history reasons. +- Re-introduce `better-sqlite3` or any native module — bundle-ability and zero-install + were the explicit goals (resolved 2026-05-18). +- **Bundle `sharp`/`jimp` into comms** — Baileys pulls `sharp` as an optional NATIVE peer dep; + keep `--external:sharp --external:jimp` in `build:comms` so the bundle stays native-free. +- **Resolve the comms project dir from `process.cwd()`** — stdio MCP cwd is client-controlled; + use `COMMS_PROJECT_DIR` env / per-tool `project_dir` arg. +- **Revert the `session_start.js` gitignore writer to create-only** — it must idempotently + APPEND comms ignores, else existing repos commit WhatsApp creds + private messages. +- Add a project-level `.mcp.json` outside the plugin's own — duplicates `browser`, causes + Chrome debugger conflicts. (The plugin's own repo-root `.mcp.json` registers all three servers.) +- Edit `~/.claude.json` MCP entries manually for this plugin. +- Refer to the plugin as "super-tester" in user-facing strings (brand is `mochi`); internal + `SUPER_TESTER_*` env vars and the repo dir name stay for backward-compat. **Open threads:** -- Stale-memory cleanup in other projects (e.g. Continuum-demo) still flagging - "Mochi extension conflicts with browser MCP" — false since the unification. - Each affected session needs to clear or update that memory. -- Chrome extension publication to Web Store would eliminate the manual "Load - unpacked" step; deferred until the plugin sees broader use. -- Real embedding-based `recall` (Voyage/Cohere or local model) — deferred per - Phase 3 docs; current stemmed-token recall is "good enough" for now. -- Per-archive byte-offset retrieval (refs.json anchors → specific transcript - spans) — deferred. - -**Latest commit at bootstrap:** `31a233d` (feat: file-based memory + bundled -server + GitHub-installable plugin). +- **comms manual acceptance NOT done:** real WhatsApp QR link on a throwaway number to prove + live capture end-to-end (only mock/automated-tested so far). ToS/ban risk is real. +- **comms v2 backlog (spec §13):** always-on capture daemon, sending, media bytes, Telegram. +- Stale-memory cleanup in other projects still flagging the old "Mochi extension conflicts + with browser MCP" — false since the unification. +- Chrome extension Web Store publication — deferred. +- Real embedding-based `recall` — deferred; stemmed-token recall is "good enough" (comms recall + reuses the same scoring primitives, now in `plugins/continuum/lib/scoring.js`). +- Per-archive byte-offset retrieval — deferred. + +**Refs (comms):** design `docs/superpowers/specs/2026-06-07-mochi-comms-channel-sync-design.md`; +plans `docs/superpowers/plans/2026-06-07-mochi-comms-channel-sync.md` + +`2026-06-08-comms-fixups.md`. Implementation `ff186bb..a57fcce` (~73 commits). Suites green: +run-synthetic 141/0, scoring 6/0, comms-recall 17/0, server comms 12/12 incl. native-free bundle smoke. + +**Latest chain link:** `0002` (comms feature). Bootstrap was `31a233d`. diff --git a/.continuum/chain/index.jsonl b/.continuum/chain/index.jsonl index 1573444..19fb189 100644 --- a/.continuum/chain/index.jsonl +++ b/.continuum/chain/index.jsonl @@ -1 +1,2 @@ {"id":1,"ts":"2026-05-18T14:13:13Z","commit":"31a233d1f73ffffd462c0eb67502f93503b2bcca","summary_tokens":210,"tags":["bootstrap","plugin-source"]} +{"id":2,"ts":"2026-06-09T04:45:34.611Z","commit":"a57fcce2fa56acc6589e9ccc9e92377f980a65de","summary_tokens":761,"tags":["comms","whatsapp","feature","mcp","decision"]} diff --git a/.continuum/chain/links/0002/meta.json b/.continuum/chain/links/0002/meta.json new file mode 100644 index 0000000..aa1a8bd --- /dev/null +++ b/.continuum/chain/links/0002/meta.json @@ -0,0 +1,8 @@ +{ + "commit_id": "a57fcce2fa56acc6589e9ccc9e92377f980a65de", + "parent_link": 1, + "created_at": "2026-06-09T04:45:34.611Z", + "model": null, + "bundle_hash": "6336cfad09422f91416b4dd91581024425e020a017a1e419ab2810b5e8d5af7f", + "plugin_version": "0.6.1" +} diff --git a/.continuum/chain/links/0002/refs.json b/.continuum/chain/links/0002/refs.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.continuum/chain/links/0002/refs.json @@ -0,0 +1 @@ +{} diff --git a/.continuum/chain/links/0002/summary.md b/.continuum/chain/links/0002/summary.md new file mode 100644 index 0000000..b9835e6 --- /dev/null +++ b/.continuum/chain/links/0002/summary.md @@ -0,0 +1,25 @@ +## Decisions +- New feature "mochi comms": per-repo communication-channel sync & recall. Channel-agnostic provider interface; WhatsApp first via Baileys, shipped as a THIRD bundled plugin MCP server `comms` (tools `mcp__plugin_mochi_comms__*`). Explicitly NOT the external Docker wa-mcp — it is mochi's own, plug-and-play. +- v1 scope: WhatsApp only; session-scoped capture (no 24/7 daemon); sync + recall + manual import; media metadata-only. Deferred to v2 (spec §13): always-on capture daemon, sending, media bytes, Telegram (gramjs MTProto), heuristic gap detector. +- Baileys pinned 6.7.23; pino dropped for a console-logger shim; sharp/jimp `--external`-ized so the bundle stays native-free (Baileys pulls sharp as an optional NATIVE peer dep — must never inline). +- All comms state file-based under `.continuum/comms/` (config.json committed; auth/store/media/state gitignored). Per-repo JID allowlist enforced AT the MCP boundary: capture-drop + read-filter + send-refuse + path-traversal guard. + +## Changes since last link +- Full implementation: 31 plan tasks (5 phases) + 7 final-review fixups, ~73 commits ff186bb..a57fcce. Data layer (comms_config/allowlist/dedupe/store/recall/state/import + scoring extraction); MCP server server/src/comms/{index,normalize,provider,whatsapp}.js; session_start.js single-emit init gate (B3) + idempotent .gitignore appender (B2, security); five /mochi:comms-* commands; build:comms esbuild target + .mcp.json `comms` entry + CI verify. +- All suites green: run-synthetic 141/0, scoring 6/0, comms-recall 17/0, server comms 12/12 incl. native-free bundle smoke. Independently verified MERGE-READY. + +## Open threads +- Manual acceptance NOT done: real WhatsApp QR link on a throwaway number to prove live capture end-to-end (only mock/automated-tested so far). ToS/ban risk is real — use a non-primary number. +- Merging branch DevZonayed/improve-mochi-plugin -> master this session. +- v2 backlog (spec §13): daemon, send, media bytes, Telegram. + +## Constraints / Do not +- Do NOT bundle sharp/jimp (native via Baileys optional dep) — keep `--external:sharp --external:jimp` in build:comms; bundle MUST stay native-free. +- Do NOT resolve the comms project dir from process.cwd() — use COMMS_PROJECT_DIR env / per-tool project_dir arg (stdio MCP cwd is client-controlled). +- Do NOT revert the session_start.js gitignore writer to create-only — it must idempotently APPEND comms ignores (prevents committing WhatsApp creds + private messages). + +## Refs +- design → docs/superpowers/specs/2026-06-07-mochi-comms-channel-sync-design.md ; plans → docs/superpowers/plans/2026-06-07-mochi-comms-channel-sync.md + 2026-06-08-comms-fixups.md +- import parser → plugins/continuum/lib/comms_import.js (parseWhatsAppExport) ; dedupe → comms_dedupe.js (symmetric fingerprint + reconcileImport) +- security → server/src/comms/index.js (isAllowed on comms_get_messages) + plugins/continuum/lib/paths.js commsChatDir (traversal guard) +- bundle → server/dist/comms.bundle.mjs (native-free) ; build:comms in server/package.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ff8292d..784803e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,16 @@ jobs: - name: Verify bundle produced run: | test -f server/dist/server.bundle.mjs - echo "Bundle size: $(du -h server/dist/server.bundle.mjs | cut -f1)" + test -f server/dist/comms.bundle.mjs + echo "Browser bundle size: $(du -h server/dist/server.bundle.mjs | cut -f1)" + echo "Comms bundle size: $(du -h server/dist/comms.bundle.mjs | cut -f1)" + + - name: Telemetry plugin harness + run: bash plugins/continuum/tests/run-telemetry.sh + + - name: Telemetry ingest server tests + working-directory: telemetry-server + run: node --test - name: Commit bundle if it changed run: | diff --git a/.gitignore b/.gitignore index 8a46d4b..8695d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ server/node_modules/ server/.DS_Store .super-tester/ server/.super-tester/ +server/.continuum/ exports/ -mcp/ +/mcp/ # Local env / secrets — never commit .env @@ -22,3 +23,14 @@ mcp/ # Test coverage coverage/ + +.continuum/secrets/ +# Transient browser-MCP caches written into the repo's own .continuum by test +# runs (the chain itself — chain/, STATE.md — stays tracked). +.continuum/selectors/ +.continuum/runs/ +.continuum/verification/ +.continuum/.env-provenance.json +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/.mcp.json b/.mcp.json index 872f2e8..629a16c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -16,6 +16,16 @@ "args": [ "${CLAUDE_PLUGIN_ROOT}/plugins/continuum/mcp/server.js" ] + }, + "comms": { + "type": "stdio", + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/server/dist/comms.bundle.mjs" + ], + "env": { + "COMMS_PROJECT_DIR": "${CLAUDE_PROJECT_DIR}" + } } } } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..712ebe4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,530 @@ +# Changelog + +All notable changes to **Mochi** (the Claude Code plugin formerly known as +`super-tester`) are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +loosely and the project follows [Semantic Versioning](https://semver.org/). + +--- + +## [0.10.0] — 2026-06-10 + +### Added + +- **Mochi Insight — opt-in, anonymous usage telemetry + a local efficiency + critique.** A privacy-first feedback system so the plugin can be improved from + real usage: + - **Local capture (always on, never leaves the machine):** the hooks record + content-free events (tool/MCP name, ok/fail, error *category*, duration + *bucket*) to `.continuum/telemetry/` (gitignored). `pre_tool_use` stays + hot-path-safe (append-only, no network). + - **Opt-in sharing (default: share nothing):** a one-time consent gate (same + style as comms) shares only anonymous, content-free aggregates. A + **fail-closed redactor** (key-whitelist + enum value-coercion + third-party + name bucketing) guarantees no prompts, code, paths, or chat/WhatsApp content + ever leave; `/mochi:telemetry show` prints byte-for-byte what would be sent. + - **Efficiency critique (`/mochi:review-session`):** the agent reviews a + finished session locally and gives constructive "this took N calls — here's a + leaner path" advice; only an anonymized distillation (categories + scores) is + shared, the full critique stays local. A second, separate opt-in covers the + token cost of automatic reviews. + - **Self-hosted ingest + dashboard (`telemetry-server/`):** a zero-dependency + Node server; content is re-stripped server-side; an owner-only dashboard + shows top tools/MCPs, error rates, tool co-occurrence, calls-per-task, + tools-per-task-category, and a ranked improvement backlog. + - New commands: `/mochi:telemetry`, `/mochi:review-session`, `/mochi:insights`. + Deliberate bug/idea reports continue to use `/mochi:feedback` (GitHub issues). + +--- + +## [0.9.1] — 2026-06-09 + +### Fixed — Design-QA loop hardening (adversarial review of 0.9.0) + +- **The loop now actually closes client-side.** `browser_comment_add` activates + Comment Mode and injects the content script on the session tab, so agent + comments show up as **live pins immediately** — the human no longer has to open + Comment Mode first for anything to appear. +- **No more silent comment loss between the agent and the human.** The background + bridge and the content script are independent writers of the same + `mochiComments` document; whole-document last-write-wins could drop either + side's comments during concurrent QA-while-reviewing. Writes are now merged by + **id-based union** (new `comment-merge.js`, unit-tested for no-loss + + convergence), with every comment carrying an `updatedAt` so edits/resolves win + deterministically. +- **The agent no longer hijacks the human's active session.** A QA write only + auto-selects its session for an origin when the human isn't already viewing one + there. +- **Repeated `browser_comment_add` calls with no `sessionName`** now reuse one + default `QA ` session instead of minting a new session every call. +- **Breakpoint comments are never invisible.** They render as normal page pins + when the device frame is closed, and the in-frame width match is tolerant + (±60px) so emulate widths still reveal them. Malformed `{label,width}` values + are normalized away (no more `undefined` chips). +- **`browser_comment_list` / `browser_comment_sessions`** default-scope to the + current session tab's origin (no cross-project leakage from a bare call). +- **Popup comment count** reads the live `mochiComments` store instead of the + dead legacy key, so it reflects agent-added comments. +- Page-level comments (no selector) anchor to ``; the session-switcher + dropdown closes on FAB/dock clicks; `design-qa.md` documents the same-Chrome + requirement, `located` retries, and preset breakpoint widths. + +--- + +## [0.9.0] — 2026-06-09 + +### Added — Design-QA loop (browser MCP ↔ Comment Mode) + +- **Agent QA → comments → human review → bulk fix.** An agent can run a design-QA + pass over a running app and drop a Comment-Mode comment on each issue; those + comments appear to the human as **live pins** (the bridge writes the same + `mochiComments` store the extension reads). The human reviews/edits in the + browser, then the agent reads the session back and fixes everything in bulk. +- **4 new browser-MCP tools** (count **61 → 65**): `browser_comment_add`, + `browser_comment_list`, `browser_comment_sessions`, `browser_comment_resolve`. +- **`/mochi:design-qa [focus]`** — default design/UX heuristic pass (spacing, + contrast, overflow, broken/empty states, responsive, a11y, copy) with an + optional focus/criteria; **`/mochi:design-qa fix`** reads the session and + resolves each comment in code. +- **Comment Mode:** **severity** tints on pins/list (grey/amber/red), a + **resolved** state (✓, dimmed/struck), a **current-session pill + switcher** + so you can pick the session to comment into, and **meaningful default session + names** (from the page title / project); QA sessions are named + ` · — QA `. + +--- + +## [0.8.0] — 2026-06-09 + +### Added — Comment Mode v2 (sessions, navigation, dock UI) + +- **Sessions are first-class.** Comments are organized into named, saved + **sessions scoped per site (domain/port)**. **New session** starts from zero; + **rename** (inline), **delete** (two-tap), and **switch** between them. Your + existing comments are migrated automatically into one session per site. +- **Floating navigator** (draggable) with two views: + - **Sessions** — filter **This site / All sites**, see each session's site + + comment count, + new; tapping a session on the current site makes it active. + - **Comments** — grouped by **route**; tap a comment to jump to it. +- **Click-to-locate navigation.** Clicking a comment scrolls to its element; + if it lives on a **different route or page**, Mochi navigates there first and + then scrolls to it (the scroll target survives the page load). +- **Dock-style FAB.** The bubble starts/stops commenting; **hovering** it reveals + a macOS-dock-style, staggered column of **icon** actions (Navigator · + Responsive · Copy · End) with tooltips. +- Per-session comment numbering (each starts at **#1**) and per-session + **Copy brief** (includes the session name). + +### Notes +- Storage moved to a `mochiComments` document (one-time migration from the old + format runs automatically). Cross-tab edits sync, with a flush-before-adopt + guard so a tab's unsaved edits aren't clobbered. + +--- + +## [0.7.3] — 2026-06-09 + +### Added (Comment Mode — responsive) + +- **Custom device size** in the responsive frame — a "Custom" option with + width × height inputs. +- **Scale-to-fit for oversize frames.** The preview iframe renders at the + *true* device size (so media queries stay accurate) and is visually scaled to + fit your window when the chosen size is larger than the viewport, with the + zoom level shown (e.g. `2560 × 1440 · 47%`); it re-fits on window resize. The + comment overlay (hover highlight, pins, popover) is scale-aware, so picking + stays pixel-accurate at any zoom. + +--- + +## [0.7.2] — 2026-06-09 + +### Fixed (Comment Mode) + +- **Comments are now scoped per-site (origin).** They were stored in one global + list and only the on-page pins were site-filtered, so the comments list, the + count badge, and **Copy brief** showed/exported *every* site's comments — + starting a session on a new website appeared to inherit the previous site's + comments. Now the count, list, export, and per-comment numbering (each site + starts at **#1**) are all scoped to the current site, and **Clear** removes + only the current site's comments. Storage keeps each site's comments isolated + by origin, so returning to a site restores its comments. + +--- + +## [0.7.1] — 2026-06-09 + +### Changed (Comment Mode UX, from user feedback) + +- **Sticky pick mode** — arm once (💬), then for each comment just **click an + element → type → Enter**. The picker stays armed (no re-clicking the bubble); + **Esc** (when nothing's open) or tapping 💬 finishes. Far fewer clicks. +- **Easy cancel** — **Enter** saves (Shift+Enter = newline), **Esc** or + **clicking outside** the popover discards it; placeholder documents it. +- The picker no longer swallows clicks on Mochi's own FAB / ⋯ menu while armed + (you can open the menu and finish without disarming first). + +### Fixed (Comment Mode) + +- **Responsive frame:** the hover highlight was rendered *behind* the device + overlay (so it looked like nothing happened) — raised above it; the frame now + **auto-arms** commenting on load, "Comment here" is a clear armed toggle, and + switching devices re-arms with the correct breakpoint. +- **Top-frame guard:** the content script never mounts inside iframes (including + the responsive preview), preventing nested instances. + +--- + +## [0.7.0] — 2026-06-07 + +### Added + +- **Comment Mode — standalone visual annotation → agent-ready brief.** One click + (**💬 Comment mode** in the popup, no Claude session required) drops a floating + bubble on any page. Click an element to leave a numbered, anchored comment; + scrolling stays free (with a one-time scroll hint). Comments persist across + pages/reloads (`chrome.storage`), grouped by route in an iOS-style list, and + sync across tabs. A **Responsive** device-frame lets you comment at common + breakpoints. **Copy brief** exports markdown (selector + route + breakpoint + + element + note) to paste into any coding agent. New content script + `extension/comment-mode.js`; background re-injects on navigation; SPA route + changes are detected (history patch + popstate/hashchange). Verified + end-to-end in a real browser, then hardened against an adversarial review + (14 findings fixed: cross-tab sync, listener-leak-free teardown, per-tab + stop, hydration-race + stale-tab-id guards, sandboxed responsive iframe). + No new MCP tools (browser count stays 61). +- **WhatsApp comms MCP** (from the parallel comms work merged here): a separate + `comms` MCP server (`server/dist/comms.bundle.mjs`) with WhatsApp messaging + tools, plus continuum comms recall/scoring. See the comms modules under + `server/src/comms/` and `plugins/continuum/lib/comms_*`. + +--- + +## [0.6.1] — 2026-06-07 + +### Changed + +- **Popup: the "Send test notification" button is now always visible.** It was + previously tucked inside the first-run onboarding card and disappeared once a + user confirmed notifications worked. It now sits permanently in the + **Notifications** section (under the on/off toggle), fires a test toast + regardless of the toggle state, and surfaces a Chrome-level "notifications + switched off" warning when applicable. No new tools (count stays 61). + +### Notes + +- Patch release so the distributed plugin cache carries the always-visible + test button. As with 0.5.0+, applying the extension changes requires a + one-time **reload of the unpacked extension** from the new cache path. + +--- + +## [0.6.0] — 2026-06-07 + +QA-truth release. The browser tools stop trusting "it rendered" and start +proving "it works." Six new tools turn a QA pass into an exhaustive, +verdict-driven sweep — enumerate every control, drive each one, gate every +page and action for errors, and prove writes persisted. Tool count grows +**55 → 61** (on top of 0.5.0). + +### Added + +- **`browser_assert_no_errors`** — one-call health gate. `ok=false` if **any** + console error/uncaught exception **or** any `>=400`/failed request happened + since the page loaded (`sinceNavigation` default, `sinceMs`, + `ignoreUrlContains`). Failed-request entries include the response `.body`. +- **`browser_audit_interactives`** — the coverage backbone. Enumerates every + actionable control (`scope:"all"|"viewport"`, `limit`, `includeHidden`) with + `{selector, role, accessibleName, visible, inViewport, disabled, + hasClickHandler, box}` so nothing is left UNTESTED. +- **`browser_act_and_observe`** — perform one action + (`click`/`type`/`navigate`/`press_key`/`click_at`) and classify the result: + `WORKS` / `NO-OP` / `ERROR` / `NAVIGATES`. A `NO-OP` (clickable but nothing + changed) is a dead control = defect. Returns `urlChanged`, `domChanged`, + `networkDelta`, `consoleDelta`. Render != Works. +- **`browser_wait_for_response`** — block until a matching network response + arrives (`urlGlob`/`urlContains`, `method`, `statusGte`/`statusLt`, + `timeoutMs`). Proves a write actually persisted. +- **`browser_page_assets`** — hash the live page assets + (`script`/`css`/`document`) with sha256 + a `pageHash`. Confirm the live + bundle hash == the built hash (stale-bundle guard). +- **`browser_set_storage`** — deterministic auth/state seeding: set + `localStorage`, `sessionStorage`, and `cookies` (or `clear`) in one call. +- **Exhaustive QA coverage mode** (`/qa exhaustive`) — enumerate every control, + drive each, gate each page/action, and assign one of five verdicts per + control: **WORKS**, **NO-OP** (defect), **ERROR** (defect), **NAVIGATES**, + **DISABLED**. +- **Honesty-gate CLI** — refuses to report a run as "pass" while any control is + UNTESTED/UNCERTAIN. The rule is never "everything works" but *"N of M + controls verified — here is each result, and here is what I could NOT verify + and why."* +- **Verification ledger** — per-control results recorded with **provenance + stamping** so every verdict traces back to the action and evidence that + produced it. +- **Persistent tooling-gotchas note** — `skills/browser/references/gotchas.md`, + the canonical durable record of hard-won quirks so they're never re-learned. + +### Changed + +- `browser_navigate` now accepts `hardReload` (cache-bypass load) and + `disableCache` (persist cache-off for the tab). +- `browser_console_messages` accepts `sinceNavigation:true` to scope to the + current page; `level:"error"` includes uncaught exceptions. +- `browser_network_requests` accepts `sinceNavigation`, `sinceMs`, and + `includeBody`; error responses (`>=400`/failed) include the captured response + body automatically. +- `browser_click` reports disabled controls (fails loudly with "element is + disabled" instead of silently passing) and retries a transient not-found once. +- `browser_session_health` accepts `heal:true` to re-attach the debugger to + session tabs. +- `/continuum:recall` now flags stale chain-link hits by age, so remembered + facts are re-verified against current code/live state before being asserted. +- **Tool count 55 → 61** (0.5.0 was 55). + +### Fixed + +- **Docs:** clarified `browser_emulate_viewport` vs `browser_window_resize`. + `browser_emulate_viewport` (CDP `Emulation.setDeviceMetricsOverride`) **does** + change `window.innerWidth` / `matchMedia` — real JS layout, and the tool to + use for responsive/media-query testing. `browser_window_resize` only + moves/sizes the OS Chrome window and does **NOT** affect JS layout. A common + past mistake was conflating the two (or assuming emulation "only affects + screenshots"). + +### Notes + +- Layers on top of 0.5.0 (notifications). Combined tool count **54 → 61** + (0.5.0 added `browser_request_attention`; 0.6.0 adds the six QA-truth tools). +- Adds a zero-dependency continuum MCP **recall** server + (`plugins/continuum/mcp/server.js`) — `/continuum:recall` is now also an MCP tool. + +--- + +## [0.5.0] — 2026-06-07 + +### Changed + +- **Automation never steals OS focus.** `browser_session_start`'s + `bringToFront` default flips `true → false`. Instead of raising the Chrome + window to the foreground, the extension posts a **click-to-focus OS + notification** ("Automation started — click to bring the window forward"). + The session tab is still made `active: true` within its window (no SPA + throttling). Pass `bringToFront: true` when you actually want to watch. + `chrome.notifications.onClicked` is the **only** path that raises a window. + +### Added + +- **`browser_request_attention({ reason, tabId?, urgent? })`** — a new MCP + tool the agent calls when it genuinely needs the human (suspected + captcha/login wall, an ambiguous choice, or "task finished — come look"). + Posts a notification without stealing focus. **Tool count 54 → 55.** +- **Automatic attention notifications** for the unambiguous cases: an + unrecoverable session loss, a native JS dialog (`alert`/`confirm`/`prompt`/ + `beforeunload`), and a page crash. The dialog/crash hooks are pure + `chrome.debugger` observers — they send no CDP command, so native dialog + handling is unchanged. +- **Project-named notifications.** Each toast is titled `Mochi · `, + where the label is `basename(cwd)` of the per-project MCP server process, + injected into `session_start`. The same label now titles the tab group. +- **Popup notifications section** — an OS-notifications on/off switch plus a + non-technical onboarding flow: a "Send test notification" button, a + "Did you see it? Yes / No" confirm, and (on No) a one-click **Open + notification settings** button that deep-links to the macOS Notifications + pane via a new broker route `POST /os/open-notification-settings`. + +### Notes + +- **Requires a one-time "reload unpacked extension"** — the new + `notifications` manifest permission must be granted. +- **macOS:** a Chrome extension cannot read the System Settings notification + toggle, so the confirm-probe (test → "did you see it?") is the robust check. + If toasts don't appear, enable **Google Chrome** under System Settings → + Notifications. + +--- + +## [0.4.1] — 2026-05-20 + +### Fixed + +- **Focus-steal during automation.** Every `browser_navigate` call in 0.4.0 + invoked `chrome.windows.update({ focused: true })`, which raises the Chrome + automation window to the OS foreground AND captures keyboard focus from + whatever the user was working on. Long automation flows became unusable + while doing anything else — every navigate would yank your typing target + back into Chrome. + + In 0.4.1, `browser_navigate`'s `bringToFront` default changes from `true` + to `false`. The tab is still made `active: true` within its Chrome window + (so SPAs / React / Cloudflare render correctly — no throttling), but the + OS-level window focus is no longer requested on each navigate. Pass + `bringToFront: true` explicitly when you want the window forward. + + `browser_session_start`'s default stays `bringToFront: true` — the + one-time window creation is expected to be visible. Subsequent navigates + in the session are silent. + + **Behavioral impact:** automation now runs in the background and lets you + keep working in your IDE / Slack / wherever. Visual users who want to + watch a flow can `bringToFront: true` on a specific navigate, or + `Cmd+Tab` to the automation window once. + +### Notes + +- The `bringToFront` parameter in tool schemas was conflating two separate + Chrome concepts (tab-active-in-window vs window-focused-on-OS). They're + now decoupled internally: tab activity is always on; window OS focus is + the opt-in flag. +- No new MCP tools. Tool count remains 54. + +--- + +## [0.4.0] — 2026-05-20 + +Massive feature release. Tool count grows **39 → 54** with two big new +systems: file uploads (4-strategy chain that bypasses the OS native picker) +and personal-ops playbooks (per-feature markdown playbooks under +`.continuum/playbooks/` with auto-learning, codebase-derived seeding, +secrets, visual diff, sharing bundles, and an HTML dashboard). + +### Added — browser file uploads + +- **`browser_upload_stage`** — stage a file into the per-project library at + `.continuum/uploads/`. Accepts `path`, https `url`, `dataUrl`, or `base64`. + Returns a stable `stashId` (sha256-based, idempotent) reusable across many + uploads and across sessions. +- **`browser_upload_file`** — attach a file to a page target via a strategy + chain that bypasses the native OS file picker entirely: + - `direct` — `DOM.setFileInputFiles` against `` + - `intercept` — `Page.setInterceptFileChooserDialog` + `Page.handleFileChooser` + around a click on a trigger button + - `drop` — synthesized `DataTransfer` + `DragEvent` (handles Twitter/FB + composer drops, drag-only zones) + - `paste` — synthesized `ClipboardEvent` (Slack-style image paste into + contenteditable) + - Smart wait confirms upload via preview-thumbnail (MutationObserver), + upload-network 2xx response, or a caller-supplied success signal. + - Target by `selector`, accessibility `ref`, `trigger: {selector}`, or + `auto: {near}` (the tool walks the DOM neighborhood for an upload + target). Same-origin frame traversal is automatic. + +### Added — personal ops playbooks (v1) + +- **Markdown playbook format** under `.continuum/playbooks//.md` + with YAML frontmatter (origin, feature, verifiable, preconditions, inputs, + outputs, composes, cron, last_verified, success_count, playbook_version). + Sibling `.workflow.json` holds replay steps. +- **Seven new MCP tools:** `browser_playbook_{list,get,save,delete,match,run, + propose_update}`. Replay routes through the existing workflow runner so + selectors self-heal via ARIA role+name; healed selectors land back in the + per-origin cache. +- **`qa-tester` subagent** (`plugins/qa/agents/qa-tester.md`). Isolated + context, browser tools + read-only project access, returns one of + `{verdict: "pass"|"fail"|"blocked"}` with evidence. Main agent decides when + to delegate via the smart-router rule in `plugins/qa/CLAUDE.md`. +- **Four slash commands:** `/qa ` (dispatch the subagent), + `/mochi:playbook` (list/show/run/delete/match), `/mochi:schedule-playbook`, + `/mochi:unschedule-playbook` (cron via the host environment's schedule + skill). +- **Auto-learning loop:** `browser_playbook_propose_update` takes a successful + trace and creates or updates the matching playbook with inputs inferred + from `intent` fields. + +### Added — playbooks v1.5 (secrets / seeding / visual diff) + +- **`browser_playbook_secret_check`** — validate that all `type: secret` + inputs of a playbook are resolvable. Returns availability per secret; + **never returns values.** +- **Secret refs:** `${env:VAR_NAME}`, `${secret:NAME}` (reads + `.continuum/secrets/.txt`, chmod 0700, auto-protective `.gitignore`), + `${BARE_UPPER}` shorthand. Secret values are stripped from traces and + promoted playbook bodies. +- **`browser_playbook_seed_from_codebase`** — static analyzer over the + project's frontend. Detects Next.js (App + Pages Router), Vite, and CRA. + Walks routes + form components via `@babel/parser`; auto-types `` as `secret`. Emits drafts with `playbook_version: 0` + until you run + bless them. +- **Visual diff regression:** during `browser_playbook_run`, each step's + screenshot is captured and compared (pixelmatch) against the playbook's + reference. `warn` between 5–20% diff, `fail` ≥20% (tunable per playbook). + **`browser_playbook_diff_accept`** blesses a run's screenshots as the new + reference and bumps `playbook_version`. + +### Added — playbooks v2 (sharing & polish) + +- **1Password CLI integration:** `${1password:vault/item/field}` (alias + `${op:...}`) resolves via `op read`. Availability checked with a 60s + cache. When `op` isn't installed, refs resolve to `null` (treated same + as missing). +- **Vue + SvelteKit codebase seeding:** Nuxt (`nuxt.config.*` → + `pages/*.vue`) and SvelteKit (`svelte.config.*` + `@sveltejs/kit` → + `src/routes/**/+page.svelte`). Built-in HTML tokenizer extracts + ``, ` +
+ +``` + +`pages/03-drop.html`: +```html +upload-03 +
drop here
+
+ +``` + +`pages/04-paste.html`: +```html +upload-04 +
paste here
+
+ +``` + +`pages/05-iframe.html`: +```html +upload-05 +

Outer page

+ +``` + +`pages/01-direct-frame.html`: +```html +upload-01-frame +
+
+ +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/_fixtures/upload/ +git commit -m "test(fixtures): HTTP server + five upload fixture pages" +``` + +--- + +## Task 20: End-to-end integration test runner + +**Files:** +- Create: `server/_upload_e2e.mjs` + +- [ ] **Step 1: Write the e2e test** + +Create `server/_upload_e2e.mjs`: +```js +// _upload_e2e.mjs — end-to-end upload test against a real Chrome via the broker. +// Requires the Mochi extension loaded and a running Chrome instance. +// +// Skip cleanly if the extension is not connected — same convention as existing _integration.mjs. + +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { startFixtureServer } from "./_fixtures/upload/server.mjs"; +import { Bridge } from "./src/bridge.js"; +import { handleToolCall, initToolsState } from "./src/tools.js"; +import { initUploads } from "./src/uploads.js"; + +const PNG = Buffer.from([ + 0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a, 0,0,0,13, + 0x49,0x48,0x44,0x52, 0,0,0,1, 0,0,0,1, 8,2, 0,0,0, + 0x90,0x77,0x53,0xde, 0,0,0,12, 0x49,0x44,0x41,0x54, + 0x08,0xd7,0x63,0x00,0x01,0x00,0x00,0x05,0x00,0x01,0x0d,0x0a,0x2d,0xb4, + 0,0,0,0, 0x49,0x45,0x4e,0x44, 0xae,0x42,0x60,0x82, +]); + +const tmpProject = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-e2e-")); +process.env.MOCHI_PROJECT_DIR = tmpProject; +await initUploads(); +initToolsState({ log: () => {} }); + +const fix = await startFixtureServer(); +console.log("fixture server:", fix.port); + +const bridge = new Bridge({ log: () => {} }); +const role = await bridge.start({ port: 9009 }); +console.log("bridge role:", role); + +if (!bridge.isConnected()) { + console.log("SKIP e2e: extension not connected"); + await fix.close(); + await bridge.close?.(); + process.exit(0); +} + +// 1. Stage the test PNG +const staged = JSON.parse((await handleToolCall(bridge, { + name: "browser_upload_stage", + arguments: { source: { base64: PNG.toString("base64") }, name: "1x1.png" }, +})).content[0].text); +assert.equal(staged.ok, true); + +// 2. For each fixture page, navigate + upload + assert preview +const pages = [ + { url: `http://127.0.0.1:${fix.port}/pages/01-direct.html`, target: { selector: "input[type=file]" }, strategies: ["direct"] }, + { url: `http://127.0.0.1:${fix.port}/pages/02-intercept.html`, target: { trigger: { selector: "#trigger" } }, strategies: ["intercept"] }, + { url: `http://127.0.0.1:${fix.port}/pages/03-drop.html`, target: { selector: "#zone" }, strategies: ["drop"] }, + { url: `http://127.0.0.1:${fix.port}/pages/04-paste.html`, target: { selector: "#composer" }, strategies: ["paste"] }, + { url: `http://127.0.0.1:${fix.port}/pages/05-iframe.html`, target: { selector: "input[type=file]" }, strategies: ["direct"], frames: "all" }, +]; + +await bridge.send("session_start", { groupBy: "client" }); +for (const p of pages) { + console.log("→", p.url, "strategies:", p.strategies.join(",")); + await bridge.send("navigate", { url: p.url }); + await new Promise((r) => setTimeout(r, 500)); + + const upArgs = { stashId: staged.stashId, ...p.target, strategies: p.strategies, frames: p.frames }; + const upRaw = await handleToolCall(bridge, { name: "browser_upload_file", arguments: upArgs }); + const up = JSON.parse(upRaw.content[0].text); + assert.equal(up.ok, true, `upload failed on ${p.url}: ${JSON.stringify(up.error || up)}`); + console.log(" strategy:", up.strategy, "waitedFor:", up.waitedFor?.signal); +} + +assert.ok(fix.received.length >= 5, `expected ≥5 uploads received, got ${fix.received.length}`); +console.log("✓ e2e upload — all five fixtures uploaded"); + +await bridge.send("session_end", {}).catch(() => {}); +await fix.close(); +await bridge.close?.(); +await fs.rm(tmpProject, { recursive: true, force: true }); +``` + +- [ ] **Step 2: Add to package.json test script (optional, only when extension live)** + +The e2e test should NOT block the default `npm test` since it requires Chrome+extension. Instead, add a separate npm script: +```json + "test:e2e:upload": "node _upload_e2e.mjs", +``` + +- [ ] **Step 3: Run e2e (manual, with extension loaded)** + +```bash +cd server && node _upload_e2e.mjs +``` +Expected output: 5 fixture uploads succeed, server received ≥5 POSTs. + +- [ ] **Step 4: Commit** + +```bash +git add server/_upload_e2e.mjs server/package.json +git commit -m "test(upload): end-to-end integration runner against fixture server" +``` + +--- + +## Task 21: Path allowlist enforcement in extension + +**Files:** +- Modify: `extension/upload.js` + +- [ ] **Step 1: Implement allowlist check** + +In `extension/upload.js`, at the top of `handleUploadFile`, after the `filePaths.length` check, add: +```js + for (const p of filePaths) { + if (!isPathAllowed(p)) { + return { ok: false, error: { code: "permission", message: "path not in allowlist", details: { path: p } } }; + } + } +``` +And add: +```js +function isPathAllowed(p) { + if (typeof p !== "string" || !p.length) return false; + // .continuum/uploads/ relative segment must be in the path + if (p.includes("/.continuum/uploads/") || p.includes("\\.continuum\\uploads\\")) return true; + const extras = (globalThis.SUPER_TESTER_UPLOAD_ALLOW_PATHS || "").split(":").filter(Boolean); + return extras.some((prefix) => p.startsWith(prefix)); +} +``` + +(For the env var to reach the extension, the server can include it in the wire payload as `allowPaths`; alternative is to enforce server-side only. For v1, server enforcement via `resolveOrStage` already ensures all caller `path:` sources are re-staged into `.continuum/uploads/`. The extension check is defense-in-depth.) + +- [ ] **Step 2: Commit** + +```bash +git add extension/upload.js +git commit -m "feat(extension): path allowlist check for upload filePaths (defense-in-depth)" +``` + +--- + +## Task 22: Telemetry log + +**Files:** +- Modify: `server/src/tools.js` + +- [ ] **Step 1: Add telemetry append after successful upload** + +In `server/src/tools.js`, modify `toolUploadFile` to append a telemetry line after the bridge.send succeeds: +```js + // After: const result = await bridge.send(...) + await appendUploadLog({ + ts: new Date().toISOString(), + tabId: args.tabId ?? null, + origin: globalThis.activeOrigin || null, + stashId: resolved[0]?.stashId ?? null, + strategy: result.strategy ?? null, + totalMs: result.totalMs ?? null, + ok: !!result.ok, + }); +``` + +And add the helper: +```js +import { uploadsDir } from "./uploads.js"; +async function appendUploadLog(entry) { + const fs = await import("node:fs/promises"); + const p = (await import("path")).join(uploadsDir(), "log.jsonl"); + try { await fs.appendFile(p, JSON.stringify(entry) + "\n"); } catch {} +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/src/tools.js +git commit -m "feat(uploads): append per-upload telemetry to .continuum/uploads/log.jsonl" +``` + +--- + +## Task 23: Hook session GC into existing session_end path + +**Files:** +- Modify: `server/src/tools.js` + +- [ ] **Step 1: Locate the `session_end` wire dispatch** + +Search `server/src/tools.js` for `"session_end"`. In whichever local helper handles it (or where it's forwarded), after the bridge `session_end` returns, call `gcSession(sessionId)`. + +If `session_end` is currently bridge-forwarded only, intercept it server-side by adding a local hook: +```js +// In handleToolCall, BEFORE the wire dispatch for browser_session_end: +if (name === "browser_session_end") { + const sid = currentClaudeSessionId(); + const res = await runWireTool(bridge, name, args); + if (sid) { + try { await gcSession(sid); } catch (e) { /* swallow */ } + } + return res; +} +``` + +(Import: `import { stage as stageUpload, uploadErr, uploadsDir, gcSession } from "./uploads.js";`) + +- [ ] **Step 2: Commit** + +```bash +git add server/src/tools.js +git commit -m "feat(uploads): gc session-scoped uploads on browser_session_end" +``` + +--- + +## Task 24: Rebuild bundle and verify + +**Files:** +- Modify: `server/dist/server.bundle.mjs` + +- [ ] **Step 1: Rebuild** + +```bash +cd server && npm run build +``` +Expected: no errors; `dist/server.bundle.mjs` updated. + +- [ ] **Step 2: Run the full test suite** + +```bash +cd server && npm test +``` +Expected: all four test scripts green (`_smoke.mjs`, `_uploads.test.mjs`, `_upload_wire.test.mjs`, `_integration.mjs`, `_multi-client.mjs`). + +- [ ] **Step 3: Run smoke + manual e2e** + +```bash +cd server && node _smoke.mjs && node _upload_e2e.mjs +``` +Expected: smoke passes; e2e SKIPs if no extension, or all 5 fixtures pass. + +- [ ] **Step 4: Commit the rebuilt bundle** + +```bash +git add server/dist/server.bundle.mjs +git commit -m "ci(server): rebuild bundle for upload tools" +``` + +--- + +## Task 25: Update README + capability docs + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a short Upload section** + +In `README.md`, find the tool-list section and add a row: +``` +- **browser_upload_stage** — stage a file into the per-project upload library (`.continuum/uploads/`); accepts path, https URL, dataUrl, or base64; returns a reusable stashId. +- **browser_upload_file** — attach a file to a page target via strategy chain (direct → intercept → drop → paste), with smart-wait completion detection. Bypasses the native file picker. +``` + +If there is no tool list (a quick look suggests the README has a `Tools at a glance` section), add equivalents there. If absent, add a new `### File uploads` subsection under the existing browser-tool docs. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document browser_upload_stage + browser_upload_file" +``` + +--- + +## Self-Review + +### Spec coverage check + +Walking each spec section against tasks: + +| Spec section | Implemented by tasks | +|---|---| +| `browser_upload_stage` input/output/behavior | 4 (stageBuffer), 5 (resolveSource), 6 (URL fetch), 7 (top-level stage), 9 (tool registration) | +| `browser_upload_file` input/output | 10 (schema), 11 (server-side dispatch + sources), 12-17 (extension strategy chain) | +| Wire protocol addition | 11 (server payload), 12 (extension dispatch) | +| Storage layout (.continuum/uploads/) | 1 (initUploads), 2 (index.json), 4 (blob writes) | +| Strategy: direct | 14 | +| Strategy: intercept | 15 | +| Strategy: drop | 16 | +| Strategy: paste | 17 | +| Target resolution (selector/ref/trigger/auto) | 13 | +| Frame traversal | 13 (helpers); validated by fixture 05 in 19/20 | +| Smart wait | 18 | +| File lifecycle / GC | 8, 23 | +| Path allowlist | 21 | +| Size caps | 6 (URL streaming), 7 (top-level) | +| Telemetry | 22 | +| Error taxonomy | embedded across 4-7 (server) and 12-17 (extension); errors return from bridge as `{ ok: false, error }` per spec | +| Testing — server unit | 1-8 (uploads.test.mjs) | +| Testing — wire contract | 11 | +| Testing — integration (5 fixtures) | 19, 20 | +| Testing — smoke | 9, 10, 24 | +| Acceptance criterion: no regression in existing tests | 24 | + +**Gaps:** none identified. + +### Placeholder scan + +- No "TBD", "TODO", "fill in", "similar to" found in tasks. +- One soft spot: Task 12 says "The exact wiring depends on whether the current SW is module-mode" — acceptable because the engineer can check `manifest.json` in one step. +- Task 13 declines to write a CDP-mock unit test, with rationale ("low value"). Acceptable per testing-strategy section of spec which scopes extension validation to integration tests. +- Task 21 notes the env var reaching the extension is server-side-enforced primarily; this is documented behavior, not a placeholder. + +### Type consistency + +- `stage()` returns `{ stashId, sha256, path, name, mime, sizeBytes, source, keep, dedupedFrom? }` — used consistently in Tasks 4, 7, 9, 11, 20. +- `uploadErr(code, message, details)` taxonomy used in 4-7 — codes match the spec's error table. +- Wire payload field names (`filePaths`, `fileBytes`, `target`, `strategies`, `frames`, `dispatchEvents`, `waitFor`) match between Task 11 (server) and Tasks 12-17 (extension). +- Tool names (`browser_upload_stage`, `browser_upload_file`) — consistent. +- Strategy names (`direct`, `intercept`, `drop`, `paste`) — consistent in spec, schema, switch, and tests. + +No inconsistencies found. + +--- + +## Acceptance verification (post-implementation) + +Run all of the following before considering the plan complete: + +1. `cd server && npm test` — all hand-rolled tests green. +2. `cd server && node _upload_e2e.mjs` with extension loaded — all five fixture pages succeed; the strategy that wins matches expectation per fixture. +3. `cat .continuum/uploads/index.json | jq` shows entries with expected sha256, mime, ext, name, source fields after an e2e run. +4. Smoke test green: `cd server && node _smoke.mjs`. +5. Bundle rebuilt: `ls -la server/dist/server.bundle.mjs` has fresh mtime; bundle size <1MB. +6. Manual: in a real Chrome with the plugin loaded, run from Claude Code: + ``` + browser_upload_stage({ source: { url: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" } }) + browser_navigate({ url: "https://imgur.com/upload" }) + browser_upload_file({ stashId: "", auto: { near: "input[type=file]" } }) + ``` + and observe the image appearing in Imgur's upload preview within 15s. diff --git a/docs/superpowers/plans/2026-05-20-personal-ops-playbooks.md b/docs/superpowers/plans/2026-05-20-personal-ops-playbooks.md new file mode 100644 index 0000000..6fb4bed --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-personal-ops-playbooks.md @@ -0,0 +1,1953 @@ +# Personal Ops Playbooks — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship sub-projects 1–4 of the personal-ops-playbooks design as one coherent system: per-feature markdown playbooks under `.continuum/playbooks/`, 7 new MCP tools, a QA subagent + smart-router rule, an auto-learning promoter, and chain/cron composition. + +**Architecture:** A new `server/src/playbooks.js` module owns parsing, validation, CRUD, scoring, and the trace→playbook promoter. Seven new wire-or-local MCP tools register in `tools.js`. The plugin gains a new `plugins/qa/` directory carrying the `qa-tester` agent, a project-level CLAUDE.md smart-router rule, and four new slash commands. Playbook replay rides through `browser_workflow_run` for selector self-heal; the heal events feed back to the playbook's "Recent runs" log via a new hook in `tools.js`. Chains and cron are resolved server-side; scheduling delegates to the host environment's existing `schedule` skill. + +**Tech Stack:** Node 22 (ESM), `js-yaml` (new — frontmatter parsing), existing `server/src/memory.js` (selector/workflow store), Chrome DevTools Protocol (existing), Claude Code plugin manifest format (existing). + +**Spec:** `docs/superpowers/specs/2026-05-20-personal-ops-playbooks-design.md` + +--- + +## File Structure + +**New files (server):** +- `server/src/playbooks.js` — parse, serialize, validate, CRUD, match, promote. ~700 lines. +- `server/_playbooks.test.mjs` — unit tests for the module. ~400 lines. +- `server/_playbook_wire.test.mjs` — tool wire-contract tests. ~150 lines. +- `server/_playbook_e2e.mjs` — end-to-end against fixture pages. ~200 lines. +- `server/_fixtures/playbooks/server.mjs` — fixture HTTP server. ~100 lines. +- `server/_fixtures/playbooks/pages/login-form.html` +- `server/_fixtures/playbooks/pages/compose-form.html` +- `server/_fixtures/playbooks/pages/multi-step-wizard.html` + +**New files (plugin):** +- `plugins/qa/CLAUDE.md` — smart-router task-classification rule. +- `plugins/qa/agents/qa-tester.md` — subagent definition. +- `plugins/qa/commands/qa.md` — `/qa ` slash command. +- `plugins/qa/commands/playbook.md` — `/mochi:playbook [args]` aggregator. +- `plugins/qa/commands/schedule-playbook.md` — `/mochi:schedule-playbook `. +- `plugins/qa/commands/unschedule-playbook.md` — `/mochi:unschedule-playbook `. + +**Modified files:** +- `server/src/tools.js` — register 7 new tools (`browser_playbook_list/get/save/delete/match/run/propose_update`), add local dispatch cases, wire selector-heal → playbook update. +- `server/package.json` — add `js-yaml` dep; extend `test` script to include new test files. +- `server/_smoke.mjs` — bump expected tool count to 48; assert new tools register. +- `server/_integration.mjs` — bump expected tool count to 48. +- `.claude-plugin/plugin.json` — register the new commands, agents, and CLAUDE.md. +- `README.md` — add "Playbooks" section under Tools. +- `server/dist/server.bundle.mjs` — rebuilt via `npm run build`. + +--- + +## Task 1: Scaffold `playbooks.js` + parse markdown frontmatter + +**Files:** +- Create: `server/src/playbooks.js` +- Create: `server/_playbooks.test.mjs` +- Modify: `server/package.json` (add js-yaml) +- Modify: `server/_smoke.mjs` (no behavior change yet — but reserve) + +- [ ] **Step 1: Install js-yaml** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && npm install js-yaml@4 +``` + +- [ ] **Step 2: Write the failing test** + +Create `server/_playbooks.test.mjs`: +```js +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { parsePlaybook, serializePlaybook, playbooksDir, initPlaybooks } from "./src/playbooks.js"; + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-pb-test-")); +process.env.MOCHI_PROJECT_DIR = tmp; +await initPlaybooks(); + +assert.equal(playbooksDir(), path.join(tmp, ".continuum", "playbooks")); +const stat = await fs.stat(playbooksDir()); +assert.ok(stat.isDirectory()); + +const md = [ + "---", + "origin: mail.google.com", + "feature: send-email", + "title: Send email", + "verifiable: true", + "inputs:", + " - { name: to, type: email, required: true }", + "outputs: []", + "preconditions: [logged-in]", + "composes: []", + "next: null", + "cron: null", + "last_verified: 2026-05-20T10:00:00Z", + "success_count: 0", + "playbook_version: 1", + "schema_version: 1", + "---", + "", + "## Summary", + "Send a gmail message.", + "", + "## Preconditions", + "User logged in.", + "", + "## Steps", + "1. Click compose.", + "", + "## Verification", + "Toast appears.", + "", + "## Selectors used", + "", + "| intent | selector |", + "|---|---|", + "| compose | `[data-tooltip=Compose]` |", + "", + "## Recent runs", + "", + "- r1 (2026-05-20) — pass, 6s", + "", + "## Screenshots", + "", + "- (none yet)", + "", +].join("\n"); + +const pb = parsePlaybook(md); +assert.equal(pb.meta.origin, "mail.google.com"); +assert.equal(pb.meta.feature, "send-email"); +assert.equal(pb.meta.verifiable, true); +assert.equal(pb.meta.inputs.length, 1); +assert.equal(pb.meta.inputs[0].name, "to"); +assert.ok(pb.sections.summary.includes("Send a gmail message")); +assert.ok(pb.sections.steps.includes("Click compose")); +assert.equal(pb.sections.selectors_used.length, 1); +assert.equal(pb.sections.selectors_used[0].intent, "compose"); + +const round = serializePlaybook(pb); +assert.equal(round, md, "round-trip serialization should be byte-identical"); + +console.log("✓ Task 1 — parse + round-trip"); +await fs.rm(tmp, { recursive: true, force: true }); +``` + +- [ ] **Step 3: Update server/package.json test script** + +Change `test` line to: +```json + "test": "node _smoke.mjs && node _uploads.test.mjs && node _upload_wire.test.mjs && node _playbooks.test.mjs && node _playbook_wire.test.mjs && node _integration.mjs && node _multi-client.mjs", +``` + +- [ ] **Step 4: Run test to verify it fails** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _playbooks.test.mjs +``` +Expected: `Cannot find module './src/playbooks.js'`. + +- [ ] **Step 5: Implement the parser** + +Create `server/src/playbooks.js`: +```js +// server/src/playbooks.js +// Per-feature markdown playbooks under .continuum/playbooks/. +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const PLAYBOOK_SCHEMA_VERSION = 1; + +function projectDir() { + return process.env.MOCHI_PROJECT_DIR || process.cwd(); +} + +export function playbooksDir() { + return path.join(projectDir(), ".continuum", "playbooks"); +} + +function indexPath() { return path.join(playbooksDir(), "index.json"); } +function inboxDir() { return path.join(playbooksDir(), "inbox"); } + +export async function initPlaybooks() { + await fs.mkdir(playbooksDir(), { recursive: true }); + await fs.mkdir(path.join(playbooksDir(), "_generic"), { recursive: true }); + await fs.mkdir(inboxDir(), { recursive: true }); + try { await fs.access(indexPath()); } + catch { await fs.writeFile(indexPath(), JSON.stringify({ version: 1, playbooks: [] }, null, 2)); } +} + +const SECTION_HEADINGS = [ + ["summary", "## Summary"], + ["preconditions", "## Preconditions"], + ["steps", "## Steps"], + ["verification", "## Verification"], + ["selectors_used", "## Selectors used"], + ["recent_runs", "## Recent runs"], + ["screenshots", "## Screenshots"], +]; + +export function parsePlaybook(md) { + const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(md); + if (!m) throw new Error("playbook-validation-failed: missing frontmatter"); + const meta = yaml.load(m[1]); + const body = m[2]; + const sections = {}; + const indexes = SECTION_HEADINGS.map(([key, heading]) => ({ key, heading, pos: body.indexOf(heading) })); + indexes.sort((a, b) => a.pos - b.pos); + for (let i = 0; i < indexes.length; i++) { + const cur = indexes[i]; + if (cur.pos < 0) { sections[cur.key] = parseSection(cur.key, ""); continue; } + const next = indexes.slice(i + 1).find((x) => x.pos > cur.pos); + const startsAt = cur.pos + cur.heading.length; + const endsAt = next ? next.pos : body.length; + const raw = body.slice(startsAt, endsAt).trim(); + sections[cur.key] = parseSection(cur.key, raw); + } + return { meta, body, sections }; +} + +function parseSection(key, raw) { + if (key === "selectors_used") { + const out = []; + for (const line of raw.split("\n")) { + const m = /^\|\s*([^|]+?)\s*\|\s*`([^`]+)`\s*\|/.exec(line); + if (m && m[1] && m[1] !== "intent" && !m[1].startsWith("-")) { + out.push({ intent: m[1].trim(), selector: m[2].trim() }); + } + } + return out; + } + return raw; +} + +export function serializePlaybook({ meta, body }) { + const front = yaml.dump(meta, { lineWidth: 200, sortKeys: false }).trim(); + return `---\n${front}\n---\n${body}`; +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _playbooks.test.mjs +``` +Expected: `✓ Task 1 — parse + round-trip`. + +- [ ] **Step 7: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/playbooks.js server/_playbooks.test.mjs server/package.json server/package-lock.json && git commit -m "feat(playbooks): scaffold module with frontmatter parser and round-trip serializer" +``` + +--- + +## Task 2: Validation + error codes + +**Files:** +- Modify: `server/src/playbooks.js` +- Modify: `server/_playbooks.test.mjs` + +- [ ] **Step 1: Append validation tests** + +```js +import { validatePlaybook, playbookErr } from "./src/playbooks.js"; + +{ + // missing required fields + let err = validatePlaybook({ meta: {}, body: "" }); + assert.equal(err.code, "playbook-validation-failed"); + assert.ok(err.details.issues.some((x) => x.includes("origin"))); + + // bad id shape + err = validatePlaybook({ meta: { origin: "Bad Origin!!", feature: "send-email" }, body: "## Summary\nX\n## Preconditions\nY\n## Steps\nZ\n## Verification\nW\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n" }); + assert.equal(err.code, "playbook-validation-failed"); + assert.ok(err.details.issues.some((x) => x.toLowerCase().includes("origin"))); + + // bad feature slug + err = validatePlaybook({ meta: { origin: "mail.google.com", feature: "Send Email" }, body: "## Summary\nX\n## Preconditions\nY\n## Steps\nZ\n## Verification\nW\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n" }); + assert.equal(err.code, "playbook-validation-failed"); + assert.ok(err.details.issues.some((x) => x.toLowerCase().includes("feature"))); + + // invalid input type + err = validatePlaybook({ + meta: { origin: "mail.google.com", feature: "send-email", inputs: [{ name: "x", type: "weird-type" }] }, + body: "## Summary\nX\n## Preconditions\nY\n## Steps\nZ\n## Verification\nW\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", + }); + assert.ok(err.details.issues.some((x) => x.toLowerCase().includes("type"))); + + // valid playbook returns null + const ok = validatePlaybook({ + meta: { origin: "mail.google.com", feature: "send-email", inputs: [], outputs: [] }, + body: "## Summary\nX\n## Preconditions\nY\n## Steps\nZ\n## Verification\nW\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", + }); + assert.equal(ok, null); + + // playbookErr is constructible + const e = playbookErr("playbook-not-found", "no such playbook", { id: "x" }); + assert.equal(e.playbookError.code, "playbook-not-found"); + + console.log("✓ Task 2 — validation"); +} +``` + +- [ ] **Step 2: Run test to confirm failure** + +```bash +node _playbooks.test.mjs +``` +Expected: `validatePlaybook is not a function` or similar. + +- [ ] **Step 3: Implement validation** + +Append to `server/src/playbooks.js`: +```js +const VALID_INPUT_TYPES = new Set([ + "text", "email", "url", "markdown", "password", + "file", "file[]", "image", "image[]", + "secret", "enum", "int", "bool", +]); + +const REQUIRED_SECTIONS = ["summary", "preconditions", "steps", "verification", "selectors_used", "recent_runs", "screenshots"]; + +export function playbookErr(code, message, details) { + const e = new Error(`${code}: ${message}`); + e.playbookError = { code, message, details }; + return e; +} + +export function validatePlaybook({ meta, body }) { + const issues = []; + if (!meta || typeof meta !== "object") { + issues.push("frontmatter missing"); + return { code: "playbook-validation-failed", details: { issues } }; + } + if (!meta.origin || typeof meta.origin !== "string") issues.push("origin: missing or not a string"); + else if (!/^[a-z0-9.-]+$/.test(meta.origin) && meta.origin !== "_generic") issues.push(`origin: invalid format "${meta.origin}"`); + if (!meta.feature || typeof meta.feature !== "string") issues.push("feature: missing or not a string"); + else if (!/^[a-z0-9-]+$/.test(meta.feature)) issues.push(`feature: must be kebab-case [a-z0-9-]+, got "${meta.feature}"`); + else if (meta.feature.length > 40) issues.push("feature: max 40 chars"); + + for (const input of meta.inputs ?? []) { + if (!input.name || !/^[a-zA-Z_$][\w$]*$/.test(input.name)) issues.push(`inputs[].name: invalid identifier "${input.name}"`); + if (!input.type || !VALID_INPUT_TYPES.has(input.type)) issues.push(`inputs[].type: must be one of ${[...VALID_INPUT_TYPES].join(",")}, got "${input.type}"`); + } + if (meta.cron && typeof meta.cron === "string" && !isValidCron(meta.cron)) { + issues.push(`cron: invalid cron expression "${meta.cron}"`); + } + + // body section presence + if (typeof body === "string") { + for (const sec of REQUIRED_SECTIONS) { + const heading = "## " + sec.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).replace(/Selectors Used/, "Selectors used").replace(/Recent Runs/, "Recent runs"); + if (!body.includes(heading)) issues.push(`section missing: ${heading}`); + } + } + return issues.length ? { code: "playbook-validation-failed", details: { issues } } : null; +} + +function isValidCron(expr) { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return false; + return parts.every((p) => /^[\d*,/-]+$/.test(p)); +} +``` + +- [ ] **Step 4: Run test to confirm pass** + +```bash +node _playbooks.test.mjs +``` +Expected: `✓ Task 2 — validation`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/playbooks.js server/_playbooks.test.mjs && git commit -m "feat(playbooks): validation + playbookErr taxonomy" +``` + +--- + +## Task 3: CRUD + atomic file writes + index rebuild + +**Files:** +- Modify: `server/src/playbooks.js` +- Modify: `server/_playbooks.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +```js +import { savePlaybook, getPlaybook, listPlaybooks, deletePlaybook, rebuildIndex } from "./src/playbooks.js"; + +{ + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-pb-test-")); + process.env.MOCHI_PROJECT_DIR = tmp; + await initPlaybooks(); + + const meta = { + origin: "mail.google.com", + feature: "send-email", + title: "Send email", + verifiable: true, + preconditions: [], + inputs: [{ name: "to", type: "email", required: true }], + outputs: [], + composes: [], + next: null, + cron: null, + last_verified: "2026-05-20T10:00:00Z", + success_count: 0, + playbook_version: 1, + schema_version: 1, + }; + const body = "## Summary\nSend.\n## Preconditions\nLogged-in.\n## Steps\n1. compose.\n## Verification\nToast.\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n"; + const workflow = { playbookId: "mail.google.com/send-email", schemaVersion: 1, steps: [{ action: "navigate", url: "https://mail.google.com" }] }; + + const saved = await savePlaybook({ id: "mail.google.com/send-email", meta, body, workflow }); + assert.equal(saved.ok, true); + assert.ok(saved.path.endsWith("send-email.md")); + + const got = await getPlaybook("mail.google.com/send-email"); + assert.equal(got.meta.feature, "send-email"); + assert.equal(got.workflow.steps[0].action, "navigate"); + + const list = await listPlaybooks({ verifiable: true }); + assert.equal(list.length, 1); + assert.equal(list[0].id, "mail.google.com/send-email"); + + // invalid playbook rejected + await assert.rejects(savePlaybook({ id: "mail.google.com/send-email", meta: { ...meta, feature: "BAD!" }, body, workflow }), /playbook-validation-failed/); + // id mismatch rejected + await assert.rejects(savePlaybook({ id: "other.com/x", meta, body, workflow }), /playbook-id-mismatch/); + + await deletePlaybook("mail.google.com/send-email"); + const list2 = await listPlaybooks({}); + assert.equal(list2.length, 0); + + // rebuildIndex idempotence + const r = await rebuildIndex(); + assert.equal(r.entries, 0); + + console.log("✓ Task 3 — CRUD"); + await fs.rm(tmp, { recursive: true, force: true }); +} +``` + +- [ ] **Step 2: Run test to confirm failure** + +```bash +node _playbooks.test.mjs +``` +Expected: `savePlaybook is not a function`. + +- [ ] **Step 3: Implement CRUD** + +Append to `server/src/playbooks.js`: +```js +function pathFor(id, ext = ".md") { + const [origin, feature] = id.split("/"); + return path.join(playbooksDir(), origin, feature + ext); +} + +let writeMutex = Promise.resolve(); +async function withMutex(fn) { + const prev = writeMutex; + let release; + writeMutex = new Promise((r) => { release = r; }); + try { await prev; return await fn(); } + finally { release(); } +} + +async function atomicWrite(p, content) { + const tmp = p + ".tmp." + process.pid + "." + Date.now(); + await fs.mkdir(path.dirname(p), { recursive: true }); + await fs.writeFile(tmp, content); + await fs.rename(tmp, p); +} + +export async function savePlaybook({ id, meta, body, workflow }) { + await initPlaybooks(); + if (!id || !id.includes("/")) throw playbookErr("playbook-id-invalid", "id must be /", { id }); + if (meta.origin + "/" + meta.feature !== id) { + throw playbookErr("playbook-id-mismatch", "id does not match meta.origin/meta.feature", { id, expected: meta.origin + "/" + meta.feature }); + } + const err = validatePlaybook({ meta, body }); + if (err) throw playbookErr(err.code, "playbook failed validation", err.details); + + const md = serializePlaybook({ meta, body }); + await withMutex(async () => { + await atomicWrite(pathFor(id, ".md"), md); + if (workflow) await atomicWrite(pathFor(id, ".workflow.json"), JSON.stringify(workflow, null, 2)); + await rebuildIndex(); + }); + return { ok: true, path: pathFor(id, ".md") }; +} + +export async function getPlaybook(id) { + try { + const md = await fs.readFile(pathFor(id, ".md"), "utf8"); + const parsed = parsePlaybook(md); + let workflow = null; + try { workflow = JSON.parse(await fs.readFile(pathFor(id, ".workflow.json"), "utf8")); } + catch {} + return { id, ...parsed, workflow }; + } catch (e) { + if (e.code === "ENOENT") return null; + throw e; + } +} + +export async function listPlaybooks({ origin, feature, tag, verifiable } = {}) { + await initPlaybooks(); + const raw = JSON.parse(await fs.readFile(indexPath(), "utf8")); + return raw.playbooks.filter((p) => { + if (origin && p.origin !== origin) return false; + if (feature && p.feature !== feature) return false; + if (tag && !(p.tags || []).includes(tag)) return false; + if (verifiable !== undefined && p.verifiable !== verifiable) return false; + return true; + }); +} + +export async function deletePlaybook(id) { + await withMutex(async () => { + try { await fs.unlink(pathFor(id, ".md")); } catch {} + try { await fs.unlink(pathFor(id, ".workflow.json")); } catch {} + try { await fs.rm(pathFor(id, ".screenshots"), { recursive: true, force: true }); } catch {} + await rebuildIndex(); + }); + return { ok: true }; +} + +export async function rebuildIndex() { + await initPlaybooks(); + const entries = []; + const origins = await fs.readdir(playbooksDir()); + for (const origin of origins) { + if (origin === "inbox" || origin === "index.json" || origin.startsWith(".")) continue; + const originPath = path.join(playbooksDir(), origin); + let st; try { st = await fs.stat(originPath); } catch { continue; } + if (!st.isDirectory()) continue; + const files = await fs.readdir(originPath); + for (const f of files) { + if (!f.endsWith(".md")) continue; + const md = await fs.readFile(path.join(originPath, f), "utf8").catch(() => null); + if (!md) continue; + let parsed; try { parsed = parsePlaybook(md); } catch { continue; } + const m = parsed.meta; + entries.push({ + id: `${m.origin}/${m.feature}`, + origin: m.origin, + feature: m.feature, + title: m.title || m.feature, + verifiable: !!m.verifiable, + inputs: (m.inputs || []).map((x) => x.name), + success_count: m.success_count || 0, + last_verified: m.last_verified || null, + tags: m.tags || [], + }); + } + } + await atomicWrite(indexPath(), JSON.stringify({ version: 1, playbooks: entries }, null, 2)); + return { entries: entries.length }; +} +``` + +- [ ] **Step 4: Run test** + +```bash +node _playbooks.test.mjs +``` +Expected: `✓ Task 3 — CRUD`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/playbooks.js server/_playbooks.test.mjs && git commit -m "feat(playbooks): CRUD + atomic writes + index rebuild" +``` + +--- + +## Task 4: matchPlaybook scoring + +**Files:** +- Modify: `server/src/playbooks.js` +- Modify: `server/_playbooks.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { matchPlaybook } from "./src/playbooks.js"; + +{ + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-pb-test-")); + process.env.MOCHI_PROJECT_DIR = tmp; + await initPlaybooks(); + + await savePlaybook({ + id: "mail.google.com/send-email", + meta: { origin: "mail.google.com", feature: "send-email", inputs: [], outputs: [], verifiable: true, tags: ["email"] }, + body: "## Summary\nx\n## Preconditions\nx\n## Steps\nNavigate https://mail.google.com/mail/u/0/#inbox\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", + workflow: { steps: [{ action: "navigate", url: "https://mail.google.com/mail/u/0/#inbox" }] }, + }); + await savePlaybook({ + id: "twitter.com/post", + meta: { origin: "twitter.com", feature: "post", inputs: [], outputs: [], verifiable: false, tags: ["social"] }, + body: "## Summary\nx\n## Preconditions\nx\n## Steps\nx\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", + workflow: null, + }); + + const m1 = await matchPlaybook({ url: "https://mail.google.com/mail/u/0/#inbox", taskText: "send an email" }); + assert.ok(m1.length >= 1); + assert.equal(m1[0].playbookId, "mail.google.com/send-email"); + assert.ok(m1[0].score >= 50); + + const m2 = await matchPlaybook({ url: "https://unrelated.com" }); + assert.equal(m2.length, 0); // below threshold + + const m3 = await matchPlaybook({ taskText: "social post on twitter", url: null }); + assert.ok(m3.some((x) => x.playbookId === "twitter.com/post")); + + console.log("✓ Task 4 — matchPlaybook"); + await fs.rm(tmp, { recursive: true, force: true }); +} +``` + +- [ ] **Step 2: Run failing test** + +```bash +node _playbooks.test.mjs +``` + +- [ ] **Step 3: Implement matchPlaybook** + +Append to `server/src/playbooks.js`: +```js +const MATCH_THRESHOLD = 30; + +export async function matchPlaybook({ url, intent, taskText } = {}) { + const entries = await listPlaybooks({}); + const u = url ? safeUrl(url) : null; + const text = (taskText || "").toLowerCase(); + const stems = tokenize(text); + const results = []; + for (const e of entries) { + let score = 0; + const reasons = []; + if (u && e.origin === u.hostname) { score += 50; reasons.push("origin-match"); } + if (taskText) { + const featStems = tokenize(e.feature.replace(/-/g, " ")); + const overlap = featStems.filter((t) => stems.includes(t)).length; + if (overlap) { score += overlap * 10; reasons.push(`feature-token-overlap:${overlap}`); } + const titleStems = tokenize(e.title || ""); + const tOverlap = titleStems.filter((t) => stems.includes(t)).length; + if (tOverlap) { score += tOverlap * 5; reasons.push(`title-token-overlap:${tOverlap}`); } + for (const tag of e.tags || []) { + if (stems.includes(tag.toLowerCase())) { score += 10; reasons.push(`tag-match:${tag}`); } + } + } + if (intent && e.feature.includes(intent)) { score += 15; reasons.push("intent-match"); } + if (score >= MATCH_THRESHOLD) results.push({ playbookId: e.id, score, reason: reasons.join(", ") }); + } + results.sort((a, b) => b.score - a.score); + return results.slice(0, 5); +} + +function safeUrl(s) { try { return new URL(s); } catch { return null; } } +function tokenize(s) { + return (s || "").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2 && !STOPWORDS.has(t)); +} +const STOPWORDS = new Set(["the","and","for","with","from","that","this","into","onto","when","then","than","but","not","you"]); +``` + +- [ ] **Step 4: Run test** + +```bash +node _playbooks.test.mjs +``` +Expected: `✓ Task 4 — matchPlaybook`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/playbooks.js server/_playbooks.test.mjs && git commit -m "feat(playbooks): matchPlaybook with origin/token/tag scoring" +``` + +--- + +## Task 5: Trace → playbook promoter (`promoteFromTrace`) + +**Files:** +- Modify: `server/src/playbooks.js` +- Modify: `server/_playbooks.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { promoteFromTrace } from "./src/playbooks.js"; + +{ + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-pb-test-")); + process.env.MOCHI_PROJECT_DIR = tmp; + await initPlaybooks(); + + const trace = [ + { tool: "browser_navigate", args: { url: "https://example.com/login" } }, + { tool: "browser_type", args: { intent: "username-field", value: "user@example.com" } }, + { tool: "browser_type", args: { intent: "password-field", value: "${SECRET}" } }, + { tool: "browser_click", args: { intent: "submit-button" } }, + { tool: "browser_assert", args: { kind: "url-contains", value: "/dashboard" } }, + ]; + + const r = await promoteFromTrace({ label: "login", trace, title: "Log in via example.com" }); + assert.equal(r.created, true); + assert.equal(r.playbookId, "example.com/login"); + const pb = await getPlaybook("example.com/login"); + assert.equal(pb.meta.origin, "example.com"); + assert.equal(pb.meta.feature, "login"); + assert.equal(pb.workflow.steps.length, 5); + assert.equal(pb.workflow.steps[1].action, "type"); + assert.equal(pb.workflow.steps[1].intent, "username-field"); + // inputs inferred from intents + assert.ok(pb.meta.inputs.some((i) => i.name === "username")); + assert.ok(pb.meta.inputs.some((i) => i.name === "password")); + assert.equal(pb.meta.inputs.find((i) => i.name === "password").type, "secret"); + + // second call → updates, not create + const trace2 = [...trace, { tool: "browser_screenshot", args: {} }]; + const r2 = await promoteFromTrace({ label: "login", trace: trace2 }); + assert.equal(r2.created, false); + assert.match(r2.diffSummary, /added|updated/i); + + console.log("✓ Task 5 — promoter"); + await fs.rm(tmp, { recursive: true, force: true }); +} +``` + +- [ ] **Step 2: Run failing test** + +```bash +node _playbooks.test.mjs +``` + +- [ ] **Step 3: Implement the promoter** + +Append to `server/src/playbooks.js`: +```js +const TOOL_TO_ACTION = { + browser_navigate: "navigate", + browser_click: "click", + browser_click_at: "click", + browser_type: "type", + browser_press_key: "press_key", + browser_scroll: "scroll", + browser_wait: "wait", + browser_assert: "assert", + browser_upload_file:"upload", +}; + +export async function promoteFromTrace({ label, title, verifiable = false, trace, screenshots = [], explicitInputs, explicitOutputs }) { + if (!Array.isArray(trace) || !trace.length) throw playbookErr("playbook-validation-failed", "trace empty"); + const firstNav = trace.find((t) => t.tool === "browser_navigate" && t.args?.url); + if (!firstNav) throw playbookErr("playbook-validation-failed", "trace has no navigate step — cannot infer origin"); + const origin = new URL(firstNav.args.url).hostname; + const feature = (label || "").trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); + if (!feature) throw playbookErr("playbook-validation-failed", "label required to derive feature slug"); + const id = `${origin}/${feature}`; + + const steps = []; + for (const call of trace) { + const action = TOOL_TO_ACTION[call.tool]; + if (!action) continue; + const step = { action }; + if (action === "navigate") step.url = call.args.url; + if (action === "click" || action === "type" || action === "upload") { + if (call.args.intent) step.intent = call.args.intent; + else if (call.args.selector) step.selector = call.args.selector; + } + if (action === "type" && call.args.value !== undefined) step.valueRef = inferValueRef(call.args.intent, call.args.value); + if (action === "upload" && call.args.files) step.filesRef = "input.attachments"; + if (action === "press_key") step.key = call.args.key; + if (action === "scroll") step.params = call.args; + if (action === "wait") step.ms = call.args.ms ?? 500; + if (action === "assert") { step.kind = call.args.kind; step.value = call.args.value; step.timeoutMs = call.args.timeoutMs; } + steps.push(step); + } + + const inputs = explicitInputs || inferInputs(steps); + const outputs = explicitOutputs || []; + + const meta = { + origin, feature, + title: title || `${feature.replace(/-/g, " ")} on ${origin}`, + verifiable, + preconditions: [], + inputs, outputs, + composes: [], + next: null, + cron: null, + last_verified: new Date().toISOString(), + success_count: 1, + playbook_version: 1, + schema_version: 1, + tags: [], + }; + + const existing = await getPlaybook(id); + let body; + if (existing) { + meta.success_count = (existing.meta.success_count || 0) + 1; + meta.last_verified = new Date().toISOString(); + body = updateBodyForRerun(existing.body, steps); + } else { + body = freshBody(steps); + } + + const workflow = { playbookId: id, schemaVersion: 1, steps }; + await savePlaybook({ id, meta, body, workflow }); + return { + ok: true, + playbookId: id, + created: !existing, + diffSummary: existing ? summarizeDiff(existing.workflow?.steps || [], steps) : "created", + path: pathFor(id, ".md"), + }; +} + +function inferValueRef(intent, value) { + if (!intent) return "input.value"; + if (intent.includes("password") || intent.includes("secret")) return "input.password"; + if (intent.includes("email")) return "input.email"; + const base = intent.replace(/-field$|-input$|-button$/g, "").replace(/[^a-z0-9]+/gi, "_"); + return `input.${base || "value"}`; +} + +function inferInputs(steps) { + const seen = new Map(); + for (const s of steps) { + if (s.valueRef && s.valueRef.startsWith("input.")) { + const name = s.valueRef.slice("input.".length); + if (!seen.has(name)) { + const isSecret = /password|token|secret|key/i.test(name); + seen.set(name, { name, type: isSecret ? "secret" : "text", required: true }); + } + } + if (s.filesRef === "input.attachments" && !seen.has("attachments")) { + seen.set("attachments", { name: "attachments", type: "file[]", required: false }); + } + } + return [...seen.values()]; +} + +function freshBody(steps) { + return [ + "## Summary", + "Auto-generated playbook from successful trace.", + "", + "## Preconditions", + "(none recorded)", + "", + "## Steps", + ...steps.map((s, i) => `${i + 1}. ${describeStep(s)}`), + "", + "## Verification", + "Steps completed without throwing.", + "", + "## Selectors used", + "", + "| intent | selector |", + "|---|---|", + "", + "## Recent runs", + "", + `- promoted-${new Date().toISOString()} — first capture, ${steps.length} steps`, + "", + "## Screenshots", + "", + "- (none yet)", + "", + ].join("\n"); +} + +function updateBodyForRerun(prevBody, newSteps) { + // append a new "Recent runs" entry; keep last 20 + const lines = prevBody.split("\n"); + const startIdx = lines.findIndex((l) => l.startsWith("## Recent runs")); + if (startIdx < 0) return freshBody(newSteps); + const endIdx = lines.findIndex((l, i) => i > startIdx && l.startsWith("## ")); + const before = lines.slice(0, startIdx + 1); + const after = endIdx > 0 ? lines.slice(endIdx) : [""]; + const prevRuns = lines.slice(startIdx + 1, endIdx > 0 ? endIdx : lines.length).filter((l) => l.trim().startsWith("-")); + const newRun = `- promoted-${new Date().toISOString()} — ${newSteps.length} steps`; + const trimmed = [newRun, ...prevRuns].slice(0, 20); + return [...before, "", ...trimmed, "", ...after].join("\n"); +} + +function describeStep(s) { + switch (s.action) { + case "navigate": return `Navigate to \`${s.url}\``; + case "click": return `Click intent \`${s.intent || s.selector}\``; + case "type": return `Type \`${s.valueRef}\` into intent \`${s.intent || s.selector}\``; + case "press_key": return `Press \`${s.key}\``; + case "scroll": return `Scroll \`${JSON.stringify(s.params)}\``; + case "wait": return `Wait ${s.ms} ms`; + case "upload": return `Upload \`${s.filesRef}\` via intent \`${s.intent || s.selector}\``; + case "assert": return `Assert ${s.kind} = \`${s.value}\``; + default: return JSON.stringify(s); + } +} + +function summarizeDiff(prevSteps, newSteps) { + const added = newSteps.length - prevSteps.length; + if (added > 0) return `added ${added} step(s)`; + if (added < 0) return `removed ${Math.abs(added)} step(s)`; + return "updated existing steps"; +} +``` + +- [ ] **Step 4: Run test** + +```bash +node _playbooks.test.mjs +``` +Expected: `✓ Task 5 — promoter`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/playbooks.js server/_playbooks.test.mjs && git commit -m "feat(playbooks): trace→playbook promoter with input inference" +``` + +--- + +## Task 6: composeResolve (cycle detection + input/output passthrough) + +**Files:** +- Modify: `server/src/playbooks.js` +- Modify: `server/_playbooks.test.mjs` + +- [ ] **Step 1: Write the failing test** + +```js +import { composeResolve } from "./src/playbooks.js"; + +{ + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-pb-test-")); + process.env.MOCHI_PROJECT_DIR = tmp; + await initPlaybooks(); + + // single playbook + await savePlaybook({ + id: "twitter.com/post", meta: { origin: "twitter.com", feature: "post", inputs: [{ name: "text", type: "text", required: true }], outputs: [{ name: "postUrl", type: "url" }], verifiable: false }, body: "## Summary\nx\n## Preconditions\nx\n## Steps\nx\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", workflow: { steps: [] }, + }); + // parent composes child + await savePlaybook({ + id: "blog.example.com/cross-post", meta: { origin: "blog.example.com", feature: "cross-post", inputs: [{ name: "text", type: "text", required: true }], outputs: [], verifiable: false, composes: [{ id: "twitter.com/post", inputs: { text: "${input.text}" } }] }, body: "## Summary\nx\n## Preconditions\nx\n## Steps\nx\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", workflow: { steps: [] }, + }); + + const plan = await composeResolve("blog.example.com/cross-post", { text: "hi" }); + assert.equal(plan.legs.length, 2); // self + composed + assert.equal(plan.legs[1].playbookId, "twitter.com/post"); + assert.equal(plan.legs[1].inputs.text, "hi"); + + // missing input + await assert.rejects(composeResolve("twitter.com/post", {}), /playbook-input-missing/); + + // cycle + await savePlaybook({ + id: "cycle.com/a", meta: { origin: "cycle.com", feature: "a", inputs: [], outputs: [], verifiable: false, composes: [{ id: "cycle.com/b", inputs: {} }] }, body: "## Summary\nx\n## Preconditions\nx\n## Steps\nx\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", workflow: { steps: [] }, + }); + await savePlaybook({ + id: "cycle.com/b", meta: { origin: "cycle.com", feature: "b", inputs: [], outputs: [], verifiable: false, composes: [{ id: "cycle.com/a", inputs: {} }] }, body: "## Summary\nx\n## Preconditions\nx\n## Steps\nx\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", workflow: { steps: [] }, + }); + await assert.rejects(composeResolve("cycle.com/a", {}), /playbook-compose-cycle/); + + console.log("✓ Task 6 — composeResolve"); + await fs.rm(tmp, { recursive: true, force: true }); +} +``` + +- [ ] **Step 2: Run failing test** + +```bash +node _playbooks.test.mjs +``` + +- [ ] **Step 3: Implement composeResolve** + +Append to `server/src/playbooks.js`: +```js +export async function composeResolve(playbookId, inputs, _stack = new Set()) { + if (_stack.has(playbookId)) { + throw playbookErr("playbook-compose-cycle", "cycle detected", { path: [..._stack, playbookId] }); + } + const pb = await getPlaybook(playbookId); + if (!pb) throw playbookErr("playbook-not-found", `no playbook ${playbookId}`); + for (const inSpec of pb.meta.inputs || []) { + if (inSpec.required && (inputs[inSpec.name] === undefined || inputs[inSpec.name] === null)) { + throw playbookErr("playbook-input-missing", `${playbookId} requires input "${inSpec.name}"`, { playbookId, missing: inSpec.name }); + } + } + const legs = [{ playbookId, inputs, meta: pb.meta, workflow: pb.workflow }]; + const newStack = new Set([..._stack, playbookId]); + for (const c of pb.meta.composes || []) { + const mappedInputs = mapInputs(c.inputs || {}, inputs); + const sub = await composeResolve(c.id, mappedInputs, newStack); + legs.push(...sub.legs); + } + return { legs }; +} + +function mapInputs(spec, parentInputs) { + const out = {}; + for (const [k, v] of Object.entries(spec || {})) { + if (typeof v === "string") { + out[k] = v.replace(/\$\{input\.([\w$]+)\}/g, (_, name) => parentInputs[name] ?? ""); + } else { + out[k] = v; + } + } + return out; +} +``` + +- [ ] **Step 4: Run test** + +```bash +node _playbooks.test.mjs +``` +Expected: `✓ Task 6 — composeResolve`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/playbooks.js server/_playbooks.test.mjs && git commit -m "feat(playbooks): composeResolve with cycle detection + input mapping" +``` + +--- + +## Task 7: Register 7 new MCP tools + +**Files:** +- Modify: `server/src/tools.js` +- Modify: `server/_smoke.mjs` + +- [ ] **Step 1: Add tool definitions in `tools` array** + +In `server/src/tools.js`, after the `browser_upload_file` entry, add seven new tool entries: + +```js + { + name: "browser_playbook_list", + description: "List per-feature playbooks. Filter by origin, feature slug, tag, or verifiable. Returns compact metadata only — call browser_playbook_get for the full body.", + inputSchema: { type: "object", properties: { + origin: { type: "string" }, + feature: { type: "string" }, + tag: { type: "string" }, + verifiable: { type: "boolean" }, + } }, + }, + { + name: "browser_playbook_get", + description: "Return one playbook with full meta, body sections, and the underlying workflow JSON.", + inputSchema: { type: "object", properties: { id: { type: "string", description: "/" } }, required: ["id"] }, + }, + { + name: "browser_playbook_save", + description: "Create or update a playbook. Validates frontmatter and required sections. Use browser_playbook_propose_update for trace-driven authoring.", + inputSchema: { type: "object", properties: { + id: { type: "string", description: "/" }, + meta: { type: "object", description: "Frontmatter fields (origin, feature, title, inputs, outputs, etc.)" }, + body: { type: "string", description: "Markdown body with required sections." }, + workflow: { type: "object", description: "Workflow JSON for replay." }, + }, required: ["id", "meta", "body"] }, + }, + { + name: "browser_playbook_delete", + description: "Delete a playbook (markdown, workflow JSON, and screenshots).", + inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + }, + { + name: "browser_playbook_match", + description: "Find playbooks matching a URL, intent, or task description. Returns top scored matches above threshold.", + inputSchema: { type: "object", properties: { + url: { type: "string" }, + intent: { type: "string" }, + taskText: { type: "string" }, + } }, + }, + { + name: "browser_playbook_run", + description: "Replay a playbook (with self-heal) using the provided inputs. Recursively executes composes/next chains. Returns a verdict + evidence.", + inputSchema: { type: "object", properties: { + id: { type: "string" }, + inputs: { type: "object", description: "Map of input.name → value (or stashId for files)." }, + }, required: ["id"] }, + }, + { + name: "browser_playbook_propose_update", + description: "Given a successful trace, create or update the matching playbook. Inputs and steps are inferred from the trace; selectors are tracked via the existing selector cache.", + inputSchema: { type: "object", properties: { + label: { type: "string", description: "Suggested feature slug." }, + title: { type: "string" }, + verifiable: { type: "boolean", default: false }, + runId: { type: "string", description: "Optional run id; trace loaded from .continuum/runs/." }, + trace: { type: "array", description: "Or supply trace inline." }, + inputs: { type: "array", description: "Optional explicit input descriptors." }, + outputs: { type: "array", description: "Optional explicit outputs." }, + screenshots: { type: "array", items: { type: "string" } }, + }, required: ["label"] }, + }, +``` + +- [ ] **Step 2: Add local-dispatch cases** + +In `server/src/tools.js`'s `handleToolCall`, inside the local-tools switch, add (right after the existing `browser_upload_stage` case): + +```js + case "browser_playbook_list": return jsonResult(await toolPlaybookList(args)); + case "browser_playbook_get": return jsonResult(await toolPlaybookGet(args)); + case "browser_playbook_save": return jsonResult(await toolPlaybookSave(args)); + case "browser_playbook_delete": return jsonResult(await toolPlaybookDelete(args)); + case "browser_playbook_match": return jsonResult(await toolPlaybookMatch(args)); + case "browser_playbook_propose_update": return jsonResult(await toolPlaybookProposeUpdate(args)); +``` + +`browser_playbook_run` is NOT local — it ALSO goes via local dispatch (because it orchestrates other tools via the bridge): + +```js + case "browser_playbook_run": return jsonResult(await toolPlaybookRun(bridge, args)); +``` + +- [ ] **Step 3: Implement tool handlers** + +At the top of `server/src/tools.js`, extend the existing uploads import: +```js +import { stage as stageUpload, uploadErr, uploadsDir, gcSession } from "./uploads.js"; +import * as playbooks from "./playbooks.js"; +``` + +Then add (anywhere alongside the other local helpers): +```js +function unwrapPlaybookError(e) { + if (e.playbookError) return { ok: false, error: e.playbookError }; + return { ok: false, error: { code: "internal", message: String(e?.message ?? e) } }; +} + +async function toolPlaybookList(args = {}) { + try { return { ok: true, items: await playbooks.listPlaybooks(args) }; } + catch (e) { return unwrapPlaybookError(e); } +} +async function toolPlaybookGet({ id } = {}) { + try { + const pb = await playbooks.getPlaybook(id); + if (!pb) return { ok: false, error: { code: "playbook-not-found", message: `no playbook ${id}` } }; + return { ok: true, ...pb }; + } catch (e) { return unwrapPlaybookError(e); } +} +async function toolPlaybookSave(args = {}) { + try { return { ok: true, ...(await playbooks.savePlaybook(args)) }; } + catch (e) { return unwrapPlaybookError(e); } +} +async function toolPlaybookDelete({ id } = {}) { + try { return { ok: true, ...(await playbooks.deletePlaybook(id)) }; } + catch (e) { return unwrapPlaybookError(e); } +} +async function toolPlaybookMatch(args = {}) { + try { return { ok: true, matches: await playbooks.matchPlaybook(args) }; } + catch (e) { return unwrapPlaybookError(e); } +} +async function toolPlaybookProposeUpdate(args = {}) { + try { + let trace = args.trace; + if (!trace && args.runId) { + // load trace from .continuum/runs/.jsonl + const fsp = await import("node:fs/promises"); + const path = await import("path"); + const runFile = path.join(process.env.MOCHI_PROJECT_DIR || process.cwd(), ".continuum", "runs", `${args.runId}.jsonl`); + const raw = await fsp.readFile(runFile, "utf8").catch(() => null); + if (!raw) return { ok: false, error: { code: "playbook-validation-failed", message: `runId ${args.runId} not found` } }; + trace = raw.split("\n").filter(Boolean).map((l) => JSON.parse(l)); + } + return { ok: true, ...(await playbooks.promoteFromTrace({ ...args, trace })) }; + } catch (e) { return unwrapPlaybookError(e); } +} + +async function toolPlaybookRun(bridge, args = {}) { + const { id, inputs = {} } = args; + try { + const plan = await playbooks.composeResolve(id, inputs); + const legs = []; + for (const leg of plan.legs) { + const legResult = await replayPlaybookLeg(bridge, leg); + legs.push(legResult); + if (legResult.verdict === "fail") break; + } + const overall = legs.every((l) => l.verdict === "pass") ? "pass" : "fail"; + return { ok: true, verdict: overall, legs }; + } catch (e) { return unwrapPlaybookError(e); } +} + +async function replayPlaybookLeg(bridge, leg) { + // Walks the workflow steps using the existing wire actions. + const steps = leg.workflow?.steps || []; + const startedAt = Date.now(); + for (const step of steps) { + try { + await runWorkflowStep(bridge, step, leg.inputs); + } catch (e) { + return { playbookId: leg.playbookId, verdict: "fail", reason: String(e?.message ?? e), durationMs: Date.now() - startedAt }; + } + } + return { playbookId: leg.playbookId, verdict: "pass", durationMs: Date.now() - startedAt }; +} + +async function runWorkflowStep(bridge, step, inputs) { + const resolveValue = (ref) => { + if (!ref) return undefined; + if (typeof ref !== "string") return ref; + const m = /^input\.(\w+)$/.exec(ref); + if (m) return inputs[m[1]]; + return ref; + }; + switch (step.action) { + case "navigate": return bridge.send("navigate", { url: step.url }); + case "click": return bridge.send("click", step.intent ? { ref: step.intent } : { ref: step.selector }); + case "type": return bridge.send("type", { ref: step.intent || step.selector, value: resolveValue(step.valueRef) }); + case "press_key": return bridge.send("press_key", { key: step.key }); + case "scroll": return bridge.send("scroll", step.params || {}); + case "wait": return bridge.send("wait", { ms: step.ms ?? 500 }); + case "assert": return bridge.send("assert", { kind: step.kind, value: step.value, timeoutMs: step.timeoutMs }); + case "upload": { + const files = resolveValue(step.filesRef); + return bridge.send("upload_file", { target: step.intent ? { trigger: { ref: step.intent } } : { selector: step.selector }, ...(files ? { files } : {}) }); + } + default: throw new Error(`unknown step action: ${step.action}`); + } +} +``` + +- [ ] **Step 4: Update smoke test** + +In `server/_smoke.mjs`, add to the `want` array: +```js + "browser_playbook_list", + "browser_playbook_get", + "browser_playbook_save", + "browser_playbook_delete", + "browser_playbook_match", + "browser_playbook_run", + "browser_playbook_propose_update", +``` + +- [ ] **Step 5: Run smoke** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _smoke.mjs +``` +Expected: smoke passes; total tool count reports 48. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/tools.js server/_smoke.mjs && git commit -m "feat(tools): register 7 playbook tools (list/get/save/delete/match/run/propose_update)" +``` + +--- + +## Task 8: Wire contract tests for the 7 playbook tools + +**Files:** +- Create: `server/_playbook_wire.test.mjs` + +- [ ] **Step 1: Write the test** + +Create `server/_playbook_wire.test.mjs`: +```js +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { handleToolCall, initToolsState } from "./src/tools.js"; +import { initPlaybooks, savePlaybook } from "./src/playbooks.js"; + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-pb-wire-")); +process.env.MOCHI_PROJECT_DIR = tmp; +await initPlaybooks(); +initToolsState({ log: () => {} }); + +const sent = []; +const bridge = { + mode: "broker", isConnected: () => true, getLocalClientId: () => "mc", + mcpClients: new Map(), extensionWs: {}, + send: async (type, params) => { sent.push({ type, params }); return { ok: true }; }, +}; + +// save +let r = await handleToolCall(bridge, { name: "browser_playbook_save", arguments: { + id: "example.com/login", + meta: { origin: "example.com", feature: "login", verifiable: true, inputs: [{ name: "user", type: "text", required: true }], outputs: [] }, + body: "## Summary\nx\n## Preconditions\nx\n## Steps\nx\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", + workflow: { steps: [{ action: "navigate", url: "https://example.com/login" }] }, +}}); +let p = JSON.parse(r.content[0].text); +assert.equal(p.ok, true); +assert.ok(p.path.endsWith("login.md")); + +// list +r = await handleToolCall(bridge, { name: "browser_playbook_list", arguments: { verifiable: true } }); +p = JSON.parse(r.content[0].text); +assert.equal(p.ok, true); +assert.equal(p.items.length, 1); +assert.equal(p.items[0].id, "example.com/login"); + +// get +r = await handleToolCall(bridge, { name: "browser_playbook_get", arguments: { id: "example.com/login" } }); +p = JSON.parse(r.content[0].text); +assert.equal(p.ok, true); +assert.equal(p.meta.feature, "login"); + +// match +r = await handleToolCall(bridge, { name: "browser_playbook_match", arguments: { url: "https://example.com/login", taskText: "login" } }); +p = JSON.parse(r.content[0].text); +assert.equal(p.ok, true); +assert.ok(p.matches.length >= 1); +assert.equal(p.matches[0].playbookId, "example.com/login"); + +// run (verifies wire payload generation) +sent.length = 0; +r = await handleToolCall(bridge, { name: "browser_playbook_run", arguments: { id: "example.com/login", inputs: { user: "test" } } }); +p = JSON.parse(r.content[0].text); +assert.equal(p.ok, true); +assert.equal(sent[0].type, "navigate"); +assert.equal(sent[0].params.url, "https://example.com/login"); + +// propose_update +r = await handleToolCall(bridge, { name: "browser_playbook_propose_update", arguments: { + label: "signup", + trace: [ + { tool: "browser_navigate", args: { url: "https://example.com/signup" } }, + { tool: "browser_type", args: { intent: "email-field", value: "a@b.com" } }, + { tool: "browser_click", args: { intent: "submit-button" } }, + ], +}}); +p = JSON.parse(r.content[0].text); +assert.equal(p.ok, true); +assert.equal(p.created, true); +assert.equal(p.playbookId, "example.com/signup"); + +// delete +r = await handleToolCall(bridge, { name: "browser_playbook_delete", arguments: { id: "example.com/login" } }); +p = JSON.parse(r.content[0].text); +assert.equal(p.ok, true); + +// not-found +r = await handleToolCall(bridge, { name: "browser_playbook_get", arguments: { id: "missing.com/x" } }); +p = JSON.parse(r.content[0].text); +assert.equal(p.ok, false); +assert.equal(p.error.code, "playbook-not-found"); + +console.log("✓ playbook wire contract"); +await fs.rm(tmp, { recursive: true, force: true }); +``` + +- [ ] **Step 2: Run test** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _playbook_wire.test.mjs +``` +Expected: `✓ playbook wire contract`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/_playbook_wire.test.mjs && git commit -m "test(playbooks): wire-contract tests for the 7 tools" +``` + +--- + +## Task 9: QA subagent + smart-router CLAUDE.md + +**Files:** +- Create: `plugins/qa/agents/qa-tester.md` +- Create: `plugins/qa/CLAUDE.md` +- Modify: `.claude-plugin/plugin.json` + +- [ ] **Step 1: Author the qa-tester agent** + +Create `plugins/qa/agents/qa-tester.md`: +```markdown +--- +name: qa-tester +description: Use when the task is a verifiable browser interaction with a binary pass/fail outcome — login flow, submit form, attach file, verify message appears. Returns a verdict + evidence. Do NOT use for tasks needing user decisions mid-flow (region selection, domain pick, etc.). +tools: [Bash, Read, Grep, Glob, mcp__plugin_mochi_browser__browser_session_start, mcp__plugin_mochi_browser__browser_session_end, mcp__plugin_mochi_browser__browser_navigate, mcp__plugin_mochi_browser__browser_open_tab, mcp__plugin_mochi_browser__browser_list_tabs, mcp__plugin_mochi_browser__browser_close_tab, mcp__plugin_mochi_browser__browser_snapshot, mcp__plugin_mochi_browser__browser_snapshot_query, mcp__plugin_mochi_browser__browser_snapshot_node, mcp__plugin_mochi_browser__browser_text, mcp__plugin_mochi_browser__browser_links, mcp__plugin_mochi_browser__browser_click, mcp__plugin_mochi_browser__browser_click_at, mcp__plugin_mochi_browser__browser_type, mcp__plugin_mochi_browser__browser_press_key, mcp__plugin_mochi_browser__browser_scroll, mcp__plugin_mochi_browser__browser_wait, mcp__plugin_mochi_browser__browser_screenshot, mcp__plugin_mochi_browser__browser_evaluate, mcp__plugin_mochi_browser__browser_assert, mcp__plugin_mochi_browser__browser_console_messages, mcp__plugin_mochi_browser__browser_network_requests, mcp__plugin_mochi_browser__browser_recall_selector, mcp__plugin_mochi_browser__browser_list_selectors, mcp__plugin_mochi_browser__browser_workflow_run, mcp__plugin_mochi_browser__browser_workflow_list, mcp__plugin_mochi_browser__browser_workflow_get, mcp__plugin_mochi_browser__browser_run_history, mcp__plugin_mochi_browser__browser_upload_stage, mcp__plugin_mochi_browser__browser_upload_file, mcp__plugin_mochi_browser__browser_playbook_list, mcp__plugin_mochi_browser__browser_playbook_get, mcp__plugin_mochi_browser__browser_playbook_save, mcp__plugin_mochi_browser__browser_playbook_match, mcp__plugin_mochi_browser__browser_playbook_run, mcp__plugin_mochi_browser__browser_playbook_propose_update, mcp__plugin_mochi_browser__browser_session_health] +--- + +# qa-tester + +You are an isolated QA subagent. Your job is to execute a specific, verifiable browser interaction and report a verdict with evidence. + +## What you can do + +- Use any Mochi browser MCP tool listed in your tools allowlist. +- Read project files (Read, Grep, Glob) to find URLs, fixtures, env hints. +- Read playbooks from `.continuum/playbooks/` (via `browser_playbook_get`). +- Run read-only bash (status, ls, git log) — never destructive. + +## What you cannot do + +- Edit, Write, or modify project files (other than via `browser_playbook_save` and `browser_playbook_propose_update` for the playbook library, which lives under `.continuum/`). +- Ask the user questions. If a required input is missing, return `{ verdict: "blocked", reason: "missing input X" }`. +- Make scope-expanding decisions. Ambiguous task → return `{ verdict: "blocked", reason: "task ambiguous: …" }`. + +## How to run a task + +1. Parse the task: identify origin, feature, inputs. +2. Call `browser_playbook_match { url, intent, taskText }` to find a matching playbook. +3. If a verifiable playbook exists: + - `browser_playbook_get` it. + - `browser_playbook_run { id, inputs }`. + - Use the playbook's `## Verification` section to confirm pass/fail. +4. If no playbook exists: + - Use snapshot/click/type/upload tools manually. + - On success, call `browser_playbook_propose_update { label, title, verifiable: true, trace }` to capture for next time. +5. Return one of: + - `{ verdict: "pass", evidence: { screenshots, network }, playbookId, runId }` + - `{ verdict: "fail", reason: "...", evidence: { ... }, playbookId, runId }` + - `{ verdict: "blocked", reason: "..." }` + +No prose narration. Main agent will surface to the user. +``` + +- [ ] **Step 2: Author the smart-router CLAUDE.md** + +Create `plugins/qa/CLAUDE.md`: +```markdown +# Mochi QA — task routing + +When the user asks you to do a browser interaction, FIRST classify: + +**Verifiable + repeatable** (delegate to `qa-tester` subagent via the `Agent` tool with `subagent_type: "qa-tester"`): +- Has a binary pass/fail outcome ("does login work?", "verify the upload preview appears", "test the checkout flow"). +- All inputs known up front (no mid-flow user decisions). +- Failure mode is clear (page didn't load, button missing, assertion failed). + +**Operational + decisive** (stay in-line in this conversation): +- May require mid-flow user input ("which AWS region?", "which domain?", "review this draft?"). +- Has side effects on real infrastructure or external accounts. +- Outcome isn't a clean pass/fail (you might need to backtrack and choose differently). + +## Routing rules + +1. Before any browser-leaning task, call `browser_playbook_match { url, intent, taskText }` to see if a playbook exists. +2. If a `verifiable: true` playbook matches AND the task fits the "verifiable + repeatable" bucket above → spawn `qa-tester` with the task + the playbook id. +3. If the task is operational, stay in-line: + - Read the matching playbook (`browser_playbook_get`) before acting. + - Follow the playbook's `## Steps` as guidance. + - Ask the user for any missing inputs declared in `meta.inputs[]`. + - After completing successfully, call `browser_playbook_propose_update` to grow the playbook from the trace. +4. If no playbook matches, proceed with manual snapshot/click/type. On success, call `browser_playbook_propose_update` to capture the flow. + +## What goes in a playbook + +Treat each `.continuum/playbooks//.md` as a contract. Playbook frontmatter declares `inputs[]` with types — `email`, `text`, `markdown`, `file[]`, `secret`, etc. When you see `type: file[]` or `type: image` in inputs, the playbook needs files: call `browser_upload_stage` first to get a `stashId`, then pass it to `browser_upload_file` (or `browser_playbook_run` will plumb it via the `upload` step automatically). + +Secrets (`type: secret`) are NEVER logged in traces or proposed playbook bodies. Resolve them from `process.env.` at run time; never write the value into the playbook. +``` + +- [ ] **Step 3: Register in plugin.json** + +Read `.claude-plugin/plugin.json` and add `agents`, append commands, and reference the QA CLAUDE.md. Replace the `commands` array and add `agents`: + +```json +{ + "name": "mochi", + "description": "A browser companion for AI assistants. Bundled MCP tools for Chrome automation, persistent project memory (Continuum chain), and an in-page hint modal — pair with the Mochi Chrome extension.", + "version": "0.3.0", + "author": { + "name": "Jonayed Ahamed", + "email": "dev.jonayed@gmail.com" + }, + "homepage": "https://github.com/DevZonayed/Mochi", + "keywords": ["browser", "automation", "qa", "testing", "memory", "continuum", "mcp", "chrome"], + "commands": [ + "./plugins/continuum/commands/checkpoint.md", + "./plugins/continuum/commands/dream.md", + "./plugins/continuum/commands/feedback.md", + "./plugins/continuum/commands/recall.md", + "./plugins/continuum/commands/rename.md", + "./plugins/continuum/commands/render.md", + "./plugins/continuum/commands/status.md", + "./plugins/qa/commands/qa.md", + "./plugins/qa/commands/playbook.md", + "./plugins/qa/commands/schedule-playbook.md", + "./plugins/qa/commands/unschedule-playbook.md" + ], + "agents": [ + "./plugins/qa/agents/qa-tester.md" + ], + "hooks": "./plugins/continuum/hooks/hooks.json" +} +``` + +- [ ] **Step 4: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add plugins/qa/agents/qa-tester.md plugins/qa/CLAUDE.md .claude-plugin/plugin.json && git commit -m "feat(qa): qa-tester subagent + smart-router CLAUDE.md" +``` + +--- + +## Task 10: Slash commands for `/qa`, `/mochi:playbook`, `/mochi:schedule-playbook`, `/mochi:unschedule-playbook` + +**Files:** +- Create: `plugins/qa/commands/qa.md` +- Create: `plugins/qa/commands/playbook.md` +- Create: `plugins/qa/commands/schedule-playbook.md` +- Create: `plugins/qa/commands/unschedule-playbook.md` + +- [ ] **Step 1: `/qa` slash command** + +Create `plugins/qa/commands/qa.md`: +```markdown +--- +description: Dispatch a verifiable browser task to the qa-tester subagent. Use for regression checks, smoke tests, and any task with a clean pass/fail outcome. +argument-hint: "" +allowed-tools: [Task] +--- + +Dispatch the `qa-tester` subagent with the user's task: $ARGUMENTS + +Use the `Task` (Agent) tool with `subagent_type: "qa-tester"` and pass the user's task verbatim as the prompt. If the task is clearly ambiguous (no clean pass/fail outcome) explain why and ask the user to clarify before dispatching. + +After the subagent returns its verdict, surface a short summary to the user: +- On `pass`: "✓ Task passed via playbook `` (run ``). Evidence: ." +- On `fail`: "✗ Task failed: ``. Evidence: <…>. Suggest re-running with adjusted inputs." +- On `blocked`: "Cannot run as-is: ``. Need: ." +``` + +- [ ] **Step 2: `/mochi:playbook` aggregator command** + +Create `plugins/qa/commands/playbook.md`: +```markdown +--- +description: Manage Mochi playbooks (list, show, run, delete). Plays through the browser MCP tools. +argument-hint: " [args]" +allowed-tools: [Bash] +--- + +Parse `$ARGUMENTS` as a verb plus arguments: + +- `list [--origin=…] [--tag=…] [--verifiable]` → call `browser_playbook_list` with the parsed filters; render as a markdown table. +- `show ` → call `browser_playbook_get`; render the meta + summary + steps to the user. +- `run [--input.=] …` → call `browser_playbook_run` with parsed inputs; surface the verdict. +- `delete ` → call `browser_playbook_delete`; confirm. +- `match ` → call `browser_playbook_match`; render top hits. + +If no verb given, render help with these verbs and an example. +``` + +- [ ] **Step 3: `/mochi:schedule-playbook`** + +Create `plugins/qa/commands/schedule-playbook.md`: +```markdown +--- +description: Schedule a playbook to run on a recurring cron schedule. Uses the host environment's schedule skill. +argument-hint: "" +allowed-tools: [Bash, Skill] +--- + +The user wants to schedule playbook `$ARGUMENTS` for recurring execution. + +1. Call `browser_playbook_get` for the id. If `meta.cron` is absent or empty, report: "playbook `$ARGUMENTS` has no `cron:` field. Edit the playbook frontmatter to set one (e.g., `cron: '0 7 * * 1-5'` for weekdays at 7am)." +2. Otherwise, invoke the `schedule` skill (if available in this environment) to register a routine that runs `browser_playbook_run { id: "$ARGUMENTS", inputs: }` on the given cron schedule. The routine output should be appended to `.continuum/playbooks/inbox//.md`. +3. If the `schedule` skill is not available in this environment, report: "scheduling unavailable in this env; please run the playbook manually via `/mochi:playbook run $ARGUMENTS`." +``` + +- [ ] **Step 4: `/mochi:unschedule-playbook`** + +Create `plugins/qa/commands/unschedule-playbook.md`: +```markdown +--- +description: Cancel a scheduled playbook routine. +argument-hint: "" +allowed-tools: [Bash, Skill] +--- + +Cancel the scheduled routine for playbook `$ARGUMENTS`. + +1. Use the `schedule` skill (if available) to find the routine that runs this playbook (typically named `mochi-playbook:$ARGUMENTS`) and delete it. +2. If no such routine exists, report: "no scheduled routine found for `$ARGUMENTS`." +``` + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add plugins/qa/commands/ && git commit -m "feat(qa): slash commands — /qa, /mochi:playbook, schedule/unschedule" +``` + +--- + +## Task 11: Fixture HTTP server + 3 fixture pages + +**Files:** +- Create: `server/_fixtures/playbooks/server.mjs` +- Create: `server/_fixtures/playbooks/pages/login-form.html` +- Create: `server/_fixtures/playbooks/pages/compose-form.html` +- Create: `server/_fixtures/playbooks/pages/multi-step-wizard.html` + +- [ ] **Step 1: Create the fixture server** + +Create `server/_fixtures/playbooks/server.mjs`: +```js +import http from "node:http"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function startPlaybookFixtureServer() { + const submissions = []; + const server = http.createServer(async (req, res) => { + if (req.method === "POST" && (req.url === "/submit" || req.url === "/upload")) { + const chunks = []; for await (const c of req) chunks.push(c); + const body = Buffer.concat(chunks); + submissions.push({ url: req.url, contentType: req.headers["content-type"] || "", sizeBytes: body.length, ts: Date.now() }); + res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === "GET" && req.url.startsWith("/pages/")) { + const p = path.join(__dirname, req.url); + try { const data = await fs.readFile(p); res.writeHead(200, { "content-type": "text/html" }); res.end(data); } + catch { res.writeHead(404); res.end("not found"); } + return; + } + res.writeHead(404); res.end("?"); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + resolve({ port, submissions, close: () => new Promise((r) => server.close(r)) }); + }); + }); +} +``` + +- [ ] **Step 2: Login-form fixture** + +Create `server/_fixtures/playbooks/pages/login-form.html`: +```html +fixture: login +
+ + + +
+
+ +``` + +- [ ] **Step 3: Compose-form fixture (covers upload step)** + +Create `server/_fixtures/playbooks/pages/compose-form.html`: +```html +fixture: compose +
+ + + + + + + +
+
+
+ +``` + +- [ ] **Step 4: Multi-step wizard fixture (covers chain composition)** + +Create `server/_fixtures/playbooks/pages/multi-step-wizard.html`: +```html +fixture: wizard +
+ + +
+ + +
+ +``` + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/_fixtures/playbooks/ && git commit -m "test(fixtures): playbook fixture server + 3 pages (login, compose, wizard)" +``` + +--- + +## Task 12: End-to-end integration runner + +**Files:** +- Create: `server/_playbook_e2e.mjs` +- Modify: `server/package.json` (add npm script) + +- [ ] **Step 1: Write the e2e runner** + +Create `server/_playbook_e2e.mjs`: +```js +// _playbook_e2e.mjs — exercise the playbook system end-to-end against +// real Chrome via the broker + the three playbook fixture pages. +// Skips cleanly if the extension is not connected. +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { startPlaybookFixtureServer } from "./_fixtures/playbooks/server.mjs"; +import { Bridge } from "./src/bridge.js"; +import { handleToolCall, initToolsState } from "./src/tools.js"; +import { initPlaybooks } from "./src/playbooks.js"; + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-pb-e2e-")); +process.env.MOCHI_PROJECT_DIR = tmp; +await initPlaybooks(); +initToolsState({ log: () => {} }); + +const fix = await startPlaybookFixtureServer(); +console.log("fixture server:", fix.port); + +const bridge = new Bridge({ log: () => {} }); +const role = await bridge.start({ port: 9009 }); +if (!bridge.isConnected()) { + console.log("SKIP playbook e2e: extension not connected"); + await fix.close(); await bridge.close?.(); + process.exit(0); +} + +// 1. Promote a playbook from a synthetic login trace +const promoted = await handleToolCall(bridge, { name: "browser_playbook_propose_update", arguments: { + label: "login", + title: "Test login", + verifiable: true, + trace: [ + { tool: "browser_navigate", args: { url: `http://127.0.0.1:${fix.port}/pages/login-form.html` } }, + { tool: "browser_type", args: { intent: "username-field", value: "${input.username}" } }, + { tool: "browser_type", args: { intent: "password-field", value: "${input.password}" } }, + { tool: "browser_click", args: { intent: "submit-button" } }, + { tool: "browser_assert", args: { kind: "element-exists", value: { selector: "#status[data-success=true]" }, timeoutMs: 5000 } }, + ], +}}); +const promotedJson = JSON.parse(promoted.content[0].text); +assert.equal(promotedJson.ok, true); +console.log("✓ promoted login playbook:", promotedJson.playbookId); + +// 2. Run the playbook with explicit inputs +await bridge.send("session_start", { groupBy: "client" }); +const runRaw = await handleToolCall(bridge, { name: "browser_playbook_run", arguments: { id: promotedJson.playbookId, inputs: { username: "alice", password: "secret" } } }); +const run = JSON.parse(runRaw.content[0].text); +assert.equal(run.ok, true); +assert.equal(run.verdict, "pass", `expected pass, got: ${JSON.stringify(run)}`); +console.log("✓ played login playbook:", run.verdict); + +// 3. Match — should find the playbook by URL +const matchRaw = await handleToolCall(bridge, { name: "browser_playbook_match", arguments: { url: `http://127.0.0.1:${fix.port}/pages/login-form.html`, taskText: "log in" } }); +const match = JSON.parse(matchRaw.content[0].text); +assert.equal(match.ok, true); +assert.ok(match.matches.length >= 1, `expected ≥1 match, got ${JSON.stringify(match)}`); +console.log("✓ matched playbook"); + +// 4. Verify submissions on the fixture server +assert.ok(fix.submissions.length >= 1, "expected ≥1 fixture submission"); + +await bridge.send("session_end", {}).catch(() => {}); +await fix.close(); +await bridge.close?.(); +await fs.rm(tmp, { recursive: true, force: true }); +console.log("ALL PLAYBOOK E2E CHECKS PASSED"); +``` + +- [ ] **Step 2: Add npm script** + +In `server/package.json`, add to scripts: +```json + "test:e2e:playbook": "node _playbook_e2e.mjs", +``` + +- [ ] **Step 3: Verify syntax** + +```bash +node --check /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server/_playbook_e2e.mjs +``` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/_playbook_e2e.mjs server/package.json && git commit -m "test(playbooks): end-to-end runner against fixture pages" +``` + +--- + +## Task 13: Bump integration tool count + run full test suite + +**Files:** +- Modify: `server/_integration.mjs` + +- [ ] **Step 1: Bump expected tool count** + +In `server/_integration.mjs`, find the assertion that checks total tool count (currently 41) and bump it to 48: + +```bash +grep -n "41\|tools:" /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server/_integration.mjs | head -5 +``` +Edit the matching assertion to `48`. + +- [ ] **Step 2: Run the full suite** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && npm test +``` +Expected: all green — smoke, uploads, upload-wire, playbooks, playbook-wire, integration, multi-client. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/_integration.mjs && git commit -m "test(integration): bump tool count to 48 for playbook tools" +``` + +--- + +## Task 14: Rebuild bundle + update README + +**Files:** +- Modify: `server/dist/server.bundle.mjs` +- Modify: `README.md` + +- [ ] **Step 1: Rebuild bundle** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && npm run build +``` +Expected: rebuilds `dist/server.bundle.mjs` with no errors. + +- [ ] **Step 2: Update README tool table** + +In `README.md`, find the `## Tools (MCP)` section. Update the count to **48 tools** and add a new subsection after "File uploads": + +```markdown +### Playbooks (personal ops memory) + +| Tool | What it does | +|---|---| +| `browser_playbook_list` | List playbooks under `.continuum/playbooks/`, filter by origin/tag/verifiable. | +| `browser_playbook_get` | Return one playbook with meta + body sections + workflow JSON. | +| `browser_playbook_save` | Create/update a playbook (validates frontmatter and required sections). | +| `browser_playbook_delete` | Remove a playbook + workflow + screenshots. | +| `browser_playbook_match` | Score-match playbooks against a URL, intent, or task description. | +| `browser_playbook_run` | Replay a playbook (with self-heal) using provided inputs; recursively executes composes/next chains; returns verdict + evidence. | +| `browser_playbook_propose_update` | Given a successful trace, create or update the matching playbook. Inputs auto-inferred. | + +Combined with the `qa-tester` subagent (registered via the plugin), the +playbook library is your **personal ops memory**: each browser task you do +once becomes replayable, chainable, and scheduleable. The smart-router +rule in `plugins/qa/CLAUDE.md` teaches the main agent when to delegate +(verifiable + repeatable) vs. stay in-line (operational + decisive). + +See [`docs/superpowers/specs/2026-05-20-personal-ops-playbooks-design.md`](docs/superpowers/specs/2026-05-20-personal-ops-playbooks-design.md). +``` + +Also add four new slash commands to whatever section lists commands (if any) or note them inline: +- `/qa ` — dispatch the qa-tester subagent. +- `/mochi:playbook [args]` — list/show/run/delete/match playbooks. +- `/mochi:schedule-playbook ` — wire up cron via the host's schedule skill. +- `/mochi:unschedule-playbook ` — cancel the schedule. + +- [ ] **Step 3: Commit bundle + README** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/dist/server.bundle.mjs server/dist/server.bundle.mjs.LEGAL.txt README.md && git commit -m "ci(server): rebuild bundle for playbook tools + README" +``` + +--- + +## Self-Review + +### Spec coverage check + +| Spec section | Implemented by task(s) | +|---|---| +| Sub-project 1: playbook format + CRUD | 1 (parse/serialize), 2 (validate), 3 (CRUD + index), 4 (match), 7 (tools) | +| Sub-project 2: QA subagent + smart router | 9 (agent + CLAUDE.md + plugin.json), 10 (commands) | +| Sub-project 3: Auto-learning | 5 (promoter), 7 (browser_playbook_propose_update) | +| Sub-project 4: Chains + scheduling | 6 (composeResolve), 7 (browser_playbook_run runs chains), 10 (schedule commands) | +| Wire/contract tests | 8 | +| Integration tests | 11 (fixtures), 12 (e2e) | +| Bundle rebuild + smoke + README | 13, 14 | + +### Placeholder scan + +- No "TBD", "TODO", or "implement later" in tasks. +- Self-heal → playbook update plumbing is *not* implemented as a separate task; instead, the playbook's "Recent runs" section is updated on every promote/run via `promoteFromTrace`. This is acceptable for v1: the heal events are already recorded by `memory.js` via `updateSelector` (which fires on `find_by_role_name` heal in `tools.js`), and the next `propose_update` call picks up the new selectors automatically. Documented as such; spec's acceptance criterion 7 is met because heals land in the selector cache and any subsequent `propose_update` snapshots them. +- Scheduling is delegated to the host environment's `schedule` skill rather than a custom cron daemon; this is explicit in the spec. + +### Type consistency + +- `playbookId` is consistently `/` across all tools, tests, and the agent. +- `meta.inputs[]` shape is `{ name, type, required?, ... }` everywhere. +- `verdict` is one of `"pass"|"fail"|"blocked"` everywhere. +- `playbookErr(code, message, details)` returns errors via the `playbookError` field on the Error instance, mirroring `uploadErr`'s pattern. +- Tool names match across schemas, dispatch, and tests. + +--- + +## Acceptance verification (post-implementation) + +1. `cd server && npm test` — all hand-rolled tests green. +2. `cd server && node _playbook_e2e.mjs` with extension loaded — fixtures pass; verdicts logged. +3. `tools/list` reports 48 tools total; the 7 new `browser_playbook_*` tools appear. +4. `/qa "test login on http://127.0.0.1:/pages/login-form.html"` dispatches the subagent and returns a `pass` verdict. +5. `/mochi:playbook list` shows the auto-promoted playbook. +6. `.continuum/playbooks/index.json` survives a crash mid-write (atomic-rename test, covered by Task 3). +7. No regressions in existing tests (uploads, integration, multi-client). diff --git a/docs/superpowers/plans/2026-05-20-playbooks-v1-5.md b/docs/superpowers/plans/2026-05-20-playbooks-v1-5.md new file mode 100644 index 0000000..86f8aeb --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-playbooks-v1-5.md @@ -0,0 +1,1419 @@ +# Playbooks v1.5 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship three additive capabilities — secrets store, codebase-derived seeding, visual diff regression — that complete the playbook system for daily use. + +**Architecture:** Three new pure-function modules (`secrets.js`, `codebase-seed.js`, `visual-diff.js`) carry the logic. Three new MCP tools surface them. Existing `playbooks.js` `runWorkflowStep` + `promoteFromTrace` get small wrappers for secret resolution + scrubbing and per-step screenshot capture + diff. Bundle adds `pixelmatch`, `pngjs`, `@babel/parser`, `@babel/traverse` as deps. + +**Tech Stack:** Node 22 (ESM), `pixelmatch` (pixel-level PNG diff), `pngjs` (PNG read/write), `@babel/parser` + `@babel/traverse` (AST walks for codebase seeding), existing `js-yaml` for frontmatter, Chrome DevTools Protocol via the extension (for screenshots). + +**Spec:** `docs/superpowers/specs/2026-05-20-playbooks-v1-5-design.md` + +--- + +## File Structure + +**New files (server):** +- `server/src/secrets.js` — resolve `${env:…}` / `${secret:…}` / `${BARE_VAR}` refs, scrub traces, validate playbook secret availability. ~180 lines. +- `server/src/codebase-seed.js` — detect framework, parse routes, emit draft playbooks. ~400 lines. +- `server/src/visual-diff.js` — pixelmatch wrapper, accept flow. ~200 lines. +- `server/_secrets.test.mjs` — unit tests. ~150 lines. +- `server/_codebase_seed.test.mjs` — unit tests. ~180 lines. +- `server/_visual_diff.test.mjs` — unit tests. ~120 lines. +- `server/_fixtures/codebase/next-app/app/login/page.tsx` — synthetic fixture for codebase seeding test. +- `server/_fixtures/codebase/next-app/app/dashboard/settings/page.tsx` — second fixture page. +- `server/_fixtures/codebase/next-app/next.config.js` — marker file for detector. + +**Modified files:** +- `server/src/tools.js` — register `browser_playbook_secret_check`, `browser_playbook_seed_from_codebase`, `browser_playbook_diff_accept`; add local dispatch cases; wire `playbook_run` to use secrets + visual diff. +- `server/src/playbooks.js` — `runWorkflowStep` resolves secrets before send + scrubs traces; per-step screenshot capture + diff during runs with `visual_refs[]`. `promoteFromTrace` scrubs secret values. +- `server/_smoke.mjs` — add 3 new tool names to `want`; expect tool count 51. +- `server/_integration.mjs` — bump tool count assertion 48 → 51. +- `server/_playbook_wire.test.mjs` — extend to cover the 3 new tools. +- `server/_playbook_e2e.mjs` — add secrets + visual-diff scenarios. +- `server/package.json` — add `pixelmatch@5`, `pngjs@7`, `@babel/parser@7`, `@babel/traverse@7`; extend `test` script. +- `.gitignore` — add `.continuum/secrets/` (defense-in-depth; per-secret-dir .gitignore is the primary). +- `plugins/qa/commands/playbook.md` — extend verbs (seed, diff accept). +- `server/dist/server.bundle.mjs` — rebuilt. +- `README.md` — add v1.5 section under Playbooks. + +--- + +## Task 1: `secrets.js` module + tests + +**Files:** +- Create: `server/src/secrets.js` +- Create: `server/_secrets.test.mjs` +- Modify: `server/package.json` (extend `test` script line) +- Modify: `.gitignore` + +- [ ] **Step 1: Update `.gitignore`** + +Append to `/Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/.gitignore`: +``` +.continuum/secrets/ +``` + +- [ ] **Step 2: Write the failing test** + +Create `server/_secrets.test.mjs`: +```js +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { resolveRef, resolveInputs, scrubTrace, validatePlaybook, listAvailableSecrets, initSecrets, secretsDir } from "./src/secrets.js"; + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-secrets-")); +process.env.MOCHI_PROJECT_DIR = tmp; +await initSecrets(); +assert.equal(secretsDir(), path.join(tmp, ".continuum", "secrets")); +const stat = await fs.stat(secretsDir()); +assert.ok(stat.isDirectory()); +// auto .gitignore +const gi = await fs.readFile(path.join(secretsDir(), ".gitignore"), "utf8"); +assert.ok(gi.includes("*")); + +// env var resolution +process.env.MOCHI_TEST_PW = "supersecret"; +assert.equal(resolveRef("${env:MOCHI_TEST_PW}"), "supersecret"); +assert.equal(resolveRef("${MOCHI_TEST_PW}"), "supersecret"); + +// bare lowercase NOT shorthand env (must be ${env:foo}) +assert.equal(resolveRef("${mochi_test_pw}"), null); + +// file resolution +await fs.writeFile(path.join(secretsDir(), "api-key.txt"), "tok_abc\n", { mode: 0o600 }); +assert.equal(resolveRef("${secret:api-key}"), "tok_abc"); + +// missing file → null +assert.equal(resolveRef("${secret:missing}"), null); + +// syntax errors +assert.throws(() => resolveRef("${env}"), /secret-ref-syntax/); +assert.throws(() => resolveRef("prefix${env:X}suffix"), /secret-ref-syntax/); +assert.throws(() => resolveRef("${env:X}${env:Y}"), /secret-ref-syntax/); + +// resolveInputs +const pb = { meta: { inputs: [ + { name: "user", type: "text", required: true, ref: null }, + { name: "password", type: "secret", required: true, ref: "${env:MOCHI_TEST_PW}" }, + { name: "api_key", type: "secret", required: true, ref: "${secret:api-key}" }, + { name: "missing", type: "secret", required: false, ref: "${env:NOT_SET}" }, +] } }; +const r = resolveInputs(pb, { user: "alice" }); +assert.equal(r.resolved.user, "alice"); +assert.equal(r.resolved.password, "supersecret"); +assert.equal(r.resolved.api_key, "tok_abc"); +assert.equal(r.resolved.missing, null); +assert.equal(r.missing.length, 0); // optional missing doesn't count + +// validatePlaybook detects unavailable required secret +delete process.env.MOCHI_TEST_PW; +const v = validatePlaybook(pb); +assert.equal(v.ok, false); +assert.ok(v.missing.find((m) => m.name === "password")); + +// scrubTrace +const trace = [ + { tool: "browser_type", args: { intent: "password-field", value: "supersecret" } }, + { tool: "browser_type", args: { intent: "name-field", value: "alice" } }, +]; +const scrubbed = scrubTrace(trace, { password: "supersecret" }); +assert.equal(scrubbed[0].args.value, "[REDACTED:password]"); +assert.equal(scrubbed[1].args.value, "alice"); + +// listAvailableSecrets +process.env.GMAIL_PASSWORD = "x"; +const list = await listAvailableSecrets(); +assert.ok(list.some((s) => s.name === "GMAIL_PASSWORD" && s.source === "env")); +assert.ok(list.some((s) => s.name === "api-key" && s.source === "file")); +delete process.env.GMAIL_PASSWORD; + +console.log("✓ secrets tests"); +await fs.rm(tmp, { recursive: true, force: true }); +``` + +- [ ] **Step 3: Run failing test** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _secrets.test.mjs +``` +Expected: `Cannot find module './src/secrets.js'`. + +- [ ] **Step 4: Implement `secrets.js`** + +Create `server/src/secrets.js`: +```js +// server/src/secrets.js +// Resolve typed-secret refs at run time. Never log values into traces or playbook bodies. +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; + +function projectDir() { return process.env.MOCHI_PROJECT_DIR || process.cwd(); } +export function secretsDir() { return path.join(projectDir(), ".continuum", "secrets"); } + +const REF_PATTERN = /^\$\{([^}]+)\}$/; + +export class SecretError extends Error { + constructor(code, message, details) { + super(`${code}: ${message}`); + this.secretError = { code, message, details }; + } +} + +export async function initSecrets() { + await fs.mkdir(secretsDir(), { recursive: true }); + try { + await fs.chmod(secretsDir(), 0o700); + } catch (e) { + if (process.platform !== "win32") console.warn("[secrets] could not chmod 0700:", e?.message); + } + const gi = path.join(secretsDir(), ".gitignore"); + try { await fs.access(gi); } + catch { await fs.writeFile(gi, "*\n!.gitignore\n"); } +} + +export function resolveRef(ref) { + if (ref === null || ref === undefined) return null; + if (typeof ref !== "string") throw new SecretError("secret-ref-syntax", "ref must be a string"); + const trimmed = ref.trim(); + const m = REF_PATTERN.exec(trimmed); + if (!m) throw new SecretError("secret-ref-syntax", `not a single \${…} ref: "${ref}"`); + const inner = m[1].trim(); + if (!inner) throw new SecretError("secret-ref-syntax", "empty ref"); + + // ${env:NAME} or ${secret:NAME} or ${BARE_UPPERCASE} + const colonIdx = inner.indexOf(":"); + if (colonIdx < 0) { + if (/^[A-Z_][A-Z0-9_]*$/.test(inner)) { + return process.env[inner] ?? null; + } + throw new SecretError("secret-ref-syntax", `bare refs must be UPPER_SNAKE: "${inner}"`); + } + const kind = inner.slice(0, colonIdx).trim(); + const name = inner.slice(colonIdx + 1).trim(); + if (!name) throw new SecretError("secret-ref-syntax", `empty name in "${ref}"`); + if (kind === "env") { + return process.env[name] ?? null; + } + if (kind === "secret") { + const p = path.join(secretsDir(), name + ".txt"); + try { + const raw = fsSync.readFileSync(p, "utf8"); + return raw.replace(/\r?\n$/, ""); + } catch { return null; } + } + throw new SecretError("secret-ref-syntax", `unknown kind "${kind}"; want env or secret`); +} + +export function resolveInputs(playbook, callerInputs = {}) { + const inputs = playbook?.meta?.inputs || []; + const resolved = { ...callerInputs }; + const missing = []; + for (const spec of inputs) { + if (resolved[spec.name] !== undefined && resolved[spec.name] !== null) continue; + if (spec.ref) { + const v = resolveRef(spec.ref); + resolved[spec.name] = v; + if (v === null && spec.required) missing.push({ name: spec.name, ref: spec.ref, source: refSource(spec.ref) }); + } else if (spec.required) { + missing.push({ name: spec.name, ref: null, source: null }); + } else { + resolved[spec.name] = null; + } + } + return { resolved, missing }; +} + +function refSource(ref) { + const m = REF_PATTERN.exec(ref.trim()); + if (!m) return null; + const inner = m[1].trim(); + if (inner.startsWith("secret:")) return "file"; + return "env"; +} + +export function validatePlaybook(playbook) { + const { missing } = resolveInputs(playbook, {}); + // For validation, we only care about required secrets specifically: + const secretMissing = missing.filter((m) => { + const spec = (playbook?.meta?.inputs || []).find((s) => s.name === m.name); + return spec?.type === "secret" && spec?.required && spec?.ref; + }); + return { ok: secretMissing.length === 0, missing: secretMissing }; +} + +export function scrubTrace(trace, resolvedSecrets) { + const secretValues = new Set( + Object.entries(resolvedSecrets || {}) + .filter(([, v]) => typeof v === "string" && v.length > 0) + .map(([, v]) => v), + ); + const nameByValue = {}; + for (const [name, v] of Object.entries(resolvedSecrets || {})) { + if (typeof v === "string" && v.length > 0) nameByValue[v] = name; + } + return trace.map((call) => { + if (!call?.args) return call; + const args = { ...call.args }; + for (const k of Object.keys(args)) { + if (typeof args[k] === "string" && secretValues.has(args[k])) { + args[k] = `[REDACTED:${nameByValue[args[k]]}]`; + } + } + return { ...call, args }; + }); +} + +export async function listAvailableSecrets() { + const out = []; + for (const [name] of Object.entries(process.env)) { + if (/PASSWORD|TOKEN|SECRET|KEY|API/i.test(name)) out.push({ name, source: "env" }); + } + try { + const files = await fs.readdir(secretsDir()); + for (const f of files) { + if (f.endsWith(".txt")) out.push({ name: f.slice(0, -4), source: "file" }); + } + } catch {} + return out; +} +``` + +- [ ] **Step 5: Run test passes** + +```bash +node _secrets.test.mjs +``` +Expected: `✓ secrets tests`. + +- [ ] **Step 6: Update package.json test script** + +Change the `test` line to include `_secrets.test.mjs`: +```json + "test": "node _smoke.mjs && node _uploads.test.mjs && node _upload_wire.test.mjs && node _playbooks.test.mjs && node _playbook_wire.test.mjs && node _secrets.test.mjs && node _integration.mjs && node _multi-client.mjs", +``` + +- [ ] **Step 7: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/secrets.js server/_secrets.test.mjs server/package.json .gitignore && git commit -m "feat(secrets): resolve env/file refs + scrub traces + validate availability" +``` + +--- + +## Task 2: `codebase-seed.js` module + tests + +**Files:** +- Create: `server/src/codebase-seed.js` +- Create: `server/_codebase_seed.test.mjs` +- Create: `server/_fixtures/codebase/next-app/next.config.js` +- Create: `server/_fixtures/codebase/next-app/app/login/page.tsx` +- Create: `server/_fixtures/codebase/next-app/app/dashboard/settings/page.tsx` +- Modify: `server/package.json` (add @babel/parser, @babel/traverse) + +- [ ] **Step 1: Install deps** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && npm install @babel/parser@7 @babel/traverse@7 +``` + +- [ ] **Step 2: Create fixture files** + +Create `server/_fixtures/codebase/next-app/next.config.js`: +```js +module.exports = {}; +``` + +Create `server/_fixtures/codebase/next-app/app/login/page.tsx`: +```tsx +"use client"; +import { useState } from "react"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + return ( +
{}}> + setEmail(e.target.value)} /> + setPassword(e.target.value)} /> + +
+ ); +} +``` + +Create `server/_fixtures/codebase/next-app/app/dashboard/settings/page.tsx`: +```tsx +export default function SettingsPage() { + return ( +
+ + + +
+ ); +} +``` + +- [ ] **Step 3: Write the failing test** + +Create `server/_codebase_seed.test.mjs`: +```js +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { seedFromCodebase, detectFramework } from "./src/codebase-seed.js"; +import { initPlaybooks, getPlaybook } from "./src/playbooks.js"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-seed-")); +process.env.MOCHI_PROJECT_DIR = tmp; +await initPlaybooks(); + +const fixturePath = path.join(__dirname, "_fixtures", "codebase", "next-app"); + +// detector +const fw = await detectFramework(fixturePath); +assert.equal(fw.kind, "next-app-router"); + +// dry run +const dry = await seedFromCodebase({ projectRoot: fixturePath, domain: "app.localhost:3000", dryRun: true }); +assert.equal(dry.framework, "next-app-router"); +assert.ok(dry.drafts.length >= 2); +assert.equal(dry.written, 0); +const login = dry.drafts.find((d) => d.id === "app.localhost:3000/login"); +assert.ok(login); +assert.ok(login.inputs >= 2); +const settings = dry.drafts.find((d) => d.id === "app.localhost:3000/dashboard-settings"); +assert.ok(settings); + +// real run +const real = await seedFromCodebase({ projectRoot: fixturePath, domain: "app.localhost:3000" }); +assert.equal(real.written, real.drafts.length); + +// password auto-typed as secret +const pb = await getPlaybook("app.localhost:3000/login"); +assert.ok(pb); +const pwInput = pb.meta.inputs.find((i) => i.name === "password"); +assert.ok(pwInput); +assert.equal(pwInput.type, "secret"); +const emailInput = pb.meta.inputs.find((i) => i.name === "email"); +assert.equal(emailInput.type, "email"); + +// playbook_version: 0 marker +assert.equal(pb.meta.playbook_version, 0); +assert.equal(pb.meta.verifiable, false); + +// re-seed: idempotent for drafts +const real2 = await seedFromCodebase({ projectRoot: fixturePath, domain: "app.localhost:3000" }); +assert.equal(real2.written, real2.drafts.length); // updates drafts in place + +// non-overwrite of non-draft playbook: manually bump version on login → re-seed should skip +const fsp = await import("node:fs/promises"); +const loginPath = path.join(tmp, ".continuum", "playbooks", "app.localhost:3000", "login.md"); +let md = await fsp.readFile(loginPath, "utf8"); +md = md.replace("playbook_version: 0", "playbook_version: 1"); +await fsp.writeFile(loginPath, md); +const real3 = await seedFromCodebase({ projectRoot: fixturePath, domain: "app.localhost:3000" }); +assert.ok(real3.warnings.some((w) => w.includes("login"))); +assert.ok(real3.skipped >= 1); + +console.log("✓ codebase-seed tests"); +await fs.rm(tmp, { recursive: true, force: true }); +``` + +- [ ] **Step 4: Run failing test** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _codebase_seed.test.mjs +``` +Expected: `Cannot find module './src/codebase-seed.js'`. + +- [ ] **Step 5: Implement `codebase-seed.js`** + +Create `server/src/codebase-seed.js`: +```js +// server/src/codebase-seed.js +// Static analyzer over project frontends → draft playbooks. +import fs from "node:fs/promises"; +import path from "node:path"; +import { parse as babelParse } from "@babel/parser"; +import _traverse from "@babel/traverse"; +import { savePlaybook, getPlaybook } from "./playbooks.js"; + +const traverse = _traverse.default || _traverse; + +export async function detectFramework(root) { + const has = async (p) => { try { await fs.access(path.join(root, p)); return true; } catch { return false; } }; + const hasNextConfig = (await has("next.config.js")) || (await has("next.config.mjs")) || (await has("next.config.ts")); + if (hasNextConfig) { + if (await has("app")) return { kind: "next-app-router", appDir: "app" }; + if (await has("pages")) return { kind: "next-pages-router", pagesDir: "pages" }; + } + if ((await has("vite.config.js")) || (await has("vite.config.ts"))) return { kind: "vite-react", srcDir: "src" }; + try { + const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")); + if (pkg.dependencies?.["react-scripts"]) return { kind: "cra", srcDir: "src" }; + } catch {} + return { kind: "none" }; +} + +export async function seedFromCodebase({ projectRoot, domain, dryRun = false } = {}) { + const root = projectRoot || process.env.MOCHI_PROJECT_DIR || process.cwd(); + if (!domain) throw new Error("seed-domain-missing: pass a `domain` (e.g. 'app.localhost:3000')"); + const fw = await detectFramework(root); + if (fw.kind === "none") return { ok: true, framework: "none", drafts: [], written: 0, skipped: 0, warnings: ["no frontend framework detected"] }; + + const drafts = []; + if (fw.kind === "next-app-router") drafts.push(...await scanNextAppRouter(root, fw.appDir, domain)); + if (fw.kind === "next-pages-router") drafts.push(...await scanNextPagesRouter(root, fw.pagesDir, domain)); + if (fw.kind === "vite-react" || fw.kind === "cra") drafts.push(...await scanReactSrc(root, fw.srcDir, domain)); + + if (dryRun) return { ok: true, framework: fw.kind, drafts: draftsMeta(drafts), written: 0, skipped: 0, warnings: [] }; + + let written = 0, skipped = 0; + const warnings = []; + for (const d of drafts) { + const existing = await getPlaybook(d.id); + if (existing && (existing.meta?.playbook_version || 0) > 0) { + skipped++; + warnings.push(`skipped ${d.id}: non-draft playbook already exists (playbook_version=${existing.meta.playbook_version})`); + continue; + } + await savePlaybook({ id: d.id, meta: d.meta, body: d.body, workflow: d.workflow }); + written++; + } + return { ok: true, framework: fw.kind, drafts: draftsMeta(drafts), written, skipped, warnings }; +} + +function draftsMeta(drafts) { + return drafts.map((d) => ({ id: d.id, source: d.source, inputs: d.meta.inputs.length, steps: d.workflow.steps.length })); +} + +async function scanNextAppRouter(root, appDir, domain) { + const out = []; + const base = path.join(root, appDir); + async function walk(dir, route = "") { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (e.name.startsWith("(") || e.name.startsWith("_")) { await walk(p, route); continue; } // route groups + const seg = e.name.startsWith("[") ? "" : e.name; // dynamic segments stripped + await walk(p, route + (seg ? "/" + seg : "")); + continue; + } + if (e.isFile() && /^page\.(tsx|jsx|ts|js)$/.test(e.name)) { + const draft = await buildDraftFromFile(p, route || "/", domain); + if (draft) out.push(draft); + } + } + } + await walk(base); + return out; +} + +async function scanNextPagesRouter(root, pagesDir, domain) { + const out = []; + const base = path.join(root, pagesDir); + async function walk(dir, route = "") { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { await walk(p, route + "/" + e.name); continue; } + if (e.isFile() && /\.(tsx|jsx|ts|js)$/.test(e.name) && !e.name.startsWith("_")) { + const seg = e.name.replace(/\.(tsx|jsx|ts|js)$/, ""); + const sub = seg === "index" ? "" : "/" + seg; + const draft = await buildDraftFromFile(p, (route || "") + sub || "/", domain); + if (draft) out.push(draft); + } + } + } + await walk(base); + return out; +} + +async function scanReactSrc(root, srcDir, domain) { + const out = []; + const base = path.join(root, srcDir); + async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) await walk(p); + else if (e.isFile() && /\.(tsx|jsx)$/.test(e.name)) { + const fakeRoute = "/" + e.name.replace(/\.(tsx|jsx)$/, "").replace(/Page$/, "").toLowerCase(); + const draft = await buildDraftFromFile(p, fakeRoute, domain); + if (draft) out.push(draft); + } + } + } + await walk(base); + return out; +} + +async function buildDraftFromFile(filePath, route, domain) { + let source; + try { source = await fs.readFile(filePath, "utf8"); } + catch { return null; } + let ast; + try { ast = babelParse(source, { sourceType: "module", plugins: ["jsx", "typescript"] }); } + catch (e) { return null; } + const fields = []; + let hasForm = false; + let submitButton = null; + traverse(ast, { + JSXOpeningElement(p) { + const name = nodeName(p.node.name); + if (name === "form" || /Form$/.test(name)) hasForm = true; + if (name === "input" || name === "textarea" || name === "select") { + fields.push(extractField(p.node, name)); + } + if (name === "button") { + const type = attrValue(p.node, "type"); + if (type === "submit" || !submitButton) submitButton = { ...extractField(p.node, "button") }; + } + }, + }); + if (!hasForm && !fields.length) return null; + + const feature = slugFromRoute(route); + const id = `${domain}/${feature}`; + const inputs = uniqueInputs(fields.map(fieldToInput)); + const steps = stepsFromFields(route, fields, submitButton); + const meta = { + origin: domain, + feature, + title: `${humanize(feature)} (draft)`, + verifiable: false, + preconditions: [], + inputs, + outputs: [], + composes: [], + next: null, + cron: null, + last_verified: null, + success_count: 0, + playbook_version: 0, + schema_version: 1, + tags: ["draft", "seeded"], + }; + const body = freshBody({ route, source: filePath, fields, submitButton, steps }); + return { + id, + source: path.relative(process.env.MOCHI_PROJECT_DIR || process.cwd(), filePath), + meta, + body, + workflow: { playbookId: id, schemaVersion: 1, steps }, + }; +} + +function nodeName(n) { + if (n.type === "JSXIdentifier") return n.name; + if (n.type === "JSXMemberExpression") return nodeName(n.property); + return ""; +} +function attrValue(opening, attr) { + for (const a of opening.attributes || []) { + if (a.type === "JSXAttribute" && a.name?.name === attr) { + if (a.value?.type === "StringLiteral") return a.value.value; + if (a.value?.type === "Literal") return a.value.value; + if (a.value?.type === "JSXExpressionContainer") { + const e = a.value.expression; + if (e?.type === "StringLiteral") return e.value; + } + } + } + return null; +} +function extractField(opening, tag) { + return { + tag, + type: attrValue(opening, "type"), + name: attrValue(opening, "name"), + id: attrValue(opening, "id"), + aria: attrValue(opening, "aria-label"), + testid: attrValue(opening, "data-testid"), + placeholder: attrValue(opening, "placeholder"), + accept: attrValue(opening, "accept"), + }; +} +function slugFromRoute(route) { + return route.replace(/^\//, "").replace(/\//g, "-").replace(/[^a-z0-9-]/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "index"; +} +function humanize(s) { return s.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join(" "); } +function fieldToInput(f) { + const name = camelOrSnake(f.name || f.testid || f.aria || f.id || f.placeholder || f.tag); + let type = "text"; + if (f.type === "email") type = "email"; + if (f.type === "password") type = "secret"; + if (f.type === "url") type = "url"; + if (f.type === "file") { + const accept = (f.accept || "").toLowerCase(); + if (accept.includes("image")) type = "image"; + else type = "file"; + } + return { name, type, required: true }; +} +function camelOrSnake(s) { + if (!s) return "value"; + return s.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase(); +} +function uniqueInputs(arr) { + const seen = new Map(); + for (const i of arr) if (!seen.has(i.name)) seen.set(i.name, i); + return [...seen.values()]; +} +function stepsFromFields(route, fields, submitButton) { + const steps = [{ action: "navigate", url: `\${baseUrl}${route}` }]; + for (const f of fields) { + const input = fieldToInput(f); + const intent = (f.testid || f.aria || input.name) + "-field"; + steps.push({ action: "type", intent, valueRef: `input.${input.name}` }); + } + if (submitButton) { + const intent = (submitButton.testid || submitButton.aria || "submit") + "-button"; + steps.push({ action: "click", intent }); + } + return steps; +} +function freshBody({ route, source, fields, submitButton, steps }) { + return [ + "## Summary", + `Draft playbook generated from \`${path.basename(source)}\` for route \`${route}\`. Run it once to verify selectors + outcome, then bump \`playbook_version\` from 0 to 1.`, + "", + "## Preconditions", + "(none recorded; add manually)", + "", + "## Steps", + ...steps.map((s, i) => `${i + 1}. ${describeStep(s)}`), + "", + "## Verification", + "Add explicit success criterion (e.g., 'Redirects to /dashboard' or 'Success toast appears').", + "", + "## Selectors used", + "", + "| intent | selector |", + "|---|---|", + ...fields.map((f) => { + const intent = (f.testid || f.aria || f.name || "field") + "-field"; + const sel = f.testid ? `[data-testid=\"${f.testid}\"]` : f.id ? `#${f.id}` : `[name=\"${f.name}\"]`; + return `| ${intent} | \`${sel}\` |`; + }), + submitButton ? `| ${(submitButton.testid || submitButton.aria || "submit") + "-button"} | \`${submitButton.testid ? `[data-testid="${submitButton.testid}"]` : "button[type=submit]"}\` |` : "", + "", + "## Recent runs", + "", + `- seeded-${new Date().toISOString()} — auto-generated draft, untested`, + "", + "## Screenshots", + "", + "- (none yet)", + "", + ].filter((l) => l !== "").join("\n"); +} +function describeStep(s) { + switch (s.action) { + case "navigate": return `Navigate to \`${s.url}\``; + case "click": return `Click intent \`${s.intent}\``; + case "type": return `Type \`${s.valueRef}\` into intent \`${s.intent}\``; + default: return JSON.stringify(s); + } +} +``` + +- [ ] **Step 6: Run test** + +```bash +node _codebase_seed.test.mjs +``` +Expected: `✓ codebase-seed tests`. + +- [ ] **Step 7: Update package.json test script** + +Change the `test` line to include `_codebase_seed.test.mjs`: +```json + "test": "node _smoke.mjs && node _uploads.test.mjs && node _upload_wire.test.mjs && node _playbooks.test.mjs && node _playbook_wire.test.mjs && node _secrets.test.mjs && node _codebase_seed.test.mjs && node _integration.mjs && node _multi-client.mjs", +``` + +- [ ] **Step 8: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/codebase-seed.js server/_codebase_seed.test.mjs server/_fixtures/codebase/ server/package.json server/package-lock.json && git commit -m "feat(codebase-seed): Next.js/Vite detector + JSX parser → draft playbooks" +``` + +--- + +## Task 3: `visual-diff.js` module + tests + +**Files:** +- Create: `server/src/visual-diff.js` +- Create: `server/_visual_diff.test.mjs` +- Modify: `server/package.json` (add pixelmatch, pngjs) + +- [ ] **Step 1: Install deps** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && npm install pixelmatch@5 pngjs@7 +``` + +- [ ] **Step 2: Write the failing test** + +Create `server/_visual_diff.test.mjs`: +```js +import assert from "node:assert/strict"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import { PNG } from "pngjs"; +import { diffStep, acceptStepShots } from "./src/visual-diff.js"; + +function makePng(w, h, fill) { + const png = new PNG({ width: w, height: h }); + for (let i = 0; i < w * h * 4; i += 4) { + png.data[i] = fill.r; png.data[i+1] = fill.g; png.data[i+2] = fill.b; png.data[i+3] = 255; + } + return PNG.sync.write(png); +} + +const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-vd-")); +const a = path.join(tmp, "a.png"), b = path.join(tmp, "b.png"), c = path.join(tmp, "c.png"); + +await fs.writeFile(a, makePng(20, 20, { r: 0, g: 0, b: 0 })); +await fs.writeFile(b, makePng(20, 20, { r: 0, g: 0, b: 0 })); // identical +await fs.writeFile(c, makePng(20, 20, { r: 255, g: 255, b: 255 })); // very different + +const m = await diffStep({ actualPath: a, refPath: b }); +assert.equal(m.verdict, "match"); +assert.equal(m.diff, 0); + +const f = await diffStep({ actualPath: a, refPath: c, warnThreshold: 0.05, failThreshold: 0.20 }); +assert.equal(f.verdict, "fail"); +assert.ok(f.diff > 0.20); +assert.ok(f.diffImagePath); +const stat = await fs.stat(f.diffImagePath); +assert.ok(stat.size > 0); + +// dim mismatch +const small = path.join(tmp, "small.png"); +await fs.writeFile(small, makePng(10, 10, { r: 0, g: 0, b: 0 })); +const dm = await diffStep({ actualPath: a, refPath: small }); +assert.equal(dm.verdict, "fail"); +assert.equal(dm.reason, "dimension-mismatch"); + +// 3% diff → warn +// flip a few pixels in `b` to get ~5% +const bData = await fs.readFile(b); +const bp = PNG.sync.read(bData); +const flipPx = Math.floor(bp.width * bp.height * 0.04); +for (let i = 0; i < flipPx; i++) { + const off = i * 4; + bp.data[off] = 255; bp.data[off+1] = 255; bp.data[off+2] = 255; +} +const bWarn = path.join(tmp, "bwarn.png"); +await fs.writeFile(bWarn, PNG.sync.write(bp)); +const w = await diffStep({ actualPath: a, refPath: bWarn, warnThreshold: 0.02, failThreshold: 0.10 }); +assert.equal(w.verdict, "warn"); + +// acceptStepShots +const refDir = path.join(tmp, "feature.screenshots"); +await fs.mkdir(refDir); +const runDir = path.join(tmp, "run-r123"); +await fs.mkdir(runDir); +await fs.writeFile(path.join(runDir, "step-02.png"), makePng(40, 40, { r: 10, g: 20, b: 30 })); +await fs.writeFile(path.join(runDir, "step-05.png"), makePng(40, 40, { r: 40, g: 50, b: 60 })); +const accepted = await acceptStepShots({ runDir, refDir, steps: [2, 5] }); +assert.equal(accepted.length, 2); +for (const a of accepted) { + const s = await fs.stat(a.refPath); assert.ok(s.size > 0); + assert.equal(typeof a.sha, "string"); assert.equal(a.sha.length, 64); +} + +console.log("✓ visual-diff tests"); +await fs.rm(tmp, { recursive: true, force: true }); +``` + +- [ ] **Step 3: Run failing test** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _visual_diff.test.mjs +``` +Expected: `Cannot find module './src/visual-diff.js'`. + +- [ ] **Step 4: Implement `visual-diff.js`** + +Create `server/src/visual-diff.js`: +```js +// server/src/visual-diff.js +// PNG pixel-diff via pixelmatch. Per-step screenshot comparison for playbook replays. +import fs from "node:fs/promises"; +import path from "node:path"; +import crypto from "node:crypto"; +import { PNG } from "pngjs"; +import pixelmatch from "pixelmatch"; + +export async function diffStep({ actualPath, refPath, warnThreshold = 0.05, failThreshold = 0.20 } = {}) { + if (!actualPath || !refPath) throw new Error("diffStep requires actualPath and refPath"); + let actualBuf, refBuf; + try { actualBuf = await fs.readFile(actualPath); } + catch (e) { return { verdict: "fail", reason: "actual-missing", diff: 1, details: { actualPath } }; } + try { refBuf = await fs.readFile(refPath); } + catch (e) { return { verdict: "fail", reason: "ref-missing", diff: 1, details: { refPath } }; } + + const actual = PNG.sync.read(actualBuf); + const ref = PNG.sync.read(refBuf); + if (actual.width !== ref.width || actual.height !== ref.height) { + return { verdict: "fail", reason: "dimension-mismatch", diff: 1, details: { actual: { w: actual.width, h: actual.height }, ref: { w: ref.width, h: ref.height } } }; + } + const diff = new PNG({ width: actual.width, height: actual.height }); + const mismatched = pixelmatch(actual.data, ref.data, diff.data, actual.width, actual.height, { threshold: 0.1 }); + const ratio = mismatched / (actual.width * actual.height); + let verdict = "match"; + let diffImagePath; + if (ratio >= failThreshold) verdict = "fail"; + else if (ratio >= warnThreshold) verdict = "warn"; + if (verdict !== "match") { + diffImagePath = actualPath.replace(/\.png$/i, "-diff.png"); + await fs.writeFile(diffImagePath, PNG.sync.write(diff)); + } + return { verdict, diff: ratio, mismatchedPixels: mismatched, diffImagePath }; +} + +export function pngSha(buf) { + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +export async function acceptStepShots({ runDir, refDir, steps } = {}) { + await fs.mkdir(refDir, { recursive: true }); + const entries = await fs.readdir(runDir); + const out = []; + const wanted = steps && steps.length ? new Set(steps.map((n) => String(n).padStart(2, "0"))) : null; + for (const f of entries) { + const m = /^step-(\d+)\.png$/i.exec(f); + if (!m) continue; + const stepNum = m[1].padStart(2, "0"); + if (wanted && !wanted.has(stepNum) && !wanted.has(String(parseInt(stepNum, 10)))) continue; + const fromPath = path.join(runDir, f); + const toPath = path.join(refDir, `step-${stepNum}.png`); + const buf = await fs.readFile(fromPath); + await fs.writeFile(toPath, buf); + out.push({ step: parseInt(stepNum, 10), refPath: toPath, sha: pngSha(buf) }); + } + return out; +} +``` + +- [ ] **Step 5: Run test** + +```bash +node _visual_diff.test.mjs +``` +Expected: `✓ visual-diff tests`. + +- [ ] **Step 6: Update package.json test script** + +Change `test` line to: +```json + "test": "node _smoke.mjs && node _uploads.test.mjs && node _upload_wire.test.mjs && node _playbooks.test.mjs && node _playbook_wire.test.mjs && node _secrets.test.mjs && node _codebase_seed.test.mjs && node _visual_diff.test.mjs && node _integration.mjs && node _multi-client.mjs", +``` + +- [ ] **Step 7: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/visual-diff.js server/_visual_diff.test.mjs server/package.json server/package-lock.json && git commit -m "feat(visual-diff): pixelmatch diff + accept flow" +``` + +--- + +## Task 4: Register 3 new tools + dispatch in `tools.js` + +**Files:** +- Modify: `server/src/tools.js` +- Modify: `server/_smoke.mjs` + +- [ ] **Step 1: Add tool definitions in `tools` array** + +In `server/src/tools.js`, after the last `browser_playbook_*` entry, add: +```js + { + name: "browser_playbook_secret_check", + description: "Validate that all `type: secret` inputs of a playbook are resolvable (env var or .continuum/secrets file). Returns availability per secret; never returns values.", + inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + }, + { + name: "browser_playbook_seed_from_codebase", + description: "Static-analyze the project's frontend (Next.js App/Pages Router, Vite/CRA) and emit draft playbooks per route + form. Drafts have playbook_version=0 and verifiable=false until you run + bless them.", + inputSchema: { type: "object", properties: { + projectRoot: { type: "string" }, + domain: { type: "string", description: "Origin to assign (e.g. 'app.localhost:3000')." }, + dryRun: { type: "boolean" }, + } }, + }, + { + name: "browser_playbook_diff_accept", + description: "Bless a run's per-step screenshots as the new visual reference for a playbook. Updates visual_refs[] hashes and bumps playbook_version.", + inputSchema: { type: "object", properties: { + id: { type: "string" }, + runId: { type: "string" }, + steps: { type: "array", items: { type: "number" } }, + }, required: ["id", "runId"] }, + }, +``` + +- [ ] **Step 2: Add local dispatch cases** + +In `handleToolCall`'s local switch, after the other `browser_playbook_*` cases: +```js + case "browser_playbook_secret_check": return jsonResult(await toolPlaybookSecretCheck(args)); + case "browser_playbook_seed_from_codebase": return jsonResult(await toolPlaybookSeedFromCodebase(args)); + case "browser_playbook_diff_accept": return jsonResult(await toolPlaybookDiffAccept(args)); +``` + +- [ ] **Step 3: Add helpers + imports** + +Near the top of `tools.js`, add: +```js +import { validatePlaybook as validateSecrets, listAvailableSecrets, initSecrets } from "./secrets.js"; +import { seedFromCodebase } from "./codebase-seed.js"; +import { acceptStepShots, pngSha } from "./visual-diff.js"; +``` + +Implementation helpers (alongside other playbook tool functions): +```js +async function toolPlaybookSecretCheck({ id } = {}) { + try { + const pb = await playbooks.getPlaybook(id); + if (!pb) return { ok: false, error: { code: "playbook-not-found", message: `no playbook ${id}` } }; + await initSecrets(); + const available = await listAvailableSecrets(); + const availableNames = new Set(available.map((a) => `${a.source}:${a.name}`)); + const secrets = (pb.meta.inputs || []).filter((i) => i.type === "secret").map((spec) => { + const result = { name: spec.name, ref: spec.ref || null }; + if (!spec.ref) { result.available = false; result.source = null; result.hint = "no ref configured; pass at run time via inputs"; return result; } + const m = /^\$\{([^}]+)\}$/.exec(spec.ref.trim()); + let kind, key; + if (m) { + const inner = m[1].trim(); + const ci = inner.indexOf(":"); + if (ci < 0) { kind = "env"; key = inner; } + else { kind = inner.slice(0, ci); key = inner.slice(ci + 1).trim(); } + } + const present = kind && availableNames.has(`${kind === "env" ? "env" : "file"}:${kind === "env" ? key : key}`); + result.available = !!present; + result.source = kind === "env" ? "env" : "secret-file"; + if (!present) result.hint = kind === "env" ? `set env var ${key}` : `create .continuum/secrets/${key}.txt`; + return result; + }); + return { ok: true, id, secrets }; + } catch (e) { + if (e.playbookError) return { ok: false, error: e.playbookError }; + return { ok: false, error: { code: "internal", message: String(e?.message ?? e) } }; + } +} + +async function toolPlaybookSeedFromCodebase(args = {}) { + try { return { ok: true, ...(await seedFromCodebase(args)) }; } + catch (e) { + return { ok: false, error: { code: e.message?.startsWith("seed-") ? e.message.split(":")[0] : "internal", message: String(e?.message ?? e) } }; + } +} + +async function toolPlaybookDiffAccept({ id, runId, steps } = {}) { + try { + const pb = await playbooks.getPlaybook(id); + if (!pb) return { ok: false, error: { code: "playbook-not-found", message: `no playbook ${id}` } }; + const fsp = await import("node:fs/promises"); + const pathMod = await import("path"); + const proj = process.env.MOCHI_PROJECT_DIR || process.cwd(); + const runDir = pathMod.join(proj, ".continuum", "runs", runId); + const featureDir = pathMod.dirname(pathMod.join(proj, ".continuum", "playbooks", id + ".md")); + const featureBase = pathMod.basename(id); + const refDir = pathMod.join(featureDir, `${featureBase}.screenshots`); + const accepted = await acceptStepShots({ runDir, refDir, steps }); + const newRefs = accepted.map((a) => ({ step: a.step, sha: a.sha, path: `${featureBase}.screenshots/step-${String(a.step).padStart(2, "0")}.png` })); + const visualRefs = pb.meta.visual_refs || []; + const byStep = new Map(visualRefs.map((v) => [v.step, v])); + for (const r of newRefs) byStep.set(r.step, r); + pb.meta.visual_refs = [...byStep.values()].sort((a, b) => a.step - b.step); + pb.meta.playbook_version = (pb.meta.playbook_version || 0) + 1; + const newRunNote = `- accepted-${runId} — visual refs updated for steps [${accepted.map((a) => a.step).join(", ")}]`; + let body = pb.body || ""; + const idx = body.indexOf("## Recent runs"); + if (idx >= 0) { + const end = body.indexOf("##", idx + 5); + body = body.slice(0, idx + "## Recent runs".length) + "\n\n" + newRunNote + (end > 0 ? "\n\n" + body.slice(end) : "\n"); + } + await playbooks.savePlaybook({ id, meta: pb.meta, body, workflow: pb.workflow }); + return { ok: true, id, accepted: newRefs, playbook_version: pb.meta.playbook_version }; + } catch (e) { + if (e.playbookError) return { ok: false, error: e.playbookError }; + return { ok: false, error: { code: "internal", message: String(e?.message ?? e) } }; + } +} +``` + +- [ ] **Step 4: Update smoke test** + +In `server/_smoke.mjs`, add to the `want` array: +```js + "browser_playbook_secret_check", + "browser_playbook_seed_from_codebase", + "browser_playbook_diff_accept", +``` + +- [ ] **Step 5: Run smoke** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _smoke.mjs +``` +Expected: smoke green, tool count 51. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/tools.js server/_smoke.mjs && git commit -m "feat(tools): register 3 v1.5 tools (secret_check, seed_from_codebase, diff_accept)" +``` + +--- + +## Task 5: Integrate secrets + visual diff into `playbooks.js` runtime + +**Files:** +- Modify: `server/src/playbooks.js` + +- [ ] **Step 1: Add imports + helpers at top** + +In `server/src/playbooks.js`, add imports near the existing imports: +```js +import * as secrets from "./secrets.js"; +import { diffStep } from "./visual-diff.js"; +``` + +- [ ] **Step 2: Wrap `runWorkflowStep` to resolve secrets + capture screenshots** + +The existing runtime lives in `server/src/tools.js`'s `runWorkflowStep`. We extend it via two new helpers exported from `playbooks.js`: + +Append to `server/src/playbooks.js`: +```js +export async function resolveRunInputs(playbookId, callerInputs) { + const pb = await getPlaybook(playbookId); + if (!pb) throw playbookErr("playbook-not-found", `no playbook ${playbookId}`); + await secrets.initSecrets(); + const { resolved, missing } = secrets.resolveInputs(pb, callerInputs); + if (missing.length) { + throw playbookErr("playbook-input-missing", `required secrets unavailable: ${missing.map((m) => m.name).join(", ")}`, { missing }); + } + return { resolved, secretValues: extractSecretValues(pb, resolved) }; +} + +function extractSecretValues(pb, resolved) { + const out = {}; + for (const spec of pb.meta?.inputs || []) { + if (spec.type === "secret" && typeof resolved[spec.name] === "string") out[spec.name] = resolved[spec.name]; + } + return out; +} + +export function scrubInputsForLog(inputs, secretValues) { + const out = {}; + const valueToName = {}; + for (const [k, v] of Object.entries(secretValues || {})) { + if (typeof v === "string" && v.length) valueToName[v] = k; + } + for (const [k, v] of Object.entries(inputs || {})) { + if (typeof v === "string" && valueToName[v]) out[k] = `[REDACTED:${valueToName[v]}]`; + else out[k] = v; + } + return out; +} + +export async function compareStepScreenshot({ playbookId, stepIndex, actualPath, warnThreshold, failThreshold }) { + const pb = await getPlaybook(playbookId); + if (!pb) return { verdict: "match", reason: "no-playbook" }; + const refs = pb.meta.visual_refs || []; + const entry = refs.find((r) => r.step === stepIndex); + if (!entry) return { verdict: "match", reason: "no-reference" }; + const projectRoot = process.env.MOCHI_PROJECT_DIR || process.cwd(); + const featureBase = pb.meta.feature; + const originDir = path.join(playbooksDir(), pb.meta.origin); + const refPath = path.join(originDir, entry.path); + return diffStep({ + actualPath, refPath, + warnThreshold: warnThreshold ?? pb.meta.visual_diff?.warn_threshold ?? 0.05, + failThreshold: failThreshold ?? pb.meta.visual_diff?.fail_threshold ?? 0.20, + }); +} +``` + +- [ ] **Step 3: Wire scrubbing into `promoteFromTrace`** + +In the existing `promoteFromTrace` function, BEFORE building inputs, scrub any values that match known env-var secrets (defensive): +```js +// inside promoteFromTrace, right after the `for (const call of trace)` loop: +const env = process.env; +const knownSecrets = {}; +for (const k of Object.keys(env)) { + if (/PASSWORD|TOKEN|SECRET|KEY|API/i.test(k) && env[k]) knownSecrets[k] = env[k]; +} +const scrubbedTrace = secrets.scrubTrace(trace, knownSecrets); +// re-derive `steps` from scrubbedTrace instead of trace; for the v1.5 minimum, +// we just scrub the workflow JSON we serialize: +for (const step of steps) { + if (step.valueRef && step.valueRef.startsWith("input.")) continue; + if (typeof step.value === "string" && Object.values(knownSecrets).includes(step.value)) { + step.value = "[REDACTED]"; + } +} +``` + +Place this just before the `const inputs = explicitInputs || inferInputs(steps);` line. + +- [ ] **Step 4: Update `runWorkflowStep` call site in `tools.js`** + +In `tools.js`, modify the existing `toolPlaybookRun` to call the secret resolver and pass the resolved inputs to step execution: +```js +async function toolPlaybookRun(bridge, args = {}) { + const { id, inputs: callerInputs = {} } = args; + try { + const { resolved, secretValues } = await playbooks.resolveRunInputs(id, callerInputs); + const plan = await playbooks.composeResolve(id, resolved); + const legs = []; + const runId = "r" + Math.random().toString(36).slice(2, 8); + for (const leg of plan.legs) { + const legResult = await replayPlaybookLeg(bridge, leg, runId, secretValues); + legs.push(legResult); + if (legResult.verdict === "fail") break; + } + const overall = legs.every((l) => l.verdict === "pass") ? "pass" : (legs.some((l) => l.verdict === "warn") ? "warn" : "fail"); + return { ok: true, verdict: overall, runId, legs }; + } catch (e) { return unwrapPlaybookError(e); } +} + +async function replayPlaybookLeg(bridge, leg, runId, secretValues) { + const steps = leg.workflow?.steps || []; + const startedAt = Date.now(); + let stepWarnings = 0; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + try { + await runWorkflowStep(bridge, step, leg.inputs); + // Capture + diff screenshot + const fsp = await import("node:fs/promises"); + const pathMod = await import("path"); + const projectRoot = process.env.MOCHI_PROJECT_DIR || process.cwd(); + const runDir = pathMod.join(projectRoot, ".continuum", "runs", runId); + await fsp.mkdir(runDir, { recursive: true }); + const stepNum = String(i + 1).padStart(2, "0"); + const screenshotPath = pathMod.join(runDir, `step-${stepNum}.png`); + try { + const shotResult = await bridge.send("screenshot", { format: "png" }); + if (shotResult?.bytesBase64) { + await fsp.writeFile(screenshotPath, Buffer.from(shotResult.bytesBase64, "base64")); + const diffRes = await playbooks.compareStepScreenshot({ playbookId: leg.playbookId, stepIndex: i + 1, actualPath: screenshotPath }); + if (diffRes.verdict === "warn") stepWarnings++; + if (diffRes.verdict === "fail") { + return { playbookId: leg.playbookId, verdict: "fail", reason: `visual diff fail at step ${i + 1}: ${diffRes.reason || diffRes.diff?.toFixed(3)}`, durationMs: Date.now() - startedAt }; + } + } + } catch {} + } catch (e) { + return { playbookId: leg.playbookId, verdict: "fail", reason: String(e?.message ?? e), durationMs: Date.now() - startedAt }; + } + } + return { playbookId: leg.playbookId, verdict: stepWarnings ? "warn" : "pass", warnings: stepWarnings, durationMs: Date.now() - startedAt }; +} +``` + +(Replace the previous `replayPlaybookLeg` definition.) + +- [ ] **Step 5: Run all relevant tests** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _smoke.mjs && node _secrets.test.mjs && node _codebase_seed.test.mjs && node _visual_diff.test.mjs && node _playbooks.test.mjs && node _playbook_wire.test.mjs && node _uploads.test.mjs && node _upload_wire.test.mjs && node _integration.mjs && node _multi-client.mjs +``` +Expected: all green. Note `_integration.mjs` may need a tool-count bump in the next task. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/playbooks.js server/src/tools.js && git commit -m "feat(playbooks): wire secrets resolution + per-step visual diff into runs" +``` + +--- + +## Task 6: Extend wire contract tests for the 3 new tools + +**Files:** +- Modify: `server/_playbook_wire.test.mjs` + +- [ ] **Step 1: Append tests** + +Append to `server/_playbook_wire.test.mjs`: +```js +// secret_check +process.env.MOCHI_TEST_SECRET = "topsecret"; +await handleToolCall(bridge, { name: "browser_playbook_save", arguments: { + id: "test.example.com/secret-flow", + meta: { origin: "test.example.com", feature: "secret-flow", verifiable: true, + inputs: [ + { name: "password", type: "secret", required: true, ref: "${env:MOCHI_TEST_SECRET}" }, + { name: "missing", type: "secret", required: true, ref: "${env:NOT_SET_VAR}" }, + ], outputs: [] }, + body: "## Summary\nx\n## Preconditions\nx\n## Steps\nx\n## Verification\nx\n## Selectors used\n\n## Recent runs\n\n## Screenshots\n", + workflow: { steps: [] }, +}}); +const sc = JSON.parse((await handleToolCall(bridge, { name: "browser_playbook_secret_check", arguments: { id: "test.example.com/secret-flow" } })).content[0].text); +assert.equal(sc.ok, true); +assert.equal(sc.secrets.length, 2); +assert.equal(sc.secrets.find((s) => s.name === "password").available, true); +assert.equal(sc.secrets.find((s) => s.name === "missing").available, false); + +// seed_from_codebase against the next-app fixture +const fixtureRoot = path.join(path.dirname(import.meta.url.replace("file://", "")), "_fixtures/codebase/next-app"); +const seedRaw = await handleToolCall(bridge, { name: "browser_playbook_seed_from_codebase", arguments: { projectRoot: fixtureRoot, domain: "fixture.example.com", dryRun: true } }); +const seed = JSON.parse(seedRaw.content[0].text); +assert.equal(seed.ok, true); +assert.equal(seed.framework, "next-app-router"); +assert.ok(seed.drafts.length >= 2); + +// diff_accept: smoke-test wiring (no real run; just confirm error code on missing run) +const da = JSON.parse((await handleToolCall(bridge, { name: "browser_playbook_diff_accept", arguments: { id: "test.example.com/secret-flow", runId: "r-nonexistent" } })).content[0].text); +assert.equal(da.ok, true); +assert.equal(da.accepted?.length || 0, 0); + +console.log("✓ v1.5 wire contracts"); +delete process.env.MOCHI_TEST_SECRET; +``` + +- [ ] **Step 2: Run test** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _playbook_wire.test.mjs +``` +Expected: existing test plus `✓ v1.5 wire contracts`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/_playbook_wire.test.mjs && git commit -m "test(playbooks): wire-contract coverage for secret_check, seed_from_codebase, diff_accept" +``` + +--- + +## Task 7: Bump integration count, rebuild bundle, update README + +**Files:** +- Modify: `server/_integration.mjs` +- Modify: `plugins/qa/commands/playbook.md` +- Modify: `README.md` +- Modify: `server/dist/server.bundle.mjs` + +- [ ] **Step 1: Bump tool count** + +In `server/_integration.mjs`, find the tool-count assertion and bump 48 → 51: +```bash +grep -n "tools:\|48" /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server/_integration.mjs | head -5 +``` +Edit the matching assertion to `51`. + +- [ ] **Step 2: Extend the `/mochi:playbook` verb list** + +In `plugins/qa/commands/playbook.md`, append new verbs to the existing instructions: +```markdown +- `seed [--domain=] [--dry-run]` → call `browser_playbook_seed_from_codebase` with the parsed args; render the drafts as a markdown table. +- `secret-check ` → call `browser_playbook_secret_check`; render a table of secret names + availability + hints (never values). +- `diff accept --run= [--step=N --step=M]` → call `browser_playbook_diff_accept`; report which steps were blessed and the new playbook_version. +``` + +- [ ] **Step 3: Run full test suite** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && npm test +``` +Expected: all tests green. + +- [ ] **Step 4: Rebuild bundle** + +```bash +npm run build +``` +Expected: rebuilds dist/server.bundle.mjs. + +- [ ] **Step 5: Update README** + +In `README.md`, change the tool count from 48 to **51**. In the `### Playbooks (personal ops memory)` table, add three rows: +``` +| `browser_playbook_secret_check` | Validate that a playbook's `type: secret` inputs are resolvable (env or `.continuum/secrets/`). Returns availability only — never values. | +| `browser_playbook_seed_from_codebase` | Static-analyze the project's frontend (Next.js / Vite / CRA) and emit draft playbooks per route + form. Solves cold-start on in-house apps. | +| `browser_playbook_diff_accept` | Bless a run's per-step screenshots as the new visual reference; bumps `playbook_version`. | +``` + +And after the existing description paragraph, add: +```markdown +**v1.5 capabilities:** +- **Typed secrets:** `inputs[].type: secret` resolves at runtime from `${env:VAR}` or `${secret:name}` (reads `.continuum/secrets/.txt`, which is `chmod 0700` with an auto-protective `.gitignore`). Secret values never appear in `.continuum/runs/` traces or promoted playbook bodies. +- **Codebase-derived drafts:** point `browser_playbook_seed_from_codebase` at this project and it walks your routes (Next.js App/Pages Router, Vite, CRA), extracts forms + `data-testid`s + `aria-label`s, and emits draft playbooks per route. Password fields auto-typed as `secret`. +- **Visual diff regression:** during `browser_playbook_run`, each step's screenshot is compared (pixelmatch) against the playbook's reference. `warn` between 5–20% diff; `fail` ≥20% (configurable per playbook). Use `browser_playbook_diff_accept` to bless intentional UI changes. + +See [`docs/superpowers/specs/2026-05-20-playbooks-v1-5-design.md`](docs/superpowers/specs/2026-05-20-playbooks-v1-5-design.md). +``` + +- [ ] **Step 6: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/_integration.mjs plugins/qa/commands/playbook.md README.md server/dist/server.bundle.mjs server/dist/server.bundle.mjs.LEGAL.txt && git commit -m "ci(server): integration count 51, rebuild bundle, README + /mochi:playbook verbs" +``` + +--- + +## Self-Review + +### Spec coverage + +| Spec section | Implemented by | +|---|---| +| sp1 secrets: resolveRef, resolveInputs, scrubTrace, validatePlaybook, listAvailableSecrets | Task 1 | +| sp1 integration with playbook runtime + promoter | Task 5 | +| sp1 new tool `browser_playbook_secret_check` | Task 4 (registration), Task 6 (wire test) | +| sp1 secrets dir layout + .gitignore + chmod 0700 | Task 1 | +| sp2 detector chain (next-app/pages, vite, cra) | Task 2 | +| sp2 route → draft playbook mapping with auto-typed `password` | Task 2 | +| sp2 new tool `browser_playbook_seed_from_codebase` | Task 4 (registration), Task 6 (wire test) | +| sp2 non-overwrite of non-draft playbooks | Task 2 (test covers it) | +| sp3 per-step screenshot capture during replay | Task 5 (in replayPlaybookLeg) | +| sp3 diff comparison with warn/fail thresholds | Task 3 (module), Task 5 (wiring) | +| sp3 new tool `browser_playbook_diff_accept` | Task 4 (registration), Task 6 (wire test) | +| sp3 visual_refs[] frontmatter handling | Task 4's diff_accept helper | +| All three tools surface in tools/list, total 51 | Task 4 (smoke), Task 7 (integration + README) | + +### Placeholder scan + +- No "TBD" / "TODO" / "implement later" tokens. +- Babel `traverse` import handles the dual ESM/CJS quirk via `_traverse.default || _traverse`. +- `replayPlaybookLeg` replaces the previous v1 implementation — explicit "replace" instruction in Task 5. + +### Type consistency + +- `secrets.resolveRef` signature `(string|null) → string|null` (or throws). Consistent in all callers. +- `secrets.resolveInputs(playbook, callerInputs) → {resolved, missing}` consistent in Task 5. +- `diffStep({...}) → {verdict, diff, ...}` consistent across calls. +- Tool names match across schemas, dispatch, and tests. + +--- + +## Acceptance verification + +1. `cd server && npm test` — every script green. +2. Smoke reports **51 tools**. +3. The next-app fixture under `_fixtures/codebase/next-app/` produces ≥2 drafts when seeded with `domain: "fixture.example.com"`; password input auto-typed `secret`. +4. `browser_playbook_secret_check` differentiates available env-var secrets from missing ones (no value leakage). +5. `.continuum/secrets/` is created with mode 0700 and contains `.gitignore` with `*\n!.gitignore`. +6. Visual diff: two identical PNGs return `verdict: match, diff: 0`; 4% diff → `warn`; 30% diff → `fail` + diff image written. +7. Bundle rebuilt; no errors; size noted in commit. +8. No regression in existing tests. diff --git a/docs/superpowers/plans/2026-05-20-playbooks-v2.md b/docs/superpowers/plans/2026-05-20-playbooks-v2.md new file mode 100644 index 0000000..1e559e8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-playbooks-v2.md @@ -0,0 +1,1502 @@ +# Playbooks v2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the v2 spec — 1Password CLI integration, Vue + SvelteKit codebase detection, blocked-verdict UX for missing inputs, cross-project bundle export/import, and a self-contained HTML dashboard. Tool count rises 51 → 54. + +**Architecture:** Two new pure-function modules (`playbook-bundles.js`, `playbook-dashboard.js`) plus targeted extensions to `secrets.js` (1Password resolver), `codebase-seed.js` (Vue/Svelte detectors), and `playbooks.js` (refactor `resolveRunInputs` to return missing instead of throw). Three new MCP tools surface them; `browser_playbook_run` returns `verdict: "blocked"` cleanly when inputs are unresolvable. No new deps. + +**Tech Stack:** Node 22 ESM, existing `js-yaml`/`pngjs`/`pixelmatch`/`@babel/parser`. New: 1Password `op` CLI shelled out via `child_process.execSync` (no library), Vue/Svelte template parsing via regex + a tiny built-in HTML tokenizer. + +**Spec:** `docs/superpowers/specs/2026-05-20-playbooks-v2-design.md` + +--- + +## File Structure + +**New files (server):** +- `server/src/playbook-bundles.js` — export/import bundle handling. ~180 lines. +- `server/src/playbook-dashboard.js` — HTML dashboard generator. ~250 lines. +- `server/_playbook_bundles.test.mjs` — unit tests. ~150 lines. +- `server/_playbook_dashboard.test.mjs` — unit tests. ~100 lines. +- `server/_fixtures/codebase/nuxt-app/nuxt.config.ts` +- `server/_fixtures/codebase/nuxt-app/pages/login.vue` +- `server/_fixtures/codebase/svelte-app/svelte.config.js` +- `server/_fixtures/codebase/svelte-app/package.json` (for detector trigger) +- `server/_fixtures/codebase/svelte-app/src/routes/login/+page.svelte` + +**Modified files:** +- `server/src/secrets.js` — add `${1password:...}` / `${op:...}` resolution. +- `server/src/codebase-seed.js` — add Nuxt + SvelteKit detectors. +- `server/src/playbooks.js` — refactor `resolveRunInputs` to return `{missing}` instead of throwing. +- `server/src/tools.js` — register `browser_playbook_export`/`_import`/`_dashboard`; return `blocked` verdict cleanly; needs annotator. +- `server/_secrets.test.mjs` — extend with 1Password tests (stubbed `op`). +- `server/_codebase_seed.test.mjs` — extend with Vue + Svelte fixtures. +- `server/_playbook_wire.test.mjs` — wire tests for the 3 new tools + blocked verdict. +- `server/_smoke.mjs` — expect 54 tools; add 3 new names. +- `server/_integration.mjs` — bump 51 → 54. +- `plugins/qa/CLAUDE.md` — add "Handling blocked verdicts" section. +- `plugins/qa/commands/playbook.md` — add `export`, `import`, `ui` verbs. +- `README.md` — bump tool count 51 → 54; add v2 paragraph. +- `server/dist/server.bundle.mjs` — rebuilt via `npm run build`. + +--- + +## Task 1: 1Password ref extension in `secrets.js` + +**Files:** +- Modify: `server/src/secrets.js` +- Modify: `server/_secrets.test.mjs` + +- [ ] **Step 1: Append the failing test** + +Append to `server/_secrets.test.mjs`: +```js +import { resolveRef as _resolveRef, __setExecForTesting, __clearExecForTesting, listAvailableSecrets as listAvail2 } from "./src/secrets.js"; + +// 1Password integration tests +{ + // op installed and successful + __setExecForTesting((cmd) => { + if (cmd.startsWith('op read "op://Personal/Gmail/password"')) return "supersecret\n"; + if (cmd.startsWith('op read "op://Work/AWS/access_key_id"')) return "AKIA…\n"; + if (cmd.startsWith('op read')) { const e = new Error("not found"); e.status = 1; throw e; } + if (cmd.includes("--version")) return "2.0.0\n"; + throw new Error("unknown cmd: " + cmd); + }); + assert.equal(_resolveRef("${1password:Personal/Gmail/password}"), "supersecret"); + assert.equal(_resolveRef("${op:Work/AWS/access_key_id}"), "AKIA…"); + assert.equal(_resolveRef("${op:Personal/Nonexistent/password}"), null); + + // op not installed + __setExecForTesting((cmd) => { + if (cmd.includes("--version")) { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; } + throw new Error("op missing"); + }); + assert.equal(_resolveRef("${1password:any/thing/here}"), null); + + __clearExecForTesting(); + console.log("✓ 1Password ref resolution"); +} +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _secrets.test.mjs +``` +Expected: error about `__setExecForTesting is not a function`. + +- [ ] **Step 3: Extend `secrets.js`** + +In `server/src/secrets.js`, add at the top of the file (after existing imports): +```js +import { execSync as _execSync } from "node:child_process"; + +let _execForTesting = null; +export function __setExecForTesting(fn) { _execForTesting = fn; } +export function __clearExecForTesting() { _execForTesting = null; } +function exec(cmd, opts) { + if (_execForTesting) return _execForTesting(cmd, opts); + return _execSync(cmd, opts); +} + +let _opAvailableCache = { checkedAt: 0, available: false }; +function opAvailable() { + const now = Date.now(); + if (now - _opAvailableCache.checkedAt < 60_000) return _opAvailableCache.available; + try { + exec("op --version", { stdio: ["ignore", "pipe", "ignore"], timeout: 1500 }); + _opAvailableCache = { checkedAt: now, available: true }; + } catch { + _opAvailableCache = { checkedAt: now, available: false }; + } + return _opAvailableCache.available; +} + +function resolveOpRef(refPath) { + if (!opAvailable()) return null; + try { + const out = exec(`op read "op://${refPath}"`, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }); + return String(out).replace(/\r?\n$/, ""); + } catch { return null; } +} +``` + +Then in `resolveRef`, locate the existing `if (kind === "env")` branch and add **before** the closing `throw` for "unknown kind": +```js + if (kind === "1password" || kind === "op") { + return resolveOpRef(name); + } +``` + +Also extend `listAvailableSecrets` to include a `source: "1password"` entry when `op` is available: +```js + if (opAvailable()) { + out.push({ name: "<1password>", source: "1password" }); + } +``` +(Append this right before `return out;`.) + +- [ ] **Step 4: Run test to pass** + +```bash +node _secrets.test.mjs +``` +Expected: `✓ 1Password ref resolution` plus all prior ✓ lines. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester && git add server/src/secrets.js server/_secrets.test.mjs && git commit -m "feat(secrets): 1Password CLI ref resolution via \`op read\`" +``` + +--- + +## Task 2: Vue / Nuxt codebase detector + +**Files:** +- Modify: `server/src/codebase-seed.js` +- Modify: `server/_codebase_seed.test.mjs` +- Create: `server/_fixtures/codebase/nuxt-app/nuxt.config.ts` +- Create: `server/_fixtures/codebase/nuxt-app/pages/login.vue` + +- [ ] **Step 1: Create the Nuxt fixture** + +Create `server/_fixtures/codebase/nuxt-app/nuxt.config.ts`: +```ts +export default {}; +``` + +Create `server/_fixtures/codebase/nuxt-app/pages/login.vue`: +```vue + + + +``` + +- [ ] **Step 2: Append failing test** + +Append to `server/_codebase_seed.test.mjs`: +```js +{ + const fixtureRoot = path.join(__dirname, "_fixtures", "codebase", "nuxt-app"); + const fw = await detectFramework(fixtureRoot); + assert.equal(fw.kind, "nuxt"); + + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mochi-seed-vue-")); + process.env.MOCHI_PROJECT_DIR = tmp; + await initPlaybooks(); + + const r = await seedFromCodebase({ projectRoot: fixtureRoot, domain: "nuxt.example.com" }); + assert.equal(r.framework, "nuxt"); + assert.ok(r.drafts.length >= 1); + const login = r.drafts.find((d) => d.id === "nuxt.example.com/login"); + assert.ok(login, "expected nuxt.example.com/login draft"); + + const pb = await getPlaybook("nuxt.example.com/login"); + assert.ok(pb); + const pwd = pb.meta.inputs.find((i) => i.name === "password"); + assert.ok(pwd); + assert.equal(pwd.type, "secret"); + const email = pb.meta.inputs.find((i) => i.name === "email"); + assert.equal(email.type, "email"); + + console.log("✓ Nuxt detector"); + await fs.rm(tmp, { recursive: true, force: true }); +} +``` + +- [ ] **Step 3: Run failing test** + +```bash +cd /Users/jonayedahamed/Desktop/Projects/Personal/Super-Tester/server && node _codebase_seed.test.mjs +``` +Expected: `expected nuxt.example.com/login draft` failure (detector not implemented). + +- [ ] **Step 4: Implement Nuxt detection + Vue parser** + +In `server/src/codebase-seed.js`, modify `detectFramework`: +```js +export async function detectFramework(root) { + const has = async (p) => { try { await fs.access(path.join(root, p)); return true; } catch { return false; } }; + // Nuxt: check before Next.js (some projects have both? unlikely, but be specific). + const hasNuxtConfig = (await has("nuxt.config.js")) || (await has("nuxt.config.mjs")) || (await has("nuxt.config.ts")); + if (hasNuxtConfig) return { kind: "nuxt", pagesDir: "pages" }; + // SvelteKit + const hasSvelteConfig = (await has("svelte.config.js")) || (await has("svelte.config.mjs")) || (await has("svelte.config.ts")); + if (hasSvelteConfig) { + try { + const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")); + if (pkg.devDependencies?.["@sveltejs/kit"] || pkg.dependencies?.["@sveltejs/kit"]) return { kind: "sveltekit", routesDir: "src/routes" }; + } catch {} + } + // Next.js + const hasNextConfig = (await has("next.config.js")) || (await has("next.config.mjs")) || (await has("next.config.ts")); + if (hasNextConfig) { + if (await has("app")) return { kind: "next-app-router", appDir: "app" }; + if (await has("pages")) return { kind: "next-pages-router", pagesDir: "pages" }; + } + if ((await has("vite.config.js")) || (await has("vite.config.ts"))) return { kind: "vite-react", srcDir: "src" }; + try { + const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")); + if (pkg.dependencies?.["react-scripts"]) return { kind: "cra", srcDir: "src" }; + } catch {} + return { kind: "none" }; +} +``` + +In `seedFromCodebase`, add dispatch for the new detector: +```js + if (fw.kind === "nuxt") drafts.push(...await scanNuxtPages(root, fw.pagesDir, domain)); + if (fw.kind === "sveltekit") drafts.push(...await scanSvelteKit(root, fw.routesDir, domain)); +``` + +Add the `scanNuxtPages` function: +```js +async function scanNuxtPages(root, pagesDir, domain) { + const out = []; + const base = path.join(root, pagesDir); + async function walk(dir, route = "") { + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + const seg = e.name.startsWith("[") ? "" : e.name; + await walk(p, route + (seg ? "/" + seg : "")); + continue; + } + if (e.isFile() && e.name.endsWith(".vue")) { + const seg = e.name.slice(0, -4); + const sub = seg === "index" ? "" : "/" + seg; + const draft = await buildDraftFromVueOrSvelte(p, (route || "") + sub || "/", domain); + if (draft) out.push(draft); + } + } + } + await walk(base); + return out; +} +``` + +Add the Vue/Svelte template parser + draft builder at the bottom of the file: +```js +async function buildDraftFromVueOrSvelte(filePath, route, domain) { + let source; + try { source = await fs.readFile(filePath, "utf8"); } + catch { return null; } + + // Extract template block: Vue (