diff --git a/.failproofai/policies/workflow-policies.mjs b/.failproofai/policies/workflow-policies.mjs index 80e874dd..374d57d8 100644 --- a/.failproofai/policies/workflow-policies.mjs +++ b/.failproofai/policies/workflow-policies.mjs @@ -5,6 +5,19 @@ */ import { customPolicies, allow, instruct } from "failproofai"; +/** + * Match `` only when it appears at a command boundary (start of + * string, `;`, `&&`, `||`, `|`, or newline). Avoids false-positive matches + * when the literal phrase appears inside a HEREDOC or a quoted argument + * (e.g. `gh pr edit --body "...gh pr create..."` would previously trigger + * `release-prep-check` because the regex matched anywhere in the string). + */ +function matchesCommand(cmd, verbPhrasePattern) { + return new RegExp( + String.raw`(?:^|[;\n|]|&&|\|\|)\s*` + verbPhrasePattern + String.raw`\b`, + ).test(cmd); +} + // Remind to update CHANGELOG before committing customPolicies.add({ name: "changelog-check", @@ -13,12 +26,13 @@ customPolicies.add({ fn: async (ctx) => { if (ctx.toolName !== "Bash") return allow(); const cmd = String(ctx.toolInput?.command ?? ""); - if (/git\s+commit/.test(cmd)) { + if (matchesCommand(cmd, String.raw`git\s+commit`)) { return instruct( "Check whether CHANGELOG.md needs an update for this commit. " + - "Every PR must include an entry under the `## Unreleased` section. " + - "Use the appropriate subsection: Features, Fixes, Docs, or Dependencies.\n" + - "Check the version in package.json and ensure the changelog entry matches the current version." + "Every PR must include an entry under the current `## ` section " + + "(matching `version` in package.json + today's date). If that section does not exist yet, " + + "create it above the previous version's section — there is no `## Unreleased` section. " + + "Use the appropriate subsection: Features, Fixes, Docs, or Dependencies." ); } return allow(); @@ -33,7 +47,7 @@ customPolicies.add({ fn: async (ctx) => { if (ctx.toolName !== "Bash") return allow(); const cmd = String(ctx.toolInput?.command ?? ""); - if (/git\s+commit/.test(cmd)) { + if (matchesCommand(cmd, String.raw`git\s+commit`)) { return instruct( "Check whether documentation needs updating for this change. " + "Consider: docs/*.mdx files, README.md, and examples/ directory. " + @@ -52,7 +66,7 @@ customPolicies.add({ fn: async (ctx) => { if (ctx.toolName !== "Bash") return allow(); const cmd = String(ctx.toolInput?.command ?? ""); - if (/git\s+push/.test(cmd)) { + if (matchesCommand(cmd, String.raw`git\s+push`)) { return instruct( "After pushing, check if there is an open PR for this branch. " + "If so, update the PR description to reflect the latest changes." @@ -61,3 +75,25 @@ customPolicies.add({ return allow(); }, }); + +// On `gh pr create`, instruct the agent to ensure CHANGELOG entries live +// under a versioned `## ` section so the PR ships +// release-ready. There is no `## Unreleased` section. +customPolicies.add({ + name: "release-prep-check", + description: + "On `gh pr create`, instruct the agent to ensure CHANGELOG entries are under a versioned `## ` section", + match: { events: ["PreToolUse"] }, + fn: async (ctx) => { + if (ctx.toolName !== "Bash") return allow(); + const cmd = String(ctx.toolInput?.command ?? ""); + if (!matchesCommand(cmd, String.raw`gh\s+pr\s+create`)) return allow(); + return instruct( + "Before creating the PR, ensure CHANGELOG.md entries land under a versioned section so the PR ships release-ready:\n" + + " 1. Read `version` from package.json (e.g. `0.0.10-beta.10`).\n" + + " 2. Ensure your changelog entries are under a `## ` heading. If that heading does not exist yet, create it above the previous version's section. There is NO `## Unreleased` section — entries always go under a dated, versioned heading.\n" + + " 3. If you are on a `luv-cut-X.Y.Z` branch, the cut PR handles version bump itself.\n" + + " 4. Do NOT bump `package.json`'s `version` outside of `luv-cut-*` branches — that is enforced by `block-version-bumps`." + ); + }, +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e2a1cc..00e6d746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## Unreleased +## 0.0.10-beta.10 — 2026-05-09 + +### Fixes +- Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/` 404 for OpenCode-only sessions and merging same-cwd OpenCode + other-CLI rows on the Projects page (#335). +- `.failproofai/policies/workflow-policies.mjs`: drop the `## Unreleased` section; new `release-prep-check` policy + updated `changelog-check` instruct the agent to put entries under a dated `## ` heading so each PR ships release-ready, and all four workflow policies now anchor command-phrase matches to shell boundaries to avoid false-positives from HEREDOC bodies (#335). ## 0.0.10-beta.9 — 2026-05-09 diff --git a/CLAUDE.md b/CLAUDE.md index e1d68415..95872b29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -425,7 +425,13 @@ examples/ Sample custom policy files ## Changelog Every PR **must** include an update to `CHANGELOG.md`. Add your entry under the -`## Unreleased` section at the top. Use the appropriate subsection: +current `## ` section at the top, where `` matches +`version` in `package.json` and `` is today's date. If that section +does not exist yet, create it above the previous version's section. There is **no** +`## Unreleased` section — entries always go under a dated, versioned heading, so +each feature PR ships release-ready. + +Use the appropriate subsection: - **Features** for new functionality - **Fixes** for bug fixes @@ -433,8 +439,7 @@ Every PR **must** include an update to `CHANGELOG.md`. Add your entry under the - **Dependencies** for dependency bumps Each entry should be a single line: a short description followed by the PR number -(e.g. `- Add foo support (#123)`). When a release is cut, the `Unreleased` section gets -renamed to the version and date, and a fresh `## Unreleased` heading is added. +(e.g. `- Add foo support (#123)`). ## Version bumps diff --git a/__tests__/lib/opencode-projects.test.ts b/__tests__/lib/opencode-projects.test.ts index d7c12191..92583741 100644 --- a/__tests__/lib/opencode-projects.test.ts +++ b/__tests__/lib/opencode-projects.test.ts @@ -69,11 +69,12 @@ describe("getOpenCodeProjects", () => { const projects = await getOpenCodeProjects(); expect(projects).toHaveLength(2); // Newest first — p1 has time_updated=200, p2 has 50. - expect(projects[0].name).toBe("repo"); // basename(/repo) — name was null + // `name` is the URL slug = encodeFolderName(worktree) — matches every other CLI. + expect(projects[0].name).toBe("-repo"); expect(projects[0].path).toBe("/repo"); expect(projects[0].cli).toEqual(["opencode"]); expect(projects[0].lastModified.getTime()).toBe(200); - expect(projects[1].name).toBe("Other Project"); + expect(projects[1].name).toBe("-other"); expect(projects[1].lastModified.getTime()).toBe(50); }); @@ -86,7 +87,7 @@ describe("getOpenCodeProjects", () => { ]); const projects = await getOpenCodeProjects(); expect(projects).toHaveLength(1); - expect(projects[0].name).toBe("Empty Project"); + expect(projects[0].name).toBe("-repo"); expect(projects[0].lastModified.getTime()).toBe(10); }); diff --git a/lib/opencode-projects.ts b/lib/opencode-projects.ts index 30e7ea26..b45106b1 100644 --- a/lib/opencode-projects.ts +++ b/lib/opencode-projects.ts @@ -22,7 +22,6 @@ * https://opencode.ai/docs/plugins/ (plugin model context) */ import { execFileSync } from "node:child_process"; -import { basename } from "node:path"; import { encodeFolderName } from "./paths"; import type { ProjectFolder, SessionFile } from "./projects"; import { runtimeCache } from "./runtime-cache"; @@ -91,10 +90,11 @@ function readProjectRows(): OpenCodeProjectRow[] | null { /** * Group sessions by `project_id` and produce one ProjectFolder per project. - * The folder name comes from `project.name` when set, else `basename(worktree)`, - * else the project_id (last-resort). `lastModified` is the max session - * `time_updated` for that project (or the project's own time_updated if no - * sessions exist yet). + * The folder name is `encodeFolderName(worktree)` (matches every other CLI's + * URL-slug encoding so the dashboard's `/project/[name]` route resolves), or + * the project_id when no worktree is recorded. `lastModified` is the max + * session `time_updated` for that project (or the project's own time_updated + * if no sessions exist yet). */ export async function getOpenCodeProjects(): Promise { const sessions = readSessionRows(); @@ -122,13 +122,15 @@ export async function getOpenCodeProjects(): Promise { // Emit one ProjectFolder per project that has at least one session OR a // project row (covers projects opencode knows about but hasn't run yet). + // `name` is the dashboard's URL slug — must be `encodeFolderName(cwd)` to + // match every other CLI (and the resolver in `getOpenCodeSessionsByEncodedName`). const seen = new Set(); const out: ProjectFolder[] = []; for (const [projectId, group] of groups) { seen.add(projectId); const proj = projectMap.get(projectId); const worktree = proj?.worktree ?? group.rows[0]?.directory ?? null; - const name = proj?.name?.trim() || (worktree ? basename(worktree) : projectId); + const name = worktree ? encodeFolderName(worktree) : projectId; const path = worktree ?? ""; const lastModified = new Date(Math.max(group.latest, proj?.time_updated ?? 0)); out.push({ @@ -143,7 +145,7 @@ export async function getOpenCodeProjects(): Promise { for (const p of projects ?? []) { if (seen.has(p.id)) continue; const worktree = p.worktree ?? ""; - const name = p.name?.trim() || (worktree ? basename(worktree) : p.id); + const name = worktree ? encodeFolderName(worktree) : p.id; const lastModified = new Date(p.time_updated); out.push({ name,