#!/usr/bin/env node // @sv-version: 1.27.0 /** * UserPromptSubmit Hook — Start Vibing Stacks * * Two responsibilities: * 1. Inject the per-stack workflow + project-standards context block. * 2. Multi-instance coordination: heartbeat the session, capture the title * from the first prompt (matches `claude --resume`), drain the inbox so * peer messages reach the user before this turn runs, and warn if active * peers exist. * * v1.27.0: THEME APPLY — Common Button is variant/size, not a class-string API. * v1.26.0: Grok documenter same-pass Attention crumb (P&S if gotcha=). * v1.25.0: Grok ship = one documenter (why=/slug=). Stop-recovery Grok * does not demand domain-updater. Claude TASK WORKFLOW untouched. * v1.24.0: Grok-only light work turn (no TASK WORKFLOW essay). Dirty may * answer; claim-done still needs finalize. Claude/Kimi path unchanged. * v1.23.0: Grok natives tester/research-web/security-auditor/claude-md-compactor. * v1.22.0: Grok finalize — no svs-document first Read; background:true; * HEREDOC commit body; --push after C2 (not C1). * v1.21.0: Grok writer roles pin low (4.6 floor). Child Reads svs-document. * v1.20.0: Grok spawn — do not pass reasoning_effort (schema omit). Role pin + type hook. * v1.19.0: Grok spawn custom type + reasoning_effort low; richer native agents. * v1.18.0: Grok-native lean agents; no restamp spawn after docs commit. * v1.17.0: Grok spawn — short prompt, child Reads `.grok/agents/.md` * (`general-purpose`, isolation none; no explore writer; do not paste the file). * v1.16.0: THEME APPLY (memory react-theme-parity 1.1 + skill react-theme-apply) * — CSS-first, check-theme-tokens, both modes; style-guide path if present. * v1.15.0: WP plugin ops nudge (memory owned-wordpress-plugin-ops) — search + * neutralize on owned sites; refuse cracked/nulled keys. * v1.14.0: Always refresh gitBranch from live HEAD; MAIN-ONLY ship nudge when * peers active or off main (memory multi-instance-main-only). SHIP NOW requires main. * v1.13.0: WATERMARK HYGIENE step + CONTENT nudge (memory remove-ai-watermarks; * skill remove-ai-marks) — inspect/clean Layer A when shipping user-facing prose. * v1.12.0: SHIP NOW = SAFE SHIP (push+deploy) vs ATTENTION GATE (ask before * commit); workflow auth lasts until loop closes; full artifact inventory. * v1.11.0: STOP-RECOVERY nudges from lastStopKind (COMMIT-FIRST / FINALIZE-NOW / * COMPACT-NOW); CLAUDE.md soft-cap 36k warn; memories quality-gate-before-push, * cdn-asset-deploy-verify, oauth-integration-preflight. * v1.10.1: FINALIZE nudge only when in-project source was touched. * v1.10.0: Prompt kind `status`|`ship`|`work` + SHIP NOW. * v1.9.0: VERIFY-EVIDENCE + MULTI-PART EXECUTE nudges (memory verify-before-claim) * when prompt asks for review/security/audit or lists multiple || parts — block * memory-only answers and “which first?” evasion. * v1.8.2: FINALIZE order = scope commit → documenter → domain-updater → docs commit * (was inverted; matches Claude + skill svs-finalize). * v1.8.1: peer coordination points at /svs-peers (Claude native /peers = ListAgents). * v1.8.0: THEME PARITY nudge for React UI projects (memory react-theme-parity) * when prompt/session involves .jsx/.tsx — discover theme or sibling CSS; validate * before done (long-session design drift). * v1.7.0: when filesTouched includes source, inject FINALIZE CHAIN (svs-finalize) * so Kimi/Grok cannot “forget” documenter → domain-updater. * v1.6.0: re-tag `target` + native session paths on heartbeat (Kimi/Grok parity). * v1.5.0: Cursor Opened-changes / Save-file prompts = IDE buffer sync (memory * cursor-ide-edit-sync); optional settings.local.example for acceptEdits/bypass. * v1.4.0: after commit/push, verify GitHub Actions (gh run list/watch) before * claiming deploy success — memory post-push-ci-verification + commit-manager 5.5. * v1.3.0: workflow adds dead-code hygiene step (skill `dead-code-hygiene`) between * WORK and quality gates — remove inert legacy paths before commit. * v1.2.0: workflow guidance now points at `## Recent Changes` (append-only LIFO) * instead of `## Last Change` (overwritten). Aligned with `commit-manager` v3.0.0 * and `domain-updater` v3.0.0 — multi-instance safe. */ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { ACTIVE_MS, ageMs, detectSvsTarget, drainInbox, ensureStateDirs, extractTitle, formatPeer, getGitBranch, getProjectDir, getStateDir, filterProjectPaths, heartbeat, listPeerSessions, readSession, readStdinJson, resolveNativeSessionMeta, shortId, truncate, type InboxMessage, type PromptKind, type SessionRecord, type StopKind, } from './_state.js'; const CLAUDE_MD_SOFT = 36000; const PROJECT_DIR = getProjectDir(); const ACTIVE_PROJECT = join(PROJECT_DIR, '.claude', 'config', 'active-project.json'); const STANDARDS_REVIEW = join(PROJECT_DIR, '.claude', 'config', 'standards-review.json'); let stackName = 'Unknown'; let qualityCmd = 'Run quality gates'; try { if (existsSync(ACTIVE_PROJECT)) { const config = JSON.parse(readFileSync(ACTIVE_PROJECT, 'utf8')); stackName = config.stack || 'Unknown'; const stackConfig = join(PROJECT_DIR, '.claude', 'config', 'quality-gates.json'); if (existsSync(stackConfig)) { const gates = JSON.parse(readFileSync(stackConfig, 'utf8')); qualityCmd = gates.runAll || qualityCmd; } } } catch {} interface ReviewFile { status: string; sources?: string[]; patterns?: { category: string; name: string }[]; } let standardsContext = ''; try { if (!existsSync(STANDARDS_REVIEW)) { standardsContext = `\n\nSTANDARDS REVIEW NEEDED: No standards-review.json found. ` + `This project may have existing coding standards (.cursorrules, composer.json configs). ` + `Ask the user: "I noticed this project hasn't been scanned for existing standards. ` + `Would you like me to review your codebase patterns and adapt my behavior, ` + `or should I use the default standards?"`; } else { const review: ReviewFile = JSON.parse(readFileSync(STANDARDS_REVIEW, 'utf8')); if (review.status === 'adapted' && review.patterns && review.patterns.length > 0) { const patternList = review.patterns.map(p => `- [${p.category}] ${p.name}`).join('\n'); standardsContext = `\n\nPROJECT STANDARDS (scanned from ${(review.sources || []).join(', ')}):\n${patternList}\n` + `Follow these project-specific patterns. They take priority over generic defaults.`; } } } catch {} function formatInbox(m: InboxMessage): string { const from = m.fromTitle ? `${shortId(m.fromSessionId)} "${m.fromTitle}"` : shortId(m.fromSessionId); return ` [${m.ts}] from ${from}: ${m.message}`; } function buildPeersBlock( stateDir: string, sessionId: string, prompt: string, promptKind: PromptKind ): string { ensureStateDirs(stateDir); // Heartbeat + capture title on first prompt of the session. const existing = readSession(stateDir, sessionId); const target = detectSvsTarget({ session_id: sessionId, sessionId }); const native = resolveNativeSessionMeta(PROJECT_DIR, sessionId, target); const titleNeeded = !existing || !existing.title || existing.title === '(untitled)'; const title = titleNeeded ? native.title || extractTitle(existing?.transcriptPath || native.transcriptPath, prompt) : existing!.title; // Always refresh from live HEAD — cached gitBranch goes stale after peer checkout -b. const branch = getGitBranch(PROJECT_DIR) || existing?.gitBranch; heartbeat(stateDir, sessionId, 'UserPromptSubmit', { title, cwd: PROJECT_DIR, gitBranch: branch, target: existing?.target || target, nativeSessionDir: existing?.nativeSessionDir || native.nativeSessionDir, transcriptPath: existing?.transcriptPath || native.transcriptPath, lastPromptKind: promptKind, }); const peers: SessionRecord[] = listPeerSessions(stateDir, sessionId); const inbox = drainInbox(stateDir, sessionId); const parts: string[] = []; if (peers.length > 0) { const activeCount = peers.filter(p => ageMs(p.lastSeenAt) < ACTIVE_MS).length; parts.push(''); parts.push(`PEER INSTANCES IN THIS PROJECT (${peers.length}, ${activeCount} active):`); for (const p of peers) parts.push(` - ${formatPeer(p)}`); if (activeCount > 0) { parts.push( ' ! Edit/Write of files an active peer just touched will be BLOCKED. ' + 'Use `/svs-peers` → peers.ts notify (Claude native /peers is ListAgents/SendMessage).' ); parts.push( ' ! SHARED CWD = ONE HEAD (memory: multi-instance-main-only): do NOT `git checkout -b` — ' + 'it moves every peer. Stay on main; commit+push versioned deploys from main only. ' + 'Parallel long features → `git worktree add` (separate cwd).' ); } } // Live branch gate (Stop already blocks off-main; ship must not push feature branches). { const live = getGitBranch(PROJECT_DIR); if (live && live !== 'main' && live !== 'master') { parts.push(''); parts.push(`MAIN-ONLY SHIP (memory: multi-instance-main-only) — HEAD is '${live}', not main:`); parts.push(' - FORBIDDEN: git push / scope --push / deploy / republish from this branch.'); parts.push(' - Fix: git checkout main (merge feature first if yours), then ship from main.'); parts.push(' - scope --push hard-refuses non-main unless --allow-non-main-push.'); } } if (inbox.length > 0) { parts.push(''); parts.push(`INBOX (${inbox.length} message${inbox.length === 1 ? '' : 's'} from peers — surface to the user):`); for (const m of inbox) parts.push(formatInbox(m)); } // Status checks must stay light — do not pile finalize onto "foi publicado?". if (promptKind === 'status') return parts.join('\n'); const touched = filterProjectPaths( readSession(stateDir, sessionId)?.filesTouched || existing?.filesTouched || [], PROJECT_DIR ); const hasSource = touched.some((f) => /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs|vue|svelte)$/i.test(f) ); if (hasSource && target !== 'grok') { parts.push(''); parts.push('FINALIZE CHAIN REQUIRED (source edits — skill svs-finalize; same order as Claude):'); parts.push(' 1. commit-manager → scope.ts commit HEREDOC ": " (never git add -A; no --push yet)'); parts.push(' 2. documenter → domains + _index.json'); parts.push(' 3. domain-updater → PREPEND CLAUDE.md ## Recent Changes'); parts.push(' 4. If docs dirty → scope.ts commit HEREDOC "docs: …" --push on main'); parts.push(' Grok: spawn subagent_type documenter|domain-updater, capability_mode all, background true, isolation none. Do NOT pass reasoning_effort. Writer roles pin low. SHORT prompt (HEAD+files+what shipped). Do not paste. Do not tell child to Read svs-document/AGENTS.md first. Do not poll in the same turn if the user has a next question. Do not restamp after docs:. Not explore/plan as documenter.'); parts.push(' Kimi: Read .claude/agents/.md and execute inline.'); parts.push(' FORBIDDEN: push C1 before documenter; subject-only git message when why is non-obvious; claim done while ahead of origin; skip domain-updater; trust child “done” without jq/rg verify. Stop BLOCKS if stale.'); } return parts.join('\n'); } const UI_PROMPT_RE = /\b(page|pages|layout|component|ui|ux|theme|tema|moon|tailwind|css|jsx|tsx|inertia|react|frontend|modal|sidebar|dashboard|STYLES|dark\s*mode|preline|shadcn|tabela|contraste|hover|style\s*guide)\b/i; const REACT_UI_FILE_RE = /\.(jsx|tsx)$/i; const THEME_CSS_FILE_RE = /(?:themes\/.+\.css|\.css)$/i; function projectHasReactUi(projectDir: string): boolean { const markers = [ join(projectDir, 'resources', 'js'), join(projectDir, 'resources', 'css', 'themes'), join(projectDir, 'resources', 'css', 'app.css'), ]; if (markers.some((p) => existsSync(p))) return true; try { const pkgPath = join(projectDir, 'package.json'); if (!existsSync(pkgPath)) return false; const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { dependencies?: Record; devDependencies?: Record; }; const deps = { ...pkg.dependencies, ...pkg.devDependencies }; const hasReact = Boolean(deps['react']); const hasUiStack = Boolean( deps['@inertiajs/react'] || deps['@inertiajs/vue3'] || deps['vite'] || deps['next'] || deps['tailwindcss'] ); return hasReact && hasUiStack; } catch { return false; } } function buildThemeParityBlock( projectDir: string, prompt: string, touched: string[] ): string { if (!projectHasReactUi(projectDir)) return ''; const touchedUi = touched.some((f) => REACT_UI_FILE_RE.test(f) || THEME_CSS_FILE_RE.test(f)); const promptUi = UI_PROMPT_RE.test(prompt); if (!touchedUi && !promptUi) return ''; const hasThemes = existsSync(join(projectDir, 'resources', 'css', 'themes')); const hasGuide = existsSync(join(projectDir, 'docs', 'design', 'style-guide')); const themeHint = hasThemes ? 'CSS-FIRST: patch theme.css + moon (or active theme) BOTH blocks + @theme inline before any JSX token migrate.' : 'No themes/ folder — copy class patterns from 1–2 sibling pages. Do not invent a palette.'; const guideHint = hasGuide ? '\n Style guide present: follow `docs/design/style-guide/` recipes (guide wins; grep wins on counts).' : ''; return ( `\n\nTHEME APPLY (memory: react-theme-parity 1.1 + skill react-theme-apply):\n` + ` 1. ${themeHint}${guideHint}\n` + ` 2. Order: CSS truth → Common/shell →