diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 767858a..69b10ca 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ }, "metadata": { "description": "Design superpowers for Claude Code — 35 skills that teach your agent to ideate, research trends, generate multi-screen UIs with Stitch MCP, iterate with design systems, and ship to Next.js, Svelte, React, React Native, SwiftUI, or HTML.", - "version": "1.10.0" + "version": "1.11.0" }, "plugins": [ { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index fc81c7b..b12a35d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "stitch-kit", "description": "Design superpowers for Claude Code — 35 skills that teach your agent to ideate, research, generate, iterate, and ship beautiful UIs using Google Stitch MCP.", - "version": "1.10.0", + "version": "1.11.0", "author": { "name": "gabelul" }, diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..3f3fb7c --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "stitch-kit", + "version": "1.11.0", + "description": "Design superpowers for Codex — skills that ideate, generate, and ship UIs with Google Stitch MCP, with compaction-resilient session state.", + "author": { + "name": "gabelul" + }, + "repository": "https://github.com/gabelul/stitch-kit", + "license": "Apache-2.0", + "skills": "./skills/", + "hooks": "./hooks/hooks.json" +} diff --git a/.gitignore b/.gitignore index a370f90..2aa60c1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ # Node node_modules/ + +# Stitch per-user session state (project id, screen ids, PRD drafts, transcript +# snapshots). Runtime state, never source — and the snapshots can contain +# conversation content, so it stays out of git. +.stitch/ diff --git a/README.md b/README.md index 5c4abb6..313f1d8 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ X-Goog-Api-Key = "YOUR-API-KEY" Get your API key at [stitch.withgoogle.com/settings](https://stitch.withgoogle.com/settings). Use `$stitch-kit` to activate the agent or `$stitch-orchestrator` for the full pipeline. + +**Compaction resilience (optional):** to keep your project, screens, and PRD draft across a context compaction, stitch-kit needs the `stitch-session` helper on PATH — `npm i -g @booplex/stitch-kit` provides it, and `install-codex.sh` symlinks it. For automatic re-orientation after a compaction, install stitch-kit as a Codex plugin (`codex plugin add`) and trust its hooks. Details: [docs/compaction-resilience.md](docs/compaction-resilience.md). --- diff --git a/bin/stitch-kit.mjs b/bin/stitch-kit.mjs index f6fc3f9..f7e678b 100644 --- a/bin/stitch-kit.mjs +++ b/bin/stitch-kit.mjs @@ -23,7 +23,7 @@ * Gemini CLI — extension install (no MCP config file) */ -import { existsSync, mkdirSync, cpSync, rmSync, readFileSync, writeFileSync, readdirSync, lstatSync, unlinkSync } from 'node:fs'; +import { existsSync, mkdirSync, cpSync, rmSync, readFileSync, writeFileSync, readdirSync, lstatSync, unlinkSync, symlinkSync, chmodSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -39,6 +39,7 @@ const PACKAGE_ROOT = resolve(__dirname, '..'); const HOME = homedir(); const SKILLS_SRC = join(PACKAGE_ROOT, 'skills'); const AGENTS_SRC = join(PACKAGE_ROOT, 'agents'); +const SESSION_HELPER = join(PACKAGE_ROOT, 'scripts', 'stitch-session.mjs'); const STITCH_MCP_URL = 'https://stitch.googleapis.com/mcp'; const VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version; @@ -537,6 +538,44 @@ function installMcp(client, apiKey) { // ── Generic Install / Uninstall / Status ──────────────────────────────────────── +/** + * Put the `stitch-session` helper on PATH so skills can call it by name on any + * host. Codex skills have no way to reference a bundled script (no + * ${CLAUDE_SKILL_DIR} equivalent), so a PATH launcher is the portable answer. + * `npm i -g` already links it via the package `bin` field; this covers npx and + * git-clone installs by symlinking into ~/.local/bin. Best-effort — a failure + * here only means session state won't persist on hosts that rely on the launcher. + */ +function installLauncher() { + if (commandExists('stitch-session')) { + logOk('stitch-session already on PATH'); + return; + } + try { + chmodSync(SESSION_HELPER, 0o755); // the shebang script must be executable to run as a command + } catch { + // non-fatal — npm may have already set the mode + } + const binDir = join(HOME, '.local', 'bin'); + const link = join(binDir, 'stitch-session'); + try { + mkdirSync(binDir, { recursive: true }); + // Replace an existing file or (possibly broken) symlink before linking fresh. + if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) { + rmSync(link, { force: true }); + } + symlinkSync(SESSION_HELPER, link); + logOk(`stitch-session linked → ${link}`); + const onPath = (process.env.PATH || '').split(/[:;]/).includes(binDir); + if (!onPath) { + logWarn(`${binDir} is not on your PATH — add it so skills can persist session state:`); + log(' export PATH="$HOME/.local/bin:$PATH"'); + } + } catch (err) { + logWarn(`Could not link stitch-session (${err.message}). Session state may not persist on Codex.`); + } +} + /** * Install stitch-kit for a single client: agent → skills → MCP → postInstall. * @param {Client} client - Client to install for @@ -784,6 +823,10 @@ async function install() { installClient(client, apiKey); } + // Put the session-state helper on PATH (shared across all hosts) + log(''); + installLauncher(); + // Summary log(''); log('════════════════════════════════'); diff --git a/docs/compaction-resilience.md b/docs/compaction-resilience.md new file mode 100644 index 0000000..43a4ab6 --- /dev/null +++ b/docs/compaction-resilience.md @@ -0,0 +1,72 @@ +# Compaction resilience + +## The problem + +stitch-kit flows run inside a host agent — Claude Code, Codex. When that host fills its context window, it compacts the conversation: summarises the history down to free up room, then keeps going. The host decides when this happens; a plugin of skills can't vote on it. + +Most stitch-kit flows already survive a compaction by accident of how they're built. The PRD is written to disk, generated screens live in Stitch's own backend (recoverable via `list_screens` / `get_project`), and `stitch-loop` parks all its state in files. The big context windows (Opus 1M, Codex) mean compaction usually never even fires for a single idea-to-screens run. + +The one genuine gap is a long **ideation session**: the research and answers gathered across phases live only in the conversation, because `stitch-ideate` historically wrote the PRD only at the final phase. Compact mid-brainstorm and that work was gone. + +This feature closes that gap and turns "survives by accident" into "handles it on purpose." + +## How it works — two layers + +### Layer 1: skills persist state as they go + +A single canonical writer, `scripts/stitch-session.mjs`, owns the on-disk schema. Skills call it; nothing else writes the state file, so the shape can't drift. + +State lives under `.stitch/session/` in the user's project (gitignored): + +| File | What it holds | +|------|---------------| +| `state.json` | flow, phase, active project (numeric id, name, device), generated screen ids, applied design system, pointers to artifacts | +| `prd-draft.md` | running PRD, appended after each ideation phase | +| `snapshots/` | raw transcript backups written by the PreCompact hook | +| `RESUME.md` | human/agent-readable breadcrumb refreshed on each compaction | + +`stitch-ideate` calls `set-phase` + `append-prd` at the end of every phase. `stitch-orchestrator` records the project at Step 4, each screen at Step 5, and the design system at Step 7b. Both reference the helper as `${CLAUDE_SKILL_DIR}/../../scripts/stitch-session.mjs` — one copy, reached from the skill's own directory. + +### Layer 2: hooks re-orient after a compaction + +- `hooks/pre-compact.mjs` (PreCompact, matchers `auto` + `manual`) — copies the raw transcript into `snapshots/` as a backstop and refreshes `RESUME.md`. No-ops when there's no active Stitch session. Always exits 0, because PreCompact can block compaction with exit code 2 and we never want to wedge a session. +- `hooks/session-start.mjs` (SessionStart, matchers `compact` + `resume`) — when the host re-runs SessionStart after a compaction, this prints a one-line re-orientation to stdout (which SessionStart injects into context): which project, which phase, how many screens, where the PRD draft is, and "continue, don't restart." No-ops on a fresh start or when state is stale (older than 24h). + +`hooks/hooks.json` wires both up; plugins auto-discover it at the plugin root. + +## Verifying it + +```bash +# Drive the session helper directly: +node scripts/stitch-session.mjs init ideate +node scripts/stitch-session.mjs set-project 3780 "Velvet Cinema" DESKTOP +node scripts/stitch-session.mjs status # prints the re-orientation line + +# Simulate the SessionStart hook firing after a compaction: +echo '{"source":"compact"}' | node hooks/session-start.mjs +``` + +To confirm the real path end-to-end, run a `/compact` during an active orchestrator or ideation flow and check that the next turn knows which project it was working in. + +## Host support + +Works on both **Claude Code** and **Codex CLI**, through each one's native plugin + hook system. + +- **Claude Code:** hooks auto-register from `hooks/hooks.json`; skills reach the helper via `${CLAUDE_SKILL_DIR}`. +- **Codex CLI:** install as a Codex plugin (`.codex-plugin/plugin.json`) so the hooks register. Codex fires `SessionStart` with `source: "compact"` too, and provides `CLAUDE_PLUGIN_ROOT` as a compatibility env var, so the same `hooks.json` works unchanged. Codex skills have no `${CLAUDE_SKILL_DIR}` equivalent, so the helper is exposed as an on-PATH `stitch-session` command (npm `bin`, or symlinked by the installer); the skills call it through a wrapper that falls back to the Claude Code path. + +Two Codex specifics worth knowing: +- Codex does not auto-trust plugin hooks — the user trusts them once, or the `SessionStart` re-orientation won't fire. Until trusted, state is still written to disk; it just isn't auto-resurfaced. The skills also self-check for in-progress state on activation, which covers the untrusted case. +- Codex's `PostCompact` stdout is ignored, so re-orientation rides on `SessionStart:compact` (same event as Claude Code), not `PostCompact`. + +On OpenCode and Crush (skills copied, no plugin hooks), the skill calls no-op cleanly when `stitch-session` isn't on PATH — those hosts keep the architectural resilience (PRD on disk, screens in Stitch's backend) without the hook layer. + +## Honest limitations + +- **Mid-phase reasoning gap.** The per-phase flush captures structured state, not the model's *reasoning* within a phase (why this direction, what surprised it). The durable fix is the [exec plans pattern](https://developers.openai.com/cookbook/articles/codex_exec_plans) — a continuously-updated Decision Log + Surprises file written alongside `prd-draft.md`. Tracked as a follow-up in `docs/dev-docs/exec-plans-followup.md`; the raw-transcript backstop in `snapshots/` is the recoverable-but-ugly stopgap until that lands. +- Hooks only run when stitch-kit is installed as a Claude Code plugin (they need `CLAUDE_PLUGIN_ROOT`, which the host sets only for hook execution). +- Re-orientation depends on the host firing `SessionStart` with `source: "compact"` after an auto-compaction. The docs say it does for both auto and manual; confirm with a real `/compact` in your host. + +## Why Node, not bash + jq + +The session helper and both hooks are Node (`.mjs`). Node is a hard dependency of Claude Code; `jq` is not on stock macOS or Windows. Node also runs the hooks unchanged on native Windows, where bash scripts wouldn't. diff --git a/docs/dev-docs/exec-plans-followup.md b/docs/dev-docs/exec-plans-followup.md new file mode 100644 index 0000000..52aba52 --- /dev/null +++ b/docs/dev-docs/exec-plans-followup.md @@ -0,0 +1,60 @@ +# Follow-up: exec-plan layer for stitch-ideate + +**Status:** designed, not built. Tracked from PR #15. +**Origin:** Luca @ context-coders.com pointed at OpenAI's [exec plans pattern](https://developers.openai.com/cookbook/articles/codex_exec_plans) as the right shape for the one limitation that PR left open. + +## The gap this closes + +Today's Layer-1 captures structured state (`state.json`) and PRD content (`prd-draft.md`, appended per phase). What it doesn't capture is the reasoning developed *within* a phase: why this direction, what surprised the model during research, what got dropped. That's what dies in a mid-phase compaction. + +Exec plans freezes reasoning, not artifacts. Different job from `state.json`, different shape from `prd-draft.md`. It sits alongside both. + +## Proposed file + +`.stitch/session/plan.md` — single growing markdown file, host-agnostic, written by `stitch-ideate`. Five sections, per the cookbook: + +| Section | What goes here | +|---|---| +| Purpose / Big Picture | The pitch + design direction in 2–3 sentences. | +| Progress | Timestamped checkbox list of phases. | +| Surprises & Discoveries | Research findings that changed the plan. | +| Decision Log | The *why* behind each non-obvious call (Decision / Rationale / Date). | +| Outcomes & Retrospective | Filled at the end. | + +Revisions allowed; the cookbook's rule is that every revision keeps the plan fully self-contained. + +## Where it sits + +- `state.json` — structured pointers. Hooks surface it on compact. **Keep.** +- `prd-draft.md` — the actual PRD content, phase by phase. Becomes the final PRD. **Keep.** +- `plan.md` (new) — the reasoning behind the PRD. **Adds the missing layer.** + +All three written via the `ss` wrapper; `scripts/stitch-session.mjs` owns the schema so there's still one canonical writer (new subcommand: `ss plan-add
`). + +## When stitch-ideate writes to it + +Not at phase boundaries — at *decision points*. The skill body says: append to the Decision Log whenever a direction is picked, an option dropped, or a screen list committed; append to Surprises whenever research turns up something that shifts the plan. Prompt-level discipline, no new tool. + +## How SessionStart surfaces it + +`formatStatus` gains one line: when `plan.md` exists, include `Plan at .stitch/session/plan.md (last decision: ).`. The model already reads `state.json` and `prd-draft.md` on resume; adding `plan.md` to the list is mechanical. + +## Why host-agnostic + +Exec plans is a prompt/convention pattern, not a Codex feature. The same `plan.md` works under Claude Code, Codex, OpenCode, and Crush. It rides the `ss` wrapper that already resolves cross-host. + +## Non-goals + +- Not for `stitch-orchestrator`: the orchestrator is mechanical, `state.json` is enough. +- Not replacing `prd-draft.md`: prd = artifact, plan = reasoning. +- Not a tool/MCP: prompt-level discipline in the skill, no new API. + +## Build sequence + +1. Add `ss plan-*` subcommands to `scripts/stitch-session.mjs`. +2. Update `stitch-ideate` Session-state section with Decision Log / Surprises calls at the documented moments. +3. Update `formatStatus` to surface `plan.md` when present. +4. Test: simulate a phase with a decision + a surprise; verify SessionStart surfaces the latest decision. +5. Document in `docs/compaction-resilience.md`. + +Rough estimate: 1–2 focused hours. diff --git a/docs/dev-docs/troubleshooting.md b/docs/dev-docs/troubleshooting.md new file mode 100644 index 0000000..557e3d9 --- /dev/null +++ b/docs/dev-docs/troubleshooting.md @@ -0,0 +1,95 @@ +# Troubleshooting log + +A "we stepped on this landmine so you don't have to" reference for stitch-kit. Not a changelog — non-obvious gotchas, framework quirks, and root causes worth remembering. Check here before debugging something that feels familiar. + +--- + +## `${CLAUDE_PLUGIN_ROOT}` is empty in skill-issued Bash commands + +**Symptom:** A skill instructs the model to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/foo.mjs"`, and the command runs against a path that's just `/scripts/foo.mjs` — the variable expanded to nothing. + +**Root cause:** `CLAUDE_PLUGIN_ROOT` is only injected into the environment for **hook execution**. It is NOT set in the general Bash tool environment that a skill's commands run in. `CLAUDE_PROJECT_DIR` is the same story — unset in normal Bash (cwd is the project root instead). + +**Fix:** Skills reference bundled scripts via `${CLAUDE_SKILL_DIR}`, which the harness substitutes for the skill's own directory. To reach a script at the plugin root from a skill, climb out: `${CLAUDE_SKILL_DIR}/../../scripts/stitch-session.mjs` (skill → skills → plugin root). Hooks, which DO get `CLAUDE_PLUGIN_ROOT`, reference the same canonical file via a relative import (`../scripts/...`). + +**Files:** `skills/stitch-ideate/SKILL.md`, `skills/stitch-orchestrator/SKILL.md`, `hooks/*.mjs` + +**Lesson:** `${CLAUDE_PLUGIN_ROOT}` is a hooks-and-hooks.json placeholder. For skill bodies, `${CLAUDE_SKILL_DIR}` is the documented one. Don't assume they're interchangeable. + +--- + +## PreCompact hook must always exit 0 + +**Symptom:** A failing PreCompact hook could silently block context compaction. + +**Root cause:** Per the hooks docs, `PreCompact` can block compaction with **exit code 2**. A script that hits an unhandled error and exits non-zero (or a `set -e` that trips) can therefore wedge the host's compaction. + +**Fix:** `hooks/pre-compact.mjs` wraps all work in try/catch and unconditionally `process.exit(0)`. Snapshotting and breadcrumb-writing are best-effort; their failure must never affect the session. + +**Files:** `hooks/pre-compact.mjs` + +**Lesson:** Treat PreCompact as fire-and-forget. The compaction matters more than the snapshot. + +--- + +## SessionStart fires again after a compaction + +**Confirmed behaviour:** After an auto OR manual compaction, the host re-runs `SessionStart` with `source: "compact"`. That's the injection point for restoring context — not `PostCompact`, whose output goes to the user (stderr), not the model. + +**Implication:** `hooks/session-start.mjs` matches `compact` (and `resume`) and prints the re-orientation line to stdout, which SessionStart injects into context. It must no-op on `startup`/`clear` and on stale state, because it runs on every single session start for everyone who installs the plugin. + +**Files:** `hooks/session-start.mjs`, `hooks/hooks.json` + +**Lesson:** SessionStart is the post-compaction hook in practice. Verify with a real `/compact` per host, since the firing is the one thing docs can't prove. + +--- + +## Node, not bash + jq + +**Decision:** The session helper and hooks are Node (`.mjs`), not bash + `jq`. + +**Why:** `jq` isn't on stock macOS or Windows, but Node is a hard dependency of Claude Code. Node hooks also run unchanged on native Windows, where bash scripts wouldn't. + +**Lesson:** For anything a plugin ships to other people's machines, lean on what the host already guarantees (Node) instead of a tool the user might not have (`jq`). + +--- + +## Skill changes ship to every host, not just Claude Code + +**Symptom:** A skill edit that adds a `node "${CLAUDE_SKILL_DIR}/../../scripts/..."` call works in Claude Code but misfires under Codex / OpenCode / Crush. + +**Root cause:** `bin/stitch-kit.mjs` and `install-codex.sh` install the same `skills/` into Codex (`~/.codex/skills`), OpenCode, and Crush — but they copy **only** `skills/`, not `scripts/` or `hooks/`. And `${CLAUDE_SKILL_DIR}` is a Claude Code substitution that those hosts don't expand. So a skill instruction that calls a plugin-root script resolves to nothing on three of the four hosts. + +**Fix:** Guard host-specific calls so they no-op cleanly elsewhere: `H="${CLAUDE_SKILL_DIR}/../../scripts/x.mjs"; [ -f "$H" ] && node "$H" ... || true`. Under non-Claude-Code hosts the var stays literal, `-f` is false, the call is skipped silently. Document the feature as Claude Code-only and keep the other hosts on the architectural resilience (files + Stitch backend). + +**Files:** `skills/stitch-ideate/SKILL.md`, `skills/stitch-orchestrator/SKILL.md`, `bin/stitch-kit.mjs`, `install-codex.sh` + +**Lesson:** Skills are the shared surface across all install targets. A Claude-Code-only mechanism added to a SKILL.md needs a guard, or it becomes a bug on every other host. Check `bin/stitch-kit.mjs` before assuming "plugin" means "Claude Code." + +--- + +## A symlinked CLI silently does nothing (the `import.meta.url === argv[1]` trap) + +**Symptom:** `stitch-session` works when run as `node scripts/stitch-session.mjs ...` but does nothing — no output, no state — when run as the `stitch-session` command (npm `bin` or a `~/.local/bin` symlink). Exit code is 0, so it looks like success. + +**Root cause:** The "am I the main module?" guard compared `fileURLToPath(import.meta.url) === process.argv[1]`. Node resolves symlinks for `import.meta.url` (→ the real `scripts/stitch-session.mjs`) but leaves `process.argv[1]` as the symlink path (`~/.local/bin/stitch-session`). They don't match, so `main()` never runs. npm `bin` entries are symlinks, so this breaks every launcher-based call — exactly the path the Codex on-PATH launcher depends on. + +**Fix:** `realpathSync` both sides before comparing (with a non-realpath fallback in a catch). See `invokedDirectly()` in `scripts/stitch-session.mjs`. + +**Files:** `scripts/stitch-session.mjs` + +**Lesson:** If a script is meant to be run via a symlinked launcher, the main-module check must resolve symlinks on both sides. A bare `===` is a quiet footgun the moment you put the script on PATH. + +--- + +## Codex doesn't auto-trust plugin hooks + +**Symptom:** stitch-kit is installed as a Codex plugin, state is being written to `.stitch/session/`, but after a `/compact` the model doesn't get the re-orientation line. + +**Root cause:** Codex skips plugin-bundled hooks until the user reviews and trusts them. An enabled plugin is not a trusted plugin. So `SessionStart:compact` never fires until trust is granted. + +**Fix:** Tell the user to trust the plugin's hooks once. As a belt-and-suspenders, the skills self-check for recent `.stitch/session/state.json` on activation, so resumption still works without trusted hooks. Also note: Codex's `PostCompact` stdout is ignored — `SessionStart:compact` is the only event that injects model-visible context after compaction. + +**Files:** `hooks/hooks.json`, `.codex-plugin/plugin.json`, `skills/stitch-ideate/SKILL.md`, `skills/stitch-orchestrator/SKILL.md` + +**Lesson:** On Codex, a hook existing isn't a hook running. Design the feature to degrade to a skill-level self-check when hooks aren't trusted. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..bc00e4c --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,48 @@ +{ + "hooks": { + "PreCompact": [ + { + "matcher": "auto", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs\"", + "timeout": 15 + } + ] + }, + { + "matcher": "manual", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.mjs\"", + "timeout": 15 + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"", + "timeout": 10 + } + ] + }, + { + "matcher": "resume", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/hooks/pre-compact.mjs b/hooks/pre-compact.mjs new file mode 100644 index 0000000..f1eba40 --- /dev/null +++ b/hooks/pre-compact.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * pre-compact.mjs — PreCompact hook (matchers: auto, manual) + * + * Fires right before the host compacts the conversation. By this point the + * skills have already been writing state to .stitch/session as they go, so the + * important stuff is on disk. This hook is the backstop on top of that: + * + * 1. Copies the raw transcript into snapshots/ (owner-only perms) so nothing + * is ever truly lost. Best-effort and isolated — if the copy fails, the + * breadcrumb below still gets written. + * 2. Refreshes RESUME.md with a human/agent-readable breadcrumb. + * + * Two hard rules: + * - No active Stitch session → do nothing, exit 0. + * - ALWAYS exit 0. PreCompact can block compaction with exit code 2, and the + * last thing we want is to wedge someone's session because a copy failed. + */ + +import { + readFileSync, + copyFileSync, + chmodSync, + existsSync, + mkdirSync, + writeFileSync, + readdirSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { loadState, formatStatus, sessionDir, snapshotsDir, resumeFile } from "../scripts/stitch-session.mjs"; + +/** Keep at most this many transcript snapshots so the backstop can't bloat the project. */ +const MAX_SNAPSHOTS = 5; + +/** Delete the oldest snapshots beyond MAX_SNAPSHOTS (lexical sort works — names are timestamped). */ +function pruneSnapshots(dir) { + try { + const files = readdirSync(dir) + .filter((f) => f.startsWith("transcript-") && f.endsWith(".jsonl")) + .sort(); + for (const stale of files.slice(0, Math.max(0, files.length - MAX_SNAPSHOTS))) { + rmSync(join(dir, stale), { force: true }); + } + } catch { + // pruning is housekeeping — failing it must not affect compaction + } +} + +try { + // Parse the hook input (JSON on stdin) FIRST, so we can resolve the project + // root from input.cwd before touching any state. session_id, transcript_path, + // trigger, cwd are the fields we use. + let sessionId = "session"; + let transcriptPath = ""; + let trigger = "auto"; + try { + const raw = readFileSync(0, "utf8"); + if (raw) { + const input = JSON.parse(raw); + if (input.cwd && !process.env.CLAUDE_PROJECT_DIR) process.env.CLAUDE_PROJECT_DIR = input.cwd; + sessionId = (input.session_id || "session").toString().replace(/[^\w.-]/g, "_"); + transcriptPath = (input.transcript_path || "").toString(); + trigger = (input.trigger || "auto").toString(); + } + } catch { + // missing/garbled input — proceed with defaults, still write the breadcrumb + } + + // Only do anything if there's an active Stitch session to protect. + const state = loadState(); + if (state) { + mkdirSync(sessionDir(), { recursive: true }); + + // 1. Raw transcript backstop. Isolated in its own try so a copy failure + // (transcript gone, perms, long filename) can't skip the breadcrumb. + try { + if (transcriptPath && existsSync(transcriptPath)) { + mkdirSync(snapshotsDir(), { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const dest = join(snapshotsDir(), `transcript-${sessionId}-${stamp}.jsonl`); + copyFileSync(transcriptPath, dest); + chmodSync(dest, 0o600); // snapshots can hold conversation content — owner-only + pruneSnapshots(snapshotsDir()); + } + } catch { + // best-effort — fall through to the breadcrumb + } + + // 2. Human/agent-readable breadcrumb — always attempted, even if (1) failed. + writeFileSync( + resumeFile(), + `# Stitch session — resume breadcrumb\n\n` + + `_Compaction (${trigger}) at ${new Date().toISOString()}._\n\n` + + `${formatStatus(state)}\n` + ); + } +} catch { + // Swallow everything — see the "ALWAYS exit 0" rule above. +} + +process.exit(0); diff --git a/hooks/session-start.mjs b/hooks/session-start.mjs new file mode 100644 index 0000000..f559951 --- /dev/null +++ b/hooks/session-start.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * session-start.mjs — SessionStart hook (matchers: compact, resume) + * + * Fires when a session begins or resumes. After a compaction the host re-runs + * SessionStart with source "compact", which is our one chance to tell the + * freshly-summarised model where its work actually lives. We emit the + * re-orientation as `hookSpecificOutput.additionalContext` JSON — that's the + * form both Claude Code and Codex inject as model-visible context (Codex treats + * bare stdout as weaker "developer context", so JSON is the portable choice). + * + * Hard rule: this hook runs on EVERY session start for everyone who installs + * stitch-kit. If there's no active Stitch session, it must print nothing and + * get out of the way. And it must never throw — a crashing SessionStart hook is + * a great way to ruin someone's day. So everything is wrapped and we always + * exit 0. + */ + +import { readFileSync } from "node:fs"; +import { loadState, isRecent, formatStatus } from "../scripts/stitch-session.mjs"; + +/** Sources where resurfacing state is useful. "startup"/"clear" are fresh starts — stay quiet there. */ +const RESURFACE_ON = new Set(["compact", "resume"]); + +try { + // Hook input arrives as JSON on stdin. Parse source + cwd; if we can't read or + // parse it, treat source as unknown and bail quietly rather than guessing. + let source = ""; + try { + const raw = readFileSync(0, "utf8"); + if (raw) { + const input = JSON.parse(raw); + if (input.cwd && !process.env.CLAUDE_PROJECT_DIR) process.env.CLAUDE_PROJECT_DIR = input.cwd; + source = (input.source || "").toString(); + } + } catch { + source = ""; + } + + if (RESURFACE_ON.has(source)) { + const state = loadState(); + if (state && isRecent(state)) { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: formatStatus(state), + }, + }) + "\n" + ); + } + } +} catch { + // Never let a hook failure surface to the user or block the session. +} + +process.exit(0); diff --git a/install-codex.sh b/install-codex.sh index 227c98e..6b44422 100644 --- a/install-codex.sh +++ b/install-codex.sh @@ -64,6 +64,21 @@ for agent_file in "$AGENTS_SRC"/*.md; do fi done +# Put the session-state helper on PATH. Codex skills can't reference a bundled +# script directly, so skills call `stitch-session` by name to persist state +# across context compactions. +echo "" +echo "Linking session helper → stitch-session" +LAUNCHER_DEST="$HOME/.local/bin" +mkdir -p "$LAUNCHER_DEST" +chmod +x "$REPO_DIR/scripts/stitch-session.mjs" 2>/dev/null || true +ln -sf "$REPO_DIR/scripts/stitch-session.mjs" "$LAUNCHER_DEST/stitch-session" +echo " linked: $LAUNCHER_DEST/stitch-session" +case ":$PATH:" in + *":$LAUNCHER_DEST:"*) ;; + *) echo " note: $LAUNCHER_DEST is not on your PATH — add: export PATH=\"\$HOME/.local/bin:\$PATH\"" ;; +esac + echo "" echo "Done. $linked skills linked, $skipped skipped." echo "" @@ -78,3 +93,7 @@ echo " X-Goog-Api-Key = \"YOUR-API-KEY\"" echo "" echo "Then in Codex, invoke the agent with: \$stitch-kit" echo "Or use any skill directly with: \$stitch-orchestrator" +echo "" +echo "Optional — auto re-orientation after a context compaction:" +echo " install as a Codex plugin and trust its hooks: codex plugin add ." +echo " (without it, skills still self-recover via session state — see docs/compaction-resilience.md)" diff --git a/package.json b/package.json index ff02ca5..4e429ca 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "@booplex/stitch-kit", - "version": "1.10.0", + "version": "1.11.0", "description": "Design superpowers for AI coding agents — 35 skills that teach agents to ideate, generate, iterate, and ship production UIs using Google Stitch MCP. Works with Claude Code, Codex CLI, and any MCP-compatible agent.", "bin": { - "stitch-kit": "./bin/stitch-kit.mjs" + "stitch-kit": "./bin/stitch-kit.mjs", + "stitch-session": "./scripts/stitch-session.mjs" }, "type": "module", "files": [ @@ -12,6 +13,8 @@ "agents/", "docs/", "scripts/", + "hooks/", + ".codex-plugin/", "AGENTS.md", "README.md", "CHANGELOG.md", diff --git a/scripts/stitch-session.mjs b/scripts/stitch-session.mjs new file mode 100755 index 0000000..466bf6e --- /dev/null +++ b/scripts/stitch-session.mjs @@ -0,0 +1,363 @@ +#!/usr/bin/env node +/** + * stitch-session.mjs + * + * The one place that reads and writes Stitch session state. Skills call it as a + * CLI ("node stitch-session.mjs set-project 3780 'Velvet Cinema' DESKTOP"); the + * compaction hooks import its helpers. Keeping every mutation behind this one + * module means the on-disk schema can't drift into two slightly different shapes + * over time — there's exactly one writer, one reader, one truth. + * + * Why it exists: when the host (Claude Code) compacts the conversation mid-flow, + * anything that lived only in the chat is gone. So we park the parts that + * actually matter — which project, which screens, where the PRD draft sits — on + * disk as we go. The SessionStart hook then points the model back at them after + * a compaction instead of letting it restart the whole flow. + * + * Node, not bash+jq, on purpose: Node ships with Claude Code, jq doesn't, and + * this runs unchanged on macOS, Linux, and native Windows. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + renameSync, + rmSync, + appendFileSync, + realpathSync, +} from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Current schema version. Bump only with a migration path. */ +const SCHEMA_VERSION = 1; + +/** How long state stays "worth resurfacing" after the last write. Stale state is just noise. */ +const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +/** Allowed clock skew for a "future" updatedAt before we treat it as bogus (and therefore stale). */ +const FUTURE_SKEW_MS = 5 * 60 * 1000; + +/** Device enum, used to disambiguate a trailing device arg from the project name. */ +const DEVICE_RE = /^(DESKTOP|MOBILE|AGNOSTIC)$/i; + +/** Artifact keys formatStatus knows how to surface. A typo'd key would silently never show up. */ +const ARTIFACT_KEYS = new Set(["prdDraft", "prdFinal", "designMd"]); + +/** Cap on any free-text value we inject back into the model's context after a compaction. */ +const MAX_INJECTED_LEN = 120; + +/** + * The project the user is working in. Hooks receive CLAUDE_PROJECT_DIR (they set + * it from the hook input before importing us); a hand-run CLI uses cwd, which is + * the project root. + * @returns {string} Absolute path to the user's project root. + */ +export function projectDir() { + return process.env.CLAUDE_PROJECT_DIR || process.cwd(); +} + +export function sessionDir() { + return join(projectDir(), ".stitch", "session"); +} +export function stateFile() { + return join(sessionDir(), "state.json"); +} +export function prdDraftFile() { + return join(sessionDir(), "prd-draft.md"); +} +export function snapshotsDir() { + return join(sessionDir(), "snapshots"); +} +export function resumeFile() { + return join(sessionDir(), "RESUME.md"); +} + +/** Path the model should be told to re-read — relative to project root, so it's portable. */ +const PRD_DRAFT_REL = ".stitch/session/prd-draft.md"; + +/** + * Read state.json, or null if there's no active session. + * Never throws — a missing or corrupt state file just means "no session", + * because a hook that crashes on bad JSON is worse than one that does nothing. + * @returns {object|null} + */ +export function loadState() { + try { + if (!existsSync(stateFile())) return null; + return JSON.parse(readFileSync(stateFile(), "utf8")); + } catch { + return null; + } +} + +/** + * Write state atomically (temp file + rename), stamping version + updatedAt. + * The rename keeps a half-written state.json from ever being read, and means + * the last writer wins cleanly if two subcommands race. + * @param {object} state - The state object to persist. + * @returns {object} The same state, with version/updatedAt set. + */ +export function saveState(state) { + mkdirSync(sessionDir(), { recursive: true }); + state.version = SCHEMA_VERSION; + state.updatedAt = new Date().toISOString(); + const tmp = join(sessionDir(), `.state.${process.pid}.tmp`); + writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n"); + renameSync(tmp, stateFile()); + return state; +} + +/** + * Load existing state, or start a fresh skeleton for the given flow. + * Also normalises shape so a stale/older/hand-edited file can't make a later + * write throw (e.g. generatedScreens arriving as null). + * @param {string} [flow] - "ideate" | "orchestrator" | "loop" (only used when creating fresh). + * @returns {object} + */ +function ensureState(flow) { + const state = loadState() || { + version: SCHEMA_VERSION, + flow: flow || "unknown", + phase: null, + activeProject: null, + generatedScreens: [], + appliedDesignSystem: null, + artifacts: { prdDraft: null, prdFinal: null, designMd: null }, + }; + if (!Array.isArray(state.generatedScreens)) state.generatedScreens = []; + if (!state.artifacts || typeof state.artifacts !== "object") state.artifacts = {}; + return state; +} + +/** + * Is the state fresh enough to bother resurfacing after a compaction? + * Rejects missing, unparseable, and implausibly-future timestamps (clock skew or + * a hand-edited file shouldn't keep stale state alive forever). + * @param {object|null} state + * @param {number} [maxAgeMs] + * @returns {boolean} + */ +export function isRecent(state, maxAgeMs = DEFAULT_MAX_AGE_MS) { + if (!state || !state.updatedAt) return false; + const ts = Date.parse(state.updatedAt); + if (!Number.isFinite(ts)) return false; + const age = Date.now() - ts; + if (age < -FUTURE_SKEW_MS) return false; // dated in the future beyond tolerable skew + return age < maxAgeMs; +} + +/** Screen ids come in two shapes (numeric, or a `projects/.../files/123` path). Dedupe on the tail. */ +function canonicalScreenId(id) { + if (!id) return ""; + const str = String(id); + const slash = str.lastIndexOf("/"); + return slash >= 0 ? str.slice(slash + 1) : str; +} + +/** Make an externally-influenced value safe to inject into the model's context: one line, length-capped. */ +function sanitizeForInjection(value) { + if (value == null) return ""; + // eslint-disable-next-line no-control-regex + let s = String(value).replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/\s+/g, " ").trim(); + if (s.length > MAX_INJECTED_LEN) s = s.slice(0, MAX_INJECTED_LEN - 1) + "…"; + return s; +} + +/** + * One-line summary the SessionStart hook injects after a compaction. + * This is the whole point: it tells the freshly-compacted model where its work + * lives so it picks the flow back up instead of starting over. Project and + * design-system names are treated as untrusted data — cleaned and capped — since + * they flow into the model's context. + * @param {object|null} state + * @returns {string} + */ +export function formatStatus(state) { + if (!state) return ""; + const phase = sanitizeForInjection(state.phase); + const bits = [`Stitch flow in progress (flow: ${sanitizeForInjection(state.flow)}${phase ? `, phase: ${phase}` : ""}).`]; + if (state.activeProject && state.activeProject.id) { + const name = state.activeProject.name ? ` "${sanitizeForInjection(state.activeProject.name)}"` : ""; + bits.push(`Active project ${sanitizeForInjection(state.activeProject.id)}${name}.`); + } + if (Array.isArray(state.generatedScreens) && state.generatedScreens.length) { + bits.push(`${state.generatedScreens.length} screen(s) generated.`); + } + if (state.appliedDesignSystem && state.appliedDesignSystem.name) { + bits.push(`Design system "${sanitizeForInjection(state.appliedDesignSystem.name)}" applied.`); + } + if (state.artifacts && state.artifacts.prdDraft) { + bits.push(`PRD draft at ${sanitizeForInjection(state.artifacts.prdDraft)}.`); + } + bits.push( + "Re-read .stitch/session/state.json and the PRD draft before continuing. Continue the flow, do not restart it." + ); + return bits.join(" "); +} + +/** + * Read whatever was piped on stdin, falling back to the joined args. + * Used by append-prd so callers can either pipe a heredoc or pass text inline. + * @param {string[]} args + * @returns {string} + */ +function readStdinOrArgs(args) { + let chunk = ""; + try { + if (!process.stdin.isTTY) chunk = readFileSync(0, "utf8"); + } catch { + // no readable stdin — fall through to args + } + if (!chunk) chunk = args.join(" "); + return chunk; +} + +/** + * CLI dispatch. Each subcommand mutates state through saveState so updatedAt + * and version are always stamped consistently. + * @param {string[]} argv - Arguments after the script name. + */ +function main(argv) { + const [cmd, ...args] = argv; + + switch (cmd) { + case "init": { + saveState(ensureState(args[0] || "unknown")); + break; + } + + case "set-project": { + const state = ensureState(); + const id = args[0]; + let name; + let device = null; + // Only sniff a trailing device when there's BOTH a name and a trailing + // token (3+ args). That way "set-project 99 Mobile" keeps "Mobile" as the + // project name instead of mistaking it for the device. + if (args.length > 2 && DEVICE_RE.test(args[args.length - 1])) { + device = args[args.length - 1].toUpperCase(); + name = args.slice(1, -1).join(" "); + } else { + name = args.slice(1).join(" "); + } + state.activeProject = { id, name: name || null, deviceType: device }; + saveState(state); + break; + } + + case "add-screen": { + const state = ensureState(); + const id = canonicalScreenId(args[0]); + const name = args.slice(1).join(" ") || null; + if (id) { + const existing = state.generatedScreens.find((s) => canonicalScreenId(s.id) === id); + if (existing) { + if (name) existing.name = name; // upsert a better/corrected name + } else { + state.generatedScreens.push({ id, name }); + } + } + saveState(state); + break; + } + + case "set-phase": { + const state = ensureState(); + state.phase = args.join(" ") || null; + saveState(state); + break; + } + + case "set-design-system": { + const state = ensureState(); + state.appliedDesignSystem = { + assetId: args[0] || null, + name: args.slice(1).join(" ") || null, + }; + saveState(state); + break; + } + + case "set-artifact": { + const state = ensureState(); + const key = args[0]; + if (!ARTIFACT_KEYS.has(key)) { + process.stderr.write( + `stitch-session: unknown artifact key '${key}'. Known: ${[...ARTIFACT_KEYS].join(", ")}\n` + ); + process.exit(1); + } + state.artifacts[key] = args[1] || null; + saveState(state); + break; + } + + case "append-prd": { + const chunk = readStdinOrArgs(args); + if (!chunk) { + process.stderr.write("stitch-session: append-prd received no content, nothing written\n"); + break; + } + mkdirSync(sessionDir(), { recursive: true }); + appendFileSync(prdDraftFile(), chunk.endsWith("\n") ? chunk : chunk + "\n"); + const state = ensureState(); + state.artifacts.prdDraft = PRD_DRAFT_REL; + saveState(state); + break; + } + + case "status": { + const state = loadState(); + if (state) process.stdout.write(formatStatus(state) + "\n"); + break; + } + + case "read": { + const state = loadState(); + process.stdout.write(state ? JSON.stringify(state, null, 2) + "\n" : ""); + break; + } + + case "clear": { + // "Flow done" — drop the live state, but KEEP snapshots/ as the recovery + // backstop in case the flow wasn't really done. snapshots/ self-prunes. + for (const f of [stateFile(), prdDraftFile(), resumeFile()]) { + if (existsSync(f)) rmSync(f, { force: true }); + } + break; + } + + default: + process.stderr.write( + "stitch-session: unknown command '" + + (cmd || "") + + "'. Use one of: init, set-project, add-screen, set-phase, " + + "set-design-system, set-artifact, append-prd, status, read, clear\n" + ); + process.exit(1); + } +} + +/** + * True when this file was run directly (CLI), false when imported by the hooks. + * realpath both sides so a symlinked launcher still counts as direct: npm's + * `bin` and the installer's ~/.local/bin entry are symlinks, and Node reports + * the real path for import.meta.url while argv[1] stays the symlink — a plain + * === would wrongly treat a `stitch-session` launcher call as an import and + * silently do nothing. + */ +function invokedDirectly() { + if (!process.argv[1]) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return fileURLToPath(import.meta.url) === process.argv[1]; + } +} + +if (invokedDirectly()) { + main(process.argv.slice(2)); +} diff --git a/skills/stitch-ideate/SKILL.md b/skills/stitch-ideate/SKILL.md index 9c60115..e6b2b23 100644 --- a/skills/stitch-ideate/SKILL.md +++ b/skills/stitch-ideate/SKILL.md @@ -59,6 +59,27 @@ The ideation runs in **5-7 adaptive phases** with **active research** woven in. --- +## Session state (so a compaction can't erase your work) + +Ideation is the one flow that can actually lose work to a context compaction: the research and answers from each phase live only in the conversation until the PRD file gets written at Phase 7. So flush progress to disk as you go, using the session helper. + +Call the session helper through this wrapper. It resolves `stitch-session` on PATH first (Codex, and any install that ran the `stitch-kit` installer), then the Claude Code bundled path, and no-ops if neither exists. Define it at the top of each Bash block, since every Bash run is a fresh shell: + +```bash +ss() { if command -v stitch-session >/dev/null 2>&1; then stitch-session "$@"; elif [ -f "${CLAUDE_SKILL_DIR:-/nonexistent}/../../scripts/stitch-session.mjs" ]; then node "${CLAUDE_SKILL_DIR}/../../scripts/stitch-session.mjs" "$@"; fi; true; } +``` + +Then call it at these moments: +- **Start of ideation:** `ss init ideate` +- **End of each phase:** `ss set-phase ""`, then pipe the phase's synthesized content to `append-prd` — `printf '%s\n' "" | ss append-prd` +- **Phase 7, after writing the final PRD:** `ss set-artifact prdFinal "temp/[product-name]-prd.md"` + +`append-prd` builds up `.stitch/session/prd-draft.md` phase by phase, so if the host compacts mid-brainstorm the draft is still on disk and the SessionStart hook points you straight back at it. Keep it light — one `set-phase` plus one `append-prd` per phase is the whole job. + +**Resuming:** at the very start, run `ss read`. If it returns recent state, you were compacted mid-flow — pick up from that phase and the existing `prd-draft.md` instead of restarting ideation. (This covers hosts where the SessionStart hook didn't fire, e.g. Codex hooks that aren't trusted yet.) + +--- + ## Research Engine Research is not a separate phase — it's woven into the ideation flow. Use `WebSearch` and `WebFetch` at key moments to bring real-world context into the conversation. diff --git a/skills/stitch-orchestrator/SKILL.md b/skills/stitch-orchestrator/SKILL.md index 7de9df2..67089cc 100644 --- a/skills/stitch-orchestrator/SKILL.md +++ b/skills/stitch-orchestrator/SKILL.md @@ -43,6 +43,23 @@ Note the tool namespace prefix (e.g., `stitch:` or `mcp__stitch__`). Use this pr Execute all steps autonomously. **Do not ask for confirmation between steps.** Report progress as you go. +### Session state (compaction safety) + +Record progress to disk as you go, so a context compaction can't make you restart the flow. Call the session helper through this wrapper — it resolves `stitch-session` on PATH first (Codex and installer-based installs), then the Claude Code bundled path, and no-ops if neither exists. Define it at the top of each Bash block (fresh shell each call): + +```bash +ss() { if command -v stitch-session >/dev/null 2>&1; then stitch-session "$@"; elif [ -f "${CLAUDE_SKILL_DIR:-/nonexistent}/../../scripts/stitch-session.mjs" ]; then node "${CLAUDE_SKILL_DIR}/../../scripts/stitch-session.mjs" "$@"; fi; true; } +``` + +Then call it at these moments: +- **Step 4**, once a project is created or chosen: `ss init orchestrator` then `ss set-project "" ` +- **Step 5**, after each screen generates: `ss add-screen ""` +- **Step 7b**, after creating a Stitch Design System: `ss set-design-system ""` + +If the host compacts, the SessionStart hook reads this state back and tells you which project you were in and what's already generated — so you continue instead of regenerating screens that already exist. + +**Resuming:** before Step 4, run `ss read`. If it shows a recent project with screens, you're resuming after a compaction — reuse that project and skip the screens already listed instead of regenerating them. (This covers hosts where the SessionStart hook didn't fire, e.g. Codex hooks that aren't trusted yet.) + ### Step 0.5: Ideation gate (smart detection) Before running the spec generator, score the user's request to determine if it needs ideation.