From f72f6263a4753b147c0826f55fea5199618e072e Mon Sep 17 00:00:00 2001 From: Julien Tanay Date: Mon, 7 Sep 2026 17:12:21 +0200 Subject: [PATCH 1/3] feat(vibe): add first-class Mistral Vibe support Register the mistral-vibe origin and wire plan-review gate, annotate-last, and installer surfacing. Origin in AGENT_CONFIG/PromptRuntime/PLAN_TOOL_NAMES; gate resolves newest plan from $VIBE_HOME/plans by mtime and fails open; annotate-last session discovery; installer three-layer skip + managed hook block (sh) / manual instructions (ps1/cmd); apps/vibe artifacts + skills; docs + freshness guard. --- AGENTS.md | 3 +- apps/hook/server/index.ts | 94 ++++++++- apps/hook/server/session-log.ts | 179 +++++++++++++++++ apps/hook/server/vibe-plan.test.ts | 83 ++++++++ apps/hook/server/vibe-plan.ts | 77 ++++++++ apps/hook/server/vibe-session.test.ts | 182 +++++++++++++++++ .../docs/reference/environment-variables.md | 2 +- apps/skills/core/plannotator/SKILL.md | 2 +- apps/vibe/hooks/config-snippet.toml | 6 + apps/vibe/hooks/hooks.toml | 25 +++ .../vibe/skills/plannotator-annotate/SKILL.md | 17 ++ apps/vibe/skills/plannotator-last/SKILL.md | 31 +++ apps/vibe/skills/plannotator-review/SKILL.md | 19 ++ packages/core/agents.ts | 1 + packages/server/index.ts | 4 +- packages/shared/config.ts | 3 +- packages/shared/prompts.ts | 1 + scripts/install.cmd | 132 ++++++++++++- scripts/install.ps1 | 105 +++++++++- scripts/install.sh | 186 +++++++++++++++++- scripts/install.test.ts | 59 ++++-- 21 files changed, 1179 insertions(+), 32 deletions(-) create mode 100644 apps/hook/server/vibe-plan.test.ts create mode 100644 apps/hook/server/vibe-plan.ts create mode 100644 apps/hook/server/vibe-session.test.ts create mode 100644 apps/vibe/hooks/config-snippet.toml create mode 100644 apps/vibe/hooks/hooks.toml create mode 100644 apps/vibe/skills/plannotator-annotate/SKILL.md create mode 100644 apps/vibe/skills/plannotator-last/SKILL.md create mode 100644 apps/vibe/skills/plannotator-review/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index db37464a7..51bc3821c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely, including Guided Review share links (the review UI hides "Create share link", `POST /api/guide/:jobId/share` answers `403 { error: "sharing disabled" }`, and `plannotator guide share` refuses with exit 1). Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`); the env var takes precedence. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | -| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `kiro-cli`, `pi`, `oh-my-pi`. Invalid values silently fall through to env-based detection. Unset by default. | +| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `kiro-cli`, `mistral-vibe`, `pi`, `oh-my-pi`. Invalid values silently fall through to env-based detection. Unset by default. | | `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. | | `PLANNOTATOR_ANNOTATE_HISTORY` | Set to `0` / `false` to disable ALL annotate-session writes to the data dir: per-file version history (no copies of annotated files are written; the annotate version diff is unavailable) AND the durable submitted-feedback records (#678) that single-local-file annotate sessions otherwise write to `history/{project}/{slug}/submissions/` before deleting the draft on submit. Disabling it keeps annotate sessions fully stateless but also gives up that submit crash-recovery record. URL and annotate-last sessions never write either kind of data regardless of this flag. Folder sessions write no submitted-feedback records, but they do participate in per-file version history: the first time a session serves a file through /api/doc it snapshots that file (lazily, memoized per resolved path for the life of the server), which is what powers the per-file version diff when a folder file is reopened later; setting this flag to 0 disables those folder snapshots too. Setting it to 0 additionally suppresses **feedback archive** records for every annotate surface (single file, folder, URL, live app, annotate-last), so "fully stateless annotate session" stays literally true regardless of `PLANNOTATOR_FEEDBACK_HISTORY`. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "annotateHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_FEEDBACK_HISTORY` | Set to `0` / `false` to stop archiving submitted feedback under `~/.plannotator/feedback/` (or `PLANNOTATOR_DATA_DIR`). Default: enabled, which appends one record per submission at decision-settlement time on all three surfaces and in both runtimes: plan approve/deny, code review Send Feedback / Approve (LGTM) / Close, and every annotate submit / approve / close. A review posted straight to GitHub or GitLab with `POST /api/pr-action` is delivered to the platform and is not archived locally yet. **Note that this writes the user's own feedback text, the document and code excerpts it quotes, and per-annotation metadata to disk, and nothing prunes the directory** (same policy as `plans/`, `history/`, and `guides/`); delete `~/.plannotator/feedback/` or a project subdirectory to forget, or set this to 0 to never write. Code-review records carry diff IDENTITY only (vcsType, diffType, base, gitRef, snapshotId, cwd, PR metadata, changed-file count, patch byte count), never the patch bytes; plan records carry the decision text plus a reference to the `history/{project}/{slug}/NNN.md` version the decision was made on, never a second copy of the plan. Externally sourced annotations (linters, review agents, WebMCP browser agents) are included but keep their `source` / `author` tags, so `source == null` selects the reviewer's own comments; agent job outputs (guides, tours) are not archived. This knob governs only the new archive: the `planSave` decision snapshots in `plans/` and the #678 annotate submission records under `history/` are unaffected. Annotate surfaces honor `PLANNOTATOR_ANNOTATE_HISTORY` as well. Can also be set via `~/.plannotator/config.json` (`{ "feedbackHistory": false }`); the env var takes precedence. | @@ -161,6 +161,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_SKIP_CODEX_INSTALL` | **Read by the install scripts only.** Set to `1` / `true` to skip writing the Codex integration (`hooks.json` / `config.toml` under `CODEX_HOME`, and the Codex-home stale-skill cleanup) even when Codex is detected. The installer reports the honest state ("Codex: detected, skipped (...)" vs "not detected" vs installed) and never removes an integration a previous install wired. Also settable via `~/.plannotator/config.json` (`{ "skipInstall": { "codex": true } }`); precedence is `--skip-codex` flag > env var > config. Off by default. | | `PLANNOTATOR_SKIP_GEMINI_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Gemini CLI integration (`~/.gemini` policy file, settings hook, slash commands). Config key: `skipInstall.gemini`; flag: `--skip-gemini`. Off by default. | | `PLANNOTATOR_SKIP_KIRO_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Kiro CLI integration (`~/.kiro` skills and agent, including the `~/.kiro` stale-skill sweep). Config key: `skipInstall.kiro`; flag: `--skip-kiro`. Off by default. | +| `PLANNOTATOR_SKIP_VIBE_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Mistral Vibe integration (`$VIBE_HOME` skills and the managed plan-review hook block in `$VIBE_HOME/hooks.toml`, plus the `enable_experimental_hooks = true` merge into `$VIBE_HOME/config.toml`, and the `~/.vibe` stale-skill sweep). The hook is macOS/Linux-only (Vibe spawns hooks via `/bin/sh`), so the Windows installer never writes `hooks.toml` — it prints manual setup instructions, mirroring Codex-on-Windows; skills still install cross-platform. Config key: `skipInstall.vibe`; flag: `--skip-vibe`. Off by default. | | `PLANNOTATOR_SKIP_OPENCODE_INSTALL` | **Read by the install scripts only.** Do-not-write switch for the OpenCode integration (command stubs under `~/.config/opencode/commands`, the OpenCode plugin cache clear, and the stale command-stub sweep). OpenCode has no detection leg, so there is no detected/not-detected reporting, just a skip note. Config key: `skipInstall.opencode`; flag: `--skip-opencode`. Off by default. | | `PLANNOTATOR_SKIP_SKILLS_INSTALL` | **Read by the install scripts only.** Set to `1` / `true` to skip the skills/slash-command sparse checkout entirely — no `git clone` of the release tag, so nothing is written to any skill or command scope (`~/.claude/skills`, `~/.agents/skills`, the OpenCode command stubs, the Gemini `.toml` commands, `~/.kiro`), the extras are not offered, and the skill-scope cleanup sweeps stay suspended (skip means do-not-write, never remove). The binary, sem sidecar, agent-terminal runtime, hooks, and per-agent config still install, and git stops being a hard requirement. The installer reports `Skills: skipped (...)` and the closing banner stops claiming the `/plannotator-*` commands are ready. Unlike the per-agent opt-outs this is not one agent's home — it covers every scope the checkout writes. Config key: `skipInstall.skills`; flags: `--skip-skills` (bash/cmd), `-SkipSkills` (PowerShell); precedence is flag > env var > config. Used by the `install-script-smoke` CI job, which installs a synthetic `v9.9.9` whose tag has no GitHub counterpart. Off by default. | | `PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL` | Set to `1` / `true` to skip installing the managed Node/WebTUI runtime used by compiled Bun builds for the annotate-mode agent terminal. Read by `plannotator install-runtime agent-terminal`, which the installers call automatically. | diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index aa3bfa297..2afb55246 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -158,13 +158,16 @@ import { findSessionLogsByAncestorWalk, findSessionLogsForCwd, getRecentRenderedMessages, + getRecentVibeMessages, resolveDroidSessionLogForCwd, resolveSessionLogByAncestorPids, resolveSessionLogByCwdScan, + resolveVibeSessionLogForCwd, type RenderedMessage, } from "./session-log"; import { findCodexRolloutByThreadId, getLatestCodexPlan, getRecentCodexMessages } from "./codex-session"; import { findCopilotPlanContent, findCopilotSessionByAncestorPids, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session"; +import { resolveLatestVibePlan } from "./vibe-plan"; import { formatInteractiveNoArgClarification, formatSubcommandHelp, @@ -518,8 +521,12 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // still be detected as themselves. OMPCODE still wins over the terminal // fallback below. // +// > Mistral Vibe — detected via PLANNOTATOR_ORIGIN=mistral-vibe baked into the +// hook command by the installer (Vibe's hook executor does not inject a +// unique fingerprint env var; the override check above catches it). +// // To add a new agent, also add an entry to AGENT_CONFIG in -// packages/shared/agents.ts (see header comment there). +// packages/core/agents.ts (see header comment there). const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; const detectedOrigin: Origin = (originOverride && originOverride in AGENT_CONFIG) ? originOverride : @@ -1369,6 +1376,7 @@ if (args[0] === "sessions") { const isCodex = !!codexThreadId; const isDroid = detectedOrigin === "droid"; const isCopilot = detectedOrigin === "copilot-cli"; + const isVibe = detectedOrigin === "mistral-vibe"; // Collect up to N recent assistant messages so the user can pick the right // one — defaults to the same selection as the legacy "last message" @@ -1385,7 +1393,7 @@ if (args[0] === "sessions") { // earlier branch claims the invocation. let copilotLockSessionDir: string | null = null; let copilotSessionDir: string | null = null; - if (!stdinFlag && !isCodex && !isDroid) { + if (!stdinFlag && !isCodex && !isDroid && !isVibe) { copilotLockSessionDir = findCopilotSessionByAncestorPids(); copilotSessionDir = copilotLockSessionDir ?? (isCopilot ? findCopilotSessionForCwd(projectRoot) : null); @@ -1456,6 +1464,22 @@ if (args[0] === "sessions") { .map((m) => ({ messageId: m.messageId, text: m.text, lineNumbers: [], timestamp: m.timestamp })); lastMessage = recentMessages[0] ?? null; } + } else if (isVibe) { + // Mistral Vibe path: resolve the session log for the current cwd from + // $VIBE_HOME/logs/session/ (indexed by .session_index.json), then read + // the most recent rendered assistant messages from messages.jsonl. + if (process.env.PLANNOTATOR_DEBUG) { + console.error(`[DEBUG] Vibe detected, project root: ${projectRoot}`); + } + const vibeLog = resolveVibeSessionLogForCwd(projectRoot); + if (process.env.PLANNOTATOR_DEBUG) { + console.error(`[DEBUG] Vibe selected log: ${vibeLog ?? "(none)"}`); + } + if (vibeLog) { + recentMessages = getRecentVibeMessages(vibeLog, RECENT_MESSAGES_LIMIT) + .map((m) => ({ messageId: m.messageId, text: m.text, lineNumbers: [], timestamp: m.timestamp })); + lastMessage = recentMessages[0] ?? null; + } } else { // Claude Code path: resolve session log // @@ -2257,6 +2281,72 @@ if (args[0] === "sessions") { process.exit(0); } + // Mistral Vibe: pre_tool hook matching exit_plan_mode. Vibe's tool takes + // no args, so the plan is not in the payload — resolve it from + // $VIBE_HOME/plans by mtime. Origin is set via PLANNOTATOR_ORIGIN=mistral-vibe + // baked into the hook command by the installer. + if ( + detectedOrigin === "mistral-vibe" && + event.hook_event_name === "pre_tool" && + event.tool_name === "exit_plan_mode" + ) { + const vibePlanContent = resolveLatestVibePlan(); + if (!vibePlanContent) { + console.error( + "No plan file found in $VIBE_HOME/plans. Vibe may not have written the plan yet, or VIBE_HOME is set to a non-default location." + ); + // Fail open: empty stdout + exit 0 lets Vibe pass the tool through. + process.exit(0); + } + + const vibePlanProject = (await detectProjectName()) ?? "_unknown"; + const vibeServer = await startPlannotatorServer({ + plan: vibePlanContent, + origin: "mistral-vibe", + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + htmlContent: planHtmlContent, + onReady: async (url, isRemote, port) => { + handleServerReady(url, isRemote, port); + if (isRemote && sharingEnabled) { + await writeRemoteShareLink(vibePlanContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + } + }, + }); + + registerSession({ + pid: process.pid, + port: vibeServer.port, + url: vibeServer.url, + mode: "plan", + project: vibePlanProject, + startedAt: new Date().toISOString(), + label: `plan-${vibePlanProject}`, + }); + + const vibeResult = await vibeServer.waitForDecision(); + await Bun.sleep(1500); + vibeServer.stop(); + + if (vibeResult.approved) { + console.log(JSON.stringify({ decision: "allow" })); + } else { + console.log( + JSON.stringify({ + decision: "deny", + reason: getPlanDeniedPrompt("mistral-vibe", undefined, { + toolName: getPlanToolName("mistral-vibe"), + planFileRule: "", + feedback: vibeResult.feedback || "Plan changes requested", + }), + }) + ); + } + + process.exit(0); + } + let planContent = ""; let permissionMode = "default"; let isGemini = false; diff --git a/apps/hook/server/session-log.ts b/apps/hook/server/session-log.ts index e209e462f..93b780483 100644 --- a/apps/hook/server/session-log.ts +++ b/apps/hook/server/session-log.ts @@ -28,6 +28,16 @@ const factoryConfigDir = process.env.FACTORY_CONFIG_DIR || join(homedir(), ".factory"); const DEFAULT_FACTORY_SESSIONS_DIR = join(factoryConfigDir, "sessions"); +/** + * Resolve the Vibe home directory. Vibe (Mistral's TUI agent) respects the + * VIBE_HOME env var and defaults to ~/.vibe (mirrors vibe/utils/paths.py). + */ +function resolveVibeHome(): string { + const raw = process.env.VIBE_HOME; + if (raw) return raw.startsWith("~") ? join(homedir(), raw.slice(1)) : raw; + return join(homedir(), ".vibe"); +} + /** * Normalize a cwd for comparison. On Windows, filesystems are case-insensitive * and processes can report drive letters in either case, so we lowercase and @@ -883,3 +893,172 @@ export function getRecentRenderedMessages( return []; } } + +// --- Mistral Vibe session discovery --- + +/** + * Vibe session index entry shape. Vibe writes a session index at + * $VIBE_HOME/logs/session/.session_index.json mapping session directory names + * to metadata. Only the fields we use are declared; others are tolerated. + */ +interface VibeSessionIndexEntry { + session_id: string; + cwd: string; + mtime_ns: number; + parent_session_id?: string | null; +} +type VibeSessionIndex = Record; + +function readVibeSessionIndex( + sessionLogDir: string, +): VibeSessionIndex | null { + const indexPath = join(sessionLogDir, ".session_index.json"); + try { + return JSON.parse(readFileSync(indexPath, "utf-8")); + } catch { + return null; + } +} + +/** + * Resolve the Vibe session log file (messages.jsonl) for a given cwd. + * + * Vibe stores sessions as directories under $VIBE_HOME/logs/session/, each + * containing a messages.jsonl transcript. A .session_index.json maps each + * directory name to { session_id, cwd, mtime_ns }. We filter by cwd and pick + * the newest by mtime_ns. If no index exists, fall back to scanning the + * directory for the newest session__/ whose messages.jsonl exists. + * + * Returns the absolute path to messages.jsonl, or null if none match. + */ +export function resolveVibeSessionLogForCwd( + cwd: string, + opts: { vibeHome?: string } = {}, +): string | null { + const vibeHome = opts.vibeHome + ? (opts.vibeHome.startsWith("~") ? join(homedir(), opts.vibeHome.slice(1)) : opts.vibeHome) + : resolveVibeHome(); + const sessionLogDir = join(vibeHome, "logs", "session"); + const normalizedTarget = normalizeCwdForCompare(cwd); + + const index = readVibeSessionIndex(sessionLogDir); + if (index) { + let bestDir: string | null = null; + let bestMtime = -1; + for (const [dirName, entry] of Object.entries(index)) { + if (!entry?.cwd || !entry?.mtime_ns) continue; + if (normalizeCwdForCompare(entry.cwd) !== normalizedTarget) continue; + if (entry.mtime_ns > bestMtime) { + const messagesPath = join(sessionLogDir, dirName, "messages.jsonl"); + try { + statSync(messagesPath); + bestMtime = entry.mtime_ns; + bestDir = messagesPath; + } catch { + continue; + } + } + } + // The index is authoritative: a present-but-no-match means no session + // for this cwd, so do not fall through to the cwd-blind mtime scan. + return bestDir; + } + + // Fallback: scan session__/ directories by mtime when the index is + // absent. Picks the newest whose messages.jsonl exists. + let dirs: string[]; + try { + dirs = readdirSync(sessionLogDir).filter((d) => d.startsWith("session_")); + } catch { + return null; + } + let newest: string | null = null; + let newestMtime = -1; + for (const d of dirs) { + const messagesPath = join(sessionLogDir, d, "messages.jsonl"); + try { + const mtime = statSync(messagesPath).mtimeMs; + if (mtime > newestMtime) { + newestMtime = mtime; + newest = messagesPath; + } + } catch { + continue; + } + } + return newest; +} + +interface VibeMessageEntry { + role?: string; + content?: string; + message_id?: string; + reasoning_content?: string; + tool_calls?: unknown[]; +} + +/** + * Extract up to `limit` recent rendered assistant messages from a Vibe + * messages.jsonl transcript, newest-first. + * + * Vibe's transcript line shape: { role, content, message_id, reasoning_content?, + * tool_calls? }. A rendered assistant message has role === "assistant" and a + * non-empty string `content`; reasoning-only or tool-call-only turns are skipped. + * Chunks sharing a message_id are concatenated. + */ +export function getRecentVibeMessages( + logPath: string, + limit: number, +): RenderedMessage[] { + if (limit <= 0) return []; + let lines: string[]; + try { + lines = readFileSync(logPath, "utf-8").split("\n"); + } catch { + return []; + } + + const buckets = new Map< + string, + { texts: string[]; lineNums: number[]; timestamp?: string } + >(); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + let entry: VibeMessageEntry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (entry.role !== "assistant") continue; + const text = typeof entry.content === "string" ? entry.content.trim() : ""; + if (!text) continue; + const msgId = entry.message_id; + if (!msgId) continue; + + let bucket = buckets.get(msgId); + if (!bucket) { + if (buckets.size >= limit) continue; + bucket = { texts: [], lineNums: [] }; + buckets.set(msgId, bucket); + } + bucket.texts.push(text); + bucket.lineNums.push(i + 1); + } + + return Array.from(buckets, ([messageId, b]) => { + const chrono = b.texts.slice().reverse(); + return { + messageId, + text: chrono.join("\n"), + lineNumbers: b.lineNums.slice().reverse(), + }; + }); +} + +/** Convenience: the single most recent rendered assistant message in a Vibe log. */ +export function getLastVibeRenderedMessage(logPath: string): RenderedMessage | null { + return getRecentVibeMessages(logPath, 1)[0] ?? null; +} diff --git a/apps/hook/server/vibe-plan.test.ts b/apps/hook/server/vibe-plan.test.ts new file mode 100644 index 000000000..ec37b3ded --- /dev/null +++ b/apps/hook/server/vibe-plan.test.ts @@ -0,0 +1,83 @@ +/** + * Vibe Plan Resolver Tests + * + * Run: bun test apps/hook/server/vibe-plan.test.ts + * + * Uses synthetic fixtures in temp dirs matching Vibe's $VIBE_HOME/plans layout. + */ + +import { describe, expect, test, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveLatestVibePlan, resolveVibePlansDir } from "./vibe-plan"; + +const tempDirs: string[] = []; + +function cleanup() { + for (const d of tempDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +} +afterEach(cleanup); + +function makeVibeHome(): string { + const home = mkdtempSync(join(tmpdir(), "vibe-home-")); + tempDirs.push(home); + mkdirSync(join(home, "plans"), { recursive: true }); + return home; +} + +describe("resolveVibePlansDir", () => { + test("defaults to ~/.vibe/plans when VIBE_HOME unset", () => { + delete process.env.VIBE_HOME; + const dir = resolveVibePlansDir(); + expect(dir).toBe(join(require("node:os").homedir(), ".vibe", "plans")); + }); + + test("respects VIBE_HOME override", () => { + const tmp = mkdtempSync(join(tmpdir(), "vibe-override-")); + tempDirs.push(tmp); + process.env.VIBE_HOME = tmp; + expect(resolveVibePlansDir()).toBe(join(tmp, "plans")); + delete process.env.VIBE_HOME; + }); + + test("accepts explicit vibeHome argument over env", () => { + process.env.VIBE_HOME = mkdtempSync(join(tmpdir(), "ignored-")); + tempDirs.push(process.env.VIBE_HOME!); + const arg = mkdtempSync(join(tmpdir(), "arg-wins-")); + tempDirs.push(arg); + expect(resolveVibePlansDir(arg)).toBe(join(arg, "plans")); + delete process.env.VIBE_HOME; + }); +}); + +describe("resolveLatestVibePlan", () => { + test("returns the newest .md plan by mtime", () => { + const home = makeVibeHome(); + const old = join(home, "plans", "1780000000-old-plan.md"); + const newer = join(home, "plans", "1780001000-new-plan.md"); + writeFileSync(old, "# Old plan"); + writeFileSync(newer, "# New plan"); + // Force old to be older than newer + utimesSync(old, 1, 1); + utimesSync(newer, 100, 100); + expect(resolveLatestVibePlan({ vibeHome: home })).toBe("# New plan"); + }); + + test("returns null when plans dir is empty", () => { + const home = makeVibeHome(); + expect(resolveLatestVibePlan({ vibeHome: home })).toBeNull(); + }); + + test("returns null when plans dir does not exist", () => { + const home = mkdtempSync(join(tmpdir(), "vibe-noplans-")); + tempDirs.push(home); + expect(resolveLatestVibePlan({ vibeHome: home })).toBeNull(); + }); + + test("ignores non-.md files", () => { + const home = makeVibeHome(); + writeFileSync(join(home, "plans", "note.txt"), "not a plan"); + expect(resolveLatestVibePlan({ vibeHome: home })).toBeNull(); + }); +}); diff --git a/apps/hook/server/vibe-plan.ts b/apps/hook/server/vibe-plan.ts new file mode 100644 index 000000000..33aae99db --- /dev/null +++ b/apps/hook/server/vibe-plan.ts @@ -0,0 +1,77 @@ +/** + * Vibe Plan Resolver + * + * Vibe (Mistral's TUI coding agent) writes plans to + * $VIBE_HOME/plans/{timestamp}-{slug}.md (global dir, NOT per-project) + * and the plan is NOT carried in the `exit_plan_mode` hook payload + * (the tool's args model is empty). The pre_tool hook only tells us the + * tool is about to fire, so we resolve the plan by picking the newest + * `*.md` file in the plans directory by mtime. + * + * VIBE_HOME resolution mirrors vibe/utils/paths.py:get_vibe_home(): + * $VIBE_HOME env var → ~/.vibe (default), then appends /plans. + */ + +import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +function expandTilde(p: string): string { + if (p.startsWith("~")) return join(homedir(), p.slice(1)); + return p; +} + +/** Resolve the Vibe plans directory ($VIBE_HOME/plans, default ~/.vibe/plans). */ +export function resolveVibePlansDir(vibeHomeOverride?: string): string { + const vibeHome = vibeHomeOverride + ? expandTilde(vibeHomeOverride) + : process.env.VIBE_HOME + ? expandTilde(process.env.VIBE_HOME) + : join(homedir(), ".vibe"); + return join(vibeHome, "plans"); +} + +/** + * Read the most-recently-modified `*.md` plan from the Vibe plans dir. + * Returns the file contents, or null when the dir is missing/empty. + * + * Concurrency caveat: Vibe's plans dir is global, so two sessions exiting + * plan mode simultaneously could race. Newest-by-mtime is the best + * available heuristic since the hook carries no plan path. + */ +export function resolveLatestVibePlan(opts?: { vibeHome?: string }): string | null { + const plansDir = resolveVibePlansDir(opts?.vibeHome); + if (!existsSync(plansDir)) return null; + + let entries: string[]; + try { + entries = readdirSync(plansDir); + } catch { + return null; + } + + const mdFiles = entries.filter((f) => f.endsWith(".md")); + if (mdFiles.length === 0) return null; + + let newestFile: string | null = null; + let newestMtime = -1; + for (const f of mdFiles) { + try { + const mtime = statSync(join(plansDir, f)).mtimeMs; + if (mtime > newestMtime) { + newestMtime = mtime; + newestFile = f; + } + } catch { + continue; + } + } + + if (!newestFile) return null; + + try { + return readFileSync(join(plansDir, newestFile), "utf-8"); + } catch { + return null; + } +} diff --git a/apps/hook/server/vibe-session.test.ts b/apps/hook/server/vibe-session.test.ts new file mode 100644 index 000000000..3fd1bc2d4 --- /dev/null +++ b/apps/hook/server/vibe-session.test.ts @@ -0,0 +1,182 @@ +/** + * Vibe Session Discovery Tests + * + * Run: bun test apps/hook/server/vibe-session.test.ts + * + * Uses synthetic fixtures matching Vibe's $VIBE_HOME/logs/session layout: + * session__/messages.jsonl + * .session_index.json — { "": { session_id, cwd, mtime_ns } } + */ + +import { describe, expect, test, afterEach } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync, utimesSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + resolveVibeSessionLogForCwd, + getRecentVibeMessages, + getLastVibeRenderedMessage, +} from "./session-log"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const d of tempDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function makeVibeHome(): string { + const home = mkdtempSync(join(tmpdir(), "vibe-home-")); + tempDirs.push(home); + mkdirSync(join(home, "logs", "session"), { recursive: true }); + return home; +} + +function writeSession( + home: string, + dirName: string, + messages: string, + mtimeSec?: number, +): string { + const sessionDir = join(home, "logs", "session", dirName); + mkdirSync(sessionDir, { recursive: true }); + const messagesPath = join(sessionDir, "messages.jsonl"); + writeFileSync(messagesPath, messages); + if (mtimeSec !== undefined) utimesSync(messagesPath, mtimeSec, mtimeSec); + return messagesPath; +} + +function vibeLine( + role: string, + content: string, + messageId: string, +): string { + return JSON.stringify({ role, content, message_id: messageId, injected: false }); +} + +describe("resolveVibeSessionLogForCwd", () => { + test("picks the newest session whose cwd matches via the index", () => { + const home = makeVibeHome(); + const cwd = "/Users/test/project"; + writeSession(home, "session_1_old", vibeLine("assistant", "old", "m1"), 1); + writeSession(home, "session_2_new", vibeLine("assistant", "new", "m2"), 100); + + writeFileSync( + join(home, "logs", "session", ".session_index.json"), + JSON.stringify({ + "session_1_old": { session_id: "s1", cwd, mtime_ns: 1_000_000 }, + "session_2_new": { session_id: "s2", cwd, mtime_ns: 100_000_000 }, + }), + ); + + const log = resolveVibeSessionLogForCwd(cwd, { vibeHome: home }); + expect(log).toBeTruthy(); + expect(log!.endsWith("session_2_new/messages.jsonl")).toBe(true); + }); + + test("skips sessions whose cwd differs", () => { + const home = makeVibeHome(); + writeSession(home, "session_a", vibeLine("assistant", "x", "m1"), 100); + writeFileSync( + join(home, "logs", "session", ".session_index.json"), + JSON.stringify({ + "session_a": { session_id: "a", cwd: "/elsewhere", mtime_ns: 100_000_000 }, + }), + ); + expect(resolveVibeSessionLogForCwd("/Users/test/project", { vibeHome: home })).toBeNull(); + }); + + test("falls back to directory scan when the index is absent", () => { + const home = makeVibeHome(); + const cwd = "/Users/test/project"; + writeSession(home, "session_old", vibeLine("assistant", "old", "m1"), 1); + writeSession(home, "session_new", vibeLine("assistant", "new", "m2"), 100); + // No .session_index.json — should pick the newest by file mtime. + const log = resolveVibeSessionLogForCwd(cwd, { vibeHome: home }); + expect(log).toBeTruthy(); + expect(log!.endsWith("session_new/messages.jsonl")).toBe(true); + }); + + test("returns null when no sessions exist", () => { + const home = makeVibeHome(); + expect(resolveVibeSessionLogForCwd("/Users/test/project", { vibeHome: home })).toBeNull(); + }); +}); + +describe("getRecentVibeMessages", () => { + test("extracts the last assistant message with text content", () => { + const home = makeVibeHome(); + const messagesPath = writeSession( + home, + "session_1", + [ + vibeLine("user", "hello", "u1"), + vibeLine("assistant", "first reply", "a1"), + vibeLine("assistant", "second reply", "a2"), + ].join("\n"), + ); + const last = getLastVibeRenderedMessage(messagesPath); + expect(last).not.toBeNull(); + expect(last!.text).toBe("second reply"); + expect(last!.messageId).toBe("a2"); + }); + + test("skips reasoning-only and tool-call-only assistant turns", () => { + const home = makeVibeHome(); + const messagesPath = writeSession( + home, + "session_1", + [ + vibeLine("assistant", "real reply", "a1"), + JSON.stringify({ + role: "assistant", + content: "", + reasoning_content: "thinking...", + message_id: "a2", + tool_calls: [{ id: "t1", function: { name: "bash", arguments: "{}" } }], + }), + ].join("\n"), + ); + const last = getLastVibeRenderedMessage(messagesPath); + expect(last).not.toBeNull(); + expect(last!.text).toBe("real reply"); + }); + + test("returns empty list when no assistant text exists", () => { + const home = makeVibeHome(); + const messagesPath = writeSession(home, "session_1", vibeLine("user", "hi", "u1")); + expect(getRecentVibeMessages(messagesPath, 5)).toEqual([]); + }); + + test("concatenates chunks sharing a message id", () => { + const home = makeVibeHome(); + const messagesPath = writeSession( + home, + "session_1", + [ + vibeLine("assistant", "part one", "a1"), + vibeLine("assistant", "part two", "a1"), + ].join("\n"), + ); + const msgs = getRecentVibeMessages(messagesPath, 5); + expect(msgs).toHaveLength(1); + expect(msgs[0].text).toBe("part one\npart two"); + }); + + test("respects the limit, keeping the newest messages", () => { + const home = makeVibeHome(); + const messagesPath = writeSession( + home, + "session_1", + [ + vibeLine("assistant", "oldest", "a1"), + vibeLine("assistant", "middle", "a2"), + vibeLine("assistant", "newest", "a3"), + ].join("\n"), + ); + const msgs = getRecentVibeMessages(messagesPath, 2); + expect(msgs).toHaveLength(2); + // newest-first + expect(msgs[0].text).toBe("newest"); + expect(msgs[1].text).toBe("middle"); + }); +}); diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md index 9398f26e9..903c392c8 100644 --- a/apps/marketing/src/content/docs/reference/environment-variables.md +++ b/apps/marketing/src/content/docs/reference/environment-variables.md @@ -17,7 +17,7 @@ All Plannotator environment variables and their defaults. | `PLANNOTATOR_URL_HOST` | unset (`localhost`) | Display-only hostname for advertised session URLs, e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:`. Host only: bare hostname, IPv4, or bracketed IPv6 such as `[fd7a::1]` — the runtime-chosen port is always appended, and a value carrying a scheme, port, path, or whitespace warns on stderr and falls back to `localhost`. Strictly display-only and remote-only: it never changes which interface the server binds (that stays governed by `PLANNOTATOR_REMOTE`), and a local session ignores the override — the advertised URL stays `localhost`, with a stderr warning to set `PLANNOTATOR_REMOTE=1`. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence, and setting it to an empty value suppresses a config-file `urlHost`. | | `PLANNOTATOR_BROWSER` | system default | Custom browser to open the UI in. macOS: app name or path. Linux/Windows: executable path. Can also be a script. Takes priority over `BROWSER`. Also settable per-invocation with `--browser`. | | `BROWSER` | (none) | Standard env var for specifying a browser. VS Code sets this automatically in devcontainers. Used as fallback when `PLANNOTATOR_BROWSER` is not set. | -| `PLANNOTATOR_ORIGIN` | auto-detect | Explicit agent-origin override. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `pi`, `gemini-cli`, `kiro-cli`. Invalid values silently fall through to env-based detection. | +| `PLANNOTATOR_ORIGIN` | auto-detect | Explicit agent-origin override. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `pi`, `oh-my-pi`, `gemini-cli`, `kiro-cli`, `mistral-vibe`. Invalid values silently fall through to env-based detection. | | `PLANNOTATOR_READY_FILE` | (none) | Internal host-plugin side channel. When set, Plannotator appends server-ready JSON lines containing the local UI URL. | | `PLANNOTATOR_SKIP_BROWSER_OPEN` | unset | Internal host-plugin flag. Set to `1` to prevent Plannotator from opening the browser itself when the host will open the URL. | | `PLANNOTATOR_AI` | enabled | Set to `disabled` to disable Ask AI and the Review Agents / Guided Review execution surfaces, including provider and agent-job endpoints. Persisted guide data is retained and its server APIs remain available, but the in-app history browser is hidden while AI is disabled. External agents can still open reviews and submit annotations; the annotate agent terminal is separate. | diff --git a/apps/skills/core/plannotator/SKILL.md b/apps/skills/core/plannotator/SKILL.md index e2f4e2fd9..18c21fd1a 100644 --- a/apps/skills/core/plannotator/SKILL.md +++ b/apps/skills/core/plannotator/SKILL.md @@ -168,7 +168,7 @@ plannotator improve-context | --- | --- | | `PLANNOTATOR_REMOTE=1` | Force remote mode (fixed port 19432, wide bind) for SSH/devcontainer sessions; `0` forces local. Unset means SSH auto-detection. | | `PLANNOTATOR_PORT` | Fix the port instead of a random one. | -| `PLANNOTATOR_ORIGIN` | Override agent-origin detection (`claude-code`, `codex`, `opencode`, `pi`, `oh-my-pi`, `amp`, `droid`, `copilot-cli`, `gemini-cli`, `kiro-cli`). Set it when launching Plannotator from a wrapper the detection cannot see through. | +| `PLANNOTATOR_ORIGIN` | Override agent-origin detection (`claude-code`, `codex`, `opencode`, `pi`, `oh-my-pi`, `amp`, `droid`, `copilot-cli`, `gemini-cli`, `kiro-cli`, `mistral-vibe`). Set it when launching Plannotator from a wrapper the detection cannot see through. | | `PLANNOTATOR_AI=disabled` | Disable Ask AI and agent-launched review surfaces in the UI. | | `PLANNOTATOR_SHARE=disabled` | Disable URL sharing, including guide share links. | | `PLANNOTATOR_DATA_DIR` | Move the data directory (default `~/.plannotator`): plans, history, drafts, config. | diff --git a/apps/vibe/hooks/config-snippet.toml b/apps/vibe/hooks/config-snippet.toml new file mode 100644 index 000000000..1c89c9b8a --- /dev/null +++ b/apps/vibe/hooks/config-snippet.toml @@ -0,0 +1,6 @@ +# Plannotator-managed Vibe config snippet. +# Merged into $VIBE_HOME/config.toml by the installer: if the file or the +# enable_experimental_hooks key is absent, it is appended; an existing true +# value is left alone. Vibe requires enable_experimental_hooks = true for +# any hook in hooks.toml to fire. +enable_experimental_hooks = true diff --git a/apps/vibe/hooks/hooks.toml b/apps/vibe/hooks/hooks.toml new file mode 100644 index 000000000..1face773e --- /dev/null +++ b/apps/vibe/hooks/hooks.toml @@ -0,0 +1,25 @@ +# Plannotator-managed Vibe hook block (managed; do not edit). +# Installed by scripts/install.sh into $VIBE_HOME/hooks.toml. The installer +# strips and rewrites everything between the markers on each run, so hand +# edits inside the block are lost — keep custom hooks outside the markers. +# Vibe spawns hook commands via /bin/sh on macOS/Linux, so a bare binary on +# PATH works. Windows is not supported here (Vibe runs hooks via cmd.exe and +# a .sh launcher is not executable); the Windows installers print manual +# setup instructions instead. +# +# The pre_tool hook on exit_plan_mode is the plan-review gate. The plan is +# NOT carried in the exit_plan_mode payload (its args model is empty), so +# plannotator resolves the plan by reading the newest *.md file in +# $VIBE_HOME/plans by mtime. Approve → {"decision":"allow"}; Deny → +# {"decision":"deny","reason":"..."} (the reason becomes the LLM-visible +# tool error). A startup failure fails open: empty stdout + exit 0, so +# exit_plan_mode proceeds and the user is never locked out of plan mode. +# >>> plannotator-managed-vibe-hooks (managed; do not edit) >>> +[[hooks]] +name = "plannotator-exit-plan-mode" +type = "pre_tool" +match = "exit_plan_mode" +command = "PLANNOTATOR_ORIGIN=mistral-vibe plannotator" +timeout = 345600 +description = "Plannotator plan review (managed)" +# <<< plannotator-managed-vibe-hooks <<< diff --git a/apps/vibe/skills/plannotator-annotate/SKILL.md b/apps/vibe/skills/plannotator-annotate/SKILL.md new file mode 100644 index 000000000..e91b1b60c --- /dev/null +++ b/apps/vibe/skills/plannotator-annotate/SKILL.md @@ -0,0 +1,17 @@ +--- +name: plannotator-annotate +disable-model-invocation: true +description: Open Plannotator's annotation UI for a file, folder, or URL, then address the returned annotations. +--- + +# Plannotator Annotate (Mistral Vibe) + +Run: + +```bash +PLANNOTATOR_ORIGIN=mistral-vibe plannotator annotate $ARGUMENTS +``` + +`$ARGUMENTS` should be a markdown or plain-text config file path (.md, .txt, .yaml, .json, .toml, .ini, .csv, .log, …), folder path, html file path, or URL. + +If the command reports that the arguments could not be resolved to a file, URL, or folder, work out which target the user means and re-run the command yourself with that concrete path or URL. diff --git a/apps/vibe/skills/plannotator-last/SKILL.md b/apps/vibe/skills/plannotator-last/SKILL.md new file mode 100644 index 000000000..b42f1a901 --- /dev/null +++ b/apps/vibe/skills/plannotator-last/SKILL.md @@ -0,0 +1,31 @@ +--- +name: plannotator-last +disable-model-invocation: true +description: Open Plannotator on the latest rendered assistant message and use the returned annotations to revise that message or continue. +--- + +# Plannotator Last (Mistral Vibe) + +Use this skill when the user wants to annotate the latest assistant response in Plannotator. + +Do not send a commentary/status message before running the command. The command +targets the latest rendered assistant response, so a preamble can mistakenly become the +thing being annotated. + +Run: + +```bash +PLANNOTATOR_ORIGIN=mistral-vibe plannotator last +``` + +Behavior: + +1. Run the command. +2. Wait for the annotation session to finish. +3. If feedback is returned, incorporate it into the follow-up response. +4. If the session closes without feedback, mention that briefly and continue. +5. An approval may still carry notes — a `"decision": "approved"` result with a + `"feedback"` field. Read those notes and carry them into subsequent work, but + do not redo the message over them: they are guidance, not a change request. + +Run the command yourself rather than telling the user to invoke shell syntax manually. diff --git a/apps/vibe/skills/plannotator-review/SKILL.md b/apps/vibe/skills/plannotator-review/SKILL.md new file mode 100644 index 000000000..a7e541f31 --- /dev/null +++ b/apps/vibe/skills/plannotator-review/SKILL.md @@ -0,0 +1,19 @@ +--- +name: plannotator-review +disable-model-invocation: true +description: Open Plannotator's browser-based code review UI and address the returned feedback. +--- + +# Plannotator Review (Mistral Vibe) + +Run: + +```bash +PLANNOTATOR_ORIGIN=mistral-vibe plannotator review +``` + +You may append an optional PR URL: + +```bash +PLANNOTATOR_ORIGIN=mistral-vibe plannotator review +``` diff --git a/packages/core/agents.ts b/packages/core/agents.ts index 98d471f99..4bc860b6a 100644 --- a/packages/core/agents.ts +++ b/packages/core/agents.ts @@ -27,6 +27,7 @@ export const AGENT_CONFIG = { 'codex': { name: 'Codex', badge: 'bg-purple-500/15 text-purple-400', aiProviderTypes: ['codex-sdk'] }, 'gemini-cli': { name: 'Gemini CLI', badge: 'bg-sky-500/15 text-sky-400' }, 'oh-my-pi': { name: 'Oh My Pi', badge: 'bg-fuchsia-500/15 text-fuchsia-400' }, + 'mistral-vibe': { name: 'Mistral Vibe', badge: 'bg-rose-500/15 text-rose-400' }, } as const satisfies Record; /** All recognized origin values. */ diff --git a/packages/server/index.ts b/packages/server/index.ts index ba8bf86cb..326fb260e 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -8,8 +8,8 @@ * PLANNOTATOR_PORT - Fixed port or inclusive range (default: random locally, 19432 for remote) * PLANNOTATOR_ORIGIN - Explicit origin override; validated against AGENT_CONFIG * in packages/shared/agents.ts. Supported values: - * "claude-code", "amp", "droid", "kiro-cli", "opencode", - * "codex", "copilot-cli", "gemini-cli", "pi", "oh-my-pi". + * "claude-code", "amp", "droid", "kiro-cli", "mistral-vibe", + * "opencode", "codex", "copilot-cli", "gemini-cli", "pi", "oh-my-pi". */ import type { Origin } from "@plannotator/shared/agents"; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 230e0a7bd..591e80420 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -46,7 +46,8 @@ export type PromptRuntime = | "pi" | "codex" | "gemini-cli" - | "oh-my-pi"; + | "oh-my-pi" + | "mistral-vibe"; interface PromptSectionConfig { [key: string]: string | Partial> | undefined; diff --git a/packages/shared/prompts.ts b/packages/shared/prompts.ts index 46c38eea2..cf19374ef 100644 --- a/packages/shared/prompts.ts +++ b/packages/shared/prompts.ts @@ -25,6 +25,7 @@ export const PLAN_TOOL_NAMES: Record = { codex: "ExitPlanMode", "gemini-cli": "exit_plan_mode", "oh-my-pi": "ExitPlanMode", + "mistral-vibe": "exit_plan_mode", }; export function getPlanToolName(runtime?: PromptRuntime | null): string { diff --git a/scripts/install.cmd b/scripts/install.cmd index f053939a5..d0ab67911 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -34,6 +34,7 @@ REM > default off) happens after _CONFIG_DIR is known. set "SKIP_CODEX_FLAG=0" set "SKIP_GEMINI_FLAG=0" set "SKIP_KIRO_FLAG=0" +set "SKIP_VIBE_FLAG=0" set "SKIP_OPENCODE_FLAG=0" REM Same shape, but scoped to the skills/slash-command sparse checkout rather REM than one agent's home: --skip-skills turns the whole fetch into a no-op for @@ -161,6 +162,11 @@ if /i "%~1"=="--skip-kiro" ( shift goto parse_args ) +if /i "%~1"=="--skip-vibe" ( + set "SKIP_VIBE_FLAG=1" + shift + goto parse_args +) if /i "%~1"=="--skip-opencode" ( set "SKIP_OPENCODE_FLAG=1" shift @@ -185,7 +191,7 @@ REM unquoted arg containing `&` would re-trigger metacharacter interpretation. set "CURRENT_ARG=%~1" if "!CURRENT_ARG:~0,1!"=="-" ( echo Unknown option: "%~1" >&2 - echo Usage: install.cmd [--version ^] [--verify-attestation ^| --skip-attestation] [--with-call-flow] [--extras ^| --no-extras] [--model-invocable ^] [--minimal ^| --no-minimal] [--skip-codex] [--skip-gemini] [--skip-kiro] [--skip-opencode] [--skip-skills] [--non-interactive] [--reconfigure] >&2 + echo Usage: install.cmd [--version ^] [--verify-attestation ^| --skip-attestation] [--with-call-flow] [--extras ^| --no-extras] [--model-invocable ^] [--minimal ^| --no-minimal] [--skip-codex] [--skip-gemini] [--skip-kiro] [--skip-vibe] [--skip-opencode] [--skip-skills] [--non-interactive] [--reconfigure] >&2 exit /b 1 ) REM Positional form: install.cmd vX.Y.Z (legacy interface). @@ -488,6 +494,8 @@ set "SKIP_GEMINI=0" set "SKIP_GEMINI_SOURCE=" set "SKIP_KIRO=0" set "SKIP_KIRO_SOURCE=" +set "SKIP_VIBE=0" +set "SKIP_VIBE_SOURCE=" set "SKIP_OPENCODE=0" set "SKIP_OPENCODE_SOURCE=" REM skipInstall.skills is not an agent - it opts out of the skills/slash-command @@ -496,7 +504,7 @@ set "SKIP_SKILLS=0" set "SKIP_SKILLS_SOURCE=" if exist "!_CONFIG_DIR!\config.json" ( set "PLN_CONFIG_JSON=!_CONFIG_DIR!\config.json" - for /f "usebackq delims=" %%K in (`powershell -NoProfile -Command "try { $c = Get-Content $env:PLN_CONFIG_JSON -Raw | ConvertFrom-Json } catch { exit 0 }; if (-not $c.skipInstall) { exit 0 }; foreach ($k in @('codex','gemini','kiro','opencode','skills')) { $v = $c.skipInstall.$k; if ($v -is [bool] -and $v) { $k } }"`) do ( + for /f "usebackq delims=" %%K in (`powershell -NoProfile -Command "try { $c = Get-Content $env:PLN_CONFIG_JSON -Raw | ConvertFrom-Json } catch { exit 0 }; if (-not $c.skipInstall) { exit 0 }; foreach ($k in @('codex','gemini','kiro','vibe','opencode','skills')) { $v = $c.skipInstall.$k; if ($v -is [bool] -and $v) { $k } }"`) do ( if /i "%%K"=="codex" ( set "SKIP_CODEX=1" set "SKIP_CODEX_SOURCE=config skipInstall.codex" @@ -509,6 +517,10 @@ if exist "!_CONFIG_DIR!\config.json" ( set "SKIP_KIRO=1" set "SKIP_KIRO_SOURCE=config skipInstall.kiro" ) + if /i "%%K"=="vibe" ( + set "SKIP_VIBE=1" + set "SKIP_VIBE_SOURCE=config skipInstall.vibe" + ) if /i "%%K"=="opencode" ( set "SKIP_OPENCODE=1" set "SKIP_OPENCODE_SOURCE=config skipInstall.opencode" @@ -544,6 +556,14 @@ for %%V in (0 false no) do if /i "!PLANNOTATOR_SKIP_KIRO_INSTALL!"=="%%V" ( set "SKIP_KIRO=0" set "SKIP_KIRO_SOURCE=" ) +for %%V in (1 true yes) do if /i "!PLANNOTATOR_SKIP_VIBE_INSTALL!"=="%%V" ( + set "SKIP_VIBE=1" + set "SKIP_VIBE_SOURCE=PLANNOTATOR_SKIP_VIBE_INSTALL" +) +for %%V in (0 false no) do if /i "!PLANNOTATOR_SKIP_VIBE_INSTALL!"=="%%V" ( + set "SKIP_VIBE=0" + set "SKIP_VIBE_SOURCE=" +) for %%V in (1 true yes) do if /i "!PLANNOTATOR_SKIP_OPENCODE_INSTALL!"=="%%V" ( set "SKIP_OPENCODE=1" set "SKIP_OPENCODE_SOURCE=PLANNOTATOR_SKIP_OPENCODE_INSTALL" @@ -572,6 +592,10 @@ if "!SKIP_KIRO_FLAG!"=="1" ( set "SKIP_KIRO=1" set "SKIP_KIRO_SOURCE=--skip-kiro" ) +if "!SKIP_VIBE_FLAG!"=="1" ( + set "SKIP_VIBE=1" + set "SKIP_VIBE_SOURCE=--skip-vibe" +) if "!SKIP_OPENCODE_FLAG!"=="1" ( set "SKIP_OPENCODE=1" set "SKIP_OPENCODE_SOURCE=--skip-opencode" @@ -913,6 +937,19 @@ set "KIRO_AVAILABLE=0" where kiro-cli >nul 2>&1 if !ERRORLEVEL! equ 0 set "KIRO_AVAILABLE=1" if exist "%USERPROFILE%\.kiro" set "KIRO_AVAILABLE=1" + +REM Vibe (Mistral's TUI coding agent) stores everything under VIBE_HOME when +REM set, falling back to %USERPROFILE%\.vibe. Auto-detected like the others: +REM PATH executable or an existing ~/.vibe. The plan-review hook runs on +REM macOS/Linux only (Vibe spawns hooks via /bin/sh; Windows uses cmd.exe and +REM a .sh launcher is not executable), so the Windows installer never writes +REM hooks.toml - it prints manual setup instructions, mirroring Codex-on-Windows. +REM Skills still install to %VIBE_HOME%\skills. +if not defined VIBE_HOME set "VIBE_HOME=%USERPROFILE%\.vibe" +set "VIBE_AVAILABLE=0" +where vibe >nul 2>&1 +if !ERRORLEVEL! equ 0 set "VIBE_AVAILABLE=1" +if exist "!VIBE_HOME!" set "VIBE_AVAILABLE=1" REM HONEST three-state reporting (#1178): detected-but-skipped is its own REM state, never conflated with "not detected". A Codex opt-out leaves the REM Codex home entirely untouched (no writes, no cleanup, no removal). @@ -947,6 +984,41 @@ if "!CODEX_AVAILABLE!"=="1" if "!SKIP_CODEX!"=="0" ( echo. ) +REM Vibe plan-review hooks run on macOS/Linux only (Vibe spawns hooks via /bin/sh; +REM Windows uses cmd.exe and a .sh launcher is not executable). The Windows +REM installer never writes hooks.toml automatically - it prints manual setup +REM instructions, mirroring Codex-on-Windows. A Vibe opt-out (#1178) suppresses +REM the manual instructions and this run neither creates, updates, nor removes +REM anything under the Vibe home. Skills still install to %VIBE_HOME%\skills. +if "!VIBE_AVAILABLE!"=="1" if "!SKIP_VIBE!"=="1" ( + echo. + echo Vibe: detected, skipped ^(!SKIP_VIBE_SOURCE!^). + echo The Windows installer only prints manual Vibe setup instructions; they + echo were suppressed. + if exist "!VIBE_HOME!\hooks.toml" ( + findstr /c:"plannotator" "!VIBE_HOME!\hooks.toml" >nul 2>&1 + if !ERRORLEVEL! equ 0 echo Your existing Vibe plan-review hook at !VIBE_HOME!\hooks.toml is unaffected. + ) +) +if "!VIBE_AVAILABLE!"=="1" if "!SKIP_VIBE!"=="0" ( + echo. + echo Vibe detected. + echo Vibe plan-review hooks run on macOS/Linux only ^(Vibe spawns hooks via + echo /bin/sh; on Windows a .sh launcher is not executable^). To set up plan + echo review manually on a macOS/Linux box with Vibe, add to ~/.vibe/hooks.toml: + echo. + echo [[hooks]] + echo name = "plannotator-exit-plan-mode" + echo type = "pre_tool" + echo match = "exit_plan_mode" + echo command = "PLANNOTATOR_ORIGIN=mistral-vibe plannotator" + echo timeout = 345600 + echo. + echo And ensure ~/.vibe/config.toml has: enable_experimental_hooks = true + echo. + echo Vibe skills are still installed to !VIBE_HOME!\skills from this run. +) + REM Clear any cached OpenCode plugin to force fresh download on next run. REM An OpenCode opt-out (#1178) leaves OpenCode's own cache directory alone; REM the Bun package cache is a shared cache, not OpenCode's home, and is @@ -1147,6 +1219,7 @@ if "!SKIP_SKILLS!"=="1" ( set "CHECKOUT_FAILED=0" set "KIRO_SKILLS_DIR=%USERPROFILE%\.kiro\skills" set "KIRO_AGENTS_DIR=%USERPROFILE%\.kiro\agents" +set "VIBE_SKILLS_DIR=!VIBE_HOME!\skills" set "OPENCODE_COMMANDS_DIR=%USERPROFILE%\.config\opencode\commands" set "GEMINI_COMMANDS_DIR=%USERPROFILE%\.gemini\commands" set "SKILLS_TMP=%TEMP%\plannotator-skills-%RANDOM%" @@ -1196,7 +1269,7 @@ if "!SPARSE_UNSUPPORTED!"=="1" ( if "!CLONE_OK!"=="1" ( pushd "!SKILLS_TMP!\repo" - if "!SPARSE_CLONE!"=="1" git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands >nul 2>&1 + if "!SPARSE_CLONE!"=="1" git sparse-checkout set apps/skills apps/kiro-cli apps/vibe apps/opencode-plugin/commands apps/gemini/commands >nul 2>&1 REM Claude Code reads apps\skills\claude\* (injection `!`plannotator ... $ARGUMENTS`` REM + allowed-tools, so /plannotator-* run with no permission prompt); Codex @@ -1282,6 +1355,28 @@ if "!CLONE_OK!"=="1" ( echo Installed Kiro skills to !KIRO_SKILLS_DIR!\ and agent to !KIRO_AGENTS_DIR!\plannotator.json ) + REM Vibe - hand-maintained skills (origin baked in) + single-sourced + REM knowledge skill. A Vibe opt-out (#1178) leaves !VIBE_HOME! untouched. + REM Skills work cross-platform; only the hook is macOS/Linux-only. + if "!VIBE_AVAILABLE!"=="1" if "!SKIP_VIBE!"=="0" if exist "apps\vibe\skills" ( + if not exist "!VIBE_SKILLS_DIR!" mkdir "!VIBE_SKILLS_DIR!" + REM Vibe-specific skills with origin baked in come from apps\vibe\skills. + for %%S in (plannotator-review plannotator-annotate plannotator-last) do ( + if exist "apps\vibe\skills\%%S" ( + if exist "!VIBE_SKILLS_DIR!\%%S" rmdir /s /q "!VIBE_SKILLS_DIR!\%%S" >nul 2>&1 + xcopy /s /i /y /q "apps\vibe\skills\%%S" "!VIBE_SKILLS_DIR!\%%S\" >nul 2>&1 + ) + ) + REM The plannotator knowledge skill (CLI reference) is agent-agnostic and + REM single-sourced in apps\skills\core; Vibe gets the same copy every + REM other scope does. + if exist "apps\skills\core\plannotator" ( + if exist "!VIBE_SKILLS_DIR!\plannotator" rmdir /s /q "!VIBE_SKILLS_DIR!\plannotator" >nul 2>&1 + xcopy /s /i /y /q "apps\skills\core\plannotator" "!VIBE_SKILLS_DIR!\plannotator\" >nul 2>&1 + ) + echo Installed Vibe skills to !VIBE_SKILLS_DIR!\ + ) + popd ) else ( set "CHECKOUT_FAILED=1" @@ -1317,12 +1412,14 @@ for %%C in (plannotator-review plannotator-annotate plannotator-last) do ( REM plannotator-archive no longer ships as a skill. Remove any stale installed REM copy from every skill scope so upgraders don't keep a dead skill around. -for %%D in ("!CLAUDE_SKILLS_DIR!" "!AGENTS_SKILLS_DIR!" "!KIRO_SKILLS_DIR!") do ( +for %%D in ("!CLAUDE_SKILLS_DIR!" "!AGENTS_SKILLS_DIR!" "!KIRO_SKILLS_DIR!" "!VIBE_SKILLS_DIR!") do ( REM A Kiro opt-out leaves ~/.kiro entirely untouched - including this sweep. + REM A Vibe opt-out leaves !VIBE_HOME! entirely untouched - including this sweep. REM A skills opt-out leaves every skill scope untouched, sweep included. set "SCOPE_OK=1" if "!SKIP_SKILLS!"=="1" set "SCOPE_OK=0" if /i "%%~D"=="!KIRO_SKILLS_DIR!" if "!SKIP_KIRO!"=="1" set "SCOPE_OK=0" + if /i "%%~D"=="!VIBE_SKILLS_DIR!" if "!SKIP_VIBE!"=="1" set "SCOPE_OK=0" if "!SCOPE_OK!"=="1" if exist "%%~D\plannotator-archive" ( rmdir /s /q "%%~D\plannotator-archive" >nul 2>&1 echo Removed stale plannotator-archive skill from %%~D\plannotator-archive @@ -1504,6 +1601,33 @@ if "!KIRO_AVAILABLE!"=="1" ( echo Kiro was not detected. After installing Kiro, rerun this installer to add Kiro skills. ) +echo. +echo ========================================== +echo VIBE USERS +echo ========================================== +echo. +if "!VIBE_AVAILABLE!"=="1" ( + if "!SKIP_VIBE!"=="1" ( + echo Vibe was detected, but the integration was skipped ^(!SKIP_VIBE_SOURCE!^). + echo No files under !VIBE_HOME! were written or removed. Re-run without + echo the opt-out to add Vibe skills. + ) else if "!SKIP_SKILLS!"=="1" ( + echo Vibe was detected, but skills were skipped ^(!SKIP_SKILLS_SOURCE!^), so no + echo Vibe skills were installed. The plan-review hook is macOS/Linux-only + echo and is not wired from the Windows installer. Re-run without the + echo opt-out to add Vibe skills. + ) else ( + echo Vibe skills are installed to !VIBE_HOME!\skills\ + echo The plan-review hook is macOS/Linux-only ^(Vibe spawns hooks via /bin/sh; + echo on Windows a .sh launcher is not executable^). See the manual setup + echo instructions printed above to wire plan review on a macOS/Linux box. + echo Note: improve-context ^(plan-mode enrichment^) is not wired for Vibe. + ) +) else ( + echo Vibe was not detected. After installing Mistral Vibe, rerun this installer + echo to add Vibe skills. +) + echo. echo Test the install: echo echo {"tool_input":{"plan":"# Test Plan\\n\\nHello world"}} ^| plannotator diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0f1083caf..506efc953 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -18,6 +18,7 @@ param( [switch]$SkipCodex, [switch]$SkipGemini, [switch]$SkipKiro, + [switch]$SkipVibe, [switch]$SkipOpencode, # Same shape as the per-agent switches, but scoped to the skills/slash # command sparse checkout rather than one agent's home: -SkipSkills turns @@ -365,6 +366,7 @@ if ($WithCallFlow) { $installCallFlowResolved = $true } $skipCodexResolved = $false; $skipCodexSource = "" $skipGeminiResolved = $false; $skipGeminiSource = "" $skipKiroResolved = $false; $skipKiroSource = "" +$skipVibeResolved = $false; $skipVibeSource = "" $skipOpencodeResolved = $false; $skipOpencodeSource = "" # skipInstall.skills is not an agent - it opts out of the skills/slash-command # checkout for every scope at once - but it shares the same three layers. @@ -379,6 +381,9 @@ if ($cfg -and $cfg.skipInstall) { if ($cfg.skipInstall.kiro -is [bool] -and $cfg.skipInstall.kiro) { $skipKiroResolved = $true; $skipKiroSource = "config skipInstall.kiro" } + if ($cfg.skipInstall.vibe -is [bool] -and $cfg.skipInstall.vibe) { + $skipVibeResolved = $true; $skipVibeSource = "config skipInstall.vibe" + } if ($cfg.skipInstall.opencode -is [bool] -and $cfg.skipInstall.opencode) { $skipOpencodeResolved = $true; $skipOpencodeSource = "config skipInstall.opencode" } @@ -401,6 +406,11 @@ if ($env:PLANNOTATOR_SKIP_KIRO_INSTALL -match '^(1|true|yes)$') { } elseif ($env:PLANNOTATOR_SKIP_KIRO_INSTALL -match '^(0|false|no)$') { $skipKiroResolved = $false; $skipKiroSource = "" } +if ($env:PLANNOTATOR_SKIP_VIBE_INSTALL -match '^(1|true|yes)$') { + $skipVibeResolved = $true; $skipVibeSource = "PLANNOTATOR_SKIP_VIBE_INSTALL" +} elseif ($env:PLANNOTATOR_SKIP_VIBE_INSTALL -match '^(0|false|no)$') { + $skipVibeResolved = $false; $skipVibeSource = "" +} if ($env:PLANNOTATOR_SKIP_OPENCODE_INSTALL -match '^(1|true|yes)$') { $skipOpencodeResolved = $true; $skipOpencodeSource = "PLANNOTATOR_SKIP_OPENCODE_INSTALL" } elseif ($env:PLANNOTATOR_SKIP_OPENCODE_INSTALL -match '^(0|false|no)$') { @@ -414,6 +424,7 @@ if ($env:PLANNOTATOR_SKIP_SKILLS_INSTALL -match '^(1|true|yes)$') { if ($SkipCodex) { $skipCodexResolved = $true; $skipCodexSource = "-SkipCodex" } if ($SkipGemini) { $skipGeminiResolved = $true; $skipGeminiSource = "-SkipGemini" } if ($SkipKiro) { $skipKiroResolved = $true; $skipKiroSource = "-SkipKiro" } +if ($SkipVibe) { $skipVibeResolved = $true; $skipVibeSource = "-SkipVibe" } if ($SkipOpencode) { $skipOpencodeResolved = $true; $skipOpencodeSource = "-SkipOpencode" } if ($SkipSkills) { $skipSkillsResolved = $true; $skipSkillsSource = "-SkipSkills" } @@ -732,6 +743,14 @@ if (Test-Path $codexDir) { $codexAvailable = [bool](Get-Command codex -ErrorAction SilentlyContinue) -or $codexHomeHasUserConfig # Kiro is auto-detected like Codex/Gemini: PATH executable or an existing ~/.kiro. $kiroAvailable = [bool](Get-Command kiro-cli -ErrorAction SilentlyContinue) -or (Test-Path "$env:USERPROFILE\.kiro") +# Vibe (Mistral's TUI coding agent) stores everything under $VIBE_HOME when set, +# falling back to ~/.vibe. Auto-detected like Codex/Gemini/Kiro: PATH executable +# or an existing ~/.vibe. The plan-review hook runs on macOS/Linux only (Vibe +# spawns hooks via /bin/sh; Windows uses cmd.exe and a .sh launcher is not +# executable), so the Windows installer prints manual hook instructions instead +# of wiring hooks.toml; skills still install to $VIBE_HOME\skills. +$vibeHome = if ($env:VIBE_HOME) { $env:VIBE_HOME } else { Join-Path $env:USERPROFILE ".vibe" } +$vibeAvailable = [bool](Get-Command vibe -ErrorAction SilentlyContinue) -or (Test-Path $vibeHome) if ($codexAvailable -and $skipCodexResolved) { # HONEST three-state reporting (#1178): detected-but-skipped is its own @@ -771,6 +790,45 @@ if ($codexAvailable -and $skipCodexResolved) { Write-Host " $codexExePath" } +# Vibe plan-review hooks run on macOS/Linux only (Vibe spawns hooks via /bin/sh; +# Windows uses cmd.exe and a .sh launcher is not executable). The Windows +# installer never writes $VIBE_HOME\hooks.toml automatically - it prints manual +# hook setup instructions, mirroring the Codex-on-Windows pattern. A Vibe +# opt-out (#1178) suppresses the manual instructions and this run neither +# creates, updates, nor removes anything under the Vibe home. Skills still +# install to $VIBE_HOME\skills from the sparse checkout below. +if ($vibeAvailable -and $skipVibeResolved) { + Write-Host "" + Write-Host "Vibe: detected, skipped ($skipVibeSource)." + Write-Host "The Windows installer only prints manual Vibe setup instructions; they" + Write-Host "were suppressed." + $vibeHooksProbe = Join-Path $vibeHome "hooks.toml" + if (Test-Path $vibeHooksProbe) { + $vibeHooksContent = Get-Content -Path $vibeHooksProbe -Raw -ErrorAction SilentlyContinue + if ($vibeHooksContent -match "plannotator") { + Write-Host "Your existing Vibe plan-review hook at $vibeHome\hooks.toml is unaffected." + } + } +} elseif ($vibeAvailable) { + $vibeExePath = "$installDir\plannotator.exe" + Write-Host "" + Write-Host "Vibe detected." + Write-Host "Vibe plan-review hooks run on macOS/Linux only (Vibe spawns hooks via" + Write-Host "/bin/sh; on Windows a .sh launcher is not executable). To set up plan" + Write-Host "review manually on a macOS/Linux box with Vibe, add to ~/.vibe/hooks.toml:" + Write-Host "" + Write-Host " [[hooks]]" + Write-Host " name = ""plannotator-exit-plan-mode""" + Write-Host " type = ""pre_tool""" + Write-Host " match = ""exit_plan_mode""" + Write-Host " command = ""PLANNOTATOR_ORIGIN=mistral-vibe plannotator""" + Write-Host " timeout = 345600" + Write-Host "" + Write-Host "And ensure ~/.vibe/config.toml has: enable_experimental_hooks = true" + Write-Host "" + Write-Host "Vibe skills are still installed to $vibeHome\skills from this run." +} + # Clear OpenCode plugin cache. An OpenCode opt-out (#1178) leaves OpenCode's # own cache directory alone; the Bun package cache is a shared cache, not # OpenCode's home, and is always cleared. @@ -1158,7 +1216,7 @@ try { # plain-clone fallback (#1238): that git has no sparse-checkout # subcommand, and the full checkout needs no narrowing. if ($sparseClone) { - & { $local:ErrorActionPreference = 'Continue'; git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>$null } + & { $local:ErrorActionPreference = 'Continue'; git sparse-checkout set apps/skills apps/kiro-cli apps/vibe apps/opencode-plugin/commands apps/gemini/commands 2>$null } } # Claude Code and Codex consume different skill bodies. Claude Code @@ -1217,6 +1275,23 @@ try { Write-Host "Installed Kiro skills to $kiroSkillsDir\ and agent to $kiroAgentsDir\plannotator.json" } + # Vibe: hand-maintained skills (origin baked in) + the single-sourced + # knowledge skill. A Vibe opt-out (#1178) leaves $VIBE_HOME untouched. + # Skills work cross-platform; only the hook is macOS/Linux-only. + if ($vibeAvailable -and -not $skipVibeResolved -and (Test-Path "apps\vibe\skills")) { + $vibeSkillsDir = Join-Path $vibeHome "skills" + New-Item -ItemType Directory -Force -Path $vibeSkillsDir | Out-Null + # Vibe-specific skills (origin baked in) come from apps/vibe/skills. + Copy-SkillIfPresent "apps\vibe\skills\plannotator-review" $vibeSkillsDir + Copy-SkillIfPresent "apps\vibe\skills\plannotator-annotate" $vibeSkillsDir + Copy-SkillIfPresent "apps\vibe\skills\plannotator-last" $vibeSkillsDir + # The plannotator knowledge skill (CLI reference) is agent-agnostic + # and single-sourced in apps/skills/core; Vibe gets the same copy + # every other scope does. + Copy-SkillIfPresent "apps\skills\core\plannotator" $vibeSkillsDir + Write-Host "Installed Vibe skills to $vibeSkillsDir\" + } + # OpenCode command stubs -> ~/.config/opencode/commands (always, # unless opted out via -SkipOpencode: #1178). The plugin # intercepts execution; these stubs just register the slash @@ -1284,11 +1359,13 @@ foreach ($cmd in @("plannotator-review", "plannotator-annotate", "plannotator-la # plannotator-archive no longer ships as a skill. Remove any stale installed # copy from every skill scope so upgraders don't keep a dead skill around. -foreach ($scope in @($claudeSkillsDir, $agentsSkillsDir, "$env:USERPROFILE\.kiro\skills")) { +foreach ($scope in @($claudeSkillsDir, $agentsSkillsDir, "$env:USERPROFILE\.kiro\skills", (Join-Path $vibeHome "skills"))) { # A skills opt-out leaves every skill scope untouched, sweep included. if ($skipSkillsResolved) { continue } # A Kiro opt-out leaves ~/.kiro entirely untouched - including this sweep. if ($skipKiroResolved -and ($scope -eq "$env:USERPROFILE\.kiro\skills")) { continue } + # A Vibe opt-out leaves $VIBE_HOME entirely untouched - including this sweep. + if ($skipVibeResolved -and ($scope -eq (Join-Path $vibeHome "skills"))) { continue } $staleArchivePath = Join-Path $scope "plannotator-archive" if (Test-Path $staleArchivePath) { Write-Host "Removing stale plannotator-archive skill $staleArchivePath" @@ -1495,6 +1572,30 @@ if ($kiroAvailable -and $skipKiroResolved) { } Write-Host "" Write-Host "==========================================" +Write-Host " VIBE USERS" +Write-Host "==========================================" +Write-Host "" +if ($vibeAvailable -and $skipVibeResolved) { + Write-Host "Vibe was detected, but the integration was skipped ($skipVibeSource)." + Write-Host "No files under $vibeHome were written or removed. Re-run without the" + Write-Host "opt-out to add Vibe skills." +} elseif ($vibeAvailable -and $skipSkillsResolved) { + Write-Host "Vibe was detected, but skills were skipped ($skipSkillsSource), so no" + Write-Host "Vibe skills were installed. The plan-review hook is macOS/Linux-only and" + Write-Host "is not wired from the Windows installer. Re-run without the opt-out to" + Write-Host "add Vibe skills." +} elseif ($vibeAvailable) { + Write-Host "Vibe skills are installed to $vibeHome\skills\" + Write-Host "The plan-review hook is macOS/Linux-only (Vibe spawns hooks via /bin/sh;" + Write-Host "on Windows a .sh launcher is not executable). See the manual setup" + Write-Host "instructions printed above to wire plan review on a macOS/Linux box." + Write-Host "Note: improve-context (plan-mode enrichment) is not wired for Vibe." +} else { + Write-Host "Vibe was not detected. After installing Mistral Vibe, rerun this installer" + Write-Host "to add Vibe skills." +} +Write-Host "" +Write-Host "==========================================" if ($skipSkillsResolved) { # Never claim the /plannotator-* commands are ready when nothing was # installed - that false banner is exactly what the skills-checkout diff --git a/scripts/install.sh b/scripts/install.sh index b4a43c656..8fdb481e1 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -49,7 +49,7 @@ RECONFIGURE=0 # Binary-only mode. Installs just the plannotator binary (to $INSTALL_DIR) and # no persistent state elsewhere — no sem sidecar, no CallDiff or agent-terminal runtime, no # skills, hooks, slash commands, or per-agent config (Claude, Codex, OpenCode, -# Gemini, Kiro). Set by --minimal (1) / --no-minimal (0); -1 = neither flag +# Gemini, Kiro, Vibe). Set by --minimal (1) / --no-minimal (0); -1 = neither flag # given (fall through to the PLANNOTATOR_MINIMAL env var). Resolved after arg # parsing so a flag overrides the env var in either direction. MINIMAL_FLAG=-1 @@ -61,6 +61,7 @@ MINIMAL_FLAG=-1 SKIP_CODEX_FLAG=0 SKIP_GEMINI_FLAG=0 SKIP_KIRO_FLAG=0 +SKIP_VIBE_FLAG=0 SKIP_OPENCODE_FLAG=0 # Same shape, but scoped to the skills/slash-command sparse checkout rather # than one agent's home: --skip-skills turns the whole fetch into a no-op for @@ -75,7 +76,7 @@ usage() { Usage: install.sh [--version ] [--verify-attestation | --skip-attestation] [--extras | --no-extras] [--model-invocable |none] [--minimal | --no-minimal] [--skip-codex] [--skip-gemini] - [--skip-kiro] [--skip-opencode] [--skip-skills] + [--skip-kiro] [--skip-vibe] [--skip-opencode] [--skip-skills] [--non-interactive] [--reconfigure] [--help] install.sh @@ -104,7 +105,7 @@ Options: --binary-only). Skips the sem semantic-diff sidecar, the CallDiff runtime, the agent-terminal runtime, and every per-agent integration (skills, hooks, slash commands, and config - for Claude, Codex, OpenCode, Gemini, and Kiro). No + for Claude, Codex, OpenCode, Gemini, Kiro, and Vibe). No persistent state is written outside $HOME/.local/bin (a temp download file is still used and removed). Also enabled by exporting PLANNOTATOR_MINIMAL=1. @@ -124,6 +125,11 @@ Options: (~/.kiro skills and agent). Env var: PLANNOTATOR_SKIP_KIRO_INSTALL; config key: skipInstall.kiro. + --skip-vibe Same opt-out for the Mistral Vibe integration + (hooks.toml + config.toml under VIBE_HOME, and the + Vibe-home skills). Env var: + PLANNOTATOR_SKIP_VIBE_INSTALL; config key: + skipInstall.vibe. --skip-opencode Do not write the OpenCode integration (command stubs under ~/.config/opencode/commands and the OpenCode plugin cache clear). OpenCode has no detection leg, @@ -132,7 +138,7 @@ Options: skipInstall.opencode. --skip-skills Do not fetch or write the /plannotator-* skills and slash commands (the sparse checkout that feeds Claude - Code, ~/.agents, OpenCode, Gemini, and Kiro), the + Code, ~/.agents, OpenCode, Gemini, Kiro, and Vibe), the extras, or the skill-scope cleanup sweeps. Nothing already installed is removed. The binary, hooks, and per-agent config still install. Use it where @@ -305,6 +311,10 @@ while [ $# -gt 0 ]; do SKIP_KIRO_FLAG=1 shift ;; + --skip-vibe) + SKIP_VIBE_FLAG=1 + shift + ;; --skip-opencode) SKIP_OPENCODE_FLAG=1 shift @@ -512,6 +522,8 @@ skip_gemini=0 skip_gemini_source="" skip_kiro=0 skip_kiro_source="" +skip_vibe=0 +skip_vibe_source="" skip_opencode=0 skip_opencode_source="" # skipInstall.skills is not an agent — it opts out of the skills/slash-command @@ -546,7 +558,7 @@ if [ -f "$_config_dir/config.json" ]; then }' "$_config_dir/config.json" 2>/dev/null) || _skip_install_block="" fi if [ -n "$_skip_install_block" ]; then - for _agent in codex gemini kiro opencode skills; do + for _agent in codex gemini kiro vibe opencode skills; do if printf '%s' "$_skip_install_block" | grep -q "\"$_agent\"[[:space:]]*:[[:space:]]*false"; then continue # explicit false is a veto, never a skip fi @@ -564,6 +576,10 @@ if [ -n "$_skip_install_block" ]; then skip_kiro=1 skip_kiro_source="config skipInstall.kiro" ;; + vibe) + skip_vibe=1 + skip_vibe_source="config skipInstall.vibe" + ;; opencode) skip_opencode=1 skip_opencode_source="config skipInstall.opencode" @@ -608,6 +624,16 @@ case "${PLANNOTATOR_SKIP_KIRO_INSTALL:-}" in skip_kiro_source="" ;; esac +case "${PLANNOTATOR_SKIP_VIBE_INSTALL:-}" in + 1|true|yes|TRUE|YES|True|Yes) + skip_vibe=1 + skip_vibe_source="PLANNOTATOR_SKIP_VIBE_INSTALL" + ;; + 0|false|no|FALSE|NO|False|No) + skip_vibe=0 + skip_vibe_source="" + ;; +esac case "${PLANNOTATOR_SKIP_OPENCODE_INSTALL:-}" in 1|true|yes|TRUE|YES|True|Yes) skip_opencode=1 @@ -640,6 +666,10 @@ if [ "$SKIP_KIRO_FLAG" -eq 1 ]; then skip_kiro=1 skip_kiro_source="--skip-kiro" fi +if [ "$SKIP_VIBE_FLAG" -eq 1 ]; then + skip_vibe=1 + skip_vibe_source="--skip-vibe" +fi if [ "$SKIP_OPENCODE_FLAG" -eq 1 ]; then skip_opencode=1 skip_opencode_source="--skip-opencode" @@ -1074,6 +1104,18 @@ if command -v kiro-cli >/dev/null 2>&1 || [ -d "$HOME/.kiro" ]; then kiro_available=1 fi +# Vibe (Mistral's TUI coding agent) stores everything under $VIBE_HOME when +# set, falling back to ~/.vibe (vibe/utils/paths.py:get_vibe_home). Detect via +# the `vibe` binary on PATH or an existing ~/.vibe. The hook runs on +# macOS/Linux only (Vibe spawns hooks via /bin/sh; Windows uses cmd.exe and a +# .sh launcher is not executable), so the Windows installers print manual +# instructions instead of wiring hooks. +VIBE_HOME="${VIBE_HOME:-$HOME/.vibe}" +vibe_available=0 +if command -v vibe >/dev/null 2>&1 || [ -d "$VIBE_HOME" ]; then + vibe_available=1 +fi + if [ "$codex_available" -eq 1 ] && [ "$skip_codex" -eq 1 ]; then # HONEST three-state reporting (#1178): detected-but-skipped is its own # state, never conflated with "not detected". Skip is do-not-write only: @@ -1612,6 +1654,7 @@ if [ "$skip_skills" -eq 0 ] && ! command -v git &>/dev/null; then fi KIRO_SKILLS_DIR="$HOME/.kiro/skills" +VIBE_SKILLS_DIR="$VIBE_HOME/skills" OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands" GEMINI_COMMANDS_DIR="$HOME/.gemini/commands" skills_tmp=$(mktemp -d) @@ -1717,7 +1760,7 @@ checkout_failed=0 fi cd repo || exit 1 if [ "$sparse_clone" -eq 1 ]; then - if ! git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>"$git_err"; then + if ! git sparse-checkout set apps/skills apps/kiro-cli apps/vibe apps/opencode-plugin/commands apps/gemini/commands 2>"$git_err"; then surface_git_error exit 1 fi @@ -1795,6 +1838,23 @@ checkout_failed=0 fi echo "Installed Kiro skills to ${KIRO_SKILLS_DIR}/ and agent to ~/.kiro/agents/plannotator.json" fi + + # Vibe — hand-maintained skills (origin baked in) plus the single-sourced + # knowledge skill. A Vibe opt-out (#1178) leaves $VIBE_HOME entirely + # untouched. VIBE_HOME is resolved above alongside the Vibe detection. + if [ "$vibe_available" -eq 1 ] && [ "$skip_vibe" -eq 0 ] && [ -d "apps/vibe/skills" ] && [ -n "$(ls -A apps/vibe/skills 2>/dev/null)" ]; then + mkdir -p "$VIBE_SKILLS_DIR" + # Vibe-specific skills (origin baked in) come from apps/vibe/skills. + copy_skill_if_present apps/vibe/skills/plannotator-review "$VIBE_SKILLS_DIR" + copy_skill_if_present apps/vibe/skills/plannotator-annotate "$VIBE_SKILLS_DIR" + copy_skill_if_present apps/vibe/skills/plannotator-last "$VIBE_SKILLS_DIR" + # The plannotator knowledge skill (CLI reference) is agent-agnostic and + # single-sourced in apps/skills/core; Vibe gets the same copy every + # other scope does. Without it, Vibe users get launchers but no CLI + # reference. + copy_skill_if_present apps/skills/core/plannotator "$VIBE_SKILLS_DIR" + echo "Installed Vibe skills to ${VIBE_SKILLS_DIR}/" + fi ) || checkout_failed=1 rm -rf "$skills_tmp" @@ -1824,7 +1884,7 @@ done # plannotator-archive no longer ships as a skill. Remove any stale installed # copy from every skill scope so upgraders don't keep a dead skill around. -for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" "$KIRO_SKILLS_DIR"; do +for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" "$KIRO_SKILLS_DIR" "$VIBE_SKILLS_DIR"; do # A skills opt-out leaves every skill scope untouched, sweep included. if [ "$skip_skills" -eq 1 ]; then continue @@ -1833,6 +1893,10 @@ for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" "$KIRO_SKILLS_DIR"; do if [ "$scope" = "$KIRO_SKILLS_DIR" ] && [ "$skip_kiro" -eq 1 ]; then continue fi + # A Vibe opt-out leaves $VIBE_HOME entirely untouched — including this sweep. + if [ "$scope" = "$VIBE_SKILLS_DIR" ] && [ "$skip_vibe" -eq 1 ]; then + continue + fi if [ -d "$scope/plannotator-archive" ]; then rm -rf "$scope/plannotator-archive" echo "Removed stale plannotator-archive skill from ${scope}/plannotator-archive" @@ -1978,6 +2042,89 @@ GEMINI_SETTINGS_EOF # the skills/commands install block above (apps/gemini/commands). fi +# --- Mistral Vibe support (only if Vibe is installed or configured) --- +# Vibe (Mistral's TUI coding agent) hooks are [[hooks]] TOML blocks in +# $VIBE_HOME/hooks.toml. Plan review gates exit_plan_mode via a pre_tool hook; +# the plan is not in the payload (exit_plan_mode takes no args), so plannotator +# resolves the newest plan from $VIBE_HOME/plans by mtime. A managed marker +# block coexists with any user/Orca hooks. Vibe needs +# enable_experimental_hooks = true in config.toml for any hook to fire. +VIBE_HOOKS="$VIBE_HOME/hooks.toml" +VIBE_CONFIG="$VIBE_HOME/config.toml" +VIBE_MANAGED_START="# >>> plannotator-managed-vibe-hooks (managed; do not edit) >>>" +VIBE_MANAGED_END="# <<< plannotator-managed-vibe-hooks <<<" + +if [ "$vibe_available" -eq 1 ] && [ "$skip_vibe" -eq 1 ]; then + # HONEST three-state reporting (#1178): detected-but-skipped is its own + # state. Skip is do-not-write: nothing under $VIBE_HOME is created, + # updated, or removed on this run. + echo "" + echo "Vibe: detected, skipped (${skip_vibe_source})." + if [ -f "$VIBE_HOOKS" ] && grep -q "plannotator" "$VIBE_HOOKS" 2>/dev/null; then + echo "An existing Vibe integration at ${VIBE_HOOKS} was left untouched." + fi + echo "Note: the shared agent skills in ~/.agents/skills serve multiple agents" + echo "and are still installed." +elif [ "$vibe_available" -eq 1 ]; then + mkdir -p "$VIBE_HOME" + vibe_hook_configured=0 + + # Strip any existing managed block (markers inclusive), then append a + # fresh one. awk prints every line except the managed region; a lone + # start-marker with no end-marker is bounded to EOF so it cannot swallow + # trailing user TOML beyond the contiguous managed tables. Atomic write + # via a temp file in the same dir + rename; keep a .bak for recovery. + write_vibe_hooks_block() { + _new_body="" + if [ -f "$VIBE_HOOKS" ]; then + _new_body=$(awk -v s="$VIBE_MANAGED_START" -v e="$VIBE_MANAGED_END" ' + BEGIN { in_block = 0 } + $0 == s { in_block = 1; next } + in_block && $0 == e { in_block = 0; next } + in_block { next } + { print } + ' "$VIBE_HOOKS") + # Trim trailing blank lines so the appended block sits cleanly. + _new_body=$(printf '%s\n' "$_new_body" | sed -e '/^$/N;/^\n$/D') + fi + [ -f "$VIBE_HOOKS" ] && cp "$VIBE_HOOKS" "$VIBE_HOOKS.bak" + { + printf '%s\n' "$_new_body" + cat << 'VIBE_HOOKS_BLOCK_EOF' +# >>> plannotator-managed-vibe-hooks (managed; do not edit) >>> +[[hooks]] +name = "plannotator-exit-plan-mode" +type = "pre_tool" +match = "exit_plan_mode" +command = "PLANNOTATOR_ORIGIN=mistral-vibe plannotator" +timeout = 345600 +description = "Plannotator plan review (managed)" +# <<< plannotator-managed-vibe-hooks <<< +VIBE_HOOKS_BLOCK_EOF + } > "$VIBE_HOOKS.tmp" && mv "$VIBE_HOOKS.tmp" "$VIBE_HOOKS" + } + + write_vibe_hooks_block + echo "Installed Vibe plan-review hook at ${VIBE_HOOKS}" + vibe_hook_configured=1 + + # Ensure enable_experimental_hooks = true in config.toml. Vibe requires + # this top-level key for any hook to fire. Create the file if absent; if + # the key already exists (true or false) leave it untouched so we never + # downgrade a user's explicit false, matching the Codex config-merge + # discipline. + if [ ! -f "$VIBE_CONFIG" ]; then + printf 'enable_experimental_hooks = true\n' > "$VIBE_CONFIG" + echo "Created Vibe config at ${VIBE_CONFIG}" + elif grep -Eq '^[[:space:]]*enable_experimental_hooks[[:space:]]*=' "$VIBE_CONFIG"; then + echo "Vibe config at ${VIBE_CONFIG} already sets enable_experimental_hooks; leaving it unchanged." + else + cp "$VIBE_CONFIG" "$VIBE_CONFIG.bak" + printf '\n# Added by plannotator installer\nenable_experimental_hooks = true\n' >> "$VIBE_CONFIG" + echo "Enabled Vibe experimental hooks in ${VIBE_CONFIG}" + fi +fi + echo "" echo "==========================================" echo " OPENCODE USERS" @@ -2081,6 +2228,31 @@ else fi echo "" echo "==========================================" +echo " VIBE USERS" +echo "==========================================" +echo "" +if [ "$vibe_available" -eq 1 ] && [ "$skip_vibe" -eq 1 ]; then + echo "Vibe was detected, but the integration was skipped (${skip_vibe_source})." + echo "No files under ${VIBE_HOME} were written or removed. Re-run without the" + echo "opt-out to add the plan-review hook and Vibe skills." +elif [ "$vibe_available" -eq 1 ] && [ "$skip_skills" -eq 1 ]; then + echo "Vibe was detected, and the plan-review hook is installed at ${VIBE_HOME}/hooks.toml." + echo "Skills were skipped (${skip_skills_source}), so no Vibe skills were installed." + echo "Re-run without the opt-out to add them." +elif [ "$vibe_available" -eq 1 ]; then + echo "Plan review is configured through the Vibe pre_tool hook on exit_plan_mode" + echo "in ${VIBE_HOME}/hooks.toml. enable_experimental_hooks = true is set in" + echo "${VIBE_HOME}/config.toml." + echo "" + echo "Vibe skills are installed to ${VIBE_SKILLS_DIR}/" + echo "Note: improve-context (plan-mode enrichment) is not wired for Vibe —" + echo "only the plan-review gate runs." +else + echo "Vibe was not detected. After installing Mistral Vibe, rerun this installer" + echo "to configure plan review." +fi +echo "" +echo "==========================================" if [ "$skip_skills" -eq 1 ]; then # Never claim the /plannotator-* commands are ready when nothing was # installed — that false banner is exactly what the skills-checkout guard diff --git a/scripts/install.test.ts b/scripts/install.test.ts index f924fd620..cc48bf072 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -85,7 +85,7 @@ describe("install.sh", () => { expect(script).toContain("git clone --depth 1 --filter=blob:none --sparse"); // Sparse set extended to also fetch the command stubs from the checkout. expect(script).toContain( - "git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands", + "git sparse-checkout set apps/skills apps/kiro-cli apps/vibe apps/opencode-plugin/commands apps/gemini/commands", ); expect(script).toContain("CLAUDE_SKILLS_DIR"); expect(script).toContain("AGENTS_SKILLS_DIR"); @@ -197,7 +197,7 @@ describe("install.sh", () => { expect(script).toContain('copy_skill_if_present apps/skills/extra/plannotator-setup-goal "$KIRO_SKILLS_DIR"'); expect(script).toContain('copy_skill_if_present apps/skills/extra/plannotator-visual-explainer "$KIRO_SKILLS_DIR"'); // sparse-checkout fetches apps/kiro-cli (skills + agent example). - expect(script).toContain("git sparse-checkout set apps/skills apps/kiro-cli"); + expect(script).toContain("git sparse-checkout set apps/skills apps/kiro-cli apps/vibe"); // The installer also writes the example custom agent to ~/.kiro/agents. expect(script).toContain('cp apps/kiro-cli/agents/plannotator.json "$HOME/.kiro/agents/plannotator.json"'); // Parity: no bespoke flag, like every other agent. @@ -205,6 +205,36 @@ describe("install.sh", () => { expect(script).not.toContain("INSTALL_KIRO"); }); + test("auto-installs Vibe skills + managed hook when ~/.vibe is detected (no flag)", () => { + // Vibe (Mistral's TUI coding agent) is auto-detected like Kiro/Codex: + // PATH executable or an existing ~/.vibe, never gated behind a bespoke flag. + expect(script).toContain("vibe_available=0"); + expect(script).toContain('[ -d "$VIBE_HOME" ]'); + expect(script).toContain('VIBE_HOME="${VIBE_HOME:-$HOME/.vibe}"'); + expect(script).toContain("VIBE_SKILLS_DIR"); + expect(script).toContain('$VIBE_HOME/skills'); + expect(script).toContain('if [ "$vibe_available" -eq 1 ]'); + // Vibe-specific skills (origin baked in) come from apps/vibe/skills. + for (const skill of ["plannotator-review", "plannotator-annotate", "plannotator-last"]) { + expect(script).toContain(`copy_skill_if_present apps/vibe/skills/${skill} "$VIBE_SKILLS_DIR"`); + } + // The knowledge skill is agent-agnostic and single-sourced in apps/skills/core. + expect(script).toContain('copy_skill_if_present apps/skills/core/plannotator "$VIBE_SKILLS_DIR"'); + // sparse-checkout fetches apps/vibe (skills + hook templates). + expect(script).toContain("git sparse-checkout set apps/skills apps/kiro-cli apps/vibe"); + // Managed marker block so the hook coexists with the user's own hooks.toml. + expect(script).toContain('VIBE_MANAGED_START="# >>> plannotator-managed-vibe-hooks (managed; do not edit) >>>"'); + expect(script).toContain('VIBE_MANAGED_END="# <<< plannotator-managed-vibe-hooks <<<"'); + expect(script).toContain('PLANNOTATOR_ORIGIN=mistral-vibe plannotator"'); + expect(script).toContain('match = "exit_plan_mode"'); + // config.toml must carry enable_experimental_hooks = true for any hook to fire. + expect(script).toContain("enable_experimental_hooks = true"); + expect(script).toContain('VIBE_CONFIG="$VIBE_HOME/config.toml"'); + // Parity: no bespoke flag, like every other agent. + expect(script).not.toContain("--vibe-only"); + expect(script).not.toContain("INSTALL_VIBE"); + }); + test("aggressively cleans up deprecated commands and stale skills on upgrade", () => { // Claude Code commands are deprecated in favor of skills — remove the files. expect(script).toContain("CLAUDE_COMMANDS_DIR"); @@ -225,7 +255,7 @@ describe("install.sh", () => { // plannotator-archive no longer ships as a skill — a stale installed copy // is removed unconditionally from every skill scope. expect(script).toContain( - 'for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" "$KIRO_SKILLS_DIR"; do', + 'for scope in "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR" "$KIRO_SKILLS_DIR" "$VIBE_SKILLS_DIR"; do', ); expect(script).toContain('rm -rf "$scope/plannotator-archive"'); // The removed /plannotator-archive OpenCode command stub is swept too. @@ -375,13 +405,14 @@ describe("install.sh", () => { // Flags exist for Codex plus the two integrations where the mechanism // generalizes identically (detect -> write): Gemini and Kiro. OpenCode // gets a plain do-not-write switch (no detection leg). - for (const flag of ["--skip-codex)", "--skip-gemini)", "--skip-kiro)", "--skip-opencode)"]) { + for (const flag of ["--skip-codex)", "--skip-gemini)", "--skip-kiro)", "--skip-vibe)", "--skip-opencode)"]) { expect(script).toContain(flag); } // Env vars follow the existing PLANNOTATOR_SKIP_*_INSTALL naming. expect(script).toContain("PLANNOTATOR_SKIP_CODEX_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_GEMINI_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_KIRO_INSTALL"); + expect(script).toContain("PLANNOTATOR_SKIP_VIBE_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_OPENCODE_INSTALL"); // Config layer (M2): the skipInstall OBJECT is extracted first (awk, // character-indexed so single-line JSON works too) and per-agent keys @@ -399,7 +430,7 @@ describe("install.sh", () => { expect(script).toContain("continue # explicit false is a veto, never a skip"); // skills rides the same loop: not an agent, but the same three layers // and the same skipInstall key region. - expect(script).toContain("for _agent in codex gemini kiro opencode skills; do"); + expect(script).toContain("for _agent in codex gemini kiro vibe opencode skills; do"); // The old whole-file grep form is gone. expect(script).not.toContain('grep -q \'"codex"[[:space:]]*:[[:space:]]*true\' "$_config_dir/config.json"'); // Precedence by textual layering (later assignment wins): config grep, @@ -463,7 +494,7 @@ describe("install.sh", () => { expect(envIdx).toBeGreaterThan(configIdx); expect(flagIdx).toBeGreaterThan(envIdx); // Advertised in the usage text alongside the per-agent opt-outs. - expect(script).toContain("[--skip-kiro] [--skip-opencode] [--skip-skills]"); + expect(script).toContain("[--skip-kiro] [--skip-vibe] [--skip-opencode] [--skip-skills]"); expect(script).toContain("PLANNOTATOR_SKIP_SKILLS_INSTALL; config key:"); }); @@ -591,7 +622,7 @@ describe("install.ps1", () => { test("installs core skills via git sparse-checkout to claude + agents", () => { expect(script).toContain("git clone --depth 1 --filter=blob:none --sparse"); expect(script).toContain( - "git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands", + "git sparse-checkout set apps/skills apps/kiro-cli apps/vibe apps/opencode-plugin/commands apps/gemini/commands", ); expect(script).toContain("claudeSkillsDir"); expect(script).toContain("agentsSkillsDir"); @@ -641,7 +672,7 @@ describe("install.ps1", () => { // plannotator-archive no longer ships as a skill — a stale installed copy // is removed unconditionally from every skill scope. expect(script).toContain( - 'foreach ($scope in @($claudeSkillsDir, $agentsSkillsDir, "$env:USERPROFILE\\.kiro\\skills"))', + 'foreach ($scope in @($claudeSkillsDir, $agentsSkillsDir, "$env:USERPROFILE\\.kiro\\skills", (Join-Path $vibeHome "skills")))', ); expect(script).toContain('Join-Path $scope "plannotator-archive"'); // The removed /plannotator-archive OpenCode command stub is swept too. @@ -708,16 +739,19 @@ describe("install.ps1", () => { expect(script).toContain("[switch]$SkipCodex"); expect(script).toContain("[switch]$SkipGemini"); expect(script).toContain("[switch]$SkipKiro"); + expect(script).toContain("[switch]$SkipVibe"); expect(script).toContain("[switch]$SkipOpencode"); expect(script).toContain("PLANNOTATOR_SKIP_CODEX_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_GEMINI_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_KIRO_INSTALL"); + expect(script).toContain("PLANNOTATOR_SKIP_VIBE_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_OPENCODE_INSTALL"); // Config layer parses the real nested JSON (strict boolean check, like // verifyAttestation). expect(script).toContain("$cfg.skipInstall.codex -is [bool]"); expect(script).toContain("$cfg.skipInstall.gemini -is [bool]"); expect(script).toContain("$cfg.skipInstall.kiro -is [bool]"); + expect(script).toContain("$cfg.skipInstall.vibe -is [bool]"); expect(script).toContain("$cfg.skipInstall.opencode -is [bool]"); // Precedence by textual layering (later assignment wins): config, then // env var, then switch. @@ -865,7 +899,7 @@ describe("install.cmd", () => { test("installs core skills via git sparse-checkout to claude + agents", () => { expect(script).toContain("git clone --depth 1 --filter=blob:none --sparse"); expect(script).toContain( - "git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands", + "git sparse-checkout set apps/skills apps/kiro-cli apps/vibe apps/opencode-plugin/commands apps/gemini/commands", ); expect(script).toContain("CLAUDE_SKILLS_DIR"); expect(script).toContain("AGENTS_SKILLS_DIR"); @@ -908,7 +942,7 @@ describe("install.cmd", () => { // plannotator-archive no longer ships as a skill — a stale installed copy // is removed unconditionally from every skill scope. expect(script).toContain( - 'for %%D in ("!CLAUDE_SKILLS_DIR!" "!AGENTS_SKILLS_DIR!" "!KIRO_SKILLS_DIR!") do', + 'for %%D in ("!CLAUDE_SKILLS_DIR!" "!AGENTS_SKILLS_DIR!" "!KIRO_SKILLS_DIR!" "!VIBE_SKILLS_DIR!") do', ); expect(script).toContain('rmdir /s /q "%%~D\\plannotator-archive"'); // The removed /plannotator-archive OpenCode command stub is swept too. @@ -1005,22 +1039,25 @@ describe("install.cmd", () => { expect(script).toContain('if /i "%~1"=="--skip-codex"'); expect(script).toContain('if /i "%~1"=="--skip-gemini"'); expect(script).toContain('if /i "%~1"=="--skip-kiro"'); + expect(script).toContain('if /i "%~1"=="--skip-vibe"'); expect(script).toContain('if /i "%~1"=="--skip-opencode"'); expect(script).toContain("PLANNOTATOR_SKIP_CODEX_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_GEMINI_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_KIRO_INSTALL"); + expect(script).toContain("PLANNOTATOR_SKIP_VIBE_INSTALL"); expect(script).toContain("PLANNOTATOR_SKIP_OPENCODE_INSTALL"); // Config layer (M2): the REAL JSON is parsed by PowerShell (strict // boolean check, matching install.ps1) instead of a line-oblivious // findstr - so a "codex": true under some OTHER key can never opt // anyone out and an explicit false inside skipInstall is honored. expect(script).toContain("$c.skipInstall.$k"); - expect(script).toContain("@('codex','gemini','kiro','opencode','skills')"); + expect(script).toContain("@('codex','gemini','kiro','vibe','opencode','skills')"); expect(script).toContain("$v -is [bool] -and $v"); expect(script).toContain("PLN_CONFIG_JSON"); expect(script).toContain("skipInstall.codex"); expect(script).toContain("skipInstall.gemini"); expect(script).toContain("skipInstall.kiro"); + expect(script).toContain("skipInstall.vibe"); expect(script).toContain("skipInstall.opencode"); // The old whole-file findstr form is gone. expect(script).not.toContain('findstr /r /c:"\\"codex\\"'); From e7b6d92e9a2bc611d7278ba6128cba8f07659201 Mon Sep 17 00:00:00 2001 From: Julien Tanay Date: Sun, 13 Sep 2026 19:45:03 +0200 Subject: [PATCH 2/3] fix(vibe): match Vibe 2.25+ hook reality; transcript-pinned plans; uninstall coverage Addresses the #1480 review against current Vibe upstream: - Installer: drop the enable_experimental_hooks config.toml leg (hooks are stable in Vibe 2.25+, and the old flag+pre_tool combo was never valid on any single version). Hook command is argv-only with the absolute binary path (unquoted heredoc now interpolates PLANNOTATOR_BIN), so it runs under shell and shell-free executors. Detection requires an existing VIBE home, so a stray 'vibe' binary never creates ~/.vibe. - Gate: detect the Vibe origin from the hook payload (pre_tool + exit_plan_mode), no baked env prefix; PLANNOTATOR_ORIGIN stays as the manual override. Pin the plan by scanning transcript_path for the last write_file/edit targeting the plans dir (kills cross-session cross-serve on the global dir), fall back to newest-by-mtime within a 10-minute freshness window, else fail open. The awk managed-marker comment now tells the truth about a missing end marker. - annotate-last: the index-absent fallback filters candidate sessions by meta.json working_directory instead of picking the newest across all projects. - Uninstaller: strip the managed hooks.toml block by markers and remove the Vibe skill dirs, so plannotator uninstall leaves no hook invoking a deleted binary. - Tests: vibe-plan.test.ts saves/restores VIBE_HOME in beforeEach/afterEach; new resolver and uninstall coverage; install.test.ts asserts the argv-only command and the absence of the config leg. - Docs: PLANNOTATOR_SKIP_VIBE_INSTALL row in the marketing env-vars doc; AGENTS.md row updated for the new hook shape. --- .gitignore | 3 + AGENTS.md | 2 +- apps/hook/server/index.ts | 28 +-- apps/hook/server/session-log.ts | 13 +- apps/hook/server/vibe-plan.test.ts | 166 ++++++++++++++++-- apps/hook/server/vibe-plan.ts | 116 +++++++++++- apps/hook/server/vibe-session.test.ts | 24 ++- .../docs/reference/environment-variables.md | 1 + apps/vibe/hooks/config-snippet.toml | 6 - apps/vibe/hooks/hooks.toml | 15 +- packages/server/uninstall.test.ts | 38 ++++ packages/server/uninstall.ts | 99 +++++++++++ scripts/install.cmd | 6 +- scripts/install.ps1 | 13 +- scripts/install.sh | 65 +++---- scripts/install.test.ts | 13 +- 16 files changed, 499 insertions(+), 109 deletions(-) delete mode 100644 apps/vibe/hooks/config-snippet.toml diff --git a/.gitignore b/.gitignore index 732e347d4..3490f1b92 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,6 @@ packages/ui/components/html-viewer/bridge-script.lite.ts /adr/research/SPIKE-bundle-optimization-theories-20260619-154926.md /adr/research/SPIKE-local-app-annotation-20260810.md /scripts/convert-themes.ts + +# PR-description working drafts at the repo root (untracked by design — never commit). +/.pr-desc-*.md diff --git a/AGENTS.md b/AGENTS.md index 51bc3821c..fc2e88dcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_SKIP_CODEX_INSTALL` | **Read by the install scripts only.** Set to `1` / `true` to skip writing the Codex integration (`hooks.json` / `config.toml` under `CODEX_HOME`, and the Codex-home stale-skill cleanup) even when Codex is detected. The installer reports the honest state ("Codex: detected, skipped (...)" vs "not detected" vs installed) and never removes an integration a previous install wired. Also settable via `~/.plannotator/config.json` (`{ "skipInstall": { "codex": true } }`); precedence is `--skip-codex` flag > env var > config. Off by default. | | `PLANNOTATOR_SKIP_GEMINI_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Gemini CLI integration (`~/.gemini` policy file, settings hook, slash commands). Config key: `skipInstall.gemini`; flag: `--skip-gemini`. Off by default. | | `PLANNOTATOR_SKIP_KIRO_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Kiro CLI integration (`~/.kiro` skills and agent, including the `~/.kiro` stale-skill sweep). Config key: `skipInstall.kiro`; flag: `--skip-kiro`. Off by default. | -| `PLANNOTATOR_SKIP_VIBE_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Mistral Vibe integration (`$VIBE_HOME` skills and the managed plan-review hook block in `$VIBE_HOME/hooks.toml`, plus the `enable_experimental_hooks = true` merge into `$VIBE_HOME/config.toml`, and the `~/.vibe` stale-skill sweep). The hook is macOS/Linux-only (Vibe spawns hooks via `/bin/sh`), so the Windows installer never writes `hooks.toml` — it prints manual setup instructions, mirroring Codex-on-Windows; skills still install cross-platform. Config key: `skipInstall.vibe`; flag: `--skip-vibe`. Off by default. | +| `PLANNOTATOR_SKIP_VIBE_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Mistral Vibe integration (`$VIBE_HOME` skills and the managed plan-review hook block in `$VIBE_HOME/hooks.toml`, plus the `~/.vibe` stale-skill sweep). Hooks are stable in Vibe 2.25+ (no config flag), so the installer writes only the `hooks.toml` block; its `command` is argv-only (absolute binary path, no env prefix) because Vibe's hook executor may be shell-free, and plannotator detects the Vibe origin from the hook payload (`pre_tool` + `exit_plan_mode`), with `PLANNOTATOR_ORIGIN=mistral-vibe` as the manual override. The hook is macOS/Linux-only, so the Windows installer never writes `hooks.toml` — it prints manual setup instructions, mirroring Codex-on-Windows; skills still install cross-platform. Config key: `skipInstall.vibe`; flag: `--skip-vibe`. Off by default. | | `PLANNOTATOR_SKIP_OPENCODE_INSTALL` | **Read by the install scripts only.** Do-not-write switch for the OpenCode integration (command stubs under `~/.config/opencode/commands`, the OpenCode plugin cache clear, and the stale command-stub sweep). OpenCode has no detection leg, so there is no detected/not-detected reporting, just a skip note. Config key: `skipInstall.opencode`; flag: `--skip-opencode`. Off by default. | | `PLANNOTATOR_SKIP_SKILLS_INSTALL` | **Read by the install scripts only.** Set to `1` / `true` to skip the skills/slash-command sparse checkout entirely — no `git clone` of the release tag, so nothing is written to any skill or command scope (`~/.claude/skills`, `~/.agents/skills`, the OpenCode command stubs, the Gemini `.toml` commands, `~/.kiro`), the extras are not offered, and the skill-scope cleanup sweeps stay suspended (skip means do-not-write, never remove). The binary, sem sidecar, agent-terminal runtime, hooks, and per-agent config still install, and git stops being a hard requirement. The installer reports `Skills: skipped (...)` and the closing banner stops claiming the `/plannotator-*` commands are ready. Unlike the per-agent opt-outs this is not one agent's home — it covers every scope the checkout writes. Config key: `skipInstall.skills`; flags: `--skip-skills` (bash/cmd), `-SkipSkills` (PowerShell); precedence is flag > env var > config. Used by the `install-script-smoke` CI job, which installs a synthetic `v9.9.9` whose tag has no GitHub counterpart. Off by default. | | `PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL` | Set to `1` / `true` to skip installing the managed Node/WebTUI runtime used by compiled Bun builds for the annotate-mode agent terminal. Read by `plannotator install-runtime agent-terminal`, which the installers call automatically. | diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 2afb55246..f3b37ae20 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -521,9 +521,10 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; // still be detected as themselves. OMPCODE still wins over the terminal // fallback below. // -// > Mistral Vibe — detected via PLANNOTATOR_ORIGIN=mistral-vibe baked into the -// hook command by the installer (Vibe's hook executor does not inject a -// unique fingerprint env var; the override check above catches it). +// > Mistral Vibe — detected from the pre_tool hook payload (hook_event_name +// "pre_tool" + tool_name "exit_plan_mode" is unambiguous vs Claude/Gemini), +// so the installer's hook command needs no env prefix. PLANNOTATOR_ORIGIN= +// mistral-vibe remains the manual override above. // // To add a new agent, also add an entry to AGENT_CONFIG in // packages/core/agents.ts (see header comment there). @@ -2282,15 +2283,18 @@ if (args[0] === "sessions") { } // Mistral Vibe: pre_tool hook matching exit_plan_mode. Vibe's tool takes - // no args, so the plan is not in the payload — resolve it from - // $VIBE_HOME/plans by mtime. Origin is set via PLANNOTATOR_ORIGIN=mistral-vibe - // baked into the hook command by the installer. - if ( - detectedOrigin === "mistral-vibe" && - event.hook_event_name === "pre_tool" && - event.tool_name === "exit_plan_mode" - ) { - const vibePlanContent = resolveLatestVibePlan(); + // no args, so the plan is not in the payload — the pre_tool payload carries + // transcript_path, and the resolver pins the plan this session wrote by + // scanning that transcript (newest-by-mtime within a freshness window as + // the fallback, else fail open). Detection is payload-based (pre_tool + + // exit_plan_mode is unambiguous vs Claude/Gemini); PLANNOTATOR_ORIGIN= + // mistral-vibe remains the manual override above. + const isVibeExitPlanMode = + event.hook_event_name === "pre_tool" && event.tool_name === "exit_plan_mode"; + if (isVibeExitPlanMode) { + const vibeTranscript = + typeof event.transcript_path === "string" ? event.transcript_path : undefined; + const vibePlanContent = resolveLatestVibePlan({ transcriptPath: vibeTranscript }); if (!vibePlanContent) { console.error( "No plan file found in $VIBE_HOME/plans. Vibe may not have written the plan yet, or VIBE_HOME is set to a non-default location." diff --git a/apps/hook/server/session-log.ts b/apps/hook/server/session-log.ts index 93b780483..420bc10ef 100644 --- a/apps/hook/server/session-log.ts +++ b/apps/hook/server/session-log.ts @@ -964,8 +964,10 @@ export function resolveVibeSessionLogForCwd( return bestDir; } - // Fallback: scan session__/ directories by mtime when the index is - // absent. Picks the newest whose messages.jsonl exists. + // Fallback: scan session__/ directories when the index is absent. + // Each session dir carries a meta.json with its working_directory under + // environment (see Vibe's session_logger), so filter on cwd the same way + // the index path does — never pick the newest session across all projects. let dirs: string[]; try { dirs = readdirSync(sessionLogDir).filter((d) => d.startsWith("session_")); @@ -977,6 +979,13 @@ export function resolveVibeSessionLogForCwd( for (const d of dirs) { const messagesPath = join(sessionLogDir, d, "messages.jsonl"); try { + const metaPath = join(sessionLogDir, d, "meta.json"); + const meta = JSON.parse(readFileSync(metaPath, "utf-8")); + const metaCwd = + typeof meta?.environment?.working_directory === "string" + ? meta.environment.working_directory + : undefined; + if (!metaCwd || normalizeCwdForCompare(metaCwd) !== normalizedTarget) continue; const mtime = statSync(messagesPath).mtimeMs; if (mtime > newestMtime) { newestMtime = mtime; diff --git a/apps/hook/server/vibe-plan.test.ts b/apps/hook/server/vibe-plan.test.ts index ec37b3ded..d2d03286e 100644 --- a/apps/hook/server/vibe-plan.test.ts +++ b/apps/hook/server/vibe-plan.test.ts @@ -3,21 +3,40 @@ * * Run: bun test apps/hook/server/vibe-plan.test.ts * - * Uses synthetic fixtures in temp dirs matching Vibe's $VIBE_HOME/plans layout. + * Uses synthetic fixtures in temp dirs matching Vibe's $VIBE_HOME/plans and + * logs/session layouts. VIBE_HOME is saved in beforeEach and restored in + * afterEach (house testing rule: never leak a mutated env past a test). */ -import { describe, expect, test, afterEach } from "bun:test"; +import { describe, expect, test, afterEach, beforeEach } from "bun:test"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; import { tmpdir } from "node:os"; +import { homedir } from "node:os"; import { join } from "node:path"; -import { resolveLatestVibePlan, resolveVibePlansDir } from "./vibe-plan"; +import { + resolveLatestVibePlan, + resolveVibePlansDir, + findVibePlanInTranscript, + findNewestVibePlan, + PLAN_FRESHNESS_MS, +} from "./vibe-plan"; const tempDirs: string[] = []; +let savedVibeHome: string | undefined; function cleanup() { for (const d of tempDirs.splice(0)) rmSync(d, { recursive: true, force: true }); } -afterEach(cleanup); + +beforeEach(() => { + savedVibeHome = process.env.VIBE_HOME; +}); + +afterEach(() => { + if (savedVibeHome === undefined) delete process.env.VIBE_HOME; + else process.env.VIBE_HOME = savedVibeHome; + cleanup(); +}); function makeVibeHome(): string { const home = mkdtempSync(join(tmpdir(), "vibe-home-")); @@ -26,11 +45,18 @@ function makeVibeHome(): string { return home; } +/** A Vibe transcript line: tool results carry tool_result.output shaped by + * the tool's project_result (write_file: file_path/bytes_written/content; + * edit: file/old_string/new_string). */ +function toolResultLine(output: Record): string { + return JSON.stringify({ role: "tool", tool_call_id: "tc1", tool_result: { output, duration: 0.5 } }); +} + describe("resolveVibePlansDir", () => { test("defaults to ~/.vibe/plans when VIBE_HOME unset", () => { delete process.env.VIBE_HOME; const dir = resolveVibePlansDir(); - expect(dir).toBe(join(require("node:os").homedir(), ".vibe", "plans")); + expect(dir).toBe(join(homedir(), ".vibe", "plans")); }); test("respects VIBE_HOME override", () => { @@ -38,7 +64,6 @@ describe("resolveVibePlansDir", () => { tempDirs.push(tmp); process.env.VIBE_HOME = tmp; expect(resolveVibePlansDir()).toBe(join(tmp, "plans")); - delete process.env.VIBE_HOME; }); test("accepts explicit vibeHome argument over env", () => { @@ -47,32 +72,135 @@ describe("resolveVibePlansDir", () => { const arg = mkdtempSync(join(tmpdir(), "arg-wins-")); tempDirs.push(arg); expect(resolveVibePlansDir(arg)).toBe(join(arg, "plans")); - delete process.env.VIBE_HOME; }); }); -describe("resolveLatestVibePlan", () => { - test("returns the newest .md plan by mtime", () => { +describe("findVibePlanInTranscript", () => { + test("pins the last write_file result targeting the plans dir", () => { + const home = makeVibeHome(); + const planPath = join(home, "plans", "1789000000-my-plan.md"); + writeFileSync(planPath, "# Pinned plan"); + const transcript = join(home, "transcript.jsonl"); + writeFileSync( + transcript, + [ + toolResultLine({ file_path: "/elsewhere/notes.md", bytes_written: 3, content: "x" }), + JSON.stringify({ role: "assistant", content: "writing the plan" }), + toolResultLine({ file_path: planPath, bytes_written: 10, content: "# Pinned plan" }), + "", + ].join("\n"), + ); + expect(findVibePlanInTranscript(transcript, { vibeHome: home })).toBe(planPath); + }); + + test("pins edit results via the `file` key too", () => { + const home = makeVibeHome(); + const planPath = join(home, "plans", "1789000001-edited.md"); + writeFileSync(planPath, "# Edited"); + const transcript = join(home, "t.jsonl"); + writeFileSync(transcript, [ + toolResultLine({ file: planPath, message: "ok", old_string: "a", new_string: "b" }), + ].join("\n")); + expect(findVibePlanInTranscript(transcript, { vibeHome: home })).toBe(planPath); + }); + + test("returns null when the transcript has no plans-dir write", () => { + const home = makeVibeHome(); + const transcript = join(home, "t.jsonl"); + writeFileSync(transcript, [ + toolResultLine({ file_path: "/elsewhere/notes.md", bytes_written: 3, content: "x" }), + ].join("\n")); + expect(findVibePlanInTranscript(transcript, { vibeHome: home })).toBeNull(); + }); + + test("ignores a plans-dir path that no longer exists (failed/rolled-back write)", () => { + const home = makeVibeHome(); + const transcript = join(home, "t.jsonl"); + writeFileSync(transcript, [ + toolResultLine({ file_path: join(home, "plans", "gone.md"), bytes_written: 1, content: "x" }), + ].join("\n")); + expect(findVibePlanInTranscript(transcript, { vibeHome: home })).toBeNull(); + }); + + test("returns null for a missing transcript", () => { + const home = makeVibeHome(); + expect(findVibePlanInTranscript(join(home, "nope.jsonl"), { vibeHome: home })).toBeNull(); + }); +}); + +describe("findNewestVibePlan", () => { + test("returns the newest .md plan by mtime inside the freshness window", () => { const home = makeVibeHome(); const old = join(home, "plans", "1780000000-old-plan.md"); const newer = join(home, "plans", "1780001000-new-plan.md"); writeFileSync(old, "# Old plan"); writeFileSync(newer, "# New plan"); - // Force old to be older than newer - utimesSync(old, 1, 1); - utimesSync(newer, 100, 100); - expect(resolveLatestVibePlan({ vibeHome: home })).toBe("# New plan"); + const now = Date.now(); + const sec = (ms: number) => Math.floor(ms / 1000); + utimesSync(old, sec(now - PLAN_FRESHNESS_MS - 5000), sec(now - PLAN_FRESHNESS_MS - 5000)); + utimesSync(newer, sec(now - 1000), sec(now - 1000)); + const found = findNewestVibePlan({ vibeHome: home, now }); + expect(found?.content).toBe("# New plan"); + expect(found?.path).toBe(newer); }); - test("returns null when plans dir is empty", () => { + test("returns null when the only plan is older than the freshness window", () => { const home = makeVibeHome(); - expect(resolveLatestVibePlan({ vibeHome: home })).toBeNull(); + const stale = join(home, "plans", "1780000000-stale.md"); + writeFileSync(stale, "# Stale plan"); + const now = Date.now(); + const staleSec = Math.floor((now - PLAN_FRESHNESS_MS - 60000) / 1000); + utimesSync(stale, staleSec, staleSec); + expect(findNewestVibePlan({ vibeHome: home, now })).toBeNull(); }); - test("returns null when plans dir does not exist", () => { - const home = mkdtempSync(join(tmpdir(), "vibe-noplans-")); - tempDirs.push(home); - expect(resolveLatestVibePlan({ vibeHome: home })).toBeNull(); + test("returns null when plans dir is empty or missing", () => { + const home = makeVibeHome(); + expect(findNewestVibePlan({ vibeHome: home })).toBeNull(); + const noDir = mkdtempSync(join(tmpdir(), "vibe-noplans-")); + tempDirs.push(noDir); + expect(findNewestVibePlan({ vibeHome: noDir })).toBeNull(); + }); +}); + +describe("resolveLatestVibePlan", () => { + test("prefers the transcript-pinned plan over a newer mtime plan", () => { + const home = makeVibeHome(); + const pinned = join(home, "plans", "1789000000-pinned.md"); + const newest = join(home, "plans", "1789009999-newest.md"); + writeFileSync(pinned, "# Pinned plan"); + writeFileSync(newest, "# Newest plan"); + // newest has the later mtime; the transcript points at pinned. + utimesSync(pinned, 1, 1); + utimesSync(newest, 100, 100); + const transcript = join(home, "t.jsonl"); + writeFileSync(transcript, [ + toolResultLine({ file_path: pinned, bytes_written: 13, content: "# Pinned plan" }), + ].join("\n")); + expect(resolveLatestVibePlan({ vibeHome: home, transcriptPath: transcript, now: 1000 })).toBe("# Pinned plan"); + }); + + test("falls back to newest-by-mtime when the transcript names no plan", () => { + const home = makeVibeHome(); + const newer = join(home, "plans", "1780001000-new-plan.md"); + writeFileSync(newer, "# New plan"); + const now = Date.now(); + utimesSync(newer, now - 1000, now - 1000); + const transcript = join(home, "t.jsonl"); + writeFileSync(transcript, [ + toolResultLine({ file_path: "/elsewhere/x.md", bytes_written: 1, content: "x" }), + ].join("\n")); + expect(resolveLatestVibePlan({ vibeHome: home, transcriptPath: transcript, now })).toBe("# New plan"); + }); + + test("fails open (null) with no transcript and no fresh plan", () => { + const home = makeVibeHome(); + const stale = join(home, "plans", "1780000000-stale.md"); + writeFileSync(stale, "# Stale"); + const now = Date.now(); + const staleSec = Math.floor((now - PLAN_FRESHNESS_MS - 60000) / 1000); + utimesSync(stale, staleSec, staleSec); + expect(resolveLatestVibePlan({ vibeHome: home, now })).toBeNull(); }); test("ignores non-.md files", () => { diff --git a/apps/hook/server/vibe-plan.ts b/apps/hook/server/vibe-plan.ts index 33aae99db..cdfd03504 100644 --- a/apps/hook/server/vibe-plan.ts +++ b/apps/hook/server/vibe-plan.ts @@ -4,18 +4,29 @@ * Vibe (Mistral's TUI coding agent) writes plans to * $VIBE_HOME/plans/{timestamp}-{slug}.md (global dir, NOT per-project) * and the plan is NOT carried in the `exit_plan_mode` hook payload - * (the tool's args model is empty). The pre_tool hook only tells us the - * tool is about to fire, so we resolve the plan by picking the newest - * `*.md` file in the plans directory by mtime. + * (the tool's args model is empty). The pre_tool payload DOES carry + * `transcript_path`, so we pin the plan by scanning the transcript backward + * for the last write_file/edit tool result targeting a file under the plans + * dir, and gate that file. This avoids the two hazards of a bare newest-by- + * mtime pick on a global dir: two concurrent sessions cross-serving each + * other's plans, and a stale plan from a failed write reviewing as current. + * + * Fallbacks (transcript missing, no plans write found there): newest-by-mtime + * within a freshness window (PLAN_FRESHNESS_MS). Older than that, fail open — + * an empty plan must never lock the user out of plan mode. * * VIBE_HOME resolution mirrors vibe/utils/paths.py:get_vibe_home(): * $VIBE_HOME env var → ~/.vibe (default), then appends /plans. */ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, dirname, sep } from "node:path"; import { homedir } from "node:os"; +/** Freshness window for the mtime fallback: a plan older than this is treated + * as stale (the session's write likely failed) and the gate fails open. */ +export const PLAN_FRESHNESS_MS = 10 * 60 * 1000; + function expandTilde(p: string): string { if (p.startsWith("~")) return join(homedir(), p.slice(1)); return p; @@ -31,15 +42,72 @@ export function resolveVibePlansDir(vibeHomeOverride?: string): string { return join(vibeHome, "plans"); } +function isInsidePlansDir(filePath: string, plansDir: string): boolean { + const dir = dirname(filePath); + if (dir !== plansDir) return false; + return filePath.endsWith(".md"); +} + +/** + * Find the plan file this session wrote, by scanning the Vibe transcript + * (messages.jsonl) backward for the last write_file/edit tool result whose + * target lives in the plans dir. + * + * Vibe persists tool results as lines with `tool_result.output` shaped by the + * tool's `project_result`: write_file yields `{ file_path, bytes_written, + * content }`, edit yields `{ file, old_string, new_string, ... }`. The newest + * match wins. + * + * Returns the absolute plan path, or null when the transcript is missing, + * unreadable, or contains no plans-dir write. + */ +export function findVibePlanInTranscript( + transcriptPath: string, + opts?: { vibeHome?: string }, +): string | null { + const plansDir = resolveVibePlansDir(opts?.vibeHome); + let lines: string[]; + try { + lines = readFileSync(transcriptPath, "utf-8").split("\n"); + } catch { + return null; + } + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + let entry: any; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (entry?.role !== "tool") continue; + const output = entry?.tool_result?.output; + if (!output || typeof output !== "object") continue; + const target: unknown = + typeof output.file_path === "string" ? output.file_path : + typeof output.file === "string" ? output.file : + undefined; + if (!target) continue; + if (!isInsidePlansDir(target, plansDir)) continue; + if (!existsSync(target)) continue; + return target; + } + return null; +} + /** * Read the most-recently-modified `*.md` plan from the Vibe plans dir. - * Returns the file contents, or null when the dir is missing/empty. + * Returns the file path plus contents, or null when the dir is missing/empty. * * Concurrency caveat: Vibe's plans dir is global, so two sessions exiting - * plan mode simultaneously could race. Newest-by-mtime is the best - * available heuristic since the hook carries no plan path. + * plan mode simultaneously could race. The transcript-pinned resolver above + * is the primary path; this is its fallback. */ -export function resolveLatestVibePlan(opts?: { vibeHome?: string }): string | null { +export function findNewestVibePlan( + opts?: { vibeHome?: string; now?: number }, +): { path: string; content: string; mtimeMs: number } | null { const plansDir = resolveVibePlansDir(opts?.vibeHome); if (!existsSync(plansDir)) return null; @@ -68,10 +136,40 @@ export function resolveLatestVibePlan(opts?: { vibeHome?: string }): string | nu } if (!newestFile) return null; + const planPath = join(plansDir, newestFile); + if ((opts?.now ?? Date.now()) - newestMtime > PLAN_FRESHNESS_MS) { + // Stale: the plan predates the freshness window, so the current session's + // write likely failed. Do not review a yesterday-plan as current. + return null; + } try { - return readFileSync(join(plansDir, newestFile), "utf-8"); + return { path: planPath, content: readFileSync(planPath, "utf-8"), mtimeMs: newestMtime }; } catch { return null; } } + +/** + * Resolve the plan content to gate for a Vibe pre_tool/exit_plan_mode hook. + * + * Priority: transcript-pinned file (the plan this session actually wrote) + * > newest-by-mtime plan inside the freshness window. Everything else + * (no transcript, no pinned write, no fresh plan) returns null so the gate + * fails open. + */ +export function resolveLatestVibePlan( + opts?: { vibeHome?: string; transcriptPath?: string; now?: number }, +): string | null { + if (opts?.transcriptPath) { + const pinned = findVibePlanInTranscript(opts.transcriptPath, opts); + if (pinned) { + try { + return readFileSync(pinned, "utf-8"); + } catch { + // fall through to the mtime fallback + } + } + } + return findNewestVibePlan(opts)?.content ?? null; +} diff --git a/apps/hook/server/vibe-session.test.ts b/apps/hook/server/vibe-session.test.ts index 3fd1bc2d4..915be213f 100644 --- a/apps/hook/server/vibe-session.test.ts +++ b/apps/hook/server/vibe-session.test.ts @@ -36,11 +36,18 @@ function writeSession( dirName: string, messages: string, mtimeSec?: number, + cwd?: string, ): string { const sessionDir = join(home, "logs", "session", dirName); mkdirSync(sessionDir, { recursive: true }); const messagesPath = join(sessionDir, "messages.jsonl"); writeFileSync(messagesPath, messages); + if (cwd) { + writeFileSync( + join(sessionDir, "meta.json"), + JSON.stringify({ session_id: dirName, environment: { working_directory: cwd } }), + ); + } if (mtimeSec !== undefined) utimesSync(messagesPath, mtimeSec, mtimeSec); return messagesPath; } @@ -85,17 +92,26 @@ describe("resolveVibeSessionLogForCwd", () => { expect(resolveVibeSessionLogForCwd("/Users/test/project", { vibeHome: home })).toBeNull(); }); - test("falls back to directory scan when the index is absent", () => { + test("falls back to meta.json cwd filter when the index is absent", () => { const home = makeVibeHome(); const cwd = "/Users/test/project"; - writeSession(home, "session_old", vibeLine("assistant", "old", "m1"), 1); - writeSession(home, "session_new", vibeLine("assistant", "new", "m2"), 100); - // No .session_index.json — should pick the newest by file mtime. + // An other-project session with a NEWER mtime must never win over the + // matching one: the fallback filters on meta.json cwd, not bare mtime. + writeSession(home, "session_other", vibeLine("assistant", "other", "m0"), 200, "/elsewhere"); + writeSession(home, "session_old", vibeLine("assistant", "old", "m1"), 1, cwd); + writeSession(home, "session_new", vibeLine("assistant", "new", "m2"), 100, cwd); + // No .session_index.json — picks the newest matching session by mtime. const log = resolveVibeSessionLogForCwd(cwd, { vibeHome: home }); expect(log).toBeTruthy(); expect(log!.endsWith("session_new/messages.jsonl")).toBe(true); }); + test("returns null in the fallback when no session's meta.json matches the cwd", () => { + const home = makeVibeHome(); + writeSession(home, "session_other", vibeLine("assistant", "other", "m0"), 100, "/elsewhere"); + expect(resolveVibeSessionLogForCwd("/Users/test/project", { vibeHome: home })).toBeNull(); + }); + test("returns null when no sessions exist", () => { const home = makeVibeHome(); expect(resolveVibeSessionLogForCwd("/Users/test/project", { vibeHome: home })).toBeNull(); diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md index 903c392c8..4a899704d 100644 --- a/apps/marketing/src/content/docs/reference/environment-variables.md +++ b/apps/marketing/src/content/docs/reference/environment-variables.md @@ -84,6 +84,7 @@ When running your own paste service binary, these variables configure it: | `PLANNOTATOR_SKIP_CODEX_INSTALL` | off | Set to `1` / `true` to skip writing the Codex integration (`hooks.json` / `config.toml` under `CODEX_HOME`) even when Codex is detected. The installer reports "detected, skipped" honestly and never removes an existing integration. Also via `~/.plannotator/config.json` (`{ "skipInstall": { "codex": true } }`) or the `--skip-codex` flag (flag wins over env var, which wins over config). Read by the install scripts only. | | `PLANNOTATOR_SKIP_GEMINI_INSTALL` | off | Same opt-out for the Gemini CLI integration (`~/.gemini` policy, settings hook, commands). Config key `skipInstall.gemini`; flag `--skip-gemini`. Read by the install scripts only. | | `PLANNOTATOR_SKIP_KIRO_INSTALL` | off | Same opt-out for the Kiro CLI integration (`~/.kiro` skills and agent). Config key `skipInstall.kiro`; flag `--skip-kiro`. Read by the install scripts only. | +| `PLANNOTATOR_SKIP_VIBE_INSTALL` | off | Same opt-out for the Mistral Vibe integration (the managed `hooks.toml` block and skills under `$VIBE_HOME`, default `~/.vibe`). Config key `skipInstall.vibe`; flag `--skip-vibe`. Read by the install scripts only. | | `PLANNOTATOR_SKIP_OPENCODE_INSTALL` | off | Do-not-write switch for the OpenCode integration (command stubs, plugin cache clear). Config key `skipInstall.opencode`; flag `--skip-opencode`. Read by the install scripts only. | | `PLANNOTATOR_SKIP_SKILLS_INSTALL` | off | Set to `1` / `true` to skip the skills and slash-command checkout entirely: no `git clone` of the release tag, so nothing is written to `~/.claude/skills`, `~/.agents/skills`, the OpenCode or Gemini command directories, or `~/.kiro`, and the skill-scope cleanup sweeps stay suspended. The binary, hooks, and per-agent config still install, and git stops being a hard requirement. Unlike the per-agent opt-outs above, this covers every scope the checkout writes. Config key `skipInstall.skills`; flags `--skip-skills` (bash/cmd) and `-SkipSkills` (PowerShell). Read by the install scripts only. | | `CLAUDE_CONFIG_DIR` | `~/.claude` | Custom Claude Code config directory. The install script places hooks here instead of the default location. | diff --git a/apps/vibe/hooks/config-snippet.toml b/apps/vibe/hooks/config-snippet.toml deleted file mode 100644 index 1c89c9b8a..000000000 --- a/apps/vibe/hooks/config-snippet.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Plannotator-managed Vibe config snippet. -# Merged into $VIBE_HOME/config.toml by the installer: if the file or the -# enable_experimental_hooks key is absent, it is appended; an existing true -# value is left alone. Vibe requires enable_experimental_hooks = true for -# any hook in hooks.toml to fire. -enable_experimental_hooks = true diff --git a/apps/vibe/hooks/hooks.toml b/apps/vibe/hooks/hooks.toml index 1face773e..4921a4304 100644 --- a/apps/vibe/hooks/hooks.toml +++ b/apps/vibe/hooks/hooks.toml @@ -2,24 +2,25 @@ # Installed by scripts/install.sh into $VIBE_HOME/hooks.toml. The installer # strips and rewrites everything between the markers on each run, so hand # edits inside the block are lost — keep custom hooks outside the markers. -# Vibe spawns hook commands via /bin/sh on macOS/Linux, so a bare binary on -# PATH works. Windows is not supported here (Vibe runs hooks via cmd.exe and -# a .sh launcher is not executable); the Windows installers print manual -# setup instructions instead. +# The command is argv-only (absolute binary path, no env prefix) so it runs +# under both shell-based and shell-free hook executors. Windows is not +# supported here; the Windows installers print manual setup instructions +# instead. # # The pre_tool hook on exit_plan_mode is the plan-review gate. The plan is # NOT carried in the exit_plan_mode payload (its args model is empty), so -# plannotator resolves the plan by reading the newest *.md file in -# $VIBE_HOME/plans by mtime. Approve → {"decision":"allow"}; Deny → +# plannotator resolves the plan from $VIBE_HOME/plans (transcript-pinned, +# newest-by-mtime fallback). Approve → {"decision":"allow"}; Deny → # {"decision":"deny","reason":"..."} (the reason becomes the LLM-visible # tool error). A startup failure fails open: empty stdout + exit 0, so # exit_plan_mode proceeds and the user is never locked out of plan mode. +# Requires Vibe 2.25+ (hooks are stable there; no config flag is needed). # >>> plannotator-managed-vibe-hooks (managed; do not edit) >>> [[hooks]] name = "plannotator-exit-plan-mode" type = "pre_tool" match = "exit_plan_mode" -command = "PLANNOTATOR_ORIGIN=mistral-vibe plannotator" +command = "/full/path/to/plannotator" timeout = 345600 description = "Plannotator plan review (managed)" # <<< plannotator-managed-vibe-hooks <<< diff --git a/packages/server/uninstall.test.ts b/packages/server/uninstall.test.ts index 6e88947cd..a99fa901c 100644 --- a/packages/server/uninstall.test.ts +++ b/packages/server/uninstall.test.ts @@ -309,6 +309,38 @@ describe("default uninstall", () => { writeText( join(homeDir, ".kiro", "skills", "plannotator-setup-goal", "SKILL.md"), ); + + // Vibe: a hooks.toml holding both a user hook and the installer's + // managed block, plus installed skills. The managed block must be + // stripped and the user hook preserved. + const vibeHooks = join(homeDir, ".vibe", "hooks.toml"); + writeText( + vibeHooks, + [ + '[[hooks]]', + 'name = "my-own-hook"', + 'type = "pre_tool"', + 'match = "write_file"', + 'command = "/usr/local/bin/notify"', + "", + "# >>> plannotator-managed-vibe-hooks (managed; do not edit) >>>", + "[[hooks]]", + 'name = "plannotator-exit-plan-mode"', + 'type = "pre_tool"', + 'match = "exit_plan_mode"', + `command = "${join(homeDir, ".local", "bin", "plannotator")}"`, + "timeout = 345600", + 'description = "Plannotator plan review (managed)"', + "# <<< plannotator-managed-vibe-hooks <<<", + ].join("\n"), + ); + writeText( + join(homeDir, ".vibe", "skills", "plannotator-review", "SKILL.md"), + ); + writeText( + join(homeDir, ".vibe", "skills", "my-custom-skill", "SKILL.md"), + "user's own", + ); const openCodePackageCache = join( homeDir, ".cache", @@ -477,6 +509,12 @@ describe("default uninstall", () => { expect(existsSync(join(homeDir, ".agents", "skills", "plannotator-compound"))).toBe(true); expect(existsSync(join(homeDir, ".agents", "skills", "plannotator-archive"))).toBe(false); expect(existsSync(join(homeDir, ".kiro", "skills", "plannotator-setup-goal"))).toBe(false); + expect(existsSync(join(homeDir, ".vibe", "skills", "plannotator-review"))).toBe(false); + expect(existsSync(join(homeDir, ".vibe", "skills", "my-custom-skill"))).toBe(true); + const vibeHooksAfter = readFileSync(vibeHooks, "utf8"); + expect(vibeHooksAfter).toContain("my-own-hook"); + expect(vibeHooksAfter).not.toContain("plannotator-managed-vibe-hooks"); + expect(vibeHooksAfter).not.toContain("exit_plan_mode"); expect(existsSync(customStaleLayoutEntry)).toBe(true); expect(existsSync(openCodePackageCache)).toBe(false); expect(existsSync(unrelatedScopedCache)).toBe(true); diff --git a/packages/server/uninstall.ts b/packages/server/uninstall.ts index fda700f1e..382fd2aa2 100644 --- a/packages/server/uninstall.ts +++ b/packages/server/uninstall.ts @@ -75,6 +75,16 @@ const KIRO_SKILLS = [ "plannotator-archive", ] as const; +// Installed by scripts/install.sh to $VIBE_HOME/skills (same names as the +// core skills plus the knowledge skill; the dirs are installer-created, so +// removing them by name cannot touch a user's own skill). +const VIBE_SKILLS = [ + "plannotator-review", + "plannotator-annotate", + "plannotator-last", + "plannotator", +] as const; + const STALE_CODEX_SKILLS = [ ...CORE_SKILLS, "plannotator-compound", @@ -467,6 +477,7 @@ function resolveOwnedPaths(environment: UninstallEnvironment) { const codexDir = env.CODEX_HOME || join(homeDir, ".codex"); const factoryDir = env.FACTORY_CONFIG_DIR || join(homeDir, ".factory"); const copilotDir = env.COPILOT_HOME || join(homeDir, ".copilot"); + const vibeDir = env.VIBE_HOME || join(homeDir, ".vibe"); const xdgConfigDir = env.XDG_CONFIG_HOME || join(homeDir, ".config"); const xdgCacheDir = env.XDG_CACHE_HOME || join(homeDir, ".cache"); const configDirs = uniquePaths([ @@ -489,6 +500,7 @@ function resolveOwnedPaths(environment: UninstallEnvironment) { codexDir, factoryDir, copilotDir, + vibeDir, configDirs, xdgCacheDir, windowsInstallDir, @@ -660,6 +672,8 @@ function removeHostConfigEntries( state, ); + cleanupVibeHooks(join(paths.vibeDir, "hooks.toml"), request, state); + for (const configDir of paths.configDirs) { for (const name of ["opencode.json", "opencode.jsonc"]) { cleanupOpenCodeConfig( @@ -724,6 +738,14 @@ function removeInstalledFiles( ); } + for (const skill of VIBE_SKILLS) { + removePath( + join(paths.vibeDir, "skills", skill), + request, + state, + ); + } + for (const command of LEGACY_COMMAND_NAMES) { removePath( join(paths.claudeDir, "commands", `${command}.md`), @@ -1231,6 +1253,83 @@ function cleanupCodexConfig( removePath(filePath, request, state, recovery); } +const VIBE_MANAGED_START = "# >>> plannotator-managed-vibe-hooks"; +const VIBE_MANAGED_END = "# <<< plannotator-managed-vibe-hooks"; + +/** + * Strip the installer's managed hook block from $VIBE_HOME/hooks.toml. + * + * The block is delimited by the plannotator-managed-vibe-hooks markers; only + * lines between them (markers included) are ours, so everything else stays. + * When nothing recognizable remains, the file is deleted; when a managed + * start marker exists with no end marker, the whole tail from the marker is + * treated as ours (the installer always writes both markers, so this is + * corrupt-input handling) and the file is kept only if content survives. + */ +function cleanupVibeHooks( + filePath: string, + request: UninstallRequest, + state: MutableUninstallResult, +): void { + if (!existsSync(filePath)) return; + const recovery = { + manualCleanup: `Make ${filePath} readable and writable. Remove the lines between and including the "${VIBE_MANAGED_START}" and "${VIBE_MANAGED_END}" markers; keep every other hook. Delete the file only if nothing remains.`, + }; + let content: string; + try { + content = readFileSync(filePath, "utf8"); + } catch (error) { + reportHostCleanupFailure( + `Could not inspect ${filePath}`, + formatError(error), + recovery, + request, + state, + ); + return; + } + + const lines = content.split(/\r?\n/); + const nextLines: string[] = []; + let skipping = false; + let sawStart = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!skipping && trimmed.startsWith(VIBE_MANAGED_START)) { + skipping = true; + sawStart = true; + continue; + } + if (skipping) { + if (trimmed.startsWith(VIBE_MANAGED_END)) { + skipping = false; + } + continue; + } + nextLines.push(line); + } + + if (!sawStart) return; + + const remaining = nextLines.filter((line) => line.trim().length > 0); + if (remaining.length === 0) { + removePath(filePath, request, state, recovery); + return; + } + + try { + writeFileSync(filePath, nextLines.join("\n").replace(/\s+$/, "\n")); + } catch (error) { + reportHostCleanupFailure( + `Could not rewrite ${filePath}`, + formatError(error), + recovery, + request, + state, + ); + } +} + function cleanupGeminiSettings( filePath: string, binaryPaths: readonly string[], diff --git a/scripts/install.cmd b/scripts/install.cmd index d0ab67911..6c27d9aef 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -1011,10 +1011,12 @@ if "!VIBE_AVAILABLE!"=="1" if "!SKIP_VIBE!"=="0" ( echo name = "plannotator-exit-plan-mode" echo type = "pre_tool" echo match = "exit_plan_mode" - echo command = "PLANNOTATOR_ORIGIN=mistral-vibe plannotator" + echo command = "/full/path/to/plannotator" echo timeout = 345600 echo. - echo And ensure ~/.vibe/config.toml has: enable_experimental_hooks = true + echo The command must be argv-only ^(no env-prefix^): plannotator detects + echo the Vibe origin from the hook payload. Hooks are stable in Vibe + echo 2.25+, so no config.toml flag is needed. echo. echo Vibe skills are still installed to !VIBE_HOME!\skills from this run. ) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 506efc953..1faf0bd12 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -813,18 +813,21 @@ if ($vibeAvailable -and $skipVibeResolved) { $vibeExePath = "$installDir\plannotator.exe" Write-Host "" Write-Host "Vibe detected." - Write-Host "Vibe plan-review hooks run on macOS/Linux only (Vibe spawns hooks via" - Write-Host "/bin/sh; on Windows a .sh launcher is not executable). To set up plan" - Write-Host "review manually on a macOS/Linux box with Vibe, add to ~/.vibe/hooks.toml:" + Write-Host "Vibe plan-review hooks run on macOS/Linux only (older Vibe builds" + Write-Host "spawn hooks via /bin/sh, which a .sh launcher is not executable" + Write-Host "through on Windows). To set up plan review manually on a" + Write-Host "macOS/Linux box with Vibe 2.25 or newer, add to ~/.vibe/hooks.toml:" Write-Host "" Write-Host " [[hooks]]" Write-Host " name = ""plannotator-exit-plan-mode""" Write-Host " type = ""pre_tool""" Write-Host " match = ""exit_plan_mode""" - Write-Host " command = ""PLANNOTATOR_ORIGIN=mistral-vibe plannotator""" + Write-Host " command = ""/full/path/to/plannotator""" Write-Host " timeout = 345600" Write-Host "" - Write-Host "And ensure ~/.vibe/config.toml has: enable_experimental_hooks = true" + Write-Host "The command must be argv-only (no env-prefix): plannotator detects" + Write-Host "the Vibe origin from the hook payload. Hooks are stable in Vibe" + Write-Host "2.25+, so no config.toml flag is needed." Write-Host "" Write-Host "Vibe skills are still installed to $vibeHome\skills from this run." } diff --git a/scripts/install.sh b/scripts/install.sh index 8fdb481e1..96affb175 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1105,14 +1105,17 @@ if command -v kiro-cli >/dev/null 2>&1 || [ -d "$HOME/.kiro" ]; then fi # Vibe (Mistral's TUI coding agent) stores everything under $VIBE_HOME when -# set, falling back to ~/.vibe (vibe/utils/paths.py:get_vibe_home). Detect via -# the `vibe` binary on PATH or an existing ~/.vibe. The hook runs on -# macOS/Linux only (Vibe spawns hooks via /bin/sh; Windows uses cmd.exe and a -# .sh launcher is not executable), so the Windows installers print manual -# instructions instead of wiring hooks. +# set, falling back to ~/.vibe (vibe/utils/paths.py:get_vibe_home). Detection +# requires an existing home dir, not merely a `vibe` binary on PATH: a machine +# with an unrelated `vibe` binary must not gain a ~/.vibe the user never had. +# The write legs below only run when detection passed. The hook command is +# argv-only, so it runs under both shell-based and shell-free hook executors; +# the Windows installers print manual instructions instead of wiring hooks +# (older Vibe builds spawned hooks via /bin/sh, which a .sh launcher can't +# reach on Windows). VIBE_HOME="${VIBE_HOME:-$HOME/.vibe}" vibe_available=0 -if command -v vibe >/dev/null 2>&1 || [ -d "$VIBE_HOME" ]; then +if [ -d "$VIBE_HOME" ]; then vibe_available=1 fi @@ -2044,13 +2047,14 @@ fi # --- Mistral Vibe support (only if Vibe is installed or configured) --- # Vibe (Mistral's TUI coding agent) hooks are [[hooks]] TOML blocks in -# $VIBE_HOME/hooks.toml. Plan review gates exit_plan_mode via a pre_tool hook; -# the plan is not in the payload (exit_plan_mode takes no args), so plannotator -# resolves the newest plan from $VIBE_HOME/plans by mtime. A managed marker -# block coexists with any user/Orca hooks. Vibe needs -# enable_experimental_hooks = true in config.toml for any hook to fire. +# $VIBE_HOME/hooks.toml (stable since Vibe 2.25; no config flag needed). +# Plan review gates exit_plan_mode via a pre_tool hook. The hook command is +# argv-only (absolute binary path, no env prefix) so it survives both shell +# and shell-free hook execution. plannotator detects the Vibe origin from +# the hook payload itself; PLANNOTATOR_ORIGIN=mistral-vibe remains the +# documented manual override. A managed marker block coexists with any +# user/Orca hooks. VIBE_HOOKS="$VIBE_HOME/hooks.toml" -VIBE_CONFIG="$VIBE_HOME/config.toml" VIBE_MANAGED_START="# >>> plannotator-managed-vibe-hooks (managed; do not edit) >>>" VIBE_MANAGED_END="# <<< plannotator-managed-vibe-hooks <<<" @@ -2067,13 +2071,16 @@ if [ "$vibe_available" -eq 1 ] && [ "$skip_vibe" -eq 1 ]; then echo "and are still installed." elif [ "$vibe_available" -eq 1 ]; then mkdir -p "$VIBE_HOME" - vibe_hook_configured=0 + PLANNOTATOR_BIN="${INSTALL_DIR}/plannotator" # Strip any existing managed block (markers inclusive), then append a - # fresh one. awk prints every line except the managed region; a lone - # start-marker with no end-marker is bounded to EOF so it cannot swallow - # trailing user TOML beyond the contiguous managed tables. Atomic write - # via a temp file in the same dir + rename; keep a .bak for recovery. + # fresh one. awk prints every line except the managed region. A start + # marker with no end marker swallows everything to EOF (the block is + # always written with both markers, so this is corrupt-input handling, + # not a normal path): a truncated managed block has no trailing user + # TOML worth preserving, and dropping it fails closed into a clean + # rewrite. Atomic write via a temp file in the same dir + rename; keep + # a .bak for recovery. write_vibe_hooks_block() { _new_body="" if [ -f "$VIBE_HOOKS" ]; then @@ -2090,13 +2097,13 @@ elif [ "$vibe_available" -eq 1 ]; then [ -f "$VIBE_HOOKS" ] && cp "$VIBE_HOOKS" "$VIBE_HOOKS.bak" { printf '%s\n' "$_new_body" - cat << 'VIBE_HOOKS_BLOCK_EOF' + cat << VIBE_HOOKS_BLOCK_EOF # >>> plannotator-managed-vibe-hooks (managed; do not edit) >>> [[hooks]] name = "plannotator-exit-plan-mode" type = "pre_tool" match = "exit_plan_mode" -command = "PLANNOTATOR_ORIGIN=mistral-vibe plannotator" +command = "${PLANNOTATOR_BIN}" timeout = 345600 description = "Plannotator plan review (managed)" # <<< plannotator-managed-vibe-hooks <<< @@ -2106,23 +2113,6 @@ VIBE_HOOKS_BLOCK_EOF write_vibe_hooks_block echo "Installed Vibe plan-review hook at ${VIBE_HOOKS}" - vibe_hook_configured=1 - - # Ensure enable_experimental_hooks = true in config.toml. Vibe requires - # this top-level key for any hook to fire. Create the file if absent; if - # the key already exists (true or false) leave it untouched so we never - # downgrade a user's explicit false, matching the Codex config-merge - # discipline. - if [ ! -f "$VIBE_CONFIG" ]; then - printf 'enable_experimental_hooks = true\n' > "$VIBE_CONFIG" - echo "Created Vibe config at ${VIBE_CONFIG}" - elif grep -Eq '^[[:space:]]*enable_experimental_hooks[[:space:]]*=' "$VIBE_CONFIG"; then - echo "Vibe config at ${VIBE_CONFIG} already sets enable_experimental_hooks; leaving it unchanged." - else - cp "$VIBE_CONFIG" "$VIBE_CONFIG.bak" - printf '\n# Added by plannotator installer\nenable_experimental_hooks = true\n' >> "$VIBE_CONFIG" - echo "Enabled Vibe experimental hooks in ${VIBE_CONFIG}" - fi fi echo "" @@ -2241,8 +2231,7 @@ elif [ "$vibe_available" -eq 1 ] && [ "$skip_skills" -eq 1 ]; then echo "Re-run without the opt-out to add them." elif [ "$vibe_available" -eq 1 ]; then echo "Plan review is configured through the Vibe pre_tool hook on exit_plan_mode" - echo "in ${VIBE_HOME}/hooks.toml. enable_experimental_hooks = true is set in" - echo "${VIBE_HOME}/config.toml." + echo "in ${VIBE_HOME}/hooks.toml (requires Vibe 2.25 or newer)." echo "" echo "Vibe skills are installed to ${VIBE_SKILLS_DIR}/" echo "Note: improve-context (plan-mode enrichment) is not wired for Vibe —" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index cc48bf072..0f1c757c3 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -225,11 +225,16 @@ describe("install.sh", () => { // Managed marker block so the hook coexists with the user's own hooks.toml. expect(script).toContain('VIBE_MANAGED_START="# >>> plannotator-managed-vibe-hooks (managed; do not edit) >>>"'); expect(script).toContain('VIBE_MANAGED_END="# <<< plannotator-managed-vibe-hooks <<<"'); - expect(script).toContain('PLANNOTATOR_ORIGIN=mistral-vibe plannotator"'); + // The hook command is argv-only with the absolute binary path (no env + // prefix) so it survives both shell and shell-free hook executors, and + // origin detection happens in the binary from the hook payload. + expect(script).toContain('command = "${PLANNOTATOR_BIN}"'); + expect(script).toContain("PLANNOTATOR_BIN=\"${INSTALL_DIR}/plannotator\""); expect(script).toContain('match = "exit_plan_mode"'); - // config.toml must carry enable_experimental_hooks = true for any hook to fire. - expect(script).toContain("enable_experimental_hooks = true"); - expect(script).toContain('VIBE_CONFIG="$VIBE_HOME/config.toml"'); + // Hooks are stable in Vibe 2.25+; no config.toml flag is written, and the + // installer must not touch $VIBE_HOME/config.toml at all. + expect(script).not.toContain("enable_experimental_hooks"); + expect(script).not.toContain('VIBE_CONFIG="$VIBE_HOME/config.toml"'); // Parity: no bespoke flag, like every other agent. expect(script).not.toContain("--vibe-only"); expect(script).not.toContain("INSTALL_VIBE"); From 1b58b164b3afa11be1048fa74ab1bb1dc8661b04 Mon Sep 17 00:00:00 2001 From: Julien Tanay Date: Sun, 13 Sep 2026 21:44:40 +0200 Subject: [PATCH 3/3] chore: drop the .pr-desc-*.md gitignore entry --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3490f1b92..732e347d4 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,3 @@ packages/ui/components/html-viewer/bridge-script.lite.ts /adr/research/SPIKE-bundle-optimization-theories-20260619-154926.md /adr/research/SPIKE-local-app-annotation-20260810.md /scripts/convert-themes.ts - -# PR-description working drafts at the repo root (untracked by design — never commit). -/.pr-desc-*.md