From f24a46a45c6d3f24512b97c3e410c6bbaf7a100d Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 16:47:37 -0700 Subject: [PATCH 1/3] [luv-cut-0.0.10-beta.0] chore: cut 0.0.10-beta.0 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps package.json from 0.0.9-beta.3 to 0.0.10-beta.0 and rolls the ## Unreleased changelog section into ## 0.0.10-beta.0 — 2026-05-04. Why 0.0.10-beta.0 and not 0.0.9-beta.3: 0.0.9 is already published as `latest` on npm. Per semver, 0.0.9-beta.3 < 0.0.9 — publishing it would point the `beta` dist-tag at a version semver-older than the released 0.0.9, while shipping *more* features than 0.0.9 ever had. The next pre-release after a shipped 0.0.9 must live in the 0.0.10 line. Why the version had drifted to 0.0.13-beta.1 before #284 reset it: PRs #266 (OpenCode) and #267 (Pi) each speculatively bumped package.json in their feature branches even though no release was being cut. When unified into #270, the bumps stacked (0.0.10-beta.1 → .2 → 0.0.11-beta.1 → 0.0.12-beta.1 → 0.0.13-beta.1). Going forward, feature PRs should leave package.json alone — only release-cut PRs touch the version. Adds since v0.0.9: Features: - Add Gemini CLI integration (beta) (#277) - Add OpenCode (sst/opencode) integration (beta) (#270) - Add Pi (pi-coding-agent) integration (beta) (#270) - Add GitHub Copilot CLI integration (beta) (#236) - Add Cursor Agent CLI integration (beta) (#245) - Project page lists Copilot and Cursor sessions (#245) Fixes: - Pi integration: cache sessionId in shim (#284) - Cursor integration: support cursor-agent 2026-04+ layout (#283) - block-read-outside-cwd: deny message for all 6 CLIs (#270) - require-ci-green-before-stop: scope to current HEAD (#266) - failproofai policies --uninstall: correct selector wording (#236) - README: replace broken Copilot and Cursor logos (#236, #257) - Auto-translated MDX: sanitize JSX attribute quotes (#247) Docs: - README: drop "more coming soon" tagline (#281) - README: add Gemini, Pi, Cursor to supported-CLIs list (#277, #264, #245) Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bde0c0a4..5e757dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.0.10-beta.0 — 2026-05-04 + ### Features - Add Gemini CLI integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. `--cli gemini` writes Claude-shape hook entries into `~/.gemini/settings.json` (user) or `/.gemini/settings.json` (project) using Gemini's `{matcher, hooks: [{type, command, timeout}]}` matcher-wrapper schema. Subscribes to all 11 documented events (SessionStart, SessionEnd, BeforeAgent, AfterAgent, BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool, PreCompress, Notification); BeforeModel / AfterModel / BeforeToolSelection lack a Claude canonical equivalent so no policies match on them today, but the binary still records activity for those events so future policies can opt in. The handler canonicalizes Gemini's snake_case tool names (`run_shell_command`, `read_file`, `read_many_files`, `write_file`, `replace`, `glob`, `grep_search`, `list_directory`, `web_fetch`, `google_web_search`, `write_todos`, `save_memory`, `ask_user`) to Claude PascalCase (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `LS`, `WebFetch`, `WebSearch`, `TodoWrite`, `Memory`, `AskUser`) via `GEMINI_TOOL_MAP` so existing builtin policies (block-sudo, block-rm-rf, sanitize-api-keys, …) fire unchanged on Gemini sessions. MCP tool names (`mcp__` pattern) and Skills tool names pass through unchanged. The policy evaluator emits Gemini's flat `{decision: "deny", reason}` deny shape (preferred per Gemini's "Golden Rule" exit-0 contract), `{hookSpecificOutput: {hookEventName, additionalContext}}` for context injection on BeforeAgent / AfterTool / SessionStart, and `{decision: "block", reason}` on AfterAgent for force-retry semantics matching Claude's exit-2-from-Stop "do this before stopping" pattern. Path-protection (`isAgentInternalPath` + `isAgentSettingsFile`) covers `~/.gemini/` and `.gemini/settings.json`. Frontend: `lib/cli-registry.ts` adds a `Gemini CLI` entry with a sky-blue badge; `lib/projects.ts` merges Gemini projects into `/projects`; `app/project/[name]` and `/session/[id]` extend the external-CLI fallback chain. Also ships this repo's own `.gemini/settings.json` so contributors using `gemini` get hooks active automatically — uses `$GEMINI_PROJECT_DIR` for resolver stability (Gemini also sets `$CLAUDE_PROJECT_DIR` as a back-compat alias). Verified against gemini-cli v0.40.1 (#277). - Add OpenCode (sst/opencode) integration (beta) across hooks, activity dashboard, session viewer, and `/projects` listing. `--cli opencode` writes a generated plugin shim at `.opencode/plugins/failproofai.mjs` plus a registration entry in `opencode.json`'s `plugin: []` array; SQLite-backed dashboard adapters read OpenCode's session store via `opencode db --format json`. Verified against opencode v1.14.33 (#270). diff --git a/package.json b/package.json index bc5f8e0f..b2de1499 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "failproofai", - "version": "0.0.9-beta.3", + "version": "0.0.10-beta.0", "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK", "bin": { "failproofai": "./dist/cli.mjs" From 4592c9e6f0ab8ef4570b7a4a4c57ec56bd57f4ed Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 16:56:32 -0700 Subject: [PATCH 2/3] chore: add block-version-bumps custom policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents the kind of drift that caused this very release. PRs #266 (OpenCode) and #267 (Pi) each speculatively bumped package.json in their feature branches, and when unified into #270 the bumps stacked all the way to 0.0.13-beta.1. PR #284 then over-corrected to 0.0.9-beta.3 — older than the already-published 0.0.9. The policy lives at .failproofai/policies/block-version-bumps.mjs (auto-loaded by failproofai's project-scope hooks). It blocks: - Edit/Write/MultiEdit on package.json that touches the "version" key - Bash: npm|yarn|pnpm|bun (pm) version - Bash: sed|awk|jq mutating package.json referencing "version" Allowed when on a `luv-cut-*` branch — the established release-cut branch convention. Branch detection is a best-effort `git rev-parse` that fails open (returns false) so a missing/unusable git tree never blocks a legitimate edit. Co-Authored-By: Claude Opus 4.7 --- .failproofai/policies/block-version-bumps.mjs | 94 +++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 95 insertions(+) create mode 100644 .failproofai/policies/block-version-bumps.mjs diff --git a/.failproofai/policies/block-version-bumps.mjs b/.failproofai/policies/block-version-bumps.mjs new file mode 100644 index 00000000..86633cc3 --- /dev/null +++ b/.failproofai/policies/block-version-bumps.mjs @@ -0,0 +1,94 @@ +/** + * block-version-bumps.mjs — Prevent feature PRs from bumping package.json's + * `version` field. Only release-cut PRs (branch name `luv-cut-X.Y.Z`) may. + * + * Why: PR #270 merged with package.json at 0.0.13-beta.1 because two parallel + * feature branches (#266 OpenCode, #267 Pi) had each been speculatively + * bumping the version. Stacked progression: + * + * #245 Cursor merged 0.0.10-beta.1 + * Pi dev branch 0.0.10-beta.2 + * OpenCode dev branch 0.0.11-beta.1 + * Pi+OpenCode unify merge 0.0.12-beta.1 + * Pi subscribe expand 0.0.13-beta.1 + * #270 merged 0.0.13-beta.1 + * + * PR #284 then over-corrected to 0.0.9-beta.3 (older than the published + * 0.0.9), which broke release readiness. Fix is procedural: only the + * release-cut PR touches the version. + */ +import { customPolicies, allow, deny } from "failproofai"; +import { execSync } from "node:child_process"; + +const VERSION_KEY_RE = /["']version["']\s*:/; +const PKG_JSON_PATH_RE = /(^|[\\/])package\.json$/; +const VERSION_CMD_RE = /\b(npm|yarn|pnpm|bun(?:\s+pm)?)\s+version\b/; +const VERSION_FILE_MUNGE_RE = /\b(sed|awk|jq)\b[^|;&]*package\.json[^|;&]*version/; + +function isOnCutBranch(cwd) { + if (!cwd) return false; + try { + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd, + encoding: "utf8", + timeout: 3000, + }).trim(); + return /^luv-cut-/.test(branch); + } catch { + return false; + } +} + +const DENY_REASON = + "Modifying package.json version is reserved for release-cut PRs " + + "(branch name pattern: luv-cut-X.Y.Z). Feature PRs must leave the version " + + "field alone — speculative bumps stack across PRs and produce drift " + + "(see PR #270, where the version jumped 0.0.10-beta.1 → 0.0.13-beta.1 because " + + "two parallel feature branches each bumped independently, and PR #284 which " + + "then over-corrected to 0.0.9-beta.3, older than the already-published 0.0.9). " + + "If you're cutting a release, switch to a `luv-cut-X.Y.Z` branch first."; + +customPolicies.add({ + name: "block-version-bumps", + description: + "Block agents from bumping package.json version outside of release-cut branches", + match: { events: ["PreToolUse"] }, + fn: async (ctx) => { + const cwd = ctx.session?.cwd; + + if (ctx.toolName === "Bash") { + const cmd = String(ctx.toolInput?.command ?? ""); + const hits = VERSION_CMD_RE.test(cmd) || VERSION_FILE_MUNGE_RE.test(cmd); + if (!hits) return allow(); + if (isOnCutBranch(cwd)) return allow(); + return deny(DENY_REASON); + } + + if (ctx.toolName === "Edit" || ctx.toolName === "MultiEdit" || ctx.toolName === "Write") { + const filePath = String(ctx.toolInput?.file_path ?? ""); + if (!PKG_JSON_PATH_RE.test(filePath)) return allow(); + + let touchesVersion = false; + if (ctx.toolName === "Write") { + touchesVersion = VERSION_KEY_RE.test(String(ctx.toolInput?.content ?? "")); + } else if (ctx.toolName === "Edit") { + touchesVersion = + VERSION_KEY_RE.test(String(ctx.toolInput?.old_string ?? "")) || + VERSION_KEY_RE.test(String(ctx.toolInput?.new_string ?? "")); + } else { + const edits = Array.isArray(ctx.toolInput?.edits) ? ctx.toolInput.edits : []; + touchesVersion = edits.some( + (e) => + VERSION_KEY_RE.test(String(e?.old_string ?? "")) || + VERSION_KEY_RE.test(String(e?.new_string ?? "")), + ); + } + + if (!touchesVersion) return allow(); + if (isOnCutBranch(cwd)) return allow(); + return deny(DENY_REASON); + } + + return allow(); + }, +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e757dfa..a1074b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Project page (`/project/[name]`): list Copilot and Cursor sessions alongside Claude + Codex, mirroring the existing merge logic on the projects index. Previously the project detail view only enumerated Claude + Codex transcripts (#245). ### Fixes +- Project-local: add `.failproofai/policies/block-version-bumps.mjs` so feature PRs can't bump `package.json`'s `version` field — only release-cut branches (`luv-cut-X.Y.Z`) may. Prevents the drift root-caused in PR #270 where two parallel feature branches each speculatively bumped, stacking 0.0.10-beta.1 → 0.0.10-beta.2 → 0.0.11-beta.1 → 0.0.12-beta.1 → 0.0.13-beta.1, and the over-correction in PR #284 that landed package.json at 0.0.9-beta.3 (older than the published 0.0.9). Blocks `Edit`/`Write` to `package.json` that touches the version field, plus Bash `npm|yarn|pnpm|bun (pm) version` and `sed|awk|jq` mutations of `package.json` mentioning `version` (#285). - Pi integration: surface `sessionId` on activity records by discovering it from Pi's on-disk transcript filename. Pi (verified empirically against pi-coding-agent v0.71.1) does NOT populate `event.sessionId` on any of its events — `session_start`, `tool_call`, `user_bash`, `input`, `tool_result`, `agent_end`, `session_shutdown` all leave it undefined. The shim now scans `~/.pi/agent/sessions/----/` for the most-recent `_.jsonl` file (filtering to files whose mtime ≥ process start so a stale transcript from a prior session in the same cwd can't pin a wrong UUID at cold start) and extracts the sessionId from the filename, then caches it per cwd for subsequent events in the same Pi process and clears the entry on `session_shutdown` reasons `new`/`resume`/`fork` so cross-session misattribution can't happen. With this fix, `PreToolUse` / `PostToolUse` / `Stop` / `SessionEnd` records now carry the sessionId so dashboard rows can deep-link to the session viewer. `SessionStart` and `UserPromptSubmit` remain unsessioned because Pi flushes the transcript file lazily, after those events fire — that's a Pi behavior we can't change client-side. Pi's encoding strips the leading `/` before replacing remaining slashes with `-`, so `/home/u/repo` → `--home-u-repo--` (NOT `---home-u-repo--`). New unit test (`__tests__/hooks/pi-extension-shim.test.ts`) covers happy path, multi-file mtime tie-breaker, missing-cwd fallback, resolution from every event type, and the per-cwd cache reset on session_shutdown (#284). - Cursor integration: surface sessions stored under cursor-agent's current on-disk layout. As of cursor-agent 2026-04+, transcripts live at `~/.cursor/projects//agent-transcripts//.jsonl` (with the JSONL records using the OpenAI-shape `{role, message: {content: [{type, text}]}}` rather than the legacy `{type, data, timestamp}` form). `lib/cursor-projects.ts` and `lib/cursor-sessions.ts` previously only probed the legacy `~/.cursor/{agent-sessions,conversations,sessions}/` paths so every recent Cursor session 404'd from the dashboard. Both modules now scan the new layout first (and decode the cwd from the encoded project-dir name, prepending `/` since Cursor's encoding drops the leading slash), then fall back to the legacy candidates for older installs. The transcript parser learned a branch for the new shape — strips the synthesized `` wrapper Cursor adds to user messages, preserves assistant text blocks, and synthesizes per-record sort timestamps since the new format omits them. Verified live on cursor-agent v2026.04.29 against a real session that the dashboard had been falsely tagging as "Claude Code" with "Session log file not found". `lib/gemini-projects.ts` now uses `encodeFolderName(cwd)` for `ProjectFolder.name` so cross-CLI merge in `mergeProjectFolders` unions on the same key and Gemini-only project links resolve through `getGeminiSessionsByEncodedName`. `policy-evaluator.ts` preserves the raw CLI `--hook` arg via a new `SessionMetadata.rawHookEventName` field captured in `handler.ts` before canonicalization, so Gemini's `hookSpecificOutput.hookEventName` round-trips correctly even when stdin omits `hook_event_name`; deny-message construction now branches on event type so non-tool events (UserPromptSubmit / SessionStart / SessionEnd / Stop) emit "Blocked prompt|session start|…" instead of the misleading "Blocked unknown tool". `lib/gemini-sessions.ts` loosens `SESSION_FILE_RE` to accept any timestamp shape (Gemini docs include seconds; the load-bearing safety check is the first-line `sessionId` validation) and replaces the whole-file `readFileSync` in `findGeminiTranscript` with a bounded 4 KB `readFirstLineSync` helper so large transcripts no longer blow memory just to inspect the metadata header. `__tests__/lib/projects.test.ts` adds three Gemini aggregation tests (Gemini-only inclusion, cross-CLI merge by encoded slug, reject-fallback) mirroring the existing Pi / Cursor / OpenCode patterns (#277). - `block-read-outside-cwd`: deny message now says "Reading agent settings file blocked" instead of "Reading Claude settings file blocked" — the policy has covered all 6 CLIs' settings files since #270 / #245 / #220 but the deny string was stale (#270). From ce2e35dedc65357a48cf239c6aec7fd6092edfa9 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Mon, 4 May 2026 17:05:02 -0700 Subject: [PATCH 3/3] chore: address CodeRabbit review on block-version-bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three valid findings, all fixed: 1. sed/awk/jq detection (line 26): regex required `package.json` to appear before `version`, missing forms like `jq '.version="x"' package.json`. Switched to two non-consuming lookaheads so either ordering matches within a shell segment. 2. Value-only Edit/MultiEdit bypass (lines 74-84): an agent could issue `Edit { old_string: '"0.0.9-beta.3"', new_string: '"0.0.10-beta.0"' }` — neither string contains the literal `"version"` key, so the previous check let it through. Added STANDALONE_SEMVER_VALUE_RE plus an editTouchesVersion() helper that catches a value-only swap when both sides are bare semver-quoted strings that differ. The anchors (^ / $) and leading-digit requirement intentionally exclude range-prefixed dep entries (`"^1.2.3"`) and key-prefixed ones (`"react": "18.2.0"`), so dep-version Edits aren't false-positive. 3. Loose cut-branch match (line 36): `^luv-cut-/` allowed any suffix (e.g. `luv-cut-feature`). Tightened to require a semver-shape suffix: `^luv-cut-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`. Verified via 16 regex test cases (sed orderings, dep edits with keys, range prefixes, cut branch shapes). Co-Authored-By: Claude Opus 4.7 --- .failproofai/policies/block-version-bumps.mjs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/.failproofai/policies/block-version-bumps.mjs b/.failproofai/policies/block-version-bumps.mjs index 86633cc3..7ec8684b 100644 --- a/.failproofai/policies/block-version-bumps.mjs +++ b/.failproofai/policies/block-version-bumps.mjs @@ -21,9 +21,19 @@ import { customPolicies, allow, deny } from "failproofai"; import { execSync } from "node:child_process"; const VERSION_KEY_RE = /["']version["']\s*:/; +// Standalone semver-quoted value: matches `"0.0.10-beta.0"` but NOT `"react": "0.0.10-beta.0"` +// (the surrounding key would prevent the ^ / $ anchors from matching). Range-prefixed +// dep versions like `"^1.2.3"` also fall through because the leading `"` is followed by `^`, +// not a digit. So a value-only Edit on the package's own version is the only thing this +// catches without false-positiving on dep edits. +const STANDALONE_SEMVER_VALUE_RE = /^["']\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?["']$/; const PKG_JSON_PATH_RE = /(^|[\\/])package\.json$/; const VERSION_CMD_RE = /\b(npm|yarn|pnpm|bun(?:\s+pm)?)\s+version\b/; -const VERSION_FILE_MUNGE_RE = /\b(sed|awk|jq)\b[^|;&]*package\.json[^|;&]*version/; +// Lookaheads catch both orderings: `sed -i 's/.../.../' package.json` AND +// `jq '.version="x"' package.json`. Both must appear within the same shell segment. +const VERSION_FILE_MUNGE_RE = + /\b(sed|awk|jq)\b(?=[^|;&]*package\.json)(?=[^|;&]*\bversion\b)/; +const CUT_BRANCH_RE = /^luv-cut-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; function isOnCutBranch(cwd) { if (!cwd) return false; @@ -33,12 +43,27 @@ function isOnCutBranch(cwd) { encoding: "utf8", timeout: 3000, }).trim(); - return /^luv-cut-/.test(branch); + return CUT_BRANCH_RE.test(branch); } catch { return false; } } +function editTouchesVersion(oldStr, newStr) { + const o = String(oldStr ?? ""); + const n = String(newStr ?? ""); + if (VERSION_KEY_RE.test(o) || VERSION_KEY_RE.test(n)) return true; + // Value-only swap: both sides are bare semver-quoted values that differ. + // Catches `Edit { old_string: '"0.0.9-beta.3"', new_string: '"0.0.10-beta.0"' }`. + const trimO = o.trim(); + const trimN = n.trim(); + return ( + STANDALONE_SEMVER_VALUE_RE.test(trimO) && + STANDALONE_SEMVER_VALUE_RE.test(trimN) && + trimO !== trimN + ); +} + const DENY_REASON = "Modifying package.json version is reserved for release-cut PRs " + "(branch name pattern: luv-cut-X.Y.Z). Feature PRs must leave the version " + @@ -72,16 +97,10 @@ customPolicies.add({ if (ctx.toolName === "Write") { touchesVersion = VERSION_KEY_RE.test(String(ctx.toolInput?.content ?? "")); } else if (ctx.toolName === "Edit") { - touchesVersion = - VERSION_KEY_RE.test(String(ctx.toolInput?.old_string ?? "")) || - VERSION_KEY_RE.test(String(ctx.toolInput?.new_string ?? "")); + touchesVersion = editTouchesVersion(ctx.toolInput?.old_string, ctx.toolInput?.new_string); } else { const edits = Array.isArray(ctx.toolInput?.edits) ? ctx.toolInput.edits : []; - touchesVersion = edits.some( - (e) => - VERSION_KEY_RE.test(String(e?.old_string ?? "")) || - VERSION_KEY_RE.test(String(e?.new_string ?? "")), - ); + touchesVersion = edits.some((e) => editTouchesVersion(e?.old_string, e?.new_string)); } if (!touchesVersion) return allow();