Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions .failproofai/policies/workflow-policies.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@
*/
import { customPolicies, allow, instruct } from "failproofai";

/**
* Match `<verb-phrase>` 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",
Expand All @@ -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 `## <version> — <YYYY-MM-DD>` 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();
Expand All @@ -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. " +
Expand All @@ -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."
Expand All @@ -61,3 +75,25 @@ customPolicies.add({
return allow();
},
});

// On `gh pr create`, instruct the agent to ensure CHANGELOG entries live
// under a versioned `## <version> — <date>` 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 `## <version> — <date>` 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 `## <version> — <today's date in YYYY-MM-DD>` 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`."
);
},
});
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<slug>` 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 `## <version> — <YYYY-MM-DD>` 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

Expand Down
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,16 +425,21 @@ 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 `## <version> — <YYYY-MM-DD>` section at the top, where `<version>` matches
`version` in `package.json` and `<YYYY-MM-DD>` 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
- **Docs** for documentation-only changes
- **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

Expand Down
7 changes: 4 additions & 3 deletions __tests__/lib/opencode-projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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);
});

Expand Down
16 changes: 9 additions & 7 deletions lib/opencode-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ProjectFolder[]> {
const sessions = readSessionRows();
Expand Down Expand Up @@ -122,13 +122,15 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {

// 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<string>();
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({
Expand All @@ -143,7 +145,7 @@ export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
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,
Expand Down